Require MFA during bootstrap mode
This commit is contained in:
parent
56d279a8e6
commit
937cb39de6
5 changed files with 75 additions and 3 deletions
|
|
@ -38,6 +38,10 @@ func New(cfg Config, httpClient HTTPClient) *PrivacyIDEAAdapter {
|
||||||
// registered in privacyIDEA. Fails closed: any infrastructure error returns
|
// registered in privacyIDEA. Fails closed: any infrastructure error returns
|
||||||
// (false, err) so callers cannot bypass the check.
|
// (false, err) so callers cannot bypass the check.
|
||||||
func (a *PrivacyIDEAAdapter) CheckMFARequired(ctx context.Context, userID string) (bool, error) {
|
func (a *PrivacyIDEAAdapter) CheckMFARequired(ctx context.Context, userID string) (bool, error) {
|
||||||
|
if a.cfg.RequireForAll {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
endpoint := strings.TrimRight(a.cfg.BaseURL, "/") + "/token/"
|
endpoint := strings.TrimRight(a.cfg.BaseURL, "/") + "/token/"
|
||||||
|
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,27 @@ func TestCheckMFARequired_ActiveTokenPresent_ReturnsTrue(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCheckMFARequired_RequireForAll_ReturnsTrueWithoutTokenList(t *testing.T) {
|
||||||
|
client := &mockHTTPClient{
|
||||||
|
doFn: func(_ *http.Request) (*http.Response, error) {
|
||||||
|
t.Fatal("token-list endpoint must not be called when RequireForAll is enabled")
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := testConfig()
|
||||||
|
cfg.RequireForAll = true
|
||||||
|
adapter := privacyidea.New(cfg, client)
|
||||||
|
|
||||||
|
required, err := adapter.CheckMFARequired(context.Background(), "alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !required {
|
||||||
|
t.Error("expected MFA required=true when RequireForAll is enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckMFARequired_InactiveTokenOnly_ReturnsFalse(t *testing.T) {
|
func TestCheckMFARequired_InactiveTokenOnly_ReturnsFalse(t *testing.T) {
|
||||||
client := &mockHTTPClient{
|
client := &mockHTTPClient{
|
||||||
doFn: func(_ *http.Request) (*http.Response, error) {
|
doFn: func(_ *http.Request) (*http.Response, error) {
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,20 @@ import "net/http"
|
||||||
// Config holds all connection parameters for the privacyIDEA adapter.
|
// Config holds all connection parameters for the privacyIDEA adapter.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// BaseURL is the privacyIDEA server base URL, e.g. "https://privacyidea.local".
|
// BaseURL is the privacyIDEA server base URL, e.g. "https://privacyidea.local".
|
||||||
BaseURL string
|
BaseURL string `yaml:"baseURL"`
|
||||||
|
|
||||||
// AdminToken is the service-account JWT used to authenticate requests to the
|
// AdminToken is the service-account JWT used to authenticate requests to the
|
||||||
// privacyIDEA admin API.
|
// privacyIDEA admin API.
|
||||||
AdminToken string
|
AdminToken string `yaml:"adminToken"`
|
||||||
|
|
||||||
// Realm is the privacyIDEA realm to scope token and validate requests.
|
// Realm is the privacyIDEA realm to scope token and validate requests.
|
||||||
// Defaults to "netkingdom" when empty.
|
// Defaults to "netkingdom" when empty.
|
||||||
Realm string
|
Realm string `yaml:"realm"`
|
||||||
|
|
||||||
|
// RequireForAll skips privacyIDEA token-list discovery and requires MFA for
|
||||||
|
// every authenticated upstream user. This is useful during bootstrap when
|
||||||
|
// token-list admin credentials may not be durable yet.
|
||||||
|
RequireForAll bool `yaml:"requireForAll,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// realm returns the effective realm, falling back to "netkingdom".
|
// realm returns the effective realm, falling back to "netkingdom".
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,40 @@ clients:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoad_PrivacyIDEARequireForAll(t *testing.T) {
|
||||||
|
keyPath := writeTempFile(t, "placeholder-key")
|
||||||
|
yaml := `
|
||||||
|
issuer: "https://kc.example.com"
|
||||||
|
port: 8080
|
||||||
|
tokenLifetime: "15m"
|
||||||
|
privateKeyPem: "` + keyPath + `"
|
||||||
|
environment: "dev"
|
||||||
|
privacyidea:
|
||||||
|
baseURL: "http://privacyidea.mfa.svc.cluster.local:8080"
|
||||||
|
adminToken: "service-token"
|
||||||
|
realm: "coulomb"
|
||||||
|
requireForAll: true
|
||||||
|
clients:
|
||||||
|
- clientId: "netkingdom-bootstrap-console"
|
||||||
|
displayName: "NetKingdom Bootstrap Console"
|
||||||
|
redirectUris:
|
||||||
|
- "http://127.0.0.1:8876/oidc/callback"
|
||||||
|
clientType: "public"
|
||||||
|
`
|
||||||
|
cfgPath := writeTempFile(t, yaml)
|
||||||
|
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.PrivacyIDEA.Realm != "coulomb" {
|
||||||
|
t.Errorf("PrivacyIDEA.Realm: got %q", cfg.PrivacyIDEA.Realm)
|
||||||
|
}
|
||||||
|
if !cfg.PrivacyIDEA.RequireForAll {
|
||||||
|
t.Error("PrivacyIDEA.RequireForAll should load from YAML")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoad_FileNotFound(t *testing.T) {
|
func TestLoad_FileNotFound(t *testing.T) {
|
||||||
_, err := config.Load(filepath.Join(t.TempDir(), "nonexistent.yaml"))
|
_, err := config.Load(filepath.Join(t.TempDir(), "nonexistent.yaml"))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,14 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
||||||
// Check MFA requirement.
|
// Check MFA requirement.
|
||||||
mfaRequired, err := h.MFA.CheckMFARequired(ctx, result.Username)
|
mfaRequired, err := h.MFA.CheckMFARequired(ctx, result.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
h.Emitter.Emit(ctx, telemetry.Event{
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
EventType: telemetry.EventAuthFailure,
|
||||||
|
ClientID: ps.ClientID,
|
||||||
|
Endpoint: "/authorize/callback",
|
||||||
|
Result: "failure",
|
||||||
|
ErrorType: "mfa_check_error",
|
||||||
|
})
|
||||||
http.Error(w, "mfa check error", http.StatusInternalServerError)
|
http.Error(w, "mfa check error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue