Files
key-cape/src/internal/adapters/privacyidea/adapter.go
tegwick d05c73dc19 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>
2026-03-13 01:50:31 +01:00

154 lines
4.7 KiB
Go

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"`
}