diff --git a/driver/config/config.go b/driver/config/config.go index 461ca18719f9..3afcafebfab9 100644 --- a/driver/config/config.go +++ b/driver/config/config.go @@ -1238,12 +1238,3 @@ func (p *Config) getTSLCertificates(daemon, certBase64, keyBase64, certPath, key p.l.Infof("TLS has not been configured for %s, skipping", daemon) return nil } - -func (p *Config) PasswordlessMethods() []string { - if p.WebAuthnForPasswordless() { - return []string{ - "webauthn", - } - } - return nil -} diff --git a/driver/config/config_test.go b/driver/config/config_test.go index 32025956cc58..68a1bef609d0 100644 --- a/driver/config/config_test.go +++ b/driver/config/config_test.go @@ -1033,10 +1033,8 @@ func TestPasswordless(t *testing.T) { configx.WithValue(config.ViperKeyWebAuthnPasswordless, true)) require.NoError(t, err) - assert.Equal(t, []string{"webauthn"}, conf.PasswordlessMethods()) assert.True(t, conf.WebAuthnForPasswordless()) conf.MustSet(config.ViperKeyWebAuthnPasswordless, false) - assert.Empty(t, conf.PasswordlessMethods()) assert.False(t, conf.WebAuthnForPasswordless()) } diff --git a/identity/aal.go b/identity/aal.go index 0f783c052ca2..662a761c3c0d 100644 --- a/identity/aal.go +++ b/identity/aal.go @@ -1,37 +1,61 @@ package identity -func DetermineAAL(cts []CredentialsType, passwordless []string) AuthenticatorAssuranceLevel { +import "github.com/tidwall/gjson" + +func IsCredentialAAL1(credential Credentials, webAuthnIsPasswordless bool) bool { + switch credential.Type { + case CredentialsTypeRecoveryLink: + fallthrough + case CredentialsTypeOIDC: + fallthrough + case "v0.6_legacy_session": + fallthrough + case CredentialsTypePassword: + return true + case CredentialsTypeWebAuthn: + for _, c := range gjson.GetBytes(credential.Config, "credentials").Array() { + if c.Get("is_passwordless").Bool() { + return webAuthnIsPasswordless + } + } + return false + } + return false +} + +func IsCredentialAAL2(credential Credentials, webAuthnIsPasswordless bool) bool { + switch credential.Type { + case CredentialsTypeTOTP: + return true + case CredentialsTypeLookup: + return true + case CredentialsTypeWebAuthn: + creds := gjson.GetBytes(credential.Config, "credentials").Array() + if len(creds) == 0 { + // Legacy credential before passwordless -> AAL2 + return true + } + for _, c := range gjson.GetBytes(credential.Config, "credentials").Array() { + if !c.Get("is_passwordless").Bool() { + return !webAuthnIsPasswordless + } + } + } + return false +} + +func MaximumAAL(c map[CredentialsType]Credentials, conf interface { + WebAuthnForPasswordless() bool +}) AuthenticatorAssuranceLevel { aal := NoAuthenticatorAssuranceLevel var firstFactor bool var secondFactor bool - for _, a := range cts { - switch a { - case CredentialsTypeRecoveryLink: - fallthrough - case CredentialsTypeOIDC: - fallthrough - case "v0.6_legacy_session": - fallthrough - case CredentialsTypePassword: + for _, a := range c { + if IsCredentialAAL1(a, conf.WebAuthnForPasswordless()) { firstFactor = true - case CredentialsTypeTOTP: + } else if IsCredentialAAL2(a, conf.WebAuthnForPasswordless()) { secondFactor = true - case CredentialsTypeLookup: - secondFactor = true - case CredentialsTypeWebAuthn: - var wasFirstFactor bool - for _, pl := range passwordless { - if pl == CredentialsTypeWebAuthn.String() { - firstFactor = true - wasFirstFactor = true - break - } - } - - if !wasFirstFactor { - secondFactor = true - } } } diff --git a/identity/aal_test.go b/identity/aal_test.go index 6d2313f877a5..f32714ba5fcb 100644 --- a/identity/aal_test.go +++ b/identity/aal_test.go @@ -6,13 +6,20 @@ import ( "github.com/stretchr/testify/assert" ) -func TestDetermineAAL(t *testing.T) { +type fakeConfig struct { + less bool +} + +func (f *fakeConfig) WebAuthnForPasswordless() bool { + return f.less +} +func TestDetermineAAL(t *testing.T) { for _, tc := range []struct { - d string - methods []CredentialsType - passwordless []string - expected AuthenticatorAssuranceLevel + d string + methods map[CredentialsType]Credentials + expected AuthenticatorAssuranceLevel + webAuthnPasswordless bool }{ { d: "no amr means no assurance", @@ -20,121 +27,168 @@ func TestDetermineAAL(t *testing.T) { }, { d: "password is aal1", - methods: []CredentialsType{CredentialsTypePassword}, + methods: map[CredentialsType]Credentials{CredentialsTypePassword: {Type: CredentialsTypePassword}}, expected: AuthenticatorAssuranceLevel1, }, { d: "oidc is aal1", - methods: []CredentialsType{CredentialsTypeOIDC}, + methods: map[CredentialsType]Credentials{CredentialsTypeOIDC: {Type: CredentialsTypeOIDC}}, expected: AuthenticatorAssuranceLevel1, }, { d: "recovery is aal1", - methods: []CredentialsType{CredentialsTypeRecoveryLink}, + methods: map[CredentialsType]Credentials{CredentialsTypeRecoveryLink: {Type: CredentialsTypeRecoveryLink}}, expected: AuthenticatorAssuranceLevel1, }, { d: "legacy is aal1", - methods: []CredentialsType{"v0.6_legacy_session"}, + methods: map[CredentialsType]Credentials{"v0.6_legacy_session": {Type: "v0.6_legacy_session"}}, expected: AuthenticatorAssuranceLevel1, }, { d: "mix of password, oidc, recovery is still aal1", - methods: []CredentialsType{ - CredentialsTypeRecoveryLink, CredentialsTypeOIDC, CredentialsTypePassword, + methods: map[CredentialsType]Credentials{ + CredentialsTypeRecoveryLink: {Type: CredentialsTypeRecoveryLink}, + CredentialsTypeOIDC: {Type: CredentialsTypeOIDC}, + CredentialsTypePassword: {Type: CredentialsTypePassword}, }, expected: AuthenticatorAssuranceLevel1, }, { - d: "just totp is aal0", - methods: []CredentialsType{ - CredentialsTypeTOTP, - }, + d: "just totp is aal0", + methods: map[CredentialsType]Credentials{CredentialsTypeTOTP: {Type: CredentialsTypeTOTP}}, expected: NoAuthenticatorAssuranceLevel, }, { d: "password + totp is aal2", - methods: []CredentialsType{ - CredentialsTypePassword, - CredentialsTypeTOTP, + methods: map[CredentialsType]Credentials{ + CredentialsTypeTOTP: {Type: CredentialsTypeTOTP}, + CredentialsTypePassword: {Type: CredentialsTypePassword}, }, expected: AuthenticatorAssuranceLevel2, }, { d: "password + lookup is aal2", - methods: []CredentialsType{ - CredentialsTypePassword, - CredentialsTypeLookup, + methods: map[CredentialsType]Credentials{ + CredentialsTypeLookup: {Type: CredentialsTypeLookup}, + CredentialsTypePassword: {Type: CredentialsTypePassword}, }, expected: AuthenticatorAssuranceLevel2, }, { d: "password + webauthn is aal2", - methods: []CredentialsType{ - CredentialsTypePassword, - CredentialsTypeWebAuthn, + methods: map[CredentialsType]Credentials{ + CredentialsTypeWebAuthn: {Type: CredentialsTypeWebAuthn}, + CredentialsTypePassword: {Type: CredentialsTypePassword}, }, expected: AuthenticatorAssuranceLevel2, }, { d: "oidc + totp is aal2", - methods: []CredentialsType{ - CredentialsTypeOIDC, - CredentialsTypeTOTP, + methods: map[CredentialsType]Credentials{ + CredentialsTypeOIDC: {Type: CredentialsTypeOIDC}, + CredentialsTypeTOTP: {Type: CredentialsTypeTOTP}, }, expected: AuthenticatorAssuranceLevel2, }, { d: "oidc + lookup is aal2", - methods: []CredentialsType{ - CredentialsTypeOIDC, - CredentialsTypeLookup, + methods: map[CredentialsType]Credentials{ + CredentialsTypeOIDC: {Type: CredentialsTypeOIDC}, + CredentialsTypeLookup: {Type: CredentialsTypeLookup}, }, expected: AuthenticatorAssuranceLevel2, }, { d: "recovery link + totp is aal2", - methods: []CredentialsType{ - CredentialsTypeRecoveryLink, - CredentialsTypeTOTP, + methods: map[CredentialsType]Credentials{ + CredentialsTypeRecoveryLink: {Type: CredentialsTypeRecoveryLink}, + CredentialsTypeTOTP: {Type: CredentialsTypeTOTP}, }, expected: AuthenticatorAssuranceLevel2, }, { d: "recovery link + lookup is aal2", - methods: []CredentialsType{ - CredentialsTypeRecoveryLink, - CredentialsTypeLookup, + methods: map[CredentialsType]Credentials{ + CredentialsTypeRecoveryLink: {Type: CredentialsTypeRecoveryLink}, + CredentialsTypeLookup: {Type: CredentialsTypeLookup}, }, expected: AuthenticatorAssuranceLevel2, }, { d: "webauthn only is aal1 if passwordless is set", - methods: []CredentialsType{ - CredentialsTypeWebAuthn, + methods: map[CredentialsType]Credentials{ + CredentialsTypeWebAuthn: {Type: CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":true}]}`)}, }, - passwordless: []string{CredentialsTypeWebAuthn.String()}, - expected: AuthenticatorAssuranceLevel1, + expected: AuthenticatorAssuranceLevel1, + webAuthnPasswordless: true, }, { d: "webauthn and another method is aal1 if passwordless is set", - methods: []CredentialsType{ - CredentialsTypePassword, - CredentialsTypeWebAuthn, + methods: map[CredentialsType]Credentials{ + CredentialsTypePassword: {Type: CredentialsTypePassword}, + CredentialsTypeWebAuthn: {Type: CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":true}]}`)}, }, - passwordless: []string{CredentialsTypeWebAuthn.String()}, - expected: AuthenticatorAssuranceLevel1, + expected: AuthenticatorAssuranceLevel1, }, { d: "webauthn only is unknown if passwordless is not set", - methods: []CredentialsType{ - CredentialsTypeWebAuthn, + methods: map[CredentialsType]Credentials{ + CredentialsTypeWebAuthn: {Type: CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":false}]}`)}, + }, + expected: NoAuthenticatorAssuranceLevel, + }, + { + d: "webauthn with password is aal1 if passwordless is set", + methods: map[CredentialsType]Credentials{ + CredentialsTypePassword: {Type: CredentialsTypePassword}, + CredentialsTypeWebAuthn: {Type: CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":true}]}`)}, + }, + expected: AuthenticatorAssuranceLevel1, + }, + { + d: "webauthn with password and two credentials is aal2 if no passwordless", + methods: map[CredentialsType]Credentials{ + CredentialsTypePassword: {Type: CredentialsTypePassword}, + CredentialsTypeWebAuthn: {Type: CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":false},{"is_passwordless":true}]}`)}, + }, + expected: AuthenticatorAssuranceLevel2, + }, + { + d: "webauthn with password and two credentials is aal1 if passwordless", + methods: map[CredentialsType]Credentials{ + CredentialsTypePassword: {Type: CredentialsTypePassword}, + CredentialsTypeWebAuthn: {Type: CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":false},{"is_passwordless":true}]}`)}, + }, + expected: AuthenticatorAssuranceLevel1, + webAuthnPasswordless: true, + }, + { + d: "webauthn with password and two credentials without passwordless key is aal2", + methods: map[CredentialsType]Credentials{ + CredentialsTypePassword: {Type: CredentialsTypePassword}, + CredentialsTypeWebAuthn: {Type: CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{},{}]}`)}, + }, + expected: AuthenticatorAssuranceLevel2, + }, + { + d: "webauthn with two credentials is still aal1 if passwordless", + methods: map[CredentialsType]Credentials{ + CredentialsTypeWebAuthn: {Type: CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":false},{"is_passwordless":true}]}`)}, + }, + expected: AuthenticatorAssuranceLevel1, + webAuthnPasswordless: true, + }, + { + d: "webauthn with two credentials is no aal", + methods: map[CredentialsType]Credentials{ + CredentialsTypeWebAuthn: {Type: CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":false},{"is_passwordless":true}]}`)}, }, expected: NoAuthenticatorAssuranceLevel, }, } { t.Run("case="+tc.d, func(t *testing.T) { - assert.Equal(t, tc.expected, DetermineAAL(tc.methods, tc.passwordless)) + assert.Equal(t, tc.expected, MaximumAAL(tc.methods, &fakeConfig{less: tc.webAuthnPasswordless})) }) } } diff --git a/internal/testhelpers/handler_mock.go b/internal/testhelpers/handler_mock.go index 4bf5cc0bdd1e..da3e59e8f701 100644 --- a/internal/testhelpers/handler_mock.go +++ b/internal/testhelpers/handler_mock.go @@ -33,7 +33,7 @@ func MockSetSession(t *testing.T, reg mockDeps, conf *config.Config) httprouter. i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i)) - activeSession, _ := session.NewActiveSession(i, conf, time.Now().UTC(), identity.CredentialsTypePassword, reg.Config(r.Context()).PasswordlessMethods()) + activeSession, _ := session.NewActiveSession(i, conf, time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) if aal := r.URL.Query().Get("set_aal"); len(aal) > 0 { activeSession.AuthenticatorAssuranceLevel = identity.AuthenticatorAssuranceLevel(aal) } @@ -118,9 +118,9 @@ func MockSessionCreateHandlerWithIdentityAndAMR(t *testing.T, reg mockDeps, i *i sess.ExpiresAt = time.Now().UTC().Add(time.Hour * 24) sess.Active = true for _, method := range methods { - sess.CompletedLoginFor(method) + sess.CompletedLoginFor(method, "") } - sess.SetAuthenticatorAssuranceLevel(nil) + sess.SetAuthenticatorAssuranceLevel() if _, err := reg.Config(context.Background()).DefaultIdentityTraitsSchemaURL(); err != nil { SetDefaultIdentitySchema(reg.Config(context.Background()), "file://./stub/fake-session.schema.json") diff --git a/internal/testhelpers/identity.go b/internal/testhelpers/identity.go index 8d139b9dd30e..133a5eb4121a 100644 --- a/internal/testhelpers/identity.go +++ b/internal/testhelpers/identity.go @@ -17,7 +17,7 @@ func CreateSession(t *testing.T, reg driver.Registry) *session.Session { ctx := context.Background() i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, i)) - sess, err := session.NewActiveSession(i, reg.Config(ctx), time.Now().UTC(), identity.CredentialsTypePassword, reg.Config(context.Background()).PasswordlessMethods()) + sess, err := session.NewActiveSession(i, reg.Config(ctx), time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) require.NoError(t, err) require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess)) return sess diff --git a/internal/testhelpers/session.go b/internal/testhelpers/session.go index a454f56935ba..2e550388c8ca 100644 --- a/internal/testhelpers/session.go +++ b/internal/testhelpers/session.go @@ -114,7 +114,7 @@ func NewHTTPClientWithArbitrarySessionToken(t *testing.T, reg *driver.RegistryDe NewSessionLifespanProvider(time.Hour), time.Now(), identity.CredentialsTypePassword, - reg.Config(context.Background()).PasswordlessMethods(), + identity.AuthenticatorAssuranceLevel1, ) require.NoError(t, err, "Could not initialize session from identity.") @@ -127,7 +127,7 @@ func NewHTTPClientWithArbitrarySessionCookie(t *testing.T, reg *driver.RegistryD NewSessionLifespanProvider(time.Hour), time.Now(), identity.CredentialsTypePassword, - reg.Config(context.Background()).PasswordlessMethods(), + identity.AuthenticatorAssuranceLevel1, ) require.NoError(t, err, "Could not initialize session from identity.") @@ -140,7 +140,7 @@ func NewNoRedirectHTTPClientWithArbitrarySessionCookie(t *testing.T, reg *driver NewSessionLifespanProvider(time.Hour), time.Now(), identity.CredentialsTypePassword, - reg.Config(context.Background()).PasswordlessMethods(), + identity.AuthenticatorAssuranceLevel1, ) require.NoError(t, err, "Could not initialize session from identity.") @@ -152,7 +152,8 @@ func NewHTTPClientWithIdentitySessionCookie(t *testing.T, reg *driver.RegistryDe NewSessionLifespanProvider(time.Hour), time.Now(), identity.CredentialsTypePassword, - reg.Config(context.Background()).PasswordlessMethods()) + identity.AuthenticatorAssuranceLevel1, + ) require.NoError(t, err, "Could not initialize session from identity.") return NewHTTPClientWithSessionCookie(t, reg, s) @@ -163,7 +164,8 @@ func NewHTTPClientWithIdentitySessionToken(t *testing.T, reg *driver.RegistryDef NewSessionLifespanProvider(time.Hour), time.Now(), identity.CredentialsTypePassword, - reg.Config(context.Background()).PasswordlessMethods()) + identity.AuthenticatorAssuranceLevel1, + ) require.NoError(t, err, "Could not initialize session from identity.") return NewHTTPClientWithSessionToken(t, reg, s) diff --git a/persistence/sql/migratest/fixtures/session/7458af86-c1d8-401c-978a-8da89133f78b.json b/persistence/sql/migratest/fixtures/session/7458af86-c1d8-401c-978a-8da89133f78b.json index 6c62ae6a75e0..ba9eb8685b8c 100644 --- a/persistence/sql/migratest/fixtures/session/7458af86-c1d8-401c-978a-8da89133f78b.json +++ b/persistence/sql/migratest/fixtures/session/7458af86-c1d8-401c-978a-8da89133f78b.json @@ -7,10 +7,12 @@ "authentication_methods": [ { "method": "password", + "aal": "", "completed_at": "0001-01-01T00:00:00Z" }, { "method": "totp", + "aal": "", "completed_at": "0001-01-01T00:00:00Z" } ], diff --git a/persistence/sql/migratest/fixtures/session/8571e374-38f2-4f46-8ad3-b9d914e174d3.json b/persistence/sql/migratest/fixtures/session/8571e374-38f2-4f46-8ad3-b9d914e174d3.json index 8f8069314676..781af442a4bb 100644 --- a/persistence/sql/migratest/fixtures/session/8571e374-38f2-4f46-8ad3-b9d914e174d3.json +++ b/persistence/sql/migratest/fixtures/session/8571e374-38f2-4f46-8ad3-b9d914e174d3.json @@ -7,6 +7,7 @@ "authentication_methods": [ { "method": "v0.6_legacy_session", + "aal": "", "completed_at": "0001-01-01T00:00:00Z" } ], diff --git a/persistence/sql/migratest/fixtures/session/dcde5aaa-f789-4d3d-ae1f-76da8d57e67c.json b/persistence/sql/migratest/fixtures/session/dcde5aaa-f789-4d3d-ae1f-76da8d57e67c.json index 95b62a5d8f68..9428633842dc 100644 --- a/persistence/sql/migratest/fixtures/session/dcde5aaa-f789-4d3d-ae1f-76da8d57e67c.json +++ b/persistence/sql/migratest/fixtures/session/dcde5aaa-f789-4d3d-ae1f-76da8d57e67c.json @@ -7,6 +7,7 @@ "authentication_methods": [ { "method": "v0.6_legacy_session", + "aal": "", "completed_at": "0001-01-01T00:00:00Z" } ], diff --git a/persistence/sql/migratest/fixtures/session/f38cdebe-e567-42c9-a562-1bd4dee40998.json b/persistence/sql/migratest/fixtures/session/f38cdebe-e567-42c9-a562-1bd4dee40998.json index ec1a51759fcd..fce5377b160d 100644 --- a/persistence/sql/migratest/fixtures/session/f38cdebe-e567-42c9-a562-1bd4dee40998.json +++ b/persistence/sql/migratest/fixtures/session/f38cdebe-e567-42c9-a562-1bd4dee40998.json @@ -7,6 +7,7 @@ "authentication_methods": [ { "method": "v0.6_legacy_session", + "aal": "", "completed_at": "0001-01-01T00:00:00Z" } ], diff --git a/selfservice/flow/login/handler.go b/selfservice/flow/login/handler.go index 75be27a4700c..46850e0f99e1 100644 --- a/selfservice/flow/login/handler.go +++ b/selfservice/flow/login/handler.go @@ -588,7 +588,8 @@ continueLogin: sess = session.NewInactiveSession() } - sess.CompletedLoginFor(ss.ID()) + method := ss.CompletedAuthenticationMethod(ctx) + sess.CompletedLoginFor(method.Method, method.AAL) i = interim break } diff --git a/selfservice/flow/login/hook.go b/selfservice/flow/login/hook.go index b1a1296a7547..eb8238afa5e5 100644 --- a/selfservice/flow/login/hook.go +++ b/selfservice/flow/login/hook.go @@ -75,7 +75,7 @@ func (e *HookExecutor) requiresAAL2(r *http.Request, s *session.Session, a *Flow } func (e *HookExecutor) PostLoginHook(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity, s *session.Session) error { - if err := s.Activate(i, e.d.Config(r.Context()), time.Now().UTC(), e.d.Config(r.Context()).PasswordlessMethods()); err != nil { + if err := s.Activate(i, e.d.Config(r.Context()), time.Now().UTC()); err != nil { return err } diff --git a/selfservice/flow/login/hook_test.go b/selfservice/flow/login/hook_test.go index 670238c9dcd0..394ef9acb8fc 100644 --- a/selfservice/flow/login/hook_test.go +++ b/selfservice/flow/login/hook_test.go @@ -52,7 +52,7 @@ func TestLoginExecutor(t *testing.T) { a.Active = identity.CredentialsType(strategy) a.RequestURL = x.RequestURL(r).String() sess := session.NewInactiveSession() - sess.CompletedLoginFor(identity.CredentialsTypePassword) + sess.CompletedLoginFor(identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) if useIdentity == nil { useIdentity = testhelpers.SelfServiceHookCreateFakeIdentity(t, reg) } diff --git a/selfservice/flow/login/strategy.go b/selfservice/flow/login/strategy.go index 5669dad600d3..6956cebc3357 100644 --- a/selfservice/flow/login/strategy.go +++ b/selfservice/flow/login/strategy.go @@ -19,6 +19,7 @@ type Strategy interface { RegisterLoginRoutes(*x.RouterPublic) PopulateLoginMethod(r *http.Request, requestedAAL identity.AuthenticatorAssuranceLevel, sr *Flow) error Login(w http.ResponseWriter, r *http.Request, f *Flow, ss *session.Session) (i *identity.Identity, err error) + CompletedAuthenticationMethod(ctx context.Context) session.AuthenticationMethod } type Strategies []Strategy diff --git a/selfservice/flow/recovery/hook_test.go b/selfservice/flow/recovery/hook_test.go index 7ccede8ee571..e4f4cb3a428d 100644 --- a/selfservice/flow/recovery/hook_test.go +++ b/selfservice/flow/recovery/hook_test.go @@ -33,8 +33,13 @@ func TestRecoveryExecutor(t *testing.T) { router.GET("/recovery/post", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { a, err := recovery.NewFlow(conf, time.Minute, x.FakeCSRFToken, r, reg.RecoveryStrategies(context.Background()), ft) require.NoError(t, err) - s, _ := session.NewActiveSession(i, conf, time.Now().UTC(), identity.CredentialsTypeRecoveryLink, - reg.Config(r.Context()).PasswordlessMethods()) + s, _ := session.NewActiveSession( + i, + conf, + time.Now().UTC(), + identity.CredentialsTypeRecoveryLink, + identity.AuthenticatorAssuranceLevel1, + ) a.RequestURL = x.RequestURL(r).String() if testhelpers.SelfServiceHookErrorHandler(t, w, r, recovery.ErrHookAbortFlow, reg.RecoveryExecutor().PostRecoveryHook(w, r, a, s)) { _, _ = w.Write([]byte("ok")) diff --git a/selfservice/flow/registration/hook.go b/selfservice/flow/registration/hook.go index d05400a4cf60..8c043c8ecbea 100644 --- a/selfservice/flow/registration/hook.go +++ b/selfservice/flow/registration/hook.go @@ -142,7 +142,7 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque WithField("identity_id", i.ID). Info("A new identity has registered using self-service registration.") - s, err := session.NewActiveSession(i, e.d.Config(r.Context()), time.Now().UTC(), ct, e.d.Config(r.Context()).PasswordlessMethods()) + s, err := session.NewActiveSession(i, e.d.Config(r.Context()), time.Now().UTC(), ct, identity.AuthenticatorAssuranceLevel1) if err != nil { return err } diff --git a/selfservice/flow/settings/error_test.go b/selfservice/flow/settings/error_test.go index 40470d055e37..c257350fefa2 100644 --- a/selfservice/flow/settings/error_test.go +++ b/selfservice/flow/settings/error_test.go @@ -141,7 +141,7 @@ func TestHandleError(t *testing.T) { t.Cleanup(reset) // This needs an authenticated client in order to call the RouteGetFlow endpoint - s, err := session.NewActiveSession(&id, testhelpers.NewSessionLifespanProvider(time.Hour), time.Now(), identity.CredentialsTypePassword, nil) + s, err := session.NewActiveSession(&id, testhelpers.NewSessionLifespanProvider(time.Hour), time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) require.NoError(t, err) c := testhelpers.NewHTTPClientWithSessionToken(t, reg, s) diff --git a/selfservice/flow/settings/hook_test.go b/selfservice/flow/settings/hook_test.go index 9806a760ccf7..f384d9f9456e 100644 --- a/selfservice/flow/settings/hook_test.go +++ b/selfservice/flow/settings/hook_test.go @@ -46,7 +46,7 @@ func TestSettingsExecutor(t *testing.T) { handleErr := testhelpers.SelfServiceHookSettingsErrorHandler router.GET("/settings/post", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { i := testhelpers.SelfServiceHookCreateFakeIdentity(t, reg) - sess, _ := session.NewActiveSession(i, conf, time.Now().UTC(), identity.CredentialsTypePassword, nil) + sess, _ := session.NewActiveSession(i, conf, time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) a, err := settings.NewFlow(conf, time.Minute, r, sess.Identity, ft) require.NoError(t, err) diff --git a/selfservice/flowhelpers/login.go b/selfservice/flowhelpers/login.go index bfacf285a309..b549bbb20e6f 100644 --- a/selfservice/flowhelpers/login.go +++ b/selfservice/flowhelpers/login.go @@ -13,15 +13,16 @@ func GuessForcedLoginIdentifier(r *http.Request, d interface { identity.PrivilegedPoolProvider }, f interface { IsForced() bool -}, ct identity.CredentialsType) (identifier string) { +}, ct identity.CredentialsType) (identifier string, id *identity.Identity, creds *identity.Credentials) { + var ok bool // This block adds the identifier to the method when the request is forced - as a hint for the user. if !f.IsForced() { // do nothing } else if sess, err := d.SessionManager().FetchFromRequest(r.Context(), r); err != nil { // do nothing - } else if id, err := d.PrivilegedIdentityPool().GetIdentityConfidential(r.Context(), sess.IdentityID); err != nil { + } else if id, err = d.PrivilegedIdentityPool().GetIdentityConfidential(r.Context(), sess.IdentityID); err != nil { // do nothing - } else if creds, ok := id.GetCredentials(ct); !ok { + } else if creds, ok = id.GetCredentials(ct); !ok { // do nothing } else if len(creds.Identifiers) == 0 { // do nothing diff --git a/selfservice/flowhelpers/login_test.go b/selfservice/flowhelpers/login_test.go index a3c50a1a6a82..ac8a59813535 100644 --- a/selfservice/flowhelpers/login_test.go +++ b/selfservice/flowhelpers/login_test.go @@ -22,13 +22,14 @@ func TestGuessForcedLoginIdentifier(t *testing.T) { testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/login.schema.json") i := identity.NewIdentity("") - i.Credentials[identity.CredentialsTypePassword] = identity.Credentials{ + ic := identity.Credentials{ Type: identity.CredentialsTypePassword, Identifiers: []string{"foobar"}, } + i.Credentials[identity.CredentialsTypePassword] = ic require.NoError(t, reg.IdentityManager().Create(context.Background(), i)) - sess, err := session.NewActiveSession(i, conf, time.Now(), identity.CredentialsTypePassword) + sess, err := session.NewActiveSession(i, conf, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) require.NoError(t, err) reg.SessionPersister().UpsertSession(context.Background(), sess) @@ -38,5 +39,9 @@ func TestGuessForcedLoginIdentifier(t *testing.T) { var f login.Flow f.Refresh = true - assert.Equal(t, "foobar", flowhelpers.GuessForcedLoginIdentifier(r, reg, &f, identity.CredentialsTypePassword)) + identifier, id, creds := flowhelpers.GuessForcedLoginIdentifier(r, reg, &f, identity.CredentialsTypePassword) + assert.Equal(t, "foobar", identifier) + assert.EqualValues(t, ic.Type, creds.Type) + assert.EqualValues(t, ic.Identifiers, creds.Identifiers) + assert.EqualValues(t, id.ID, id.ID) } diff --git a/selfservice/strategy/link/strategy_recovery.go b/selfservice/strategy/link/strategy_recovery.go index c177fce88068..5afa45235d7f 100644 --- a/selfservice/strategy/link/strategy_recovery.go +++ b/selfservice/strategy/link/strategy_recovery.go @@ -263,8 +263,7 @@ func (s *Strategy) recoveryIssueSession(w http.ResponseWriter, r *http.Request, return s.retryRecoveryFlowWithError(w, r, flow.TypeBrowser, err) } - sess, err := session.NewActiveSession(id, s.d.Config(r.Context()), time.Now().UTC(), identity.CredentialsTypeRecoveryLink, - s.d.Config(r.Context()).PasswordlessMethods()) + sess, err := session.NewActiveSession(id, s.d.Config(r.Context()), time.Now().UTC(), identity.CredentialsTypeRecoveryLink, identity.AuthenticatorAssuranceLevel1) if err != nil { return s.retryRecoveryFlowWithError(w, r, flow.TypeBrowser, err) } diff --git a/selfservice/strategy/lookup/settings.go b/selfservice/strategy/lookup/settings.go index 370dd1e67b5d..27b1095d49d0 100644 --- a/selfservice/strategy/lookup/settings.go +++ b/selfservice/strategy/lookup/settings.go @@ -304,7 +304,10 @@ func (s *Strategy) continueSettingsFlowConfirm(w http.ResponseWriter, r *http.Re } // Since we added the method, it also means that we have authenticated it - if err := s.d.SessionManager().SessionAddAuthenticationMethod(r.Context(), ctxUpdate.Session.ID, s.ID()); err != nil { + if err := s.d.SessionManager().SessionAddAuthenticationMethods(r.Context(), ctxUpdate.Session.ID, session.AuthenticationMethod{ + Method: s.ID(), + AAL: identity.AuthenticatorAssuranceLevel2, + }); err != nil { return err } diff --git a/selfservice/strategy/lookup/strategy.go b/selfservice/strategy/lookup/strategy.go index b2a100a9122a..30ab83bf4a7c 100644 --- a/selfservice/strategy/lookup/strategy.go +++ b/selfservice/strategy/lookup/strategy.go @@ -1,6 +1,7 @@ package lookup import ( + "context" "encoding/json" "github.com/pkg/errors" @@ -99,3 +100,10 @@ func (s *Strategy) ID() identity.CredentialsType { func (s *Strategy) NodeGroup() node.Group { return node.LookupGroup } + +func (s *Strategy) CompletedAuthenticationMethod(ctx context.Context) session.AuthenticationMethod { + return session.AuthenticationMethod{ + Method: s.ID(), + AAL: identity.AuthenticatorAssuranceLevel2, + } +} diff --git a/selfservice/strategy/oidc/strategy.go b/selfservice/strategy/oidc/strategy.go index 20ecb916ab57..b538adfa1209 100644 --- a/selfservice/strategy/oidc/strategy.go +++ b/selfservice/strategy/oidc/strategy.go @@ -460,3 +460,10 @@ func (s *Strategy) handleError(w http.ResponseWriter, r *http.Request, f flow.Fl func (s *Strategy) NodeGroup() node.Group { return node.OpenIDConnectGroup } + +func (s *Strategy) CompletedAuthenticationMethod(ctx context.Context) session.AuthenticationMethod { + return session.AuthenticationMethod{ + Method: s.ID(), + AAL: identity.AuthenticatorAssuranceLevel1, + } +} diff --git a/selfservice/strategy/oidc/strategy_login.go b/selfservice/strategy/oidc/strategy_login.go index a1bbdacbcb6b..8ddc7542203b 100644 --- a/selfservice/strategy/oidc/strategy_login.go +++ b/selfservice/strategy/oidc/strategy_login.go @@ -110,7 +110,7 @@ func (s *Strategy) processLogin(w http.ResponseWriter, r *http.Request, a *login } sess := session.NewInactiveSession() - sess.CompletedLoginFor(s.ID()) + sess.CompletedLoginFor(s.ID(), identity.AuthenticatorAssuranceLevel1) for _, c := range o.Providers { if c.Subject == claims.Subject && c.Provider == provider.Config().ID { if err = s.d.LoginHookExecutor().PostLoginHook(w, r, a, i, sess); err != nil { diff --git a/selfservice/strategy/password/login.go b/selfservice/strategy/password/login.go index b2a534a13b7c..6bf61c65f077 100644 --- a/selfservice/strategy/password/login.go +++ b/selfservice/strategy/password/login.go @@ -132,14 +132,17 @@ func (s *Strategy) PopulateLoginMethod(r *http.Request, requestedAAL identity.Au if sr.IsForced() { // We only show this method on a refresh request if the user has indeed a password set. identifier, id, _ := flowhelpers.GuessForcedLoginIdentifier(r, s.d, sr, s.ID()) + if identifier == "" { + return nil + } + count, err := s.CountActiveFirstFactorCredentials(id.Credentials) if err != nil { return err - } else if identifier == "" { - return nil } else if count == 0 { return nil } + sr.UI.SetCSRF(s.d.GenerateCSRFToken(r)) sr.UI.SetNode(node.NewInputField("identifier", identifier, node.DefaultGroup, node.InputAttributeTypeHidden)) } else { diff --git a/selfservice/strategy/password/strategy.go b/selfservice/strategy/password/strategy.go index 392039db62b8..dc5e24df7738 100644 --- a/selfservice/strategy/password/strategy.go +++ b/selfservice/strategy/password/strategy.go @@ -1,6 +1,7 @@ package password import ( + "context" "encoding/json" "github.com/ory/kratos/ui/node" @@ -99,6 +100,13 @@ func (s *Strategy) ID() identity.CredentialsType { return identity.CredentialsTypePassword } +func (s *Strategy) CompletedAuthenticationMethod(ctx context.Context) session.AuthenticationMethod { + return session.AuthenticationMethod{ + Method: s.ID(), + AAL: identity.AuthenticatorAssuranceLevel1, + } +} + func (s *Strategy) NodeGroup() node.Group { return node.PasswordGroup } diff --git a/selfservice/strategy/totp/.snapshots/TestCompleteSettings-case=device_setup_is_available_when_identity_has_no_totp_yet.json b/selfservice/strategy/totp/.snapshots/TestCompleteSettings-case=device_setup_is_available_when_identity_has_no_totp_yet.json index 3425cab72aa0..29d17cc83cd3 100644 --- a/selfservice/strategy/totp/.snapshots/TestCompleteSettings-case=device_setup_is_available_when_identity_has_no_totp_yet.json +++ b/selfservice/strategy/totp/.snapshots/TestCompleteSettings-case=device_setup_is_available_when_identity_has_no_totp_yet.json @@ -45,7 +45,7 @@ "messages": [], "meta": { "label": { - "id": 1050006, + "id": 1050017, "text": "This is your authenticator app secret. Use it if you can not scan the QR code.", "type": "info" } diff --git a/selfservice/strategy/totp/settings.go b/selfservice/strategy/totp/settings.go index 09ebd3a87a67..eb314c0a1216 100644 --- a/selfservice/strategy/totp/settings.go +++ b/selfservice/strategy/totp/settings.go @@ -207,7 +207,10 @@ func (s *Strategy) continueSettingsFlowAddTOTP(w http.ResponseWriter, r *http.Re } // Since we added the method, it also means that we have authenticated it - if err := s.d.SessionManager().SessionAddAuthenticationMethod(r.Context(), ctxUpdate.Session.ID, s.ID()); err != nil { + if err := s.d.SessionManager().SessionAddAuthenticationMethods(r.Context(), ctxUpdate.Session.ID, session.AuthenticationMethod{ + Method: s.ID(), + AAL: identity.AuthenticatorAssuranceLevel2, + }); err != nil { return nil, err } diff --git a/selfservice/strategy/totp/strategy.go b/selfservice/strategy/totp/strategy.go index 8343b722bdd1..c54b66938975 100644 --- a/selfservice/strategy/totp/strategy.go +++ b/selfservice/strategy/totp/strategy.go @@ -1,6 +1,7 @@ package totp import ( + "context" "encoding/json" "github.com/pkg/errors" @@ -102,3 +103,10 @@ func (s *Strategy) ID() identity.CredentialsType { func (s *Strategy) NodeGroup() node.Group { return node.TOTPGroup } + +func (s *Strategy) CompletedAuthenticationMethod(ctx context.Context) session.AuthenticationMethod { + return session.AuthenticationMethod{ + Method: s.ID(), + AAL: identity.AuthenticatorAssuranceLevel2, + } +} diff --git a/session/manager.go b/session/manager.go index 696bd434c345..d46307e44e43 100644 --- a/session/manager.go +++ b/session/manager.go @@ -9,8 +9,6 @@ import ( "github.com/gofrs/uuid" - "github.com/ory/kratos/identity" - "github.com/ory/herodot" ) @@ -102,8 +100,8 @@ type Manager interface { // DoesSessionSatisfy answers if a session is satisfying the AAL. DoesSessionSatisfy(r *http.Request, sess *Session, requestedAAL string) error - // SessionAddAuthenticationMethod adds one or more authentication method to the session. - SessionAddAuthenticationMethod(ctx context.Context, sid uuid.UUID, method ...identity.CredentialsType) error + // SessionAddAuthenticationMethods adds one or more authentication method to the session. + SessionAddAuthenticationMethods(ctx context.Context, sid uuid.UUID, methods ...AuthenticationMethod) error } type ManagementProvider interface { diff --git a/session/manager_http.go b/session/manager_http.go index b3e20dc3a595..3f5bddd44fd9 100644 --- a/session/manager_http.go +++ b/session/manager_http.go @@ -174,7 +174,7 @@ func (s *ManagerHTTP) PurgeFromRequest(ctx context.Context, w http.ResponseWrite } func (s *ManagerHTTP) DoesSessionSatisfy(r *http.Request, sess *Session, requestedAAL string) error { - sess.SetAuthenticatorAssuranceLevel(s.r.Config(r.Context()).PasswordlessMethods()) + sess.SetAuthenticatorAssuranceLevel() switch requestedAAL { case string(identity.AuthenticatorAssuranceLevel1): if sess.AuthenticatorAssuranceLevel >= identity.AuthenticatorAssuranceLevel1 { @@ -186,12 +186,7 @@ func (s *ManagerHTTP) DoesSessionSatisfy(r *http.Request, sess *Session, request return err } - hasCredentials := make([]identity.CredentialsType, 0) - for ct := range i.Credentials { - hasCredentials = append(hasCredentials, ct) - } - - available := identity.DetermineAAL(hasCredentials, s.r.Config(r.Context()).PasswordlessMethods()) + available := identity.MaximumAAL(i.Credentials, s.r.Config(r.Context())) if sess.AuthenticatorAssuranceLevel >= available { return nil } @@ -202,15 +197,15 @@ func (s *ManagerHTTP) DoesSessionSatisfy(r *http.Request, sess *Session, request return errors.Errorf("requested unknown aal: %s", requestedAAL) } -func (s *ManagerHTTP) SessionAddAuthenticationMethod(ctx context.Context, sid uuid.UUID, methods ...identity.CredentialsType) error { +func (s *ManagerHTTP) SessionAddAuthenticationMethods(ctx context.Context, sid uuid.UUID, ams ...AuthenticationMethod) error { // Since we added the method, it also means that we have authenticated it sess, err := s.r.SessionPersister().GetSession(ctx, sid) if err != nil { return err } - for _, m := range methods { - sess.CompletedLoginFor(m) + for _, m := range ams { + sess.CompletedLoginFor(m.Method, m.AAL) } - sess.SetAuthenticatorAssuranceLevel(s.r.Config(ctx).PasswordlessMethods()) + sess.SetAuthenticatorAssuranceLevel() return s.r.SessionPersister().UpsertSession(ctx, sess) } diff --git a/session/manager_http_test.go b/session/manager_http_test.go index 5a70bbe9e600..86ed7c64615d 100644 --- a/session/manager_http_test.go +++ b/session/manager_http_test.go @@ -144,9 +144,17 @@ func TestManagerHTTP(t *testing.T) { i := &identity.Identity{Traits: []byte("{}"), State: identity.StateActive} require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i)) sess := session.NewInactiveSession() - require.NoError(t, sess.Activate(i, conf, time.Now(), nil)) + require.NoError(t, sess.Activate(i, conf, time.Now())) require.NoError(t, reg.SessionPersister().UpsertSession(context.Background(), sess)) - require.NoError(t, reg.SessionManager().SessionAddAuthenticationMethod(context.Background(), sess.ID, identity.CredentialsTypeOIDC, identity.CredentialsTypeWebAuthn)) + require.NoError(t, reg.SessionManager().SessionAddAuthenticationMethods(context.Background(), sess.ID, + session.AuthenticationMethod{ + Method: identity.CredentialsTypeOIDC, + AAL: identity.AuthenticatorAssuranceLevel1, + }, + session.AuthenticationMethod{ + Method: identity.CredentialsTypeWebAuthn, + AAL: identity.AuthenticatorAssuranceLevel2, + })) assert.Len(t, sess.AMR, 0) actual, err := reg.SessionPersister().GetSession(context.Background(), sess.ID) @@ -195,7 +203,7 @@ func TestManagerHTTP(t *testing.T) { i := identity.Identity{Traits: []byte("{}")} require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &i)) - s, _ = session.NewActiveSession(&i, conf, time.Now(), identity.CredentialsTypePassword, nil) + s, _ = session.NewActiveSession(&i, conf, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) c := testhelpers.NewClientWithCookies(t) testhelpers.MockHydrateCookieClient(t, c, pts.URL+"/session/set") @@ -215,7 +223,7 @@ func TestManagerHTTP(t *testing.T) { i := identity.Identity{Traits: []byte("{}")} require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &i)) - s, _ = session.NewActiveSession(&i, conf, time.Now(), identity.CredentialsTypePassword, nil) + s, _ = session.NewActiveSession(&i, conf, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) c := testhelpers.NewClientWithCookies(t) testhelpers.MockHydrateCookieClient(t, c, pts.URL+"/session/set") @@ -244,7 +252,7 @@ func TestManagerHTTP(t *testing.T) { i := identity.Identity{Traits: []byte("{}")} require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &i)) - s, _ = session.NewActiveSession(&i, conf, time.Now(), identity.CredentialsTypePassword, nil) + s, _ = session.NewActiveSession(&i, conf, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) c := testhelpers.NewClientWithCookies(t) res, err := c.Get(pts.URL + "/session/set/invalid") @@ -257,7 +265,7 @@ func TestManagerHTTP(t *testing.T) { i := identity.Identity{Traits: []byte("{}")} require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &i)) - s, _ = session.NewActiveSession(&i, conf, time.Now(), identity.CredentialsTypePassword, nil) + s, _ = session.NewActiveSession(&i, conf, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) c := testhelpers.NewClientWithCookies(t) testhelpers.MockHydrateCookieClient(t, c, pts.URL+"/session/set") @@ -290,7 +298,7 @@ func TestManagerHTTP(t *testing.T) { i := identity.Identity{Traits: []byte("{}"), State: identity.StateActive} require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &i)) - s, err := session.NewActiveSession(&i, conf, time.Now(), identity.CredentialsTypePassword, nil) + s, err := session.NewActiveSession(&i, conf, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) require.NoError(t, err) require.NoError(t, reg.SessionPersister().UpsertSession(context.Background(), s)) require.NotEmpty(t, s.Token) @@ -310,7 +318,7 @@ func TestManagerHTTP(t *testing.T) { i := identity.Identity{Traits: []byte("{}"), State: identity.StateActive} require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &i)) - s, err := session.NewActiveSession(&i, conf, time.Now(), identity.CredentialsTypePassword, nil) + s, err := session.NewActiveSession(&i, conf, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) require.NoError(t, err) require.NoError(t, reg.SessionPersister().UpsertSession(context.Background(), s)) @@ -333,7 +341,7 @@ func TestManagerHTTP(t *testing.T) { i := identity.Identity{Traits: []byte("{}")} require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &i)) - s, _ = session.NewActiveSession(&i, conf, time.Now(), identity.CredentialsTypePassword, nil) + s, _ = session.NewActiveSession(&i, conf, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) c := testhelpers.NewClientWithCookies(t) testhelpers.MockHydrateCookieClient(t, c, pts.URL+"/session/set") @@ -348,9 +356,9 @@ func TestManagerHTTP(t *testing.T) { t.Run("case=revoked", func(t *testing.T) { i := identity.Identity{Traits: []byte("{}")} require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &i)) - s, _ = session.NewActiveSession(&i, conf, time.Now(), identity.CredentialsTypePassword, nil) + s, _ = session.NewActiveSession(&i, conf, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) - s, _ = session.NewActiveSession(&i, conf, time.Now(), identity.CredentialsTypePassword, nil) + s, _ = session.NewActiveSession(&i, conf, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) c := testhelpers.NewClientWithCookies(t) testhelpers.MockHydrateCookieClient(t, c, pts.URL+"/session/set") @@ -376,9 +384,9 @@ func TestManagerHTTP(t *testing.T) { run := func(t *testing.T, complete []identity.CredentialsType, requested string, i *identity.Identity, expectedError error) { s := session.NewInactiveSession() for _, m := range complete { - s.CompletedLoginFor(m) + s.CompletedLoginFor(m, "") } - require.NoError(t, s.Activate(i, conf, time.Now().UTC(), nil)) + require.NoError(t, s.Activate(i, conf, time.Now().UTC())) err := reg.SessionManager().DoesSessionSatisfy((&http.Request{}).WithContext(context.Background()), s, requested) if expectedError != nil { require.ErrorAs(t, err, &expectedError) diff --git a/session/session.go b/session/session.go index e7c93473e178..e1893edf88e1 100644 --- a/session/session.go +++ b/session/session.go @@ -96,24 +96,48 @@ func (s Session) TableName(ctx context.Context) string { return corp.ContextualizeTableName(ctx, "sessions") } -func (s *Session) CompletedLoginFor(method identity.CredentialsType) { - s.AMR = append(s.AMR, - AuthenticationMethod{Method: method, CompletedAt: time.Now().UTC()}) +func (s *Session) CompletedLoginFor(method identity.CredentialsType, aal identity.AuthenticatorAssuranceLevel) { + s.AMR = append(s.AMR, AuthenticationMethod{Method: method, AAL: aal, CompletedAt: time.Now().UTC()}) } -func (s *Session) SetAuthenticatorAssuranceLevel(passwordlessMethods []string) { - cts := make([]identity.CredentialsType, len(s.AMR)) - for k := range s.AMR { - cts[k] = s.AMR[k].Method +func (s *Session) SetAuthenticatorAssuranceLevel() { + if len(s.AMR) == 0 { + // No AMR is set + s.AuthenticatorAssuranceLevel = identity.NoAuthenticatorAssuranceLevel } - s.AuthenticatorAssuranceLevel = identity.DetermineAAL(cts, passwordlessMethods) + var isAAL1, isAAL2 bool + for _, amr := range s.AMR { + switch amr.AAL { + case identity.AuthenticatorAssuranceLevel1: + isAAL1 = true + case identity.AuthenticatorAssuranceLevel2: + isAAL2 = true + case "": + // Empty means that we are migrating an old session. In this case, AAL is not set. + isAAL1 = isAAL1 || identity.IsCredentialAAL1(identity.Credentials{Type: amr.Method}, false) + + // AAL is empty for sessions which have been created before passwordless. Thus, + // all credentials that can today be used for passwordless were used for MFA + // before. For this reason, we just assume the default (not passwordless) here. + isAAL2 = isAAL2 || identity.IsCredentialAAL2(identity.Credentials{Type: amr.Method}, false) + } + } + + if isAAL1 && isAAL2 { + s.AuthenticatorAssuranceLevel = identity.AuthenticatorAssuranceLevel2 + } else if isAAL1 { + s.AuthenticatorAssuranceLevel = identity.AuthenticatorAssuranceLevel1 + } else if len(s.AMR) > 0 { + // A fallback. If an AMR is set but we did not satisfy the above, gracefully fall back to level 1. + s.AuthenticatorAssuranceLevel = identity.AuthenticatorAssuranceLevel1 + } } -func NewActiveSession(i *identity.Identity, c lifespanProvider, authenticatedAt time.Time, completedLoginFor identity.CredentialsType, passwordlessMethods []string) (*Session, error) { +func NewActiveSession(i *identity.Identity, c lifespanProvider, authenticatedAt time.Time, completedLoginFor identity.CredentialsType, completedLoginAAL identity.AuthenticatorAssuranceLevel) (*Session, error) { s := NewInactiveSession() - s.CompletedLoginFor(completedLoginFor) - if err := s.Activate(i, c, authenticatedAt, passwordlessMethods); err != nil { + s.CompletedLoginFor(completedLoginFor, completedLoginAAL) + if err := s.Activate(i, c, authenticatedAt); err != nil { return nil, err } return s, nil @@ -129,7 +153,7 @@ func NewInactiveSession() *Session { } } -func (s *Session) Activate(i *identity.Identity, c lifespanProvider, authenticatedAt time.Time, passwordlessMethods []string) error { +func (s *Session) Activate(i *identity.Identity, c lifespanProvider, authenticatedAt time.Time) error { if i != nil && !i.IsActive() { return ErrIdentityDisabled } @@ -141,7 +165,7 @@ func (s *Session) Activate(i *identity.Identity, c lifespanProvider, authenticat s.Identity = i s.IdentityID = i.ID - s.SetAuthenticatorAssuranceLevel(passwordlessMethods) + s.SetAuthenticatorAssuranceLevel() return nil } @@ -176,6 +200,9 @@ type AuthenticationMethod struct { // The method used in this authenticator. Method identity.CredentialsType `json:"method"` + // The AAL this method introduced. + AAL identity.AuthenticatorAssuranceLevel `json:"aal"` + // When the authentication challenge was completed. CompletedAt time.Time `json:"completed_at"` } diff --git a/session/session_test.go b/session/session_test.go index e5c054bd9c16..977925f0041f 100644 --- a/session/session_test.go +++ b/session/session_test.go @@ -1,6 +1,7 @@ package session_test import ( + "fmt" "testing" "time" @@ -20,14 +21,14 @@ func TestSession(t *testing.T) { t.Run("case=active session", func(t *testing.T) { i := new(identity.Identity) i.State = identity.StateActive - s, _ := session.NewActiveSession(i, conf, authAt, identity.CredentialsTypePassword, nil) + s, _ := session.NewActiveSession(i, conf, authAt, identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) assert.True(t, s.IsActive()) require.NotEmpty(t, s.Token) require.NotEmpty(t, s.LogoutToken) assert.EqualValues(t, identity.CredentialsTypePassword, s.AMR[0].Method) i = new(identity.Identity) - s, err := session.NewActiveSession(i, conf, authAt, identity.CredentialsTypePassword, nil) + s, err := session.NewActiveSession(i, conf, authAt, identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) assert.Nil(t, s) assert.ErrorIs(t, err, session.ErrIdentityDisabled) }) @@ -39,134 +40,154 @@ func TestSession(t *testing.T) { t.Run("case=amr", func(t *testing.T) { s := session.NewInactiveSession() - s.CompletedLoginFor(identity.CredentialsTypeOIDC) + s.CompletedLoginFor(identity.CredentialsTypeOIDC, identity.AuthenticatorAssuranceLevel1) assert.EqualValues(t, identity.CredentialsTypeOIDC, s.AMR[0].Method) - s.CompletedLoginFor(identity.CredentialsTypeRecoveryLink) + s.CompletedLoginFor(identity.CredentialsTypeRecoveryLink, identity.AuthenticatorAssuranceLevel1) assert.EqualValues(t, identity.CredentialsTypeOIDC, s.AMR[0].Method) assert.EqualValues(t, identity.CredentialsTypeRecoveryLink, s.AMR[1].Method) }) t.Run("case=activate", func(t *testing.T) { s := session.NewInactiveSession() - require.NoError(t, s.Activate(&identity.Identity{State: identity.StateActive}, conf, authAt, nil)) + require.NoError(t, s.Activate(&identity.Identity{State: identity.StateActive}, conf, authAt)) assert.True(t, s.Active) assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, s.AuthenticatorAssuranceLevel) assert.Equal(t, authAt, s.AuthenticatedAt) s = session.NewInactiveSession() - require.ErrorIs(t, s.Activate(&identity.Identity{State: identity.StateInactive}, conf, authAt, nil), session.ErrIdentityDisabled) + require.ErrorIs(t, s.Activate(&identity.Identity{State: identity.StateInactive}, conf, authAt), session.ErrIdentityDisabled) assert.False(t, s.Active) assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, s.AuthenticatorAssuranceLevel) assert.Empty(t, s.AuthenticatedAt) }) - t.Run("case=aal", func(t *testing.T) { - for _, tc := range []struct { - d string - methods []identity.CredentialsType - passwordless []string - expected identity.AuthenticatorAssuranceLevel - }{ - { - d: "no amr means no assurance", - expected: identity.NoAuthenticatorAssuranceLevel, + for k, tc := range []struct { + d string + methods []session.AuthenticationMethod + expected identity.AuthenticatorAssuranceLevel + }{ + { + d: "no amr means no assurance", + expected: identity.NoAuthenticatorAssuranceLevel, + }, + { + d: "password is aal1", + methods: []session.AuthenticationMethod{{Method: identity.CredentialsTypePassword}}, + expected: identity.AuthenticatorAssuranceLevel1, + }, + { + d: "oidc is aal1", + methods: []session.AuthenticationMethod{{Method: identity.CredentialsTypeOIDC}}, + expected: identity.AuthenticatorAssuranceLevel1, + }, + { + d: "recovery is aal1", + methods: []session.AuthenticationMethod{{Method: identity.CredentialsTypeRecoveryLink}}, + expected: identity.AuthenticatorAssuranceLevel1, + }, + { + d: "mix of password, oidc, recovery is still aal1", + methods: []session.AuthenticationMethod{ + {Method: identity.CredentialsTypeRecoveryLink}, + {Method: identity.CredentialsTypeOIDC}, + {Method: identity.CredentialsTypePassword}, }, - { - d: "password is aal1", - methods: []identity.CredentialsType{identity.CredentialsTypePassword}, - expected: identity.AuthenticatorAssuranceLevel1, + expected: identity.AuthenticatorAssuranceLevel1, + }, + { + d: "just totp is gracefully aal1", + methods: []session.AuthenticationMethod{{Method: identity.CredentialsTypeTOTP}}, + expected: identity.AuthenticatorAssuranceLevel1, + }, + { + d: "password + totp is aal2", + methods: []session.AuthenticationMethod{ + {Method: identity.CredentialsTypePassword}, + {Method: identity.CredentialsTypeTOTP}, }, - { - d: "oidc is aal1", - methods: []identity.CredentialsType{identity.CredentialsTypeOIDC}, - expected: identity.AuthenticatorAssuranceLevel1, + expected: identity.AuthenticatorAssuranceLevel2, + }, + { + d: "password + lookup is aal2", + methods: []session.AuthenticationMethod{ + {Method: identity.CredentialsTypePassword}, + {Method: identity.CredentialsTypeLookup}, }, - { - d: "recovery is aal1", - methods: []identity.CredentialsType{identity.CredentialsTypeRecoveryLink}, - expected: identity.AuthenticatorAssuranceLevel1, + expected: identity.AuthenticatorAssuranceLevel2, + }, + { + d: "oidc + totp is aal2", + methods: []session.AuthenticationMethod{ + {Method: identity.CredentialsTypeOIDC}, + {Method: identity.CredentialsTypeTOTP}, }, - { - d: "mix of password, oidc, recovery is still aal1", - methods: []identity.CredentialsType{ - identity.CredentialsTypeRecoveryLink, identity.CredentialsTypeOIDC, identity.CredentialsTypePassword, - }, - expected: identity.AuthenticatorAssuranceLevel1, + expected: identity.AuthenticatorAssuranceLevel2, + }, + { + d: "oidc + lookup is aal2", + methods: []session.AuthenticationMethod{ + {Method: identity.CredentialsTypeOIDC}, + {Method: identity.CredentialsTypeLookup}, }, - { - d: "just totp is aal0", - methods: []identity.CredentialsType{ - identity.CredentialsTypeTOTP, - }, - expected: identity.NoAuthenticatorAssuranceLevel, + expected: identity.AuthenticatorAssuranceLevel2, + }, + { + d: "recovery link + totp is aal2", + methods: []session.AuthenticationMethod{ + {Method: identity.CredentialsTypeRecoveryLink}, + {Method: identity.CredentialsTypeTOTP}, }, - { - d: "password + totp is aal2", - methods: []identity.CredentialsType{ - identity.CredentialsTypePassword, - identity.CredentialsTypeTOTP, - }, - expected: identity.AuthenticatorAssuranceLevel2, + expected: identity.AuthenticatorAssuranceLevel2, + }, + { + d: "recovery link + lookup is aal2", + methods: []session.AuthenticationMethod{ + {Method: identity.CredentialsTypeRecoveryLink}, + {Method: identity.CredentialsTypeLookup}, }, - { - d: "password + lookup is aal2", - methods: []identity.CredentialsType{ - identity.CredentialsTypePassword, - identity.CredentialsTypeLookup, - }, - expected: identity.AuthenticatorAssuranceLevel2, + expected: identity.AuthenticatorAssuranceLevel2, + }, + { + d: "recovery link + passwordless webauth is aal1", + methods: []session.AuthenticationMethod{ + {Method: identity.CredentialsTypeRecoveryLink}, + {Method: identity.CredentialsTypeWebAuthn, AAL: identity.AuthenticatorAssuranceLevel1}, }, - { - d: "oidc + totp is aal2", - methods: []identity.CredentialsType{ - identity.CredentialsTypeOIDC, - identity.CredentialsTypeTOTP, - }, - expected: identity.AuthenticatorAssuranceLevel2, + expected: identity.AuthenticatorAssuranceLevel1, + }, + { + d: "respects AAL on AAL1", + methods: []session.AuthenticationMethod{ + {Method: identity.CredentialsTypePassword, AAL: identity.AuthenticatorAssuranceLevel1}, + {Method: identity.CredentialsTypeWebAuthn, AAL: identity.AuthenticatorAssuranceLevel1}, }, - { - d: "oidc + lookup is aal2", - methods: []identity.CredentialsType{ - identity.CredentialsTypeOIDC, - identity.CredentialsTypeLookup, - }, - expected: identity.AuthenticatorAssuranceLevel2, + expected: identity.AuthenticatorAssuranceLevel1, + }, + { + d: "respects AAL on AAL2 without AAL1", + methods: []session.AuthenticationMethod{ + {Method: identity.CredentialsTypePassword, AAL: identity.AuthenticatorAssuranceLevel2}, + {Method: identity.CredentialsTypeWebAuthn, AAL: identity.AuthenticatorAssuranceLevel2}, }, - { - d: "recovery link + totp is aal2", - methods: []identity.CredentialsType{ - identity.CredentialsTypeRecoveryLink, - identity.CredentialsTypeTOTP, - }, - expected: identity.AuthenticatorAssuranceLevel2, + expected: identity.AuthenticatorAssuranceLevel1, + }, + { + d: "respects AAL on AAL2", + methods: []session.AuthenticationMethod{ + {Method: identity.CredentialsTypePassword, AAL: identity.AuthenticatorAssuranceLevel1}, + {Method: identity.CredentialsTypeWebAuthn, AAL: identity.AuthenticatorAssuranceLevel2}, }, - { - d: "recovery link + lookup is aal2", - methods: []identity.CredentialsType{ - identity.CredentialsTypeRecoveryLink, - identity.CredentialsTypeLookup, - }, - expected: identity.AuthenticatorAssuranceLevel2, - }, - { - d: "recovery link + passwordless webauth is aal1", - methods: []identity.CredentialsType{ - identity.CredentialsTypeRecoveryLink, - identity.CredentialsTypeWebAuthn, - }, - passwordless: []string{identity.CredentialsTypeWebAuthn.String()}, - expected: identity.AuthenticatorAssuranceLevel1, - }, - } { - t.Run("case="+tc.d, func(t *testing.T) { - s := session.NewInactiveSession() - for _, m := range tc.methods { - s.CompletedLoginFor(m) - } + expected: identity.AuthenticatorAssuranceLevel2, + }, + } { + t.Run(fmt.Sprintf("case=%d/description=%s", k, tc.d), func(t *testing.T) { + s := session.NewInactiveSession() + for _, m := range tc.methods { + s.CompletedLoginFor(m.Method, m.AAL) + } - s.SetAuthenticatorAssuranceLevel(tc.passwordless) - assert.Equal(t, tc.expected, s.AuthenticatorAssuranceLevel) - }) - } - }) + s.SetAuthenticatorAssuranceLevel() + assert.Equal(t, tc.expected, s.AuthenticatorAssuranceLevel) + }) + } }