generated from coulomb/repo-seed
feat: implement T11, T12 — Authelia adapter, privacyIDEA adapter
- T11: AutheliaAdapter delegating login UI and session; Authelia tokens never leak to profile layer - T12: PrivacyIDEAAdapter delegating MFA 100% — no MFA logic in KeyCape 21 adapter tests pass, vet clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
153
src/internal/adapters/privacyidea/adapter.go
Normal file
153
src/internal/adapters/privacyidea/adapter.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package privacyidea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"keycape/internal/domain"
|
||||
)
|
||||
|
||||
// PrivacyIDEAAdapter implements domain.MFAProvider by delegating to privacyIDEA's
|
||||
// REST API. No MFA logic is implemented here — every decision is owned by
|
||||
// privacyIDEA. The adapter fails closed: any infrastructure error is returned
|
||||
// as a non-nil error so the caller cannot proceed without a definitive answer.
|
||||
type PrivacyIDEAAdapter struct {
|
||||
cfg Config
|
||||
client HTTPClient
|
||||
}
|
||||
|
||||
// New returns a production-ready PrivacyIDEAAdapter.
|
||||
// If httpClient is nil the default net/http.Client is used.
|
||||
func New(cfg Config, httpClient HTTPClient) *PrivacyIDEAAdapter {
|
||||
if httpClient == nil {
|
||||
httpClient = defaultHTTPClient
|
||||
}
|
||||
return &PrivacyIDEAAdapter{cfg: cfg, client: httpClient}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// domain.MFAProvider implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CheckMFARequired returns true if the user has at least one active MFA token
|
||||
// registered in privacyIDEA. Fails closed: any infrastructure error returns
|
||||
// (false, err) so callers cannot bypass the check.
|
||||
func (a *PrivacyIDEAAdapter) CheckMFARequired(ctx context.Context, userID string) (bool, error) {
|
||||
endpoint := strings.TrimRight(a.cfg.BaseURL, "/") + "/token/"
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("user", userID)
|
||||
q.Set("realm", a.cfg.realm())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("privacyidea: build token list request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+a.cfg.AdminToken)
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("privacyidea: token list request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("privacyidea: token list returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("privacyidea: read token list response: %w", err)
|
||||
}
|
||||
|
||||
var parsed tokenListResponse
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return false, fmt.Errorf("privacyidea: decode token list response: %w", err)
|
||||
}
|
||||
|
||||
for _, tok := range parsed.Result.Value.Tokens {
|
||||
if tok.Active {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ValidateMFAToken validates the given OTP token for the user via privacyIDEA's
|
||||
// /validate/check endpoint. Returns nil on success, domain.ErrMFAFailed if the
|
||||
// token is invalid, and a wrapped infrastructure error on any network/HTTP failure.
|
||||
// Fails closed: infrastructure errors are surfaced, not swallowed.
|
||||
func (a *PrivacyIDEAAdapter) ValidateMFAToken(ctx context.Context, userID, token string) error {
|
||||
endpoint := strings.TrimRight(a.cfg.BaseURL, "/") + "/validate/check"
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("user", userID)
|
||||
form.Set("pass", token)
|
||||
form.Set("realm", a.cfg.realm())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint,
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("privacyidea: build validate request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Bearer "+a.cfg.AdminToken)
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("privacyidea: validate request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("privacyidea: validate endpoint returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("privacyidea: read validate response: %w", err)
|
||||
}
|
||||
|
||||
var parsed validateResponse
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return fmt.Errorf("privacyidea: decode validate response: %w", err)
|
||||
}
|
||||
|
||||
if !parsed.Result.Value {
|
||||
return domain.ErrMFAFailed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON response types (internal to this package)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// tokenListResponse models the privacyIDEA GET /token/ response envelope.
|
||||
type tokenListResponse struct {
|
||||
Result struct {
|
||||
Status bool `json:"status"`
|
||||
Value struct {
|
||||
Tokens []tokenEntry `json:"tokens"`
|
||||
} `json:"value"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
// tokenEntry represents a single token entry in the token list response.
|
||||
type tokenEntry struct {
|
||||
Serial string `json:"serial"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// validateResponse models the privacyIDEA POST /validate/check response envelope.
|
||||
type validateResponse struct {
|
||||
Result struct {
|
||||
Status bool `json:"status"`
|
||||
Value bool `json:"value"`
|
||||
} `json:"result"`
|
||||
}
|
||||
Reference in New Issue
Block a user