Require confirmed enrollment and genuine OTP evidence for MFA
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
parent
3a36f1a507
commit
122a0d1369
7 changed files with 165 additions and 20 deletions
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}})
|
||||
|
|
|
|||
101
src/internal/adapters/privacyidea/enrollment_state_test.go
Normal file
101
src/internal/adapters/privacyidea/enrollment_state_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue