Require confirmed enrollment and genuine OTP evidence for MFA
All checks were successful
Authentication acceptance / acceptance (push) Successful in 1m10s
Build and Publish Container Image / build-and-push (push) Successful in 41s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
tegwick 2026-09-13 16:37:08 +02:00
parent 3a36f1a507
commit 122a0d1369
7 changed files with 165 additions and 20 deletions

View file

@ -99,8 +99,20 @@ func (a *PrivacyIDEAAdapter) hasActiveToken(ctx context.Context, userID string)
if tok.Active == nil {
return false, fmt.Errorf("privacyidea: token missing active state")
}
if *tok.Active {
if !*tok.Active {
continue
}
if tok.RolloutState == nil {
return false, fmt.Errorf("privacyidea: token missing enrollment state")
}
switch *tok.RolloutState {
case "", "enrolled": // Empty is the provider's legacy completed-token state.
return true, nil
case "verify", "clientwait", "pending":
// Creation alone is not possession-confirmed enrollment.
continue
default:
return false, fmt.Errorf("privacyidea: unsupported token enrollment state")
}
}
if *parsed.Result.Value.Count > len(parsed.Result.Value.Tokens) {
@ -153,7 +165,7 @@ func (a *PrivacyIDEAAdapter) ValidateMFAToken(ctx context.Context, userID, token
return fmt.Errorf("privacyidea: decode validate response: %w", err)
}
if !parsed.Result.Status || !parsed.Result.Value {
if !parsed.Result.Status || !parsed.Result.Value || strings.TrimSpace(parsed.Detail.Serial) == "" || (parsed.Detail.Type != "totp" && parsed.Detail.Type != "hotp") {
return domain.ErrMFAFailed
}
return nil
@ -213,12 +225,17 @@ type tokenListResponse struct {
// tokenEntry represents a single token entry in the token list response.
type tokenEntry struct {
Serial string `json:"serial"`
Active *bool `json:"active"`
Serial string `json:"serial"`
Active *bool `json:"active"`
RolloutState *string `json:"rollout_state"`
}
// validateResponse models the privacyIDEA POST /validate/check response envelope.
type validateResponse struct {
Detail struct {
Serial string `json:"serial"`
Type string `json:"type"`
} `json:"detail"`
Result struct {
Status bool `json:"status"`
Value bool `json:"value"`

View file

@ -61,7 +61,7 @@ func tokenListResponse(tokens []map[string]interface{}) string {
tokenJSON += ","
}
active, _ := t["active"].(bool)
tokenJSON += fmt.Sprintf(`{"serial":"TOK%d","active":%v}`, i, active)
tokenJSON += fmt.Sprintf(`{"serial":"TOK%d","active":%v,"rollout_state":"enrolled"}`, i, active)
}
tokenJSON += "]"
return fmt.Sprintf(`{"result":{"status":true,"value":{"tokens":%s,"count":%d}}}`, tokenJSON, len(tokens))
@ -69,7 +69,7 @@ func tokenListResponse(tokens []map[string]interface{}) string {
// validateResponse builds a privacyIDEA /validate/check JSON response.
func validateResponse(success bool) string {
return fmt.Sprintf(`{"result":{"status":true,"value":%v}}`, success)
return fmt.Sprintf(`{"result":{"status":true,"value":%v},"detail":{"serial":"TESTOTP","type":"totp"}}`, success)
}
// ---------------------------------------------------------------------------

View file

@ -22,7 +22,7 @@ func TestCredentialFileRenewalAndFailureRecovery(t *testing.T) {
adapter := privacyidea.New(cfg, &mockHTTPClient{doFn: func(req *http.Request) (*http.Response, error) {
seen = append(seen, req.Header.Get("Authorization"))
if req.URL.Path == "/validate/check" {
return jsonResponse(`{"result":{"status":true,"value":true}}`), nil
return jsonResponse(`{"result":{"status":true,"value":true},"detail":{"serial":"TESTOTP","type":"totp"}}`), nil
}
return jsonResponse(`{"result":{"status":true,"value":{"tokens":[],"count":0}}}`), nil
}})

View file

@ -0,0 +1,101 @@
package privacyidea_test
import (
"context"
"fmt"
"net/http"
"testing"
"keycape/internal/adapters/privacyidea"
)
func TestEnrollmentStateRequiresPossessionConfirmation(t *testing.T) {
for _, tc := range []struct {
name, state string
required, failure bool
}{
{"confirmed", `"enrolled"`, true, false},
{"legacy completed", `""`, true, false},
{"awaiting OTP", `"verify"`, false, false},
{"awaiting device", `"clientwait"`, false, false},
{"backend pending", `"pending"`, false, false},
{"missing state", `null`, false, true},
{"unknown state", `"future-state"`, false, true},
{"broken enrollment", `"broken"`, false, true},
} {
t.Run(tc.name, func(t *testing.T) {
body := fmt.Sprintf(`{"result":{"status":true,"value":{"tokens":[{"active":true,"rollout_state":%s}],"count":1}}}`, tc.state)
client := &mockHTTPClient{doFn: func(*http.Request) (*http.Response, error) { return jsonResponse(body), nil }}
a := privacyidea.New(testConfig(), client)
required, err := a.HasEnrolledFactor(context.Background(), "alice")
if required != tc.required || (err != nil) != tc.failure {
t.Fatalf("required=%v, error=%v", required, err)
}
})
}
}
func TestEnrollmentConfirmationChangesDecisionOnNextLookup(t *testing.T) {
state := "verify"
client := &mockHTTPClient{doFn: func(*http.Request) (*http.Response, error) {
return jsonResponse(fmt.Sprintf(`{"result":{"status":true,"value":{"tokens":[{"active":true,"rollout_state":%q}],"count":1}}}`, state)), nil
}}
a := privacyidea.New(testConfig(), client)
for _, step := range []struct {
state string
required bool
}{{"verify", false}, {"enrolled", true}, {"verify", false}} {
state = step.state
got, err := a.HasEnrolledFactor(context.Background(), "alice")
if err != nil || got != step.required {
t.Fatalf("state %s: required=%v error=%v", state, got, err)
}
}
}
func TestPendingEnrollmentCannotHideExistingFactor(t *testing.T) {
for _, tc := range []struct {
name, tokens string
count int
required, failure bool
}{
{"existing verified factor", `[{"active":true,"rollout_state":"verify"},{"active":true,"rollout_state":"enrolled"}]`, 2, true, false},
{"unseen page is uncertain", `[{"active":true,"rollout_state":"verify"}]`, 2, false, true},
} {
t.Run(tc.name, func(t *testing.T) {
client := &mockHTTPClient{doFn: func(*http.Request) (*http.Response, error) {
return jsonResponse(fmt.Sprintf(`{"result":{"status":true,"value":{"tokens":%s,"count":%d}}}`, tc.tokens, tc.count)), nil
}}
got, err := privacyidea.New(testConfig(), client).HasEnrolledFactor(context.Background(), "alice")
if got != tc.required || (err != nil) != tc.failure {
t.Fatalf("required=%v error=%v", got, err)
}
})
}
}
func TestPasswordPassthroughCannotGrantAAL2(t *testing.T) {
for _, tc := range []struct {
name, detail string
accepted bool
}{
{"password passthrough", `{}`, false},
{"static password token", `{"serial":"STATIC","type":"spass"}`, false},
{"missing serial", `{"type":"totp"}`, false},
{"blank serial", `{"serial":" ","type":"totp"}`, false},
{"missing factor type", `{"serial":"OTP"}`, false},
{"unknown factor type", `{"serial":"OTP","type":"future"}`, false},
{"TOTP confirmed", `{"serial":"OTP","type":"totp"}`, true},
{"HOTP confirmed", `{"serial":"OTP","type":"hotp"}`, true},
} {
t.Run(tc.name, func(t *testing.T) {
client := &mockHTTPClient{doFn: func(*http.Request) (*http.Response, error) {
return jsonResponse(fmt.Sprintf(`{"result":{"status":true,"value":true},"detail":%s}`, tc.detail)), nil
}}
err := privacyidea.New(testConfig(), client).ValidateMFAToken(context.Background(), "alice", "submitted-value")
if (err == nil) != tc.accepted {
t.Fatalf("accepted=%v, error=%v", err == nil, err)
}
})
}
}

View file

@ -34,7 +34,7 @@ func TestEnrollmentLookupFiltersBeforePagination(t *testing.T) {
if req.Header.Get("Authorization") != "service-jwt" {
t.Fatal("provider requires raw JWT")
}
return jsonResponse(`{"result":{"status":true,"value":{"tokens":[{"active":true}],"count":20}}}`), nil
return jsonResponse(`{"result":{"status":true,"value":{"tokens":[{"active":true,"rollout_state":"enrolled"}],"count":20}}}`), nil
}})
required, err := adapter.HasEnrolledFactor(context.Background(), "alice")
if err != nil || !required {