From 86af67d1be2bb50852b742d5801cac349f2a0318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Fri, 11 Aug 2023 18:36:18 +0300 Subject: [PATCH] feat(api/v2): implement U2F session check (#6339) --- .gitignore | 1 - Makefile | 7 +- internal/api/grpc/session/v2/session.go | 57 ++-- .../session/v2/session_integration_test.go | 133 +++++++--- internal/api/grpc/session/v2/session_test.go | 51 +++- .../api/grpc/user/v2/otp_integration_test.go | 14 +- .../api/oidc/auth_request_integration_test.go | 24 +- internal/api/oidc/client_integration_test.go | 4 +- internal/api/oidc/oidc_integration_test.go | 9 +- .../eventstore/token_verifier.go | 11 +- internal/command/auth_request_test.go | 8 +- internal/command/oidc_session_test.go | 4 +- internal/command/session.go | 29 +- internal/command/session_model.go | 71 +++-- internal/command/session_model_test.go | 75 ++++++ internal/command/session_passkey.go | 84 ------ internal/command/session_passkeys_test.go | 131 --------- internal/command/session_test.go | 50 +--- internal/command/session_webauhtn.go | 89 +++++++ internal/command/session_webauthn_test.go | 250 ++++++++++++++++++ internal/command/user_converter.go | 14 +- internal/command/user_human_webauthn.go | 12 +- internal/command/user_human_webauthn_model.go | 14 + internal/integration/client.go | 33 ++- internal/query/projection/session.go | 53 ++-- internal/query/projection/session_test.go | 53 +++- internal/query/session.go | 86 +++--- internal/query/sessions_test.go | 114 ++++---- internal/repository/session/eventstore.go | 4 +- internal/repository/session/session.go | 66 ++--- internal/static/i18n/bg.yaml | 4 +- internal/static/i18n/de.yaml | 4 +- internal/static/i18n/en.yaml | 4 +- internal/static/i18n/es.yaml | 4 +- internal/static/i18n/fr.yaml | 4 +- internal/static/i18n/it.yaml | 4 +- internal/static/i18n/ja.yaml | 4 +- internal/static/i18n/mk.yaml | 4 +- internal/static/i18n/pl.yaml | 4 +- internal/static/i18n/pt.yaml | 4 +- internal/static/i18n/zh.yaml | 4 +- internal/webauthn/client.go | 24 +- internal/webauthn/webauthn.go | 12 +- proto/buf.yaml | 1 + proto/zitadel/session/v2alpha/challenge.proto | 39 ++- proto/zitadel/session/v2alpha/session.proto | 10 +- .../session/v2alpha/session_service.proto | 19 +- 47 files changed, 1035 insertions(+), 665 deletions(-) create mode 100644 internal/command/session_model_test.go delete mode 100644 internal/command/session_passkey.go delete mode 100644 internal/command/session_passkeys_test.go create mode 100644 internal/command/session_webauhtn.go create mode 100644 internal/command/session_webauthn_test.go diff --git a/.gitignore b/.gitignore index 1ed24a2345..db17a6ead4 100644 --- a/.gitignore +++ b/.gitignore @@ -64,7 +64,6 @@ docs/docs/apis/proto **/.sass-cache /internal/api/ui/login/static/resources/themes/zitadel/css/zitadel.css /internal/api/ui/login/static/resources/themes/zitadel/css/zitadel.css.map -zitadel zitadel-*-* # local diff --git a/Makefile b/Makefile index a11884654c..f6ecc754bf 100644 --- a/Makefile +++ b/Makefile @@ -90,12 +90,15 @@ clean: core_unit_test: go test -race -coverprofile=profile.cov ./... -.PHONY: core_integration_test -core_integration_test: +.PHONY: core_integration_setup +core_integration_setup: go build -o zitadel main.go ./zitadel init --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml ./zitadel setup --masterkeyFromEnv --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml $(RM) zitadel + +.PHONY: core_integration_test +core_integration_test: core_integration_setup go test -tags=integration -race -p 1 -v -coverprofile=profile.cov -coverpkg=./internal/...,./cmd/... ./internal/integration ./internal/api/grpc/... ./internal/notification/handlers/... ./internal/api/oidc/... .PHONY: console_lint diff --git a/internal/api/grpc/session/v2/session.go b/internal/api/grpc/session/v2/session.go index e842476c25..ea61528fcb 100644 --- a/internal/api/grpc/session/v2/session.go +++ b/internal/api/grpc/session/v2/session.go @@ -47,7 +47,7 @@ func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRe } challengeResponse, cmds := s.challengesToCommand(req.GetChallenges(), checks) - set, err := s.command.CreateSession(ctx, cmds, req.GetDomain(), metadata) + set, err := s.command.CreateSession(ctx, cmds, metadata) if err != nil { return nil, err } @@ -107,7 +107,6 @@ func sessionToPb(s *query.Session) *session.Session { Sequence: s.Sequence, Factors: factorsToPb(s), Metadata: s.Metadata, - Domain: s.Domain, } } @@ -119,7 +118,7 @@ func factorsToPb(s *query.Session) *session.Factors { return &session.Factors{ User: user, Password: passwordFactorToPb(s.PasswordFactor), - Passkey: passkeyFactorToPb(s.PasskeyFactor), + WebAuthN: webAuthNFactorToPb(s.WebAuthNFactor), Intent: intentFactorToPb(s.IntentFactor), } } @@ -142,12 +141,13 @@ func intentFactorToPb(factor query.SessionIntentFactor) *session.IntentFactor { } } -func passkeyFactorToPb(factor query.SessionPasskeyFactor) *session.PasskeyFactor { - if factor.PasskeyCheckedAt.IsZero() { +func webAuthNFactorToPb(factor query.SessionWebAuthNFactor) *session.WebAuthNFactor { + if factor.WebAuthNCheckedAt.IsZero() { return nil } - return &session.PasskeyFactor{ - VerifiedAt: timestamppb.New(factor.PasskeyCheckedAt), + return &session.WebAuthNFactor{ + VerifiedAt: timestamppb.New(factor.WebAuthNCheckedAt), + UserVerified: factor.UserVerified, } } @@ -244,36 +244,47 @@ func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([ if intent := checks.GetIntent(); intent != nil { sessionChecks = append(sessionChecks, command.CheckIntent(intent.GetIntentId(), intent.GetToken())) } - if passkey := checks.GetPasskey(); passkey != nil { - sessionChecks = append(sessionChecks, s.command.CheckPasskey(passkey.GetCredentialAssertionData())) + if passkey := checks.GetWebAuthN(); passkey != nil { + sessionChecks = append(sessionChecks, s.command.CheckWebAuthN(passkey.GetCredentialAssertionData())) } return sessionChecks, nil } -func (s *Server) challengesToCommand(challenges []session.ChallengeKind, cmds []command.SessionCommand) (*session.Challenges, []command.SessionCommand) { - if len(challenges) == 0 { +func (s *Server) challengesToCommand(challenges *session.RequestChallenges, cmds []command.SessionCommand) (*session.Challenges, []command.SessionCommand) { + if challenges == nil { return nil, cmds } resp := new(session.Challenges) - for _, c := range challenges { - switch c { - case session.ChallengeKind_CHALLENGE_KIND_UNSPECIFIED: - continue - case session.ChallengeKind_CHALLENGE_KIND_PASSKEY: - passkeyChallenge, cmd := s.createPasskeyChallengeCommand() - resp.Passkey = passkeyChallenge - cmds = append(cmds, cmd) - } + if req := challenges.GetWebAuthN(); req != nil { + challenge, cmd := s.createWebAuthNChallengeCommand(req) + resp.WebAuthN = challenge + cmds = append(cmds, cmd) } return resp, cmds } -func (s *Server) createPasskeyChallengeCommand() (*session.Challenges_Passkey, command.SessionCommand) { - challenge := &session.Challenges_Passkey{ +func (s *Server) createWebAuthNChallengeCommand(req *session.RequestChallenges_WebAuthN) (*session.Challenges_WebAuthN, command.SessionCommand) { + challenge := &session.Challenges_WebAuthN{ PublicKeyCredentialRequestOptions: new(structpb.Struct), } - return challenge, s.command.CreatePasskeyChallenge(domain.UserVerificationRequirementRequired, challenge.PublicKeyCredentialRequestOptions) + userVerification := userVerificationRequirementToDomain(req.GetUserVerificationRequirement()) + return challenge, s.command.CreateWebAuthNChallenge(userVerification, req.GetDomain(), challenge.PublicKeyCredentialRequestOptions) +} + +func userVerificationRequirementToDomain(req session.UserVerificationRequirement) domain.UserVerificationRequirement { + switch req { + case session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_UNSPECIFIED: + return domain.UserVerificationRequirementUnspecified + case session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED: + return domain.UserVerificationRequirementRequired + case session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_PREFERRED: + return domain.UserVerificationRequirementPreferred + case session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_DISCOURAGED: + return domain.UserVerificationRequirementDiscouraged + default: + return domain.UserVerificationRequirementUnspecified + } } func userCheck(user *session.CheckUser) (userSearch, error) { diff --git a/internal/api/grpc/session/v2/session_integration_test.go b/internal/api/grpc/session/v2/session_integration_test.go index 7f6ad96643..7dd9355a71 100644 --- a/internal/api/grpc/session/v2/session_integration_test.go +++ b/internal/api/grpc/session/v2/session_integration_test.go @@ -69,7 +69,8 @@ type wantFactor int const ( wantUserFactor wantFactor = iota wantPasswordFactor - wantPasskeyFactor + wantWebAuthNFactor + wantWebAuthNFactorUserVerified wantIntentFactor ) @@ -85,10 +86,16 @@ func verifyFactors(t testing.TB, factors *session.Factors, window time.Duration, pf := factors.GetPassword() assert.NotNil(t, pf) assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window)) - case wantPasskeyFactor: - pf := factors.GetPasskey() + case wantWebAuthNFactor: + pf := factors.GetWebAuthN() assert.NotNil(t, pf) assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window)) + assert.False(t, pf.UserVerified) + case wantWebAuthNFactorUserVerified: + pf := factors.GetWebAuthN() + assert.NotNil(t, pf) + assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window)) + assert.True(t, pf.UserVerified) case wantIntentFactor: pf := factors.GetIntent() assert.NotNil(t, pf) @@ -127,7 +134,6 @@ func TestServer_CreateSession(t *testing.T) { }, }, Metadata: map[string][]byte{"foo": []byte("bar")}, - Domain: "domain", }, want: &session.CreateSessionResponse{ Details: &object.Details{ @@ -150,8 +156,11 @@ func TestServer_CreateSession(t *testing.T) { { name: "passkey without user error", req: &session.CreateSessionRequest{ - Challenges: []session.ChallengeKind{ - session.ChallengeKind_CHALLENGE_KIND_PASSKEY, + Challenges: &session.RequestChallenges{ + WebAuthN: &session.RequestChallenges_WebAuthN{ + Domain: Tester.Config.ExternalDomain, + UserVerificationRequirement: session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED, + }, }, }, wantErr: true, @@ -166,8 +175,10 @@ func TestServer_CreateSession(t *testing.T) { }, }, }, - Challenges: []session.ChallengeKind{ - session.ChallengeKind_CHALLENGE_KIND_PASSKEY, + Challenges: &session.RequestChallenges{ + WebAuthN: &session.RequestChallenges_WebAuthN{ + UserVerificationRequirement: session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED, + }, }, }, wantErr: true, @@ -188,8 +199,8 @@ func TestServer_CreateSession(t *testing.T) { } } -func TestServer_CreateSession_passkey(t *testing.T) { - // create new session with user and request the passkey challenge +func TestServer_CreateSession_webauthn(t *testing.T) { + // create new session with user and request the webauthn challenge createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ Checks: &session.Checks{ User: &session.CheckUser{ @@ -198,29 +209,31 @@ func TestServer_CreateSession_passkey(t *testing.T) { }, }, }, - Challenges: []session.ChallengeKind{ - session.ChallengeKind_CHALLENGE_KIND_PASSKEY, + Challenges: &session.RequestChallenges{ + WebAuthN: &session.RequestChallenges_WebAuthN{ + Domain: Tester.Config.ExternalDomain, + UserVerificationRequirement: session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED, + }, }, - Domain: Tester.Config.ExternalDomain, }) require.NoError(t, err) verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil) - assertionData, err := Tester.WebAuthN.CreateAssertionResponse(createResp.GetChallenges().GetPasskey().GetPublicKeyCredentialRequestOptions()) + assertionData, err := Tester.WebAuthN.CreateAssertionResponse(createResp.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), true) require.NoError(t, err) - // update the session with passkey assertion data + // update the session with webauthn assertion data updateResp, err := Client.SetSession(CTX, &session.SetSessionRequest{ SessionId: createResp.GetSessionId(), SessionToken: createResp.GetSessionToken(), Checks: &session.Checks{ - Passkey: &session.CheckPasskey{ + WebAuthN: &session.CheckWebAuthN{ CredentialAssertionData: assertionData, }, }, }) require.NoError(t, err) - verifyCurrentSession(t, createResp.GetSessionId(), updateResp.GetSessionToken(), updateResp.GetDetails().GetSequence(), time.Minute, nil, wantUserFactor, wantPasskeyFactor) + verifyCurrentSession(t, createResp.GetSessionId(), updateResp.GetSessionToken(), updateResp.GetDetails().GetSequence(), time.Minute, nil, wantUserFactor, wantWebAuthNFactorUserVerified) } func TestServer_CreateSession_successfulIntent(t *testing.T) { @@ -326,16 +339,14 @@ func TestServer_CreateSession_startedIntentFalseToken(t *testing.T) { } func TestServer_SetSession_flow(t *testing.T) { - var wantFactors []wantFactor - // create new, empty session - createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{Domain: Tester.Config.ExternalDomain}) + createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{}) require.NoError(t, err) - verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, wantFactors...) sessionToken := createResp.GetSessionToken() + verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, createResp.GetDetails().GetSequence(), time.Minute, nil) t.Run("check user", func(t *testing.T) { - wantFactors = append(wantFactors, wantUserFactor) + wantFactors := []wantFactor{wantUserFactor} resp, err := Client.SetSession(CTX, &session.SetSessionRequest{ SessionId: createResp.GetSessionId(), SessionToken: sessionToken, @@ -348,43 +359,92 @@ func TestServer_SetSession_flow(t *testing.T) { }, }) require.NoError(t, err) - verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil, wantFactors...) sessionToken = resp.GetSessionToken() + verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, wantFactors...) }) - t.Run("check passkey", func(t *testing.T) { + t.Run("check webauthn, user verified (passkey)", func(t *testing.T) { resp, err := Client.SetSession(CTX, &session.SetSessionRequest{ SessionId: createResp.GetSessionId(), SessionToken: sessionToken, - Challenges: []session.ChallengeKind{ - session.ChallengeKind_CHALLENGE_KIND_PASSKEY, + Challenges: &session.RequestChallenges{ + WebAuthN: &session.RequestChallenges_WebAuthN{ + Domain: Tester.Config.ExternalDomain, + UserVerificationRequirement: session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED, + }, }, }) require.NoError(t, err) - verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil, wantFactors...) + verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil) sessionToken = resp.GetSessionToken() - wantFactors = append(wantFactors, wantPasskeyFactor) - assertionData, err := Tester.WebAuthN.CreateAssertionResponse(resp.GetChallenges().GetPasskey().GetPublicKeyCredentialRequestOptions()) + wantFactors := []wantFactor{wantUserFactor, wantWebAuthNFactorUserVerified} + assertionData, err := Tester.WebAuthN.CreateAssertionResponse(resp.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), true) require.NoError(t, err) resp, err = Client.SetSession(CTX, &session.SetSessionRequest{ SessionId: createResp.GetSessionId(), SessionToken: sessionToken, Checks: &session.Checks{ - Passkey: &session.CheckPasskey{ + WebAuthN: &session.CheckWebAuthN{ CredentialAssertionData: assertionData, }, }, }) require.NoError(t, err) - verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil, wantFactors...) + sessionToken = resp.GetSessionToken() + verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, wantFactors...) + }) + + t.Run("check webauthn, user not verified (U2F)", func(t *testing.T) { + Tester.RegisterUserU2F( + Tester.WithAuthorizationToken(context.Background(), sessionToken), + User.GetUserId(), + ) + + for _, userVerificationRequirement := range []session.UserVerificationRequirement{ + session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_PREFERRED, + session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_DISCOURAGED, + } { + t.Run(userVerificationRequirement.String(), func(t *testing.T) { + resp, err := Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + SessionToken: sessionToken, + Challenges: &session.RequestChallenges{ + WebAuthN: &session.RequestChallenges_WebAuthN{ + Domain: Tester.Config.ExternalDomain, + UserVerificationRequirement: userVerificationRequirement, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil) + sessionToken = resp.GetSessionToken() + + wantFactors := []wantFactor{wantUserFactor, wantWebAuthNFactor} + assertionData, err := Tester.WebAuthN.CreateAssertionResponse(resp.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), false) + require.NoError(t, err) + + resp, err = Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + SessionToken: sessionToken, + Checks: &session.Checks{ + WebAuthN: &session.CheckWebAuthN{ + CredentialAssertionData: assertionData, + }, + }, + }) + require.NoError(t, err) + sessionToken = resp.GetSessionToken() + verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, wantFactors...) + }) + } }) } func Test_ZITADEL_API_missing_authentication(t *testing.T) { // create new, empty session - createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{Domain: Tester.Config.ExternalDomain}) + createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{}) require.NoError(t, err) ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", createResp.GetSessionToken())) @@ -403,16 +463,19 @@ func Test_ZITADEL_API_missing_mfa(t *testing.T) { } func Test_ZITADEL_API_success(t *testing.T) { - id, token, _, _ := Tester.CreatePasskeySession(t, CTX, User.GetUserId()) + id, token, _, _ := Tester.CreateVerfiedWebAuthNSession(t, CTX, User.GetUserId()) ctx := Tester.WithAuthorizationToken(context.Background(), token) sessionResp, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) require.NoError(t, err) - require.NotNil(t, id, sessionResp.GetSession().GetFactors().GetPasskey().GetVerifiedAt().AsTime()) + + webAuthN := sessionResp.GetSession().GetFactors().GetWebAuthN() + require.NotNil(t, id, webAuthN.GetVerifiedAt().AsTime()) + require.True(t, webAuthN.GetUserVerified()) } func Test_ZITADEL_API_session_not_found(t *testing.T) { - id, token, _, _ := Tester.CreatePasskeySession(t, CTX, User.GetUserId()) + id, token, _, _ := Tester.CreateVerfiedWebAuthNSession(t, CTX, User.GetUserId()) // test session token works ctx := Tester.WithAuthorizationToken(context.Background(), token) diff --git a/internal/api/grpc/session/v2/session_test.go b/internal/api/grpc/session/v2/session_test.go index 410ab43dcd..0f4d9b3c66 100644 --- a/internal/api/grpc/session/v2/session_test.go +++ b/internal/api/grpc/session/v2/session_test.go @@ -70,7 +70,7 @@ func Test_sessionsToPb(t *testing.T) { }, Metadata: map[string][]byte{"hello": []byte("world")}, }, - { // passkey factor + { // webAuthN factor ID: "999", CreationDate: now, ChangeDate: now, @@ -85,8 +85,9 @@ func Test_sessionsToPb(t *testing.T) { DisplayName: "donald duck", ResourceOwner: "org1", }, - PasskeyFactor: query.SessionPasskeyFactor{ - PasskeyCheckedAt: past, + WebAuthNFactor: query.SessionWebAuthNFactor{ + WebAuthNCheckedAt: past, + UserVerified: true, }, Metadata: map[string][]byte{"hello": []byte("world")}, }, @@ -136,7 +137,7 @@ func Test_sessionsToPb(t *testing.T) { }, Metadata: map[string][]byte{"hello": []byte("world")}, }, - { // passkey factor + { // webAuthN factor Id: "999", CreationDate: timestamppb.New(now), ChangeDate: timestamppb.New(now), @@ -149,8 +150,9 @@ func Test_sessionsToPb(t *testing.T) { DisplayName: "donald duck", OrganisationId: "org1", }, - Passkey: &session.PasskeyFactor{ - VerifiedAt: timestamppb.New(past), + WebAuthN: &session.WebAuthNFactor{ + VerifiedAt: timestamppb.New(past), + UserVerified: true, }, }, Metadata: map[string][]byte{"hello": []byte("world")}, @@ -432,3 +434,40 @@ func Test_userCheck(t *testing.T) { }) } } + +func Test_userVerificationRequirementToDomain(t *testing.T) { + type args struct { + req session.UserVerificationRequirement + } + tests := []struct { + args args + want domain.UserVerificationRequirement + }{ + { + args: args{session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_UNSPECIFIED}, + want: domain.UserVerificationRequirementUnspecified, + }, + { + args: args{session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED}, + want: domain.UserVerificationRequirementRequired, + }, + { + args: args{session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_PREFERRED}, + want: domain.UserVerificationRequirementPreferred, + }, + { + args: args{session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_DISCOURAGED}, + want: domain.UserVerificationRequirementDiscouraged, + }, + { + args: args{999}, + want: domain.UserVerificationRequirementUnspecified, + }, + } + for _, tt := range tests { + t.Run(tt.args.req.String(), func(t *testing.T) { + got := userVerificationRequirementToDomain(tt.args.req) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/user/v2/otp_integration_test.go b/internal/api/grpc/user/v2/otp_integration_test.go index 5d36dd361e..9f99596f2e 100644 --- a/internal/api/grpc/user/v2/otp_integration_test.go +++ b/internal/api/grpc/user/v2/otp_integration_test.go @@ -16,13 +16,13 @@ import ( func TestServer_AddOTPSMS(t *testing.T) { userID := Tester.CreateHumanUser(CTX).GetUserId() Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreatePasskeySession(t, CTX, userID) + _, sessionToken, _, _ := Tester.CreateVerfiedWebAuthNSession(t, CTX, userID) // TODO: add when phone can be added to user /* userIDPhone := Tester.CreateHumanUser(CTX).GetUserId() Tester.RegisterUserPasskey(CTX, userIDPhone) - _, sessionTokenPhone, _, _ := Tester.CreatePasskeySession(t, CTX, userIDPhone) + _, sessionTokenPhone, _, _ := Tester.CreateVerfiedWebAuthNSession(t, CTX, userIDPhone) */ type args struct { ctx context.Context @@ -99,7 +99,7 @@ func TestServer_RemoveOTPSMS(t *testing.T) { /* userID := Tester.CreateHumanUser(CTX).GetUserId() Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreatePasskeySession(t, CTX, userID) + _, sessionToken, _, _ := Tester.CreateVerfiedWebAuthNSession(t, CTX, userID) */ type args struct { @@ -157,7 +157,7 @@ func TestServer_RemoveOTPSMS(t *testing.T) { func TestServer_AddOTPEmail(t *testing.T) { userID := Tester.CreateHumanUser(CTX).GetUserId() Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreatePasskeySession(t, CTX, userID) + _, sessionToken, _, _ := Tester.CreateVerfiedWebAuthNSession(t, CTX, userID) userVerified := Tester.CreateHumanUser(CTX) _, err := Tester.Client.UserV2.VerifyEmail(CTX, &user.VerifyEmailRequest{ @@ -166,7 +166,7 @@ func TestServer_AddOTPEmail(t *testing.T) { }) require.NoError(t, err) Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) - _, sessionTokenVerified, _, _ := Tester.CreatePasskeySession(t, CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Tester.CreateVerfiedWebAuthNSession(t, CTX, userVerified.GetUserId()) type args struct { ctx context.Context @@ -238,11 +238,11 @@ func TestServer_AddOTPEmail(t *testing.T) { func TestServer_RemoveOTPEmail(t *testing.T) { userID := Tester.CreateHumanUser(CTX).GetUserId() Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreatePasskeySession(t, CTX, userID) + _, sessionToken, _, _ := Tester.CreateVerfiedWebAuthNSession(t, CTX, userID) userVerified := Tester.CreateHumanUser(CTX) Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) - _, sessionTokenVerified, _, _ := Tester.CreatePasskeySession(t, CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Tester.CreateVerfiedWebAuthNSession(t, CTX, userVerified.GetUserId()) userVerifiedCtx := Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified) _, err := Tester.Client.UserV2.VerifyEmail(userVerifiedCtx, &user.VerifyEmailRequest{ UserId: userVerified.GetUserId(), diff --git a/internal/api/oidc/auth_request_integration_test.go b/internal/api/oidc/auth_request_integration_test.go index 7651b32c87..1c5074e894 100644 --- a/internal/api/oidc/auth_request_integration_test.go +++ b/internal/api/oidc/auth_request_integration_test.go @@ -36,7 +36,7 @@ func TestOPStorage_CreateAuthRequest(t *testing.T) { func TestOPStorage_CreateAccessToken_code(t *testing.T) { clientID := createClient(t) authRequestID := createAuthRequest(t, clientID, redirectURI) - sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + sessionID, sessionToken, startTime, changeTime := Tester.CreateVerfiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ @@ -75,7 +75,7 @@ func TestOPStorage_CreateAccessToken_code(t *testing.T) { func TestOPStorage_CreateAccessToken_implicit(t *testing.T) { clientID := createImplicitClient(t) authRequestID := createAuthRequestImplicit(t, clientID, redirectURIImplicit) - sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + sessionID, sessionToken, startTime, changeTime := Tester.CreateVerfiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ @@ -125,7 +125,7 @@ func TestOPStorage_CreateAccessToken_implicit(t *testing.T) { func TestOPStorage_CreateAccessAndRefreshTokens_code(t *testing.T) { clientID := createClient(t) authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) - sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + sessionID, sessionToken, startTime, changeTime := Tester.CreateVerfiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ @@ -150,7 +150,7 @@ func TestOPStorage_CreateAccessAndRefreshTokens_refresh(t *testing.T) { provider, err := Tester.CreateRelyingParty(clientID, redirectURI) require.NoError(t, err) authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) - sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + sessionID, sessionToken, startTime, changeTime := Tester.CreateVerfiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ @@ -186,7 +186,7 @@ func TestOPStorage_RevokeToken_access_token(t *testing.T) { provider, err := Tester.CreateRelyingParty(clientID, redirectURI) require.NoError(t, err) authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) - sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + sessionID, sessionToken, startTime, changeTime := Tester.CreateVerfiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ @@ -229,7 +229,7 @@ func TestOPStorage_RevokeToken_access_token_invalid_token_hint_type(t *testing.T provider, err := Tester.CreateRelyingParty(clientID, redirectURI) require.NoError(t, err) authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) - sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + sessionID, sessionToken, startTime, changeTime := Tester.CreateVerfiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ @@ -266,7 +266,7 @@ func TestOPStorage_RevokeToken_refresh_token(t *testing.T) { provider, err := Tester.CreateRelyingParty(clientID, redirectURI) require.NoError(t, err) authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) - sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + sessionID, sessionToken, startTime, changeTime := Tester.CreateVerfiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ @@ -309,7 +309,7 @@ func TestOPStorage_RevokeToken_refresh_token_invalid_token_type_hint(t *testing. provider, err := Tester.CreateRelyingParty(clientID, redirectURI) require.NoError(t, err) authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) - sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + sessionID, sessionToken, startTime, changeTime := Tester.CreateVerfiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ @@ -344,7 +344,7 @@ func TestOPStorage_RevokeToken_refresh_token_invalid_token_type_hint(t *testing. func TestOPStorage_RevokeToken_invalid_client(t *testing.T) { clientID := createClient(t) authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) - sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + sessionID, sessionToken, startTime, changeTime := Tester.CreateVerfiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ @@ -376,7 +376,7 @@ func TestOPStorage_TerminateSession(t *testing.T) { provider, err := Tester.CreateRelyingParty(clientID, redirectURI) require.NoError(t, err) authRequestID := createAuthRequest(t, clientID, redirectURI) - sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + sessionID, sessionToken, startTime, changeTime := Tester.CreateVerfiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ @@ -413,7 +413,7 @@ func TestOPStorage_TerminateSession_refresh_grant(t *testing.T) { provider, err := Tester.CreateRelyingParty(clientID, redirectURI) require.NoError(t, err) authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) - sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + sessionID, sessionToken, startTime, changeTime := Tester.CreateVerfiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ @@ -457,7 +457,7 @@ func TestOPStorage_TerminateSession_empty_id_token_hint(t *testing.T) { provider, err := Tester.CreateRelyingParty(clientID, redirectURI) require.NoError(t, err) authRequestID := createAuthRequest(t, clientID, redirectURI) - sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + sessionID, sessionToken, startTime, changeTime := Tester.CreateVerfiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ diff --git a/internal/api/oidc/client_integration_test.go b/internal/api/oidc/client_integration_test.go index 513f25a753..bc806ddd75 100644 --- a/internal/api/oidc/client_integration_test.go +++ b/internal/api/oidc/client_integration_test.go @@ -21,7 +21,7 @@ import ( func TestOPStorage_SetUserinfoFromToken(t *testing.T) { clientID := createClient(t) authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess) - sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + sessionID, sessionToken, startTime, changeTime := Tester.CreateVerfiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ @@ -67,7 +67,7 @@ func TestOPStorage_SetIntrospectionFromToken(t *testing.T) { scope := []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess} authRequestID := createAuthRequest(t, app.GetClientId(), redirectURI, scope...) - sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + sessionID, sessionToken, startTime, changeTime := Tester.CreateVerfiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ diff --git a/internal/api/oidc/oidc_integration_test.go b/internal/api/oidc/oidc_integration_test.go index da34b30d75..88dda2b926 100644 --- a/internal/api/oidc/oidc_integration_test.go +++ b/internal/api/oidc/oidc_integration_test.go @@ -57,7 +57,7 @@ func TestMain(m *testing.M) { func Test_ZITADEL_API_missing_audience_scope(t *testing.T) { clientID := createClient(t) authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID) - sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + sessionID, sessionToken, startTime, changeTime := Tester.CreateVerfiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ @@ -92,7 +92,6 @@ func Test_ZITADEL_API_missing_authentication(t *testing.T) { Search: &session.CheckUser_UserId{UserId: User.GetUserId()}, }, }, - Domain: Tester.Config.ExternalDomain, }) require.NoError(t, err) linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ @@ -149,7 +148,7 @@ func Test_ZITADEL_API_missing_mfa(t *testing.T) { func Test_ZITADEL_API_success(t *testing.T) { clientID := createClient(t) authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope) - sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + sessionID, sessionToken, startTime, changeTime := Tester.CreateVerfiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ @@ -178,7 +177,7 @@ func Test_ZITADEL_API_success(t *testing.T) { func Test_ZITADEL_API_inactive_access_token(t *testing.T) { clientID := createClient(t) authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess, zitadelAudienceScope) - sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + sessionID, sessionToken, startTime, changeTime := Tester.CreateVerfiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ @@ -220,7 +219,7 @@ func Test_ZITADEL_API_terminated_session(t *testing.T) { provider, err := Tester.CreateRelyingParty(clientID, redirectURI) require.NoError(t, err) authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess, zitadelAudienceScope) - sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + sessionID, sessionToken, startTime, changeTime := Tester.CreateVerfiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ diff --git a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go index 5563db37dc..dcdd62017b 100644 --- a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go +++ b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go @@ -190,8 +190,12 @@ func authMethodsFromSession(session *query.Session) []domain.UserAuthMethodType if !session.PasswordFactor.PasswordCheckedAt.IsZero() { types = append(types, domain.UserAuthMethodTypePassword) } - if !session.PasskeyFactor.PasskeyCheckedAt.IsZero() { - types = append(types, domain.UserAuthMethodTypePasswordless) + if !session.WebAuthNFactor.WebAuthNCheckedAt.IsZero() { + if session.WebAuthNFactor.UserVerified { + types = append(types, domain.UserAuthMethodTypePasswordless) + } else { + types = append(types, domain.UserAuthMethodTypeU2F) + } } if !session.IntentFactor.IntentCheckedAt.IsZero() { types = append(types, domain.UserAuthMethodTypeIDP) @@ -201,9 +205,6 @@ func authMethodsFromSession(session *query.Session) []domain.UserAuthMethodType if !session.TOTPFactor.TOTPCheckedAt.IsZero() { types = append(types, domain.UserAuthMethodTypeTOTP) } - if !session.U2FFactor.U2FCheckedAt.IsZero() { - types = append(types, domain.UserAuthMethodTypeU2F) - } */ // TODO: add checks with https://github.com/zitadel/zitadel/issues/6224 /* diff --git a/internal/command/auth_request_test.go b/internal/command/auth_request_test.go index f54043b368..9e6bf8328d 100644 --- a/internal/command/auth_request_test.go +++ b/internal/command/auth_request_test.go @@ -358,7 +358,7 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { ), expectFilter( eventFromEventPusher( - session.NewAddedEvent(mockCtx, &session.NewAggregate("sessionID", "org1").Aggregate, "domain.tld")), + session.NewAddedEvent(mockCtx, &session.NewAggregate("sessionID", "org1").Aggregate)), ), ), tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { @@ -401,7 +401,7 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { ), expectFilter( eventFromEventPusher( - session.NewAddedEvent(mockCtx, &session.NewAggregate("sessionID", "org1").Aggregate, "domain.tld")), + session.NewAddedEvent(mockCtx, &session.NewAggregate("sessionID", "org1").Aggregate)), ), ), tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { @@ -444,7 +444,7 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { ), expectFilter( eventFromEventPusher( - session.NewAddedEvent(mockCtx, &session.NewAggregate("sessionID", "org1").Aggregate, "domain.tld"), + session.NewAddedEvent(mockCtx, &session.NewAggregate("sessionID", "org1").Aggregate), ), eventFromEventPusher( session.NewUserCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "org1").Aggregate, @@ -523,7 +523,7 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { ), expectFilter( eventFromEventPusher( - session.NewAddedEvent(mockCtx, &session.NewAggregate("sessionID", "org1").Aggregate, "domain.tld"), + session.NewAddedEvent(mockCtx, &session.NewAggregate("sessionID", "org1").Aggregate), ), eventFromEventPusher( session.NewUserCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "org1").Aggregate, diff --git a/internal/command/oidc_session_test.go b/internal/command/oidc_session_test.go index afbc59af56..aba917fa24 100644 --- a/internal/command/oidc_session_test.go +++ b/internal/command/oidc_session_test.go @@ -164,7 +164,7 @@ func TestCommands_AddOIDCSessionAccessToken(t *testing.T) { ), expectFilter( eventFromEventPusher( - session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate, "domain.tld"), + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate), ), eventFromEventPusher( session.NewUserCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate, @@ -365,7 +365,7 @@ func TestCommands_AddOIDCSessionRefreshAndAccessToken(t *testing.T) { ), expectFilter( eventFromEventPusher( - session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate, "domain.tld"), + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate), ), eventFromEventPusher( session.NewUserCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate, diff --git a/internal/command/session.go b/internal/command/session.go index 86e2498222..6bcf9f044d 100644 --- a/internal/command/session.go +++ b/internal/command/session.go @@ -15,7 +15,6 @@ import ( "github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/repository/session" "github.com/zitadel/zitadel/internal/repository/user" - usr_repo "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/telemetry/tracing" ) @@ -138,10 +137,8 @@ func (s *SessionCommands) Exec(ctx context.Context) error { return nil } -func (s *SessionCommands) Start(ctx context.Context, domain string) { - s.eventCommands = append(s.eventCommands, session.NewAddedEvent(ctx, s.sessionWriteModel.aggregate, domain)) - // set the domain so checks can use it - s.sessionWriteModel.Domain = domain +func (s *SessionCommands) Start(ctx context.Context) { + s.eventCommands = append(s.eventCommands, session.NewAddedEvent(ctx, s.sessionWriteModel.aggregate)) } func (s *SessionCommands) UserChecked(ctx context.Context, userID string, checkedAt time.Time) error { @@ -159,15 +156,23 @@ func (s *SessionCommands) IntentChecked(ctx context.Context, checkedAt time.Time s.eventCommands = append(s.eventCommands, session.NewIntentCheckedEvent(ctx, s.sessionWriteModel.aggregate, checkedAt)) } -func (s *SessionCommands) PasskeyChallenged(ctx context.Context, challenge string, allowedCrentialIDs [][]byte, userVerification domain.UserVerificationRequirement) { - s.eventCommands = append(s.eventCommands, session.NewPasskeyChallengedEvent(ctx, s.sessionWriteModel.aggregate, challenge, allowedCrentialIDs, userVerification)) +func (s *SessionCommands) WebAuthNChallenged(ctx context.Context, challenge string, allowedCrentialIDs [][]byte, userVerification domain.UserVerificationRequirement, rpid string) { + s.eventCommands = append(s.eventCommands, session.NewWebAuthNChallengedEvent(ctx, s.sessionWriteModel.aggregate, challenge, allowedCrentialIDs, userVerification, rpid)) } -func (s *SessionCommands) PasskeyChecked(ctx context.Context, checkedAt time.Time, tokenID string, signCount uint32) { +func (s *SessionCommands) WebAuthNChecked(ctx context.Context, checkedAt time.Time, tokenID string, signCount uint32, userVerified bool) { s.eventCommands = append(s.eventCommands, - session.NewPasskeyCheckedEvent(ctx, s.sessionWriteModel.aggregate, checkedAt), - usr_repo.NewHumanPasswordlessSignCountChangedEvent(ctx, s.sessionWriteModel.aggregate, tokenID, signCount), + session.NewWebAuthNCheckedEvent(ctx, s.sessionWriteModel.aggregate, checkedAt, userVerified), ) + if s.sessionWriteModel.WebAuthNChallenge.UserVerification == domain.UserVerificationRequirementRequired { + s.eventCommands = append(s.eventCommands, + user.NewHumanPasswordlessSignCountChangedEvent(ctx, s.sessionWriteModel.aggregate, tokenID, signCount), + ) + } else { + s.eventCommands = append(s.eventCommands, + user.NewHumanU2FSignCountChangedEvent(ctx, s.sessionWriteModel.aggregate, tokenID, signCount), + ) + } } func (s *SessionCommands) SetToken(ctx context.Context, tokenID string) { @@ -226,7 +231,7 @@ func (s *SessionCommands) commands(ctx context.Context) (string, []eventstore.Co return token, s.eventCommands, nil } -func (c *Commands) CreateSession(ctx context.Context, cmds []SessionCommand, sessionDomain string, metadata map[string][]byte) (set *SessionChanged, err error) { +func (c *Commands) CreateSession(ctx context.Context, cmds []SessionCommand, metadata map[string][]byte) (set *SessionChanged, err error) { sessionID, err := c.idGenerator.Next() if err != nil { return nil, err @@ -237,7 +242,7 @@ func (c *Commands) CreateSession(ctx context.Context, cmds []SessionCommand, ses return nil, err } cmd := c.NewSessionCommands(cmds, sessionWriteModel) - cmd.Start(ctx, sessionDomain) + cmd.Start(ctx) return c.updateSession(ctx, cmd, metadata) } diff --git a/internal/command/session_model.go b/internal/command/session_model.go index 764724e0e1..7674da9bcc 100644 --- a/internal/command/session_model.go +++ b/internal/command/session_model.go @@ -4,22 +4,18 @@ import ( "time" "github.com/zitadel/zitadel/internal/domain" - caos_errs "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/session" ) -type PasskeyChallengeModel struct { +type WebAuthNChallengeModel struct { Challenge string AllowedCrentialIDs [][]byte UserVerification domain.UserVerificationRequirement RPID string } -func (p *PasskeyChallengeModel) WebAuthNLogin(human *domain.Human, credentialAssertionData []byte) (*domain.WebAuthNLogin, error) { - if p == nil { - return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Ioqu5", "Errors.Session.Passkey.NoChallenge") - } +func (p *WebAuthNChallengeModel) WebAuthNLogin(human *domain.Human, credentialAssertionData []byte) *domain.WebAuthNLogin { return &domain.WebAuthNLogin{ ObjectRoot: human.ObjectRoot, CredentialAssertionData: credentialAssertionData, @@ -27,23 +23,23 @@ func (p *PasskeyChallengeModel) WebAuthNLogin(human *domain.Human, credentialAss AllowedCredentialIDs: p.AllowedCrentialIDs, UserVerification: p.UserVerification, RPID: p.RPID, - }, nil + } } type SessionWriteModel struct { eventstore.WriteModel - TokenID string - UserID string - UserCheckedAt time.Time - PasswordCheckedAt time.Time - IntentCheckedAt time.Time - PasskeyCheckedAt time.Time - Metadata map[string][]byte - Domain string - State domain.SessionState + TokenID string + UserID string + UserCheckedAt time.Time + PasswordCheckedAt time.Time + IntentCheckedAt time.Time + WebAuthNCheckedAt time.Time + WebAuthNUserVerified bool + Metadata map[string][]byte + State domain.SessionState - PasskeyChallenge *PasskeyChallengeModel + WebAuthNChallenge *WebAuthNChallengeModel aggregate *eventstore.Aggregate } @@ -70,10 +66,10 @@ func (wm *SessionWriteModel) Reduce() error { wm.reducePasswordChecked(e) case *session.IntentCheckedEvent: wm.reduceIntentChecked(e) - case *session.PasskeyChallengedEvent: - wm.reducePasskeyChallenged(e) - case *session.PasskeyCheckedEvent: - wm.reducePasskeyChecked(e) + case *session.WebAuthNChallengedEvent: + wm.reduceWebAuthNChallenged(e) + case *session.WebAuthNCheckedEvent: + wm.reduceWebAuthNChecked(e) case *session.TokenSetEvent: wm.reduceTokenSet(e) case *session.TerminateEvent: @@ -93,8 +89,8 @@ func (wm *SessionWriteModel) Query() *eventstore.SearchQueryBuilder { session.UserCheckedType, session.PasswordCheckedType, session.IntentCheckedType, - session.PasskeyChallengedType, - session.PasskeyCheckedType, + session.WebAuthNChallengedType, + session.WebAuthNCheckedType, session.TokenSetType, session.MetadataSetType, session.TerminateType, @@ -108,7 +104,6 @@ func (wm *SessionWriteModel) Query() *eventstore.SearchQueryBuilder { } func (wm *SessionWriteModel) reduceAdded(e *session.AddedEvent) { - wm.Domain = e.Domain wm.State = domain.SessionStateActive } @@ -125,18 +120,19 @@ func (wm *SessionWriteModel) reduceIntentChecked(e *session.IntentCheckedEvent) wm.IntentCheckedAt = e.CheckedAt } -func (wm *SessionWriteModel) reducePasskeyChallenged(e *session.PasskeyChallengedEvent) { - wm.PasskeyChallenge = &PasskeyChallengeModel{ +func (wm *SessionWriteModel) reduceWebAuthNChallenged(e *session.WebAuthNChallengedEvent) { + wm.WebAuthNChallenge = &WebAuthNChallengeModel{ Challenge: e.Challenge, AllowedCrentialIDs: e.AllowedCrentialIDs, UserVerification: e.UserVerification, - RPID: wm.Domain, + RPID: e.RPID, } } -func (wm *SessionWriteModel) reducePasskeyChecked(e *session.PasskeyCheckedEvent) { - wm.PasskeyChallenge = nil - wm.PasskeyCheckedAt = e.CheckedAt +func (wm *SessionWriteModel) reduceWebAuthNChecked(e *session.WebAuthNCheckedEvent) { + wm.WebAuthNChallenge = nil + wm.WebAuthNCheckedAt = e.CheckedAt + wm.WebAuthNUserVerified = e.UserVerified } func (wm *SessionWriteModel) reduceTokenSet(e *session.TokenSetEvent) { @@ -152,9 +148,9 @@ func (wm *SessionWriteModel) AuthenticationTime() time.Time { var authTime time.Time for _, check := range []time.Time{ wm.PasswordCheckedAt, - wm.PasskeyCheckedAt, + wm.WebAuthNCheckedAt, wm.IntentCheckedAt, - // TODO: add U2F and OTP check https://github.com/zitadel/zitadel/issues/5477 + // TODO: add OTP check https://github.com/zitadel/zitadel/issues/5477 // TODO: add OTP (sms and email) check https://github.com/zitadel/zitadel/issues/6224 } { if check.After(authTime) { @@ -170,8 +166,12 @@ func (wm *SessionWriteModel) AuthMethodTypes() []domain.UserAuthMethodType { if !wm.PasswordCheckedAt.IsZero() { types = append(types, domain.UserAuthMethodTypePassword) } - if !wm.PasskeyCheckedAt.IsZero() { - types = append(types, domain.UserAuthMethodTypePasswordless) + if !wm.WebAuthNCheckedAt.IsZero() { + if wm.WebAuthNUserVerified { + types = append(types, domain.UserAuthMethodTypePasswordless) + } else { + types = append(types, domain.UserAuthMethodTypeU2F) + } } if !wm.IntentCheckedAt.IsZero() { types = append(types, domain.UserAuthMethodTypeIDP) @@ -181,9 +181,6 @@ func (wm *SessionWriteModel) AuthMethodTypes() []domain.UserAuthMethodType { if !wm.TOTPCheckedAt.IsZero() { types = append(types, domain.UserAuthMethodTypeTOTP) } - if !wm.U2FCheckedAt.IsZero() { - types = append(types, domain.UserAuthMethodTypeU2F) - } */ // TODO: add checks with https://github.com/zitadel/zitadel/issues/6224 /* diff --git a/internal/command/session_model_test.go b/internal/command/session_model_test.go new file mode 100644 index 0000000000..bddeecf594 --- /dev/null +++ b/internal/command/session_model_test.go @@ -0,0 +1,75 @@ +package command + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/domain" +) + +func TestSessionWriteModel_AuthMethodTypes(t *testing.T) { + type fields struct { + PasswordCheckedAt time.Time + IntentCheckedAt time.Time + WebAuthNCheckedAt time.Time + WebAuthNUserVerified bool + } + tests := []struct { + name string + fields fields + want []domain.UserAuthMethodType + }{ + { + name: "password", + fields: fields{ + PasswordCheckedAt: testNow, + }, + want: []domain.UserAuthMethodType{ + domain.UserAuthMethodTypePassword, + }, + }, + { + name: "passwordless", + fields: fields{ + WebAuthNCheckedAt: testNow, + WebAuthNUserVerified: true, + }, + want: []domain.UserAuthMethodType{ + domain.UserAuthMethodTypePasswordless, + }, + }, + { + name: "u2f", + fields: fields{ + WebAuthNCheckedAt: testNow, + WebAuthNUserVerified: false, + }, + want: []domain.UserAuthMethodType{ + domain.UserAuthMethodTypeU2F, + }, + }, + { + name: "intent", + fields: fields{ + IntentCheckedAt: testNow, + }, + want: []domain.UserAuthMethodType{ + domain.UserAuthMethodTypeIDP, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wm := &SessionWriteModel{ + PasswordCheckedAt: tt.fields.PasswordCheckedAt, + IntentCheckedAt: tt.fields.IntentCheckedAt, + WebAuthNCheckedAt: tt.fields.WebAuthNCheckedAt, + WebAuthNUserVerified: tt.fields.WebAuthNUserVerified, + } + got := wm.AuthMethodTypes() + assert.Equal(t, got, tt.want) + }) + } +} diff --git a/internal/command/session_passkey.go b/internal/command/session_passkey.go deleted file mode 100644 index 6ef3fccb2f..0000000000 --- a/internal/command/session_passkey.go +++ /dev/null @@ -1,84 +0,0 @@ -package command - -import ( - "context" - "encoding/json" - - "github.com/zitadel/zitadel/internal/domain" - caos_errs "github.com/zitadel/zitadel/internal/errors" -) - -type humanPasskeys struct { - human *domain.Human - tokens []*domain.WebAuthNToken -} - -func (s *SessionCommands) getHumanPasskeys(ctx context.Context) (*humanPasskeys, error) { - humanWritemodel, err := s.gethumanWriteModel(ctx) - if err != nil { - return nil, err - } - tokenReadModel, err := s.getHumanPasswordlessTokenReadModel(ctx) - if err != nil { - return nil, err - } - return &humanPasskeys{ - human: writeModelToHuman(humanWritemodel), - tokens: readModelToPasswordlessTokens(tokenReadModel), - }, nil -} - -func (s *SessionCommands) getHumanPasswordlessTokenReadModel(ctx context.Context) (*HumanPasswordlessTokensReadModel, error) { - tokenReadModel := NewHumanPasswordlessTokensReadModel(s.sessionWriteModel.UserID, s.sessionWriteModel.ResourceOwner) - err := s.eventstore.FilterToQueryReducer(ctx, tokenReadModel) - if err != nil { - return nil, err - } - return tokenReadModel, nil -} - -func (c *Commands) CreatePasskeyChallenge(userVerification domain.UserVerificationRequirement, dst json.Unmarshaler) SessionCommand { - return func(ctx context.Context, cmd *SessionCommands) error { - humanPasskeys, err := cmd.getHumanPasskeys(ctx) - if err != nil { - return err - } - webAuthNLogin, err := c.webauthnConfig.BeginLogin(ctx, humanPasskeys.human, userVerification, cmd.sessionWriteModel.Domain, humanPasskeys.tokens...) - if err != nil { - return err - } - if err = json.Unmarshal(webAuthNLogin.CredentialAssertionData, dst); err != nil { - return caos_errs.ThrowInternal(err, "COMMAND-Yah6A", "Errors.Internal") - } - - cmd.PasskeyChallenged(ctx, webAuthNLogin.Challenge, webAuthNLogin.AllowedCredentialIDs, webAuthNLogin.UserVerification) - return nil - } -} - -func (c *Commands) CheckPasskey(credentialAssertionData json.Marshaler) SessionCommand { - return func(ctx context.Context, cmd *SessionCommands) error { - credentialAssertionData, err := json.Marshal(credentialAssertionData) - if err != nil { - return caos_errs.ThrowInvalidArgument(err, "COMMAND-ohG2o", "todo") - } - humanPasskeys, err := cmd.getHumanPasskeys(ctx) - if err != nil { - return err - } - webAuthN, err := cmd.sessionWriteModel.PasskeyChallenge.WebAuthNLogin(humanPasskeys.human, credentialAssertionData) - if err != nil { - return err - } - keyID, signCount, err := c.webauthnConfig.FinishLogin(ctx, humanPasskeys.human, webAuthN, credentialAssertionData, humanPasskeys.tokens...) - if err != nil && keyID == nil { - return err - } - _, token := domain.GetTokenByKeyID(humanPasskeys.tokens, keyID) - if token == nil { - return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Aej7i", "Errors.User.WebAuthN.NotFound") - } - cmd.PasskeyChecked(ctx, cmd.now(), token.WebAuthNTokenID, signCount) - return nil - } -} diff --git a/internal/command/session_passkeys_test.go b/internal/command/session_passkeys_test.go deleted file mode 100644 index a934c83d77..0000000000 --- a/internal/command/session_passkeys_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package command - -import ( - "context" - "io" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "golang.org/x/text/language" - - "github.com/zitadel/zitadel/internal/domain" - caos_errs "github.com/zitadel/zitadel/internal/errors" - "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/eventstore/v1/models" - "github.com/zitadel/zitadel/internal/repository/org" - "github.com/zitadel/zitadel/internal/repository/user" -) - -func TestSessionCommands_getHumanPasskeys(t *testing.T) { - userAggr := &user.NewAggregate("user1", "org1").Aggregate - - type fields struct { - eventstore *eventstore.Eventstore - sessionWriteModel *SessionWriteModel - } - type res struct { - want *humanPasskeys - err error - } - tests := []struct { - name string - fields fields - res res - }{ - { - name: "missing UID", - fields: fields{ - eventstore: &eventstore.Eventstore{}, - sessionWriteModel: &SessionWriteModel{}, - }, - res: res{ - want: nil, - err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-eeR2e", "Errors.User.UserIDMissing"), - }, - }, - { - name: "passwordless filter error", - fields: fields{ - eventstore: eventstoreExpect(t, - expectFilter( - eventFromEventPusher( - user.NewHumanAddedEvent(context.Background(), - userAggr, - "", "", "", "", "", language.Georgian, - domain.GenderDiverse, "", true, - ), - ), - ), - expectFilterError(io.ErrClosedPipe), - ), - sessionWriteModel: &SessionWriteModel{ - UserID: "user1", - }, - }, - res: res{ - want: nil, - err: io.ErrClosedPipe, - }, - }, - { - name: "ok", - fields: fields{ - eventstore: eventstoreExpect(t, - expectFilter( - eventFromEventPusher( - user.NewHumanAddedEvent(context.Background(), - userAggr, - "", "", "", "", "", language.Georgian, - domain.GenderDiverse, "", true, - ), - ), - ), - expectFilter(eventFromEventPusher( - user.NewHumanWebAuthNAddedEvent(eventstore.NewBaseEventForPush( - context.Background(), &org.NewAggregate("org1").Aggregate, user.HumanPasswordlessTokenAddedType, - ), "111", "challenge", "rpID"), - )), - ), - sessionWriteModel: &SessionWriteModel{ - UserID: "user1", - }, - }, - res: res{ - want: &humanPasskeys{ - human: &domain.Human{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "user1", - ResourceOwner: "org1", - }, - State: domain.UserStateActive, - Profile: &domain.Profile{ - PreferredLanguage: language.Georgian, - Gender: domain.GenderDiverse, - }, - Email: &domain.Email{}, - }, - tokens: []*domain.WebAuthNToken{{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "org1", - }, - WebAuthNTokenID: "111", - State: domain.MFAStateNotReady, - Challenge: "challenge", - RPID: "rpID", - }}, - }, - err: nil, - }, - }, - } - for _, tt := range tests { - s := &SessionCommands{ - eventstore: tt.fields.eventstore, - sessionWriteModel: tt.fields.sessionWriteModel, - } - got, err := s.getHumanPasskeys(context.Background()) - require.ErrorIs(t, err, tt.res.err) - assert.Equal(t, tt.res.want, got) - } -} diff --git a/internal/command/session_test.go b/internal/command/session_test.go index 2eee78cc02..099740c101 100644 --- a/internal/command/session_test.go +++ b/internal/command/session_test.go @@ -146,7 +146,6 @@ func TestCommands_CreateSession(t *testing.T) { type args struct { ctx context.Context checks []SessionCommand - domain string metadata map[string][]byte } type res struct { @@ -205,40 +204,7 @@ func TestCommands_CreateSession(t *testing.T) { expectFilter(), expectPush( eventPusherToEvents( - session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, ""), - session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, - "tokenID", - ), - ), - ), - }, - res{ - want: &SessionChanged{ - ObjectDetails: &domain.ObjectDetails{ResourceOwner: "org1"}, - ID: "sessionID", - NewToken: "token", - }, - }, - }, - { - "empty session with domain", - fields{ - idGenerator: mock.NewIDGeneratorExpectIDs(t, "sessionID"), - tokenCreator: func(sessionID string) (string, string, error) { - return "tokenID", - "token", - nil - }, - }, - args{ - ctx: authz.NewMockContext("", "org1", ""), - domain: "domain.tld", - }, - []expect{ - expectFilter(), - expectPush( - eventPusherToEvents( - session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "domain.tld"), + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate), session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "tokenID", ), @@ -262,7 +228,7 @@ func TestCommands_CreateSession(t *testing.T) { idGenerator: tt.fields.idGenerator, sessionTokenCreator: tt.fields.tokenCreator, } - got, err := c.CreateSession(tt.args.ctx, tt.args.checks, tt.args.domain, tt.args.metadata) + got, err := c.CreateSession(tt.args.ctx, tt.args.checks, tt.args.metadata) require.ErrorIs(t, err, tt.res.err) assert.Equal(t, tt.res.want, got) }) @@ -311,7 +277,7 @@ func TestCommands_UpdateSession(t *testing.T) { eventstore: eventstoreExpect(t, expectFilter( eventFromEventPusher( - session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "domain.tld")), + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), eventFromEventPusher( session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "tokenID")), @@ -336,7 +302,7 @@ func TestCommands_UpdateSession(t *testing.T) { eventstore: eventstoreExpect(t, expectFilter( eventFromEventPusher( - session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "domain.tld")), + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), eventFromEventPusher( session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "tokenID")), @@ -769,7 +735,7 @@ func TestCommands_TerminateSession(t *testing.T) { eventstore: eventstoreExpect(t, expectFilter( eventFromEventPusher( - session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "domain.tld")), + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), eventFromEventPusher( session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "tokenID")), @@ -794,7 +760,7 @@ func TestCommands_TerminateSession(t *testing.T) { eventstore: eventstoreExpect(t, expectFilter( eventFromEventPusher( - session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "domain.tld")), + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), eventFromEventPusher( session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "tokenID")), @@ -823,7 +789,7 @@ func TestCommands_TerminateSession(t *testing.T) { eventstore: eventstoreExpect(t, expectFilter( eventFromEventPusher( - session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "domain.tld")), + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), eventFromEventPusher( session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "tokenID"), @@ -854,7 +820,7 @@ func TestCommands_TerminateSession(t *testing.T) { eventstore: eventstoreExpect(t, expectFilter( eventFromEventPusher( - session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "domain.tld")), + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), eventFromEventPusher( session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "tokenID"), diff --git a/internal/command/session_webauhtn.go b/internal/command/session_webauhtn.go new file mode 100644 index 0000000000..410481efbd --- /dev/null +++ b/internal/command/session_webauhtn.go @@ -0,0 +1,89 @@ +package command + +import ( + "context" + "encoding/json" + + "github.com/zitadel/zitadel/internal/domain" + caos_errs "github.com/zitadel/zitadel/internal/errors" +) + +type humanWebAuthNTokens struct { + human *domain.Human + tokens []*domain.WebAuthNToken +} + +func (s *SessionCommands) getHumanWebAuthNTokens(ctx context.Context, userVerification domain.UserVerificationRequirement) (*humanWebAuthNTokens, error) { + humanWritemodel, err := s.gethumanWriteModel(ctx) + if err != nil { + return nil, err + } + tokenReadModel, err := s.getHumanWebAuthNTokenReadModel(ctx, userVerification) + if err != nil { + return nil, err + } + return &humanWebAuthNTokens{ + human: writeModelToHuman(humanWritemodel), + tokens: readModelToWebAuthNTokens(tokenReadModel), + }, nil +} + +func (s *SessionCommands) getHumanWebAuthNTokenReadModel(ctx context.Context, userVerification domain.UserVerificationRequirement) (readModel HumanWebAuthNTokensReadModel, err error) { + readModel = NewHumanU2FTokensReadModel(s.sessionWriteModel.UserID, s.sessionWriteModel.ResourceOwner) + if userVerification == domain.UserVerificationRequirementRequired { + readModel = NewHumanPasswordlessTokensReadModel(s.sessionWriteModel.UserID, s.sessionWriteModel.ResourceOwner) + } + err = s.eventstore.FilterToQueryReducer(ctx, readModel) + if err != nil { + return nil, err + } + return readModel, nil +} + +func (c *Commands) CreateWebAuthNChallenge(userVerification domain.UserVerificationRequirement, rpid string, dst json.Unmarshaler) SessionCommand { + return func(ctx context.Context, cmd *SessionCommands) error { + humanPasskeys, err := cmd.getHumanWebAuthNTokens(ctx, userVerification) + if err != nil { + return err + } + webAuthNLogin, err := c.webauthnConfig.BeginLogin(ctx, humanPasskeys.human, userVerification, rpid, humanPasskeys.tokens...) + if err != nil { + return err + } + if err = json.Unmarshal(webAuthNLogin.CredentialAssertionData, dst); err != nil { + return caos_errs.ThrowInternal(err, "COMMAND-Yah6A", "Errors.Internal") + } + + cmd.WebAuthNChallenged(ctx, webAuthNLogin.Challenge, webAuthNLogin.AllowedCredentialIDs, webAuthNLogin.UserVerification, rpid) + return nil + } +} + +func (c *Commands) CheckWebAuthN(credentialAssertionData json.Marshaler) SessionCommand { + return func(ctx context.Context, cmd *SessionCommands) error { + credentialAssertionData, err := json.Marshal(credentialAssertionData) + if err != nil { + return caos_errs.ThrowInternal(err, "COMMAND-ohG2o", "Errors.Internal") + } + challenge := cmd.sessionWriteModel.WebAuthNChallenge + if challenge == nil { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Ioqu5", "Errors.Session.WebAuthN.NoChallenge") + } + webAuthNTokens, err := cmd.getHumanWebAuthNTokens(ctx, challenge.UserVerification) + if err != nil { + return err + } + webAuthN := challenge.WebAuthNLogin(webAuthNTokens.human, credentialAssertionData) + + credential, err := c.webauthnConfig.FinishLogin(ctx, webAuthNTokens.human, webAuthN, credentialAssertionData, webAuthNTokens.tokens...) + if err != nil && (credential == nil || credential.ID == nil) { + return err + } + _, token := domain.GetTokenByKeyID(webAuthNTokens.tokens, credential.ID) + if token == nil { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Aej7i", "Errors.User.WebAuthN.NotFound") + } + cmd.WebAuthNChecked(ctx, cmd.now(), token.WebAuthNTokenID, credential.Authenticator.SignCount, credential.Flags.UserVerified) + return nil + } +} diff --git a/internal/command/session_webauthn_test.go b/internal/command/session_webauthn_test.go new file mode 100644 index 0000000000..3352286eb2 --- /dev/null +++ b/internal/command/session_webauthn_test.go @@ -0,0 +1,250 @@ +package command + +import ( + "context" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/domain" + caos_errs "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/repository/user" +) + +func TestSessionCommands_getHumanWebAuthNTokens(t *testing.T) { + userAggr := &user.NewAggregate("user1", "org1").Aggregate + + type fields struct { + eventstore *eventstore.Eventstore + sessionWriteModel *SessionWriteModel + } + type args struct { + userVerification domain.UserVerificationRequirement + } + type res struct { + want *humanWebAuthNTokens + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "missing UID", + fields: fields{ + eventstore: &eventstore.Eventstore{}, + sessionWriteModel: &SessionWriteModel{}, + }, + args: args{ + domain.UserVerificationRequirementDiscouraged, + }, + res: res{ + want: nil, + err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-eeR2e", "Errors.User.UserIDMissing"), + }, + }, + { + name: "passwordless filter error", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + userAggr, + "", "", "", "", "", language.Georgian, + domain.GenderDiverse, "", true, + ), + ), + ), + expectFilterError(io.ErrClosedPipe), + ), + sessionWriteModel: &SessionWriteModel{ + UserID: "user1", + }, + }, + args: args{ + domain.UserVerificationRequirementDiscouraged, + }, + res: res{ + want: nil, + err: io.ErrClosedPipe, + }, + }, + { + name: "ok, discouraged, u2f", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + userAggr, + "", "", "", "", "", language.Georgian, + domain.GenderDiverse, "", true, + ), + ), + ), + expectFilter(eventFromEventPusher( + user.NewHumanWebAuthNAddedEvent(eventstore.NewBaseEventForPush( + context.Background(), &org.NewAggregate("org1").Aggregate, user.HumanU2FTokenAddedType, + ), "111", "challenge", "rpID"), + )), + ), + sessionWriteModel: &SessionWriteModel{ + UserID: "user1", + }, + }, + args: args{ + domain.UserVerificationRequirementDiscouraged, + }, + res: res{ + want: &humanWebAuthNTokens{ + human: &domain.Human{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + State: domain.UserStateActive, + Profile: &domain.Profile{ + PreferredLanguage: language.Georgian, + Gender: domain.GenderDiverse, + }, + Email: &domain.Email{}, + }, + tokens: []*domain.WebAuthNToken{{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "org1", + }, + WebAuthNTokenID: "111", + State: domain.MFAStateNotReady, + Challenge: "challenge", + RPID: "rpID", + }}, + }, + err: nil, + }, + }, + { + name: "ok, preferred, u2f", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + userAggr, + "", "", "", "", "", language.Georgian, + domain.GenderDiverse, "", true, + ), + ), + ), + expectFilter(eventFromEventPusher( + user.NewHumanWebAuthNAddedEvent(eventstore.NewBaseEventForPush( + context.Background(), &org.NewAggregate("org1").Aggregate, user.HumanU2FTokenAddedType, + ), "111", "challenge", "rpID"), + )), + ), + sessionWriteModel: &SessionWriteModel{ + UserID: "user1", + }, + }, + args: args{ + domain.UserVerificationRequirementPreferred, + }, + res: res{ + want: &humanWebAuthNTokens{ + human: &domain.Human{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + State: domain.UserStateActive, + Profile: &domain.Profile{ + PreferredLanguage: language.Georgian, + Gender: domain.GenderDiverse, + }, + Email: &domain.Email{}, + }, + tokens: []*domain.WebAuthNToken{{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "org1", + }, + WebAuthNTokenID: "111", + State: domain.MFAStateNotReady, + Challenge: "challenge", + RPID: "rpID", + }}, + }, + err: nil, + }, + }, + { + name: "ok, required, u2f", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + userAggr, + "", "", "", "", "", language.Georgian, + domain.GenderDiverse, "", true, + ), + ), + ), + expectFilter(eventFromEventPusher( + user.NewHumanWebAuthNAddedEvent(eventstore.NewBaseEventForPush( + context.Background(), &org.NewAggregate("org1").Aggregate, user.HumanPasswordlessTokenAddedType, + ), "111", "challenge", "rpID"), + )), + ), + sessionWriteModel: &SessionWriteModel{ + UserID: "user1", + }, + }, + args: args{ + domain.UserVerificationRequirementRequired, + }, + res: res{ + want: &humanWebAuthNTokens{ + human: &domain.Human{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + State: domain.UserStateActive, + Profile: &domain.Profile{ + PreferredLanguage: language.Georgian, + Gender: domain.GenderDiverse, + }, + Email: &domain.Email{}, + }, + tokens: []*domain.WebAuthNToken{{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "org1", + }, + WebAuthNTokenID: "111", + State: domain.MFAStateNotReady, + Challenge: "challenge", + RPID: "rpID", + }}, + }, + err: nil, + }, + }, + } + for _, tt := range tests { + s := &SessionCommands{ + eventstore: tt.fields.eventstore, + sessionWriteModel: tt.fields.sessionWriteModel, + } + got, err := s.getHumanWebAuthNTokens(context.Background(), tt.args.userVerification) + require.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.want, got) + } +} diff --git a/internal/command/user_converter.go b/internal/command/user_converter.go index 62cee0bedd..7febe68530 100644 --- a/internal/command/user_converter.go +++ b/internal/command/user_converter.go @@ -112,17 +112,9 @@ func personalTokenWriteModelToToken(wm *PersonalAccessTokenWriteModel, algorithm }, base64.RawURLEncoding.EncodeToString(encrypted), nil } -func readModelToU2FTokens(wm *HumanU2FTokensReadModel) []*domain.WebAuthNToken { - tokens := make([]*domain.WebAuthNToken, len(wm.WebAuthNTokens)) - for i, token := range wm.WebAuthNTokens { - tokens[i] = writeModelToWebAuthN(token) - } - return tokens -} - -func readModelToPasswordlessTokens(wm *HumanPasswordlessTokensReadModel) []*domain.WebAuthNToken { - tokens := make([]*domain.WebAuthNToken, len(wm.WebAuthNTokens)) - for i, token := range wm.WebAuthNTokens { +func readModelToWebAuthNTokens(readModel HumanWebAuthNTokensReadModel) []*domain.WebAuthNToken { + tokens := make([]*domain.WebAuthNToken, len(readModel.GetWebAuthNTokens())) + for i, token := range readModel.GetWebAuthNTokens() { tokens[i] = writeModelToWebAuthN(token) } return tokens diff --git a/internal/command/user_human_webauthn.go b/internal/command/user_human_webauthn.go index c5e03bf3d4..70c012884b 100644 --- a/internal/command/user_human_webauthn.go +++ b/internal/command/user_human_webauthn.go @@ -24,7 +24,7 @@ func (c *Commands) getHumanU2FTokens(ctx context.Context, userID, resourceowner if tokenReadModel.UserState == domain.UserStateDeleted { return nil, caos_errs.ThrowNotFound(nil, "COMMAND-4M0ds", "Errors.User.NotFound") } - return readModelToU2FTokens(tokenReadModel), nil + return readModelToWebAuthNTokens(tokenReadModel), nil } func (c *Commands) getHumanPasswordlessTokens(ctx context.Context, userID, resourceOwner string) ([]*domain.WebAuthNToken, error) { @@ -36,7 +36,7 @@ func (c *Commands) getHumanPasswordlessTokens(ctx context.Context, userID, resou if tokenReadModel.UserState == domain.UserStateDeleted { return nil, caos_errs.ThrowNotFound(nil, "COMMAND-Mv9sd", "Errors.User.NotFound") } - return readModelToPasswordlessTokens(tokenReadModel), nil + return readModelToWebAuthNTokens(tokenReadModel), nil } func (c *Commands) getHumanU2FLogin(ctx context.Context, userID, authReqID, resourceowner string) (*domain.WebAuthNLogin, error) { @@ -454,12 +454,12 @@ func (c *Commands) finishWebAuthNLogin(ctx context.Context, userID, resourceOwne if err != nil { return nil, nil, 0, err } - keyID, signCount, err := c.webauthnConfig.FinishLogin(ctx, human, webAuthN, credentialData, tokens...) - if err != nil && keyID == nil { + credential, err := c.webauthnConfig.FinishLogin(ctx, human, webAuthN, credentialData, tokens...) + if err != nil && (credential == nil || credential.ID == nil) { return nil, nil, 0, err } - _, token := domain.GetTokenByKeyID(tokens, keyID) + _, token := domain.GetTokenByKeyID(tokens, credential.ID) if token == nil { return nil, nil, 0, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-3b7zs", "Errors.User.WebAuthN.NotFound") } @@ -470,7 +470,7 @@ func (c *Commands) finishWebAuthNLogin(ctx context.Context, userID, resourceOwne } userAgg := UserAggregateFromWriteModel(&writeModel.WriteModel) - return userAgg, token, signCount, nil + return userAgg, token, credential.Authenticator.SignCount, nil } func (c *Commands) HumanRemoveU2F(ctx context.Context, userID, webAuthNID, resourceOwner string) (*domain.ObjectDetails, error) { diff --git a/internal/command/user_human_webauthn_model.go b/internal/command/user_human_webauthn_model.go index daf9e880d4..aab9e1e57f 100644 --- a/internal/command/user_human_webauthn_model.go +++ b/internal/command/user_human_webauthn_model.go @@ -146,6 +146,12 @@ func (wm *HumanWebAuthNWriteModel) Query() *eventstore.SearchQueryBuilder { Builder() } +type HumanWebAuthNTokensReadModel interface { + eventstore.QueryReducer + GetWebAuthNTokens() []*HumanWebAuthNWriteModel + WebAuthNTokenByID(id string) (int, *HumanWebAuthNWriteModel) +} + type HumanU2FTokensReadModel struct { eventstore.WriteModel @@ -220,6 +226,10 @@ func (rm *HumanU2FTokensReadModel) Query() *eventstore.SearchQueryBuilder { } +func (wm *HumanU2FTokensReadModel) GetWebAuthNTokens() []*HumanWebAuthNWriteModel { + return wm.WebAuthNTokens +} + func (wm *HumanU2FTokensReadModel) WebAuthNTokenByID(id string) (idx int, token *HumanWebAuthNWriteModel) { for idx, token = range wm.WebAuthNTokens { if token.WebauthNTokenID == id { @@ -303,6 +313,10 @@ func (rm *HumanPasswordlessTokensReadModel) Query() *eventstore.SearchQueryBuild } +func (wm *HumanPasswordlessTokensReadModel) GetWebAuthNTokens() []*HumanWebAuthNWriteModel { + return wm.WebAuthNTokens +} + func (wm *HumanPasswordlessTokensReadModel) WebAuthNTokenByID(id string) (idx int, token *HumanWebAuthNWriteModel) { for idx, token = range wm.WebAuthNTokens { if token.WebauthNTokenID == id { diff --git a/internal/integration/client.go b/internal/integration/client.go index dd7ce06d6a..bd3557bdb9 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -146,6 +146,24 @@ func (s *Tester) RegisterUserPasskey(ctx context.Context, userID string) { logging.OnError(err).Fatal("create user passkey") } +func (s *Tester) RegisterUserU2F(ctx context.Context, userID string) { + pkr, err := s.Client.UserV2.RegisterU2F(ctx, &user.RegisterU2FRequest{ + UserId: userID, + Domain: s.Config.ExternalDomain, + }) + logging.OnError(err).Fatal("create user u2f") + attestationResponse, err := s.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) + logging.OnError(err).Fatal("create user u2f") + + _, err = s.Client.UserV2.VerifyU2FRegistration(ctx, &user.VerifyU2FRegistrationRequest{ + UserId: userID, + U2FId: pkr.GetU2FId(), + PublicKeyCredential: attestationResponse, + TokenName: "nice name", + }) + logging.OnError(err).Fatal("create user u2f") +} + func (s *Tester) SetUserPassword(ctx context.Context, userID, password string) { _, err := s.Client.UserV2.SetPassword(ctx, &user.SetPasswordRequest{ UserId: userID, @@ -209,28 +227,30 @@ func (s *Tester) CreateSuccessfulIntent(t *testing.T, idpID, userID, idpUserID s return intentID, token, writeModel.ChangeDate, writeModel.ProcessedSequence } -func (s *Tester) CreatePasskeySession(t *testing.T, ctx context.Context, userID string) (id, token string, start, change time.Time) { +func (s *Tester) CreateVerfiedWebAuthNSession(t *testing.T, ctx context.Context, userID string) (id, token string, start, change time.Time) { createResp, err := s.Client.SessionV2.CreateSession(ctx, &session.CreateSessionRequest{ Checks: &session.Checks{ User: &session.CheckUser{ Search: &session.CheckUser_UserId{UserId: userID}, }, }, - Challenges: []session.ChallengeKind{ - session.ChallengeKind_CHALLENGE_KIND_PASSKEY, + Challenges: &session.RequestChallenges{ + WebAuthN: &session.RequestChallenges_WebAuthN{ + Domain: s.Config.ExternalDomain, + UserVerificationRequirement: session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED, + }, }, - Domain: s.Config.ExternalDomain, }) require.NoError(t, err) - assertion, err := s.WebAuthN.CreateAssertionResponse(createResp.GetChallenges().GetPasskey().GetPublicKeyCredentialRequestOptions()) + assertion, err := s.WebAuthN.CreateAssertionResponse(createResp.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), true) require.NoError(t, err) updateResp, err := s.Client.SessionV2.SetSession(ctx, &session.SetSessionRequest{ SessionId: createResp.GetSessionId(), SessionToken: createResp.GetSessionToken(), Checks: &session.Checks{ - Passkey: &session.CheckPasskey{ + WebAuthN: &session.CheckWebAuthN{ CredentialAssertionData: assertion, }, }, @@ -250,7 +270,6 @@ func (s *Tester) CreatePasswordSession(t *testing.T, ctx context.Context, userID Password: password, }, }, - Domain: s.Config.ExternalDomain, }) require.NoError(t, err) return createResp.GetSessionId(), createResp.GetSessionToken(), diff --git a/internal/query/projection/session.go b/internal/query/projection/session.go index 2fceb22439..afa48b1c01 100644 --- a/internal/query/projection/session.go +++ b/internal/query/projection/session.go @@ -14,24 +14,24 @@ import ( ) const ( - SessionsProjectionTable = "projections.sessions3" + SessionsProjectionTable = "projections.sessions4" - SessionColumnID = "id" - SessionColumnCreationDate = "creation_date" - SessionColumnChangeDate = "change_date" - SessionColumnSequence = "sequence" - SessionColumnState = "state" - SessionColumnResourceOwner = "resource_owner" - SessionColumnDomain = "domain" - SessionColumnInstanceID = "instance_id" - SessionColumnCreator = "creator" - SessionColumnUserID = "user_id" - SessionColumnUserCheckedAt = "user_checked_at" - SessionColumnPasswordCheckedAt = "password_checked_at" - SessionColumnIntentCheckedAt = "intent_checked_at" - SessionColumnPasskeyCheckedAt = "passkey_checked_at" - SessionColumnMetadata = "metadata" - SessionColumnTokenID = "token_id" + SessionColumnID = "id" + SessionColumnCreationDate = "creation_date" + SessionColumnChangeDate = "change_date" + SessionColumnSequence = "sequence" + SessionColumnState = "state" + SessionColumnResourceOwner = "resource_owner" + SessionColumnInstanceID = "instance_id" + SessionColumnCreator = "creator" + SessionColumnUserID = "user_id" + SessionColumnUserCheckedAt = "user_checked_at" + SessionColumnPasswordCheckedAt = "password_checked_at" + SessionColumnIntentCheckedAt = "intent_checked_at" + SessionColumnWebAuthNCheckedAt = "webauthn_checked_at" + SessionColumnWebAuthNUserVerified = "webauthn_user_verified" + SessionColumnMetadata = "metadata" + SessionColumnTokenID = "token_id" ) type sessionProjection struct { @@ -50,14 +50,14 @@ func newSessionProjection(ctx context.Context, config crdb.StatementHandlerConfi crdb.NewColumn(SessionColumnSequence, crdb.ColumnTypeInt64), crdb.NewColumn(SessionColumnState, crdb.ColumnTypeEnum), crdb.NewColumn(SessionColumnResourceOwner, crdb.ColumnTypeText), - crdb.NewColumn(SessionColumnDomain, crdb.ColumnTypeText), crdb.NewColumn(SessionColumnInstanceID, crdb.ColumnTypeText), crdb.NewColumn(SessionColumnCreator, crdb.ColumnTypeText), crdb.NewColumn(SessionColumnUserID, crdb.ColumnTypeText, crdb.Nullable()), crdb.NewColumn(SessionColumnUserCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()), crdb.NewColumn(SessionColumnPasswordCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()), crdb.NewColumn(SessionColumnIntentCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()), - crdb.NewColumn(SessionColumnPasskeyCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()), + crdb.NewColumn(SessionColumnWebAuthNCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()), + crdb.NewColumn(SessionColumnWebAuthNUserVerified, crdb.ColumnTypeBool, crdb.Nullable()), crdb.NewColumn(SessionColumnMetadata, crdb.ColumnTypeJSONB, crdb.Nullable()), crdb.NewColumn(SessionColumnTokenID, crdb.ColumnTypeText, crdb.Nullable()), }, @@ -90,8 +90,8 @@ func (p *sessionProjection) reducers() []handler.AggregateReducer { Reduce: p.reduceIntentChecked, }, { - Event: session.PasskeyCheckedType, - Reduce: p.reducePasskeyChecked, + Event: session.WebAuthNCheckedType, + Reduce: p.reduceWebAuthNChecked, }, { Event: session.TokenSetType, @@ -142,7 +142,6 @@ func (p *sessionProjection) reduceSessionAdded(event eventstore.Event) (*handler handler.NewCol(SessionColumnCreationDate, e.CreationDate()), handler.NewCol(SessionColumnChangeDate, e.CreationDate()), handler.NewCol(SessionColumnResourceOwner, e.Aggregate().ResourceOwner), - handler.NewCol(SessionColumnDomain, e.Domain), handler.NewCol(SessionColumnState, domain.SessionStateActive), handler.NewCol(SessionColumnSequence, e.Sequence()), handler.NewCol(SessionColumnCreator, e.User), @@ -210,18 +209,18 @@ func (p *sessionProjection) reduceIntentChecked(event eventstore.Event) (*handle ), nil } -func (p *sessionProjection) reducePasskeyChecked(event eventstore.Event) (*handler.Statement, error) { - e, ok := event.(*session.PasskeyCheckedEvent) +func (p *sessionProjection) reduceWebAuthNChecked(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*session.WebAuthNCheckedEvent) if !ok { - return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-WieM4", "reduce.wrong.event.type %s", session.PasskeyCheckedType) + return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-WieM4", "reduce.wrong.event.type %s", session.WebAuthNCheckedType) } - return crdb.NewUpdateStatement( e, []handler.Column{ handler.NewCol(SessionColumnChangeDate, e.CreationDate()), handler.NewCol(SessionColumnSequence, e.Sequence()), - handler.NewCol(SessionColumnPasskeyCheckedAt, e.CheckedAt), + handler.NewCol(SessionColumnWebAuthNCheckedAt, e.CheckedAt), + handler.NewCol(SessionColumnWebAuthNUserVerified, e.UserVerified), }, []handler.Condition{ handler.NewCond(SessionColumnID, e.Aggregate().ID), diff --git a/internal/query/projection/session_test.go b/internal/query/projection/session_test.go index ae19e247ec..5feb0d452c 100644 --- a/internal/query/projection/session_test.go +++ b/internal/query/projection/session_test.go @@ -43,14 +43,13 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "INSERT INTO projections.sessions3 (id, instance_id, creation_date, change_date, resource_owner, domain, state, sequence, creator) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + expectedStmt: "INSERT INTO projections.sessions4 (id, instance_id, creation_date, change_date, resource_owner, state, sequence, creator) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", expectedArgs: []interface{}{ "agg-id", "instance-id", anyArg{}, anyArg{}, "ro-id", - "domain", domain.SessionStateActive, uint64(15), "editor-user", @@ -80,7 +79,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.sessions3 SET (change_date, sequence, user_id, user_checked_at) = ($1, $2, $3, $4) WHERE (id = $5) AND (instance_id = $6)", + expectedStmt: "UPDATE projections.sessions4 SET (change_date, sequence, user_id, user_checked_at) = ($1, $2, $3, $4) WHERE (id = $5) AND (instance_id = $6)", expectedArgs: []interface{}{ anyArg{}, anyArg{}, @@ -113,7 +112,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.sessions3 SET (change_date, sequence, password_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedStmt: "UPDATE projections.sessions4 SET (change_date, sequence, password_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", expectedArgs: []interface{}{ anyArg{}, anyArg{}, @@ -126,6 +125,40 @@ func TestSessionProjection_reduces(t *testing.T) { }, }, }, + { + name: "instance reduceWebAuthNChecked", + args: args{ + event: getEvent(testEvent( + session.WebAuthNCheckedType, + session.AggregateType, + []byte(`{ + "checkedAt": "2023-05-04T00:00:00Z", + "userVerified": true + }`), + ), eventstore.GenericEventMapper[session.WebAuthNCheckedEvent]), + }, + reduce: (&sessionProjection{}).reduceWebAuthNChecked, + want: wantReduce{ + aggregateType: eventstore.AggregateType("session"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.sessions4 SET (change_date, sequence, webauthn_checked_at, webauthn_user_verified) = ($1, $2, $3, $4) WHERE (id = $5) AND (instance_id = $6)", + expectedArgs: []interface{}{ + anyArg{}, + anyArg{}, + time.Date(2023, time.May, 4, 0, 0, 0, 0, time.UTC), + true, + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, { name: "instance reduceIntentChecked", args: args{ @@ -145,7 +178,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.sessions3 SET (change_date, sequence, intent_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedStmt: "UPDATE projections.sessions4 SET (change_date, sequence, intent_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", expectedArgs: []interface{}{ anyArg{}, anyArg{}, @@ -177,7 +210,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.sessions3 SET (change_date, sequence, token_id) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedStmt: "UPDATE projections.sessions4 SET (change_date, sequence, token_id) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", expectedArgs: []interface{}{ anyArg{}, anyArg{}, @@ -211,7 +244,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.sessions3 SET (change_date, sequence, metadata) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedStmt: "UPDATE projections.sessions4 SET (change_date, sequence, metadata) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", expectedArgs: []interface{}{ anyArg{}, anyArg{}, @@ -243,7 +276,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "DELETE FROM projections.sessions3 WHERE (id = $1) AND (instance_id = $2)", + expectedStmt: "DELETE FROM projections.sessions4 WHERE (id = $1) AND (instance_id = $2)", expectedArgs: []interface{}{ "agg-id", "instance-id", @@ -270,7 +303,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "DELETE FROM projections.sessions3 WHERE (instance_id = $1)", + expectedStmt: "DELETE FROM projections.sessions4 WHERE (instance_id = $1)", expectedArgs: []interface{}{ "agg-id", }, @@ -301,7 +334,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.sessions3 SET password_checked_at = $1 WHERE (user_id = $2) AND (password_checked_at < $3)", + expectedStmt: "UPDATE projections.sessions4 SET password_checked_at = $1 WHERE (user_id = $2) AND (password_checked_at < $3)", expectedArgs: []interface{}{ nil, "agg-id", diff --git a/internal/query/session.go b/internal/query/session.go index bcf62ba5d2..5746fe0592 100644 --- a/internal/query/session.go +++ b/internal/query/session.go @@ -29,12 +29,11 @@ type Session struct { Sequence uint64 State domain.SessionState ResourceOwner string - Domain string Creator string UserFactor SessionUserFactor PasswordFactor SessionPasswordFactor IntentFactor SessionIntentFactor - PasskeyFactor SessionPasskeyFactor + WebAuthNFactor SessionWebAuthNFactor Metadata map[string][]byte } @@ -54,8 +53,9 @@ type SessionIntentFactor struct { IntentCheckedAt time.Time } -type SessionPasskeyFactor struct { - PasskeyCheckedAt time.Time +type SessionWebAuthNFactor struct { + WebAuthNCheckedAt time.Time + UserVerified bool } type SessionsSearchQueries struct { @@ -100,10 +100,6 @@ var ( name: projection.SessionColumnResourceOwner, table: sessionsTable, } - SessionColumnDomain = Column{ - name: projection.SessionColumnDomain, - table: sessionsTable, - } SessionColumnInstanceID = Column{ name: projection.SessionColumnInstanceID, table: sessionsTable, @@ -128,8 +124,12 @@ var ( name: projection.SessionColumnIntentCheckedAt, table: sessionsTable, } - SessionColumnPasskeyCheckedAt = Column{ - name: projection.SessionColumnPasskeyCheckedAt, + SessionColumnWebAuthNCheckedAt = Column{ + name: projection.SessionColumnWebAuthNCheckedAt, + table: sessionsTable, + } + SessionColumnWebAuthNUserVerified = Column{ + name: projection.SessionColumnWebAuthNUserVerified, table: sessionsTable, } SessionColumnMetadata = Column{ @@ -221,7 +221,6 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil SessionColumnState.identifier(), SessionColumnResourceOwner.identifier(), SessionColumnCreator.identifier(), - SessionColumnDomain.identifier(), SessionColumnUserID.identifier(), SessionColumnUserCheckedAt.identifier(), LoginNameNameCol.identifier(), @@ -229,7 +228,8 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil UserResourceOwnerCol.identifier(), SessionColumnPasswordCheckedAt.identifier(), SessionColumnIntentCheckedAt.identifier(), - SessionColumnPasskeyCheckedAt.identifier(), + SessionColumnWebAuthNCheckedAt.identifier(), + SessionColumnWebAuthNUserVerified.identifier(), SessionColumnMetadata.identifier(), SessionColumnToken.identifier(), ).From(sessionsTable.identifier()). @@ -240,17 +240,17 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil session := new(Session) var ( - userID sql.NullString - userCheckedAt sql.NullTime - loginName sql.NullString - displayName sql.NullString - userResourceOwner sql.NullString - passwordCheckedAt sql.NullTime - intentCheckedAt sql.NullTime - passkeyCheckedAt sql.NullTime - metadata database.Map[[]byte] - token sql.NullString - sessionDomain sql.NullString + userID sql.NullString + userCheckedAt sql.NullTime + loginName sql.NullString + displayName sql.NullString + userResourceOwner sql.NullString + passwordCheckedAt sql.NullTime + intentCheckedAt sql.NullTime + webAuthNCheckedAt sql.NullTime + webAuthNUserPresent sql.NullBool + metadata database.Map[[]byte] + token sql.NullString ) err := row.Scan( @@ -261,7 +261,6 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil &session.State, &session.ResourceOwner, &session.Creator, - &sessionDomain, &userID, &userCheckedAt, &loginName, @@ -269,7 +268,8 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil &userResourceOwner, &passwordCheckedAt, &intentCheckedAt, - &passkeyCheckedAt, + &webAuthNCheckedAt, + &webAuthNUserPresent, &metadata, &token, ) @@ -281,7 +281,6 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil return nil, "", errors.ThrowInternal(err, "QUERY-SAder", "Errors.Internal") } - session.Domain = sessionDomain.String session.UserFactor.UserID = userID.String session.UserFactor.UserCheckedAt = userCheckedAt.Time session.UserFactor.LoginName = loginName.String @@ -289,7 +288,8 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil session.UserFactor.ResourceOwner = userResourceOwner.String session.PasswordFactor.PasswordCheckedAt = passwordCheckedAt.Time session.IntentFactor.IntentCheckedAt = intentCheckedAt.Time - session.PasskeyFactor.PasskeyCheckedAt = passkeyCheckedAt.Time + session.WebAuthNFactor.WebAuthNCheckedAt = webAuthNCheckedAt.Time + session.WebAuthNFactor.UserVerified = webAuthNUserPresent.Bool session.Metadata = metadata return session, token.String, nil @@ -305,7 +305,6 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui SessionColumnState.identifier(), SessionColumnResourceOwner.identifier(), SessionColumnCreator.identifier(), - SessionColumnDomain.identifier(), SessionColumnUserID.identifier(), SessionColumnUserCheckedAt.identifier(), LoginNameNameCol.identifier(), @@ -313,7 +312,8 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui UserResourceOwnerCol.identifier(), SessionColumnPasswordCheckedAt.identifier(), SessionColumnIntentCheckedAt.identifier(), - SessionColumnPasskeyCheckedAt.identifier(), + SessionColumnWebAuthNCheckedAt.identifier(), + SessionColumnWebAuthNUserVerified.identifier(), SessionColumnMetadata.identifier(), countColumn.identifier(), ).From(sessionsTable.identifier()). @@ -327,16 +327,16 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui session := new(Session) var ( - userID sql.NullString - userCheckedAt sql.NullTime - loginName sql.NullString - displayName sql.NullString - userResourceOwner sql.NullString - passwordCheckedAt sql.NullTime - intentCheckedAt sql.NullTime - passkeyCheckedAt sql.NullTime - metadata database.Map[[]byte] - sessionDomain sql.NullString + userID sql.NullString + userCheckedAt sql.NullTime + loginName sql.NullString + displayName sql.NullString + userResourceOwner sql.NullString + passwordCheckedAt sql.NullTime + intentCheckedAt sql.NullTime + webAuthNCheckedAt sql.NullTime + webAuthNUserPresent sql.NullBool + metadata database.Map[[]byte] ) err := rows.Scan( @@ -347,7 +347,6 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui &session.State, &session.ResourceOwner, &session.Creator, - &sessionDomain, &userID, &userCheckedAt, &loginName, @@ -355,7 +354,8 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui &userResourceOwner, &passwordCheckedAt, &intentCheckedAt, - &passkeyCheckedAt, + &webAuthNCheckedAt, + &webAuthNUserPresent, &metadata, &sessions.Count, ) @@ -363,7 +363,6 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui if err != nil { return nil, errors.ThrowInternal(err, "QUERY-SAfeg", "Errors.Internal") } - session.Domain = sessionDomain.String session.UserFactor.UserID = userID.String session.UserFactor.UserCheckedAt = userCheckedAt.Time session.UserFactor.LoginName = loginName.String @@ -371,7 +370,8 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui session.UserFactor.ResourceOwner = userResourceOwner.String session.PasswordFactor.PasswordCheckedAt = passwordCheckedAt.Time session.IntentFactor.IntentCheckedAt = intentCheckedAt.Time - session.PasskeyFactor.PasskeyCheckedAt = passkeyCheckedAt.Time + session.WebAuthNFactor.WebAuthNCheckedAt = webAuthNCheckedAt.Time + session.WebAuthNFactor.UserVerified = webAuthNUserPresent.Bool session.Metadata = metadata sessions.Sessions = append(sessions.Sessions, session) diff --git a/internal/query/sessions_test.go b/internal/query/sessions_test.go index eed2ebafdd..92df908849 100644 --- a/internal/query/sessions_test.go +++ b/internal/query/sessions_test.go @@ -17,51 +17,51 @@ import ( ) var ( - expectedSessionQuery = regexp.QuoteMeta(`SELECT projections.sessions3.id,` + - ` projections.sessions3.creation_date,` + - ` projections.sessions3.change_date,` + - ` projections.sessions3.sequence,` + - ` projections.sessions3.state,` + - ` projections.sessions3.resource_owner,` + - ` projections.sessions3.creator,` + - ` projections.sessions3.domain,` + - ` projections.sessions3.user_id,` + - ` projections.sessions3.user_checked_at,` + + expectedSessionQuery = regexp.QuoteMeta(`SELECT projections.sessions4.id,` + + ` projections.sessions4.creation_date,` + + ` projections.sessions4.change_date,` + + ` projections.sessions4.sequence,` + + ` projections.sessions4.state,` + + ` projections.sessions4.resource_owner,` + + ` projections.sessions4.creator,` + + ` projections.sessions4.user_id,` + + ` projections.sessions4.user_checked_at,` + ` projections.login_names2.login_name,` + ` projections.users8_humans.display_name,` + ` projections.users8.resource_owner,` + - ` projections.sessions3.password_checked_at,` + - ` projections.sessions3.intent_checked_at,` + - ` projections.sessions3.passkey_checked_at,` + - ` projections.sessions3.metadata,` + - ` projections.sessions3.token_id` + - ` FROM projections.sessions3` + - ` LEFT JOIN projections.login_names2 ON projections.sessions3.user_id = projections.login_names2.user_id AND projections.sessions3.instance_id = projections.login_names2.instance_id` + - ` LEFT JOIN projections.users8_humans ON projections.sessions3.user_id = projections.users8_humans.user_id AND projections.sessions3.instance_id = projections.users8_humans.instance_id` + - ` LEFT JOIN projections.users8 ON projections.sessions3.user_id = projections.users8.id AND projections.sessions3.instance_id = projections.users8.instance_id` + + ` projections.sessions4.password_checked_at,` + + ` projections.sessions4.intent_checked_at,` + + ` projections.sessions4.webauthn_checked_at,` + + ` projections.sessions4.webauthn_user_verified,` + + ` projections.sessions4.metadata,` + + ` projections.sessions4.token_id` + + ` FROM projections.sessions4` + + ` LEFT JOIN projections.login_names2 ON projections.sessions4.user_id = projections.login_names2.user_id AND projections.sessions4.instance_id = projections.login_names2.instance_id` + + ` LEFT JOIN projections.users8_humans ON projections.sessions4.user_id = projections.users8_humans.user_id AND projections.sessions4.instance_id = projections.users8_humans.instance_id` + + ` LEFT JOIN projections.users8 ON projections.sessions4.user_id = projections.users8.id AND projections.sessions4.instance_id = projections.users8.instance_id` + ` AS OF SYSTEM TIME '-1 ms'`) - expectedSessionsQuery = regexp.QuoteMeta(`SELECT projections.sessions3.id,` + - ` projections.sessions3.creation_date,` + - ` projections.sessions3.change_date,` + - ` projections.sessions3.sequence,` + - ` projections.sessions3.state,` + - ` projections.sessions3.resource_owner,` + - ` projections.sessions3.creator,` + - ` projections.sessions3.domain,` + - ` projections.sessions3.user_id,` + - ` projections.sessions3.user_checked_at,` + + expectedSessionsQuery = regexp.QuoteMeta(`SELECT projections.sessions4.id,` + + ` projections.sessions4.creation_date,` + + ` projections.sessions4.change_date,` + + ` projections.sessions4.sequence,` + + ` projections.sessions4.state,` + + ` projections.sessions4.resource_owner,` + + ` projections.sessions4.creator,` + + ` projections.sessions4.user_id,` + + ` projections.sessions4.user_checked_at,` + ` projections.login_names2.login_name,` + ` projections.users8_humans.display_name,` + ` projections.users8.resource_owner,` + - ` projections.sessions3.password_checked_at,` + - ` projections.sessions3.intent_checked_at,` + - ` projections.sessions3.passkey_checked_at,` + - ` projections.sessions3.metadata,` + + ` projections.sessions4.password_checked_at,` + + ` projections.sessions4.intent_checked_at,` + + ` projections.sessions4.webauthn_checked_at,` + + ` projections.sessions4.webauthn_user_verified,` + + ` projections.sessions4.metadata,` + ` COUNT(*) OVER ()` + - ` FROM projections.sessions3` + - ` LEFT JOIN projections.login_names2 ON projections.sessions3.user_id = projections.login_names2.user_id AND projections.sessions3.instance_id = projections.login_names2.instance_id` + - ` LEFT JOIN projections.users8_humans ON projections.sessions3.user_id = projections.users8_humans.user_id AND projections.sessions3.instance_id = projections.users8_humans.instance_id` + - ` LEFT JOIN projections.users8 ON projections.sessions3.user_id = projections.users8.id AND projections.sessions3.instance_id = projections.users8.instance_id` + + ` FROM projections.sessions4` + + ` LEFT JOIN projections.login_names2 ON projections.sessions4.user_id = projections.login_names2.user_id AND projections.sessions4.instance_id = projections.login_names2.instance_id` + + ` LEFT JOIN projections.users8_humans ON projections.sessions4.user_id = projections.users8_humans.user_id AND projections.sessions4.instance_id = projections.users8_humans.instance_id` + + ` LEFT JOIN projections.users8 ON projections.sessions4.user_id = projections.users8.id AND projections.sessions4.instance_id = projections.users8.instance_id` + ` AS OF SYSTEM TIME '-1 ms'`) sessionCols = []string{ @@ -72,7 +72,6 @@ var ( "state", "resource_owner", "creator", - "domain", "user_id", "user_checked_at", "login_name", @@ -80,7 +79,8 @@ var ( "user_resource_owner", "password_checked_at", "intent_checked_at", - "passkey_checked_at", + "webauthn_checked_at", + "webauthn_user_verified", "metadata", "token", } @@ -93,7 +93,6 @@ var ( "state", "resource_owner", "creator", - "domain", "user_id", "user_checked_at", "login_name", @@ -101,7 +100,8 @@ var ( "user_resource_owner", "password_checked_at", "intent_checked_at", - "passkey_checked_at", + "webauthn_checked_at", + "webauthn_user_verified", "metadata", "count", } @@ -146,7 +146,6 @@ func Test_SessionsPrepare(t *testing.T) { domain.SessionStateActive, "ro", "creator", - "domain", "user-id", testNow, "login-name", @@ -155,6 +154,7 @@ func Test_SessionsPrepare(t *testing.T) { testNow, testNow, testNow, + true, []byte(`{"key": "dmFsdWU="}`), }, }, @@ -173,7 +173,6 @@ func Test_SessionsPrepare(t *testing.T) { State: domain.SessionStateActive, ResourceOwner: "ro", Creator: "creator", - Domain: "domain", UserFactor: SessionUserFactor{ UserID: "user-id", UserCheckedAt: testNow, @@ -187,8 +186,9 @@ func Test_SessionsPrepare(t *testing.T) { IntentFactor: SessionIntentFactor{ IntentCheckedAt: testNow, }, - PasskeyFactor: SessionPasskeyFactor{ - PasskeyCheckedAt: testNow, + WebAuthNFactor: SessionWebAuthNFactor{ + WebAuthNCheckedAt: testNow, + UserVerified: true, }, Metadata: map[string][]byte{ "key": []byte("value"), @@ -213,7 +213,6 @@ func Test_SessionsPrepare(t *testing.T) { domain.SessionStateActive, "ro", "creator", - "domain", "user-id", testNow, "login-name", @@ -222,6 +221,7 @@ func Test_SessionsPrepare(t *testing.T) { testNow, testNow, testNow, + true, []byte(`{"key": "dmFsdWU="}`), }, { @@ -232,7 +232,6 @@ func Test_SessionsPrepare(t *testing.T) { domain.SessionStateActive, "ro", "creator2", - "domain", "user-id2", testNow, "login-name2", @@ -241,6 +240,7 @@ func Test_SessionsPrepare(t *testing.T) { testNow, testNow, testNow, + false, []byte(`{"key": "dmFsdWU="}`), }, }, @@ -259,7 +259,6 @@ func Test_SessionsPrepare(t *testing.T) { State: domain.SessionStateActive, ResourceOwner: "ro", Creator: "creator", - Domain: "domain", UserFactor: SessionUserFactor{ UserID: "user-id", UserCheckedAt: testNow, @@ -273,8 +272,9 @@ func Test_SessionsPrepare(t *testing.T) { IntentFactor: SessionIntentFactor{ IntentCheckedAt: testNow, }, - PasskeyFactor: SessionPasskeyFactor{ - PasskeyCheckedAt: testNow, + WebAuthNFactor: SessionWebAuthNFactor{ + WebAuthNCheckedAt: testNow, + UserVerified: true, }, Metadata: map[string][]byte{ "key": []byte("value"), @@ -288,7 +288,6 @@ func Test_SessionsPrepare(t *testing.T) { State: domain.SessionStateActive, ResourceOwner: "ro", Creator: "creator2", - Domain: "domain", UserFactor: SessionUserFactor{ UserID: "user-id2", UserCheckedAt: testNow, @@ -302,8 +301,9 @@ func Test_SessionsPrepare(t *testing.T) { IntentFactor: SessionIntentFactor{ IntentCheckedAt: testNow, }, - PasskeyFactor: SessionPasskeyFactor{ - PasskeyCheckedAt: testNow, + WebAuthNFactor: SessionWebAuthNFactor{ + WebAuthNCheckedAt: testNow, + UserVerified: false, }, Metadata: map[string][]byte{ "key": []byte("value"), @@ -381,7 +381,6 @@ func Test_SessionPrepare(t *testing.T) { domain.SessionStateActive, "ro", "creator", - "domain", "user-id", testNow, "login-name", @@ -390,6 +389,7 @@ func Test_SessionPrepare(t *testing.T) { testNow, testNow, testNow, + true, []byte(`{"key": "dmFsdWU="}`), "tokenID", }, @@ -403,7 +403,6 @@ func Test_SessionPrepare(t *testing.T) { State: domain.SessionStateActive, ResourceOwner: "ro", Creator: "creator", - Domain: "domain", UserFactor: SessionUserFactor{ UserID: "user-id", UserCheckedAt: testNow, @@ -417,8 +416,9 @@ func Test_SessionPrepare(t *testing.T) { IntentFactor: SessionIntentFactor{ IntentCheckedAt: testNow, }, - PasskeyFactor: SessionPasskeyFactor{ - PasskeyCheckedAt: testNow, + WebAuthNFactor: SessionWebAuthNFactor{ + WebAuthNCheckedAt: testNow, + UserVerified: true, }, Metadata: map[string][]byte{ "key": []byte("value"), diff --git a/internal/repository/session/eventstore.go b/internal/repository/session/eventstore.go index 07f458586d..89f6d775e4 100644 --- a/internal/repository/session/eventstore.go +++ b/internal/repository/session/eventstore.go @@ -7,8 +7,8 @@ func RegisterEventMappers(es *eventstore.Eventstore) { RegisterFilterEventMapper(AggregateType, UserCheckedType, UserCheckedEventMapper). RegisterFilterEventMapper(AggregateType, PasswordCheckedType, PasswordCheckedEventMapper). RegisterFilterEventMapper(AggregateType, IntentCheckedType, IntentCheckedEventMapper). - RegisterFilterEventMapper(AggregateType, PasskeyChallengedType, eventstore.GenericEventMapper[PasskeyChallengedEvent]). - RegisterFilterEventMapper(AggregateType, PasskeyCheckedType, eventstore.GenericEventMapper[PasskeyCheckedEvent]). + RegisterFilterEventMapper(AggregateType, WebAuthNChallengedType, eventstore.GenericEventMapper[WebAuthNChallengedEvent]). + RegisterFilterEventMapper(AggregateType, WebAuthNCheckedType, eventstore.GenericEventMapper[WebAuthNCheckedEvent]). RegisterFilterEventMapper(AggregateType, TokenSetType, TokenSetEventMapper). RegisterFilterEventMapper(AggregateType, MetadataSetType, MetadataSetEventMapper). RegisterFilterEventMapper(AggregateType, TerminateType, TerminateEventMapper) diff --git a/internal/repository/session/session.go b/internal/repository/session/session.go index 9b48543ecb..f2779fa503 100644 --- a/internal/repository/session/session.go +++ b/internal/repository/session/session.go @@ -12,22 +12,20 @@ import ( ) const ( - sessionEventPrefix = "session." - AddedType = sessionEventPrefix + "added" - UserCheckedType = sessionEventPrefix + "user.checked" - PasswordCheckedType = sessionEventPrefix + "password.checked" - IntentCheckedType = sessionEventPrefix + "intent.checked" - PasskeyChallengedType = sessionEventPrefix + "passkey.challenged" - PasskeyCheckedType = sessionEventPrefix + "passkey.checked" - TokenSetType = sessionEventPrefix + "token.set" - MetadataSetType = sessionEventPrefix + "metadata.set" - TerminateType = sessionEventPrefix + "terminated" + sessionEventPrefix = "session." + AddedType = sessionEventPrefix + "added" + UserCheckedType = sessionEventPrefix + "user.checked" + PasswordCheckedType = sessionEventPrefix + "password.checked" + IntentCheckedType = sessionEventPrefix + "intent.checked" + WebAuthNChallengedType = sessionEventPrefix + "webAuthN.challenged" + WebAuthNCheckedType = sessionEventPrefix + "webAuthN.checked" + TokenSetType = sessionEventPrefix + "token.set" + MetadataSetType = sessionEventPrefix + "metadata.set" + TerminateType = sessionEventPrefix + "terminated" ) type AddedEvent struct { eventstore.BaseEvent `json:"-"` - - Domain string `json:"domain,omitempty"` } func (e *AddedEvent) Data() interface{} { @@ -40,7 +38,6 @@ func (e *AddedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { func NewAddedEvent(ctx context.Context, aggregate *eventstore.Aggregate, - domain string, ) *AddedEvent { return &AddedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -48,7 +45,6 @@ func NewAddedEvent(ctx context.Context, aggregate, AddedType, ), - Domain: domain, } } @@ -190,75 +186,81 @@ func IntentCheckedEventMapper(event *repository.Event) (eventstore.Event, error) return added, nil } -type PasskeyChallengedEvent struct { +type WebAuthNChallengedEvent struct { eventstore.BaseEvent `json:"-"` Challenge string `json:"challenge,omitempty"` AllowedCrentialIDs [][]byte `json:"allowedCrentialIDs,omitempty"` UserVerification domain.UserVerificationRequirement `json:"userVerification,omitempty"` + RPID string `json:"rpid,omitempty"` } -func (e *PasskeyChallengedEvent) Data() interface{} { +func (e *WebAuthNChallengedEvent) Data() interface{} { return e } -func (e *PasskeyChallengedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { +func (e *WebAuthNChallengedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { return nil } -func (e *PasskeyChallengedEvent) SetBaseEvent(base *eventstore.BaseEvent) { +func (e *WebAuthNChallengedEvent) SetBaseEvent(base *eventstore.BaseEvent) { e.BaseEvent = *base } -func NewPasskeyChallengedEvent( +func NewWebAuthNChallengedEvent( ctx context.Context, aggregate *eventstore.Aggregate, challenge string, allowedCrentialIDs [][]byte, userVerification domain.UserVerificationRequirement, -) *PasskeyChallengedEvent { - return &PasskeyChallengedEvent{ + rpid string, +) *WebAuthNChallengedEvent { + return &WebAuthNChallengedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( ctx, aggregate, - PasskeyChallengedType, + WebAuthNChallengedType, ), Challenge: challenge, AllowedCrentialIDs: allowedCrentialIDs, UserVerification: userVerification, + RPID: rpid, } } -type PasskeyCheckedEvent struct { +type WebAuthNCheckedEvent struct { eventstore.BaseEvent `json:"-"` - CheckedAt time.Time `json:"checkedAt"` + CheckedAt time.Time `json:"checkedAt"` + UserVerified bool `json:"userVerified,omitempty"` } -func (e *PasskeyCheckedEvent) Data() interface{} { +func (e *WebAuthNCheckedEvent) Data() interface{} { return e } -func (e *PasskeyCheckedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { +func (e *WebAuthNCheckedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { return nil } -func (e *PasskeyCheckedEvent) SetBaseEvent(base *eventstore.BaseEvent) { +func (e *WebAuthNCheckedEvent) SetBaseEvent(base *eventstore.BaseEvent) { e.BaseEvent = *base } -func NewPasskeyCheckedEvent( +func NewWebAuthNCheckedEvent( ctx context.Context, aggregate *eventstore.Aggregate, checkedAt time.Time, -) *PasswordCheckedEvent { - return &PasswordCheckedEvent{ + userVerified bool, +) *WebAuthNCheckedEvent { + return &WebAuthNCheckedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( ctx, aggregate, - PasskeyCheckedType, + WebAuthNCheckedType, ), - CheckedAt: checkedAt, + CheckedAt: checkedAt, + UserVerified: userVerified, } } diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index 9f0d302cd4..b719bf191f 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -496,8 +496,8 @@ Errors: Terminated: Сесията вече е прекратена Token: Invalid: Токенът на сесията е невалиден - Passkey: - NoChallenge: Сесия без предизвикателство за парола + WebAuthN: + NoChallenge: Сесия без WebAuthN предизвикателство Intent: IDPMissing: IDP липсва в заявката SuccessURLMissing: В заявката липсва URL адрес за успех diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 9576d6e299..ff6c85d6ad 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -478,8 +478,8 @@ Errors: Terminated: Session bereits beendet Token: Invalid: Session Token ist ungültig - Passkey: - NoChallenge: Sitzung ohne Passkey-Herausforderung + WebAuthN: + NoChallenge: Sitzung ohne WebAuthN-Challenge Intent: IDPMissing: IDP ID fehlt im Request SuccessURLMissing: Success URL fehlt im Request diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index b9f92456d5..9058c15d97 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -478,8 +478,8 @@ Errors: Terminated: Session already terminated Token: Invalid: Session Token is invalid - Passkey: - NoChallenge: Session without passkey challenge + WebAuthN: + NoChallenge: Session without WebAuthN challenge Intent: IDPMissing: IDP ID is missing in the request SuccessURLMissing: Success URL is missing in the request diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index e45a075918..475280504d 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -478,8 +478,8 @@ Errors: Terminated: Sesión ya terminada Token: Invalid: El identificador de sesión no es válido - Passkey: - NoChallenge: Sesión sin desafío de contraseña + WebAuthN: + NoChallenge: Sesión sin desafío WebAuthN Intent: IDPMissing: Falta IDP en la solicitud SuccessURLMissing: Falta la URL de éxito en la solicitud diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 150d9b0302..54ad40236b 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -478,8 +478,8 @@ Errors: Terminated: La session est déjà terminée Token: Invalid: Le jeton de session n'est pas valide - Passkey: - NoChallenge: Session sans défi de clé d'accès + WebAuthN: + NoChallenge: Session sans challenge WebAuthN Intent: IDPMissing: IDP manquant dans la requête SuccessURLMissing: Success URL absent de la requête diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index fc50f082cf..958eea5b12 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -478,8 +478,8 @@ Errors: Terminated: Sessione già terminata Token: Invalid: Il token della sessione non è valido - Passkey: - NoChallenge: Sessione senza sfida passkey + WebAuthN: + NoChallenge: Sessione senza sfida WebAuthN Intent: IDPMissing: IDP mancante nella richiesta SuccessURLMissing: URL di successo mancante nella richiesta diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index bbf565e071..c0a735c1e8 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -467,8 +467,8 @@ Errors: Terminated: セッションはすでに終了しています Token: Invalid: セッショントークンが無効です - Passkey: - NoChallenge: パスキーチャレンジなしのセッション + WebAuthN: + NoChallenge: WebAuthN チャレンジを使用しないセッション Intent: IDPMissing: リクエストにIDP IDが含まれていません SuccessURLMissing: リクエストに成功時の URL がありません diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index 78d059f1f3..36d1b1c748 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -478,8 +478,8 @@ Errors: Terminated: Сесијата е веќе завршена Token: Invalid: Токенот за сесија е невалиден - Passkey: - NoChallenge: Сесија без предизвик за passkey + WebAuthN: + NoChallenge: Сесија без предизвик WebAuthN Intent: IDPMissing: ID на IDP недостасува во барањето SuccessURLMissing: URL за успех недостасува во барањето diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index fb7b80114a..1e831f6824 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -478,8 +478,8 @@ Errors: Terminated: Sesja już zakończona Token: Invalid: Token sesji jest nieprawidłowy - Passkey: - NoChallenge: Sesja bez wyzwania klucza + WebAuthN: + NoChallenge: Sesja bez wyzwania WebAuthN Intent: IDPMissing: Brak identyfikatora IDP w żądaniu SuccessURLMissing: Brak adresu URL powodzenia w żądaniu diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index 932ed66543..9b823dbfec 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -477,8 +477,8 @@ Errors: Terminated: A sessão já foi encerrada Token: Invalid: O token da sessão é inválido - Passkey: - NoChallenge: Sessão sem desafio de senha + WebAuthN: + NoChallenge: Sessão sem desafio WebAuthN Intent: IDPMissing: O ID do IDP está faltando na solicitação SuccessURLMissing: A URL de sucesso está faltando na solicitação diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 10f7072d30..c1d7229cce 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -478,8 +478,8 @@ Errors: Terminated: 会话已经终止 Token: Invalid: 会话令牌是无效的 - Passkey: - NoChallenge: 没有密码挑战的会话 + WebAuthN: + NoChallenge: 没有 WebAuthN 质询的会话 Intent: IDPMissing: 请求中缺少IDP ID SuccessURLMissing: 请求中缺少成功URL diff --git a/internal/webauthn/client.go b/internal/webauthn/client.go index 511378feed..62232154bc 100644 --- a/internal/webauthn/client.go +++ b/internal/webauthn/client.go @@ -9,9 +9,10 @@ import ( ) type Client struct { - rp virtualwebauthn.RelyingParty - auth virtualwebauthn.Authenticator - credential virtualwebauthn.Credential + rp virtualwebauthn.RelyingParty + auth virtualwebauthn.Authenticator + authVerifyUser virtualwebauthn.Authenticator + credential virtualwebauthn.Credential } func NewClient(name, domain, origin string) *Client { @@ -21,9 +22,12 @@ func NewClient(name, domain, origin string) *Client { Origin: origin, } return &Client{ - rp: rp, - auth: virtualwebauthn.NewAuthenticator(), - credential: virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2), + rp: rp, + auth: virtualwebauthn.NewAuthenticatorWithOptions(virtualwebauthn.AuthenticatorOptions{ + UserNotVerified: true, + }), + authVerifyUser: virtualwebauthn.NewAuthenticator(), + credential: virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2), } } @@ -46,7 +50,7 @@ func (c *Client) CreateAttestationResponse(optionsPb *structpb.Struct) (*structp return resp, nil } -func (c *Client) CreateAssertionResponse(optionsPb *structpb.Struct) (*structpb.Struct, error) { +func (c *Client) CreateAssertionResponse(optionsPb *structpb.Struct, verifyUser bool) (*structpb.Struct, error) { options, err := protojson.Marshal(optionsPb) if err != nil { return nil, fmt.Errorf("webauthn.Client.CreateAssertionResponse: %w", err) @@ -55,9 +59,13 @@ func (c *Client) CreateAssertionResponse(optionsPb *structpb.Struct) (*structpb. if err != nil { return nil, fmt.Errorf("webauthn.Client.CreateAssertionResponse: %w", err) } + authenticator := c.auth + if verifyUser { + authenticator = c.authVerifyUser + } resp := new(structpb.Struct) err = protojson.Unmarshal([]byte(virtualwebauthn.CreateAssertionResponse( - c.rp, c.auth, c.credential, *parsedAssertionOptions, + c.rp, authenticator, c.credential, *parsedAssertionOptions, )), resp) if err != nil { return nil, fmt.Errorf("webauthn.Client.CreateAssertionResponse: %w", err) diff --git a/internal/webauthn/webauthn.go b/internal/webauthn/webauthn.go index bced4f220f..866f174a2e 100644 --- a/internal/webauthn/webauthn.go +++ b/internal/webauthn/webauthn.go @@ -154,10 +154,10 @@ func (w *Config) BeginLogin(ctx context.Context, user *domain.Human, userVerific }, nil } -func (w *Config) FinishLogin(ctx context.Context, user *domain.Human, webAuthN *domain.WebAuthNLogin, credData []byte, webAuthNs ...*domain.WebAuthNToken) ([]byte, uint32, error) { +func (w *Config) FinishLogin(ctx context.Context, user *domain.Human, webAuthN *domain.WebAuthNLogin, credData []byte, webAuthNs ...*domain.WebAuthNToken) (*webauthn.Credential, error) { assertionData, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(credData)) if err != nil { - return nil, 0, caos_errs.ThrowInternal(err, "WEBAU-ADgv4", "Errors.User.WebAuthN.ValidateLoginFailed") + return nil, caos_errs.ThrowInternal(err, "WEBAU-ADgv4", "Errors.User.WebAuthN.ValidateLoginFailed") } webUser := &webUser{ Human: user, @@ -165,17 +165,17 @@ func (w *Config) FinishLogin(ctx context.Context, user *domain.Human, webAuthN * } webAuthNServer, err := w.serverFromContext(ctx, webAuthN.RPID, assertionData.Response.CollectedClientData.Origin) if err != nil { - return nil, 0, err + return nil, err } credential, err := webAuthNServer.ValidateLogin(webUser, WebAuthNLoginToSessionData(webAuthN), assertionData) if err != nil { - return nil, 0, caos_errs.ThrowInternal(err, "WEBAU-3M9si", "Errors.User.WebAuthN.ValidateLoginFailed") + return nil, caos_errs.ThrowInternal(err, "WEBAU-3M9si", "Errors.User.WebAuthN.ValidateLoginFailed") } if credential.Authenticator.CloneWarning { - return credential.ID, credential.Authenticator.SignCount, caos_errs.ThrowInternal(err, "WEBAU-4M90s", "Errors.User.WebAuthN.CloneWarning") + return credential, caos_errs.ThrowInternal(err, "WEBAU-4M90s", "Errors.User.WebAuthN.CloneWarning") } - return credential.ID, credential.Authenticator.SignCount, nil + return credential, nil } func (w *Config) serverFromContext(ctx context.Context, id, origin string) (*webauthn.WebAuthn, error) { diff --git a/proto/buf.yaml b/proto/buf.yaml index 483af8667e..5d5a33e93d 100644 --- a/proto/buf.yaml +++ b/proto/buf.yaml @@ -7,6 +7,7 @@ deps: breaking: use: - FILE + ignore_unstable_packages: true lint: use: - MINIMAL diff --git a/proto/zitadel/session/v2alpha/challenge.proto b/proto/zitadel/session/v2alpha/challenge.proto index 498cb729b4..ed1ef6e647 100644 --- a/proto/zitadel/session/v2alpha/challenge.proto +++ b/proto/zitadel/session/v2alpha/challenge.proto @@ -2,18 +2,47 @@ syntax = "proto3"; package zitadel.session.v2alpha; +import "google/api/field_behavior.proto"; import "google/protobuf/struct.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha;session"; -enum ChallengeKind { - CHALLENGE_KIND_UNSPECIFIED = 0; - CHALLENGE_KIND_PASSKEY = 1; +enum UserVerificationRequirement { + USER_VERIFICATION_REQUIREMENT_UNSPECIFIED = 0; + USER_VERIFICATION_REQUIREMENT_REQUIRED = 1; + USER_VERIFICATION_REQUIREMENT_PREFERRED = 2; + USER_VERIFICATION_REQUIREMENT_DISCOURAGED = 3; +} + +message RequestChallenges { + message WebAuthN { + string domain = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"Domain on which the session was created. Will be used in the WebAuthN challenge.\""; + } + ]; + UserVerificationRequirement user_verification_requirement = 2 [ + (validate.rules).enum = { + defined_only: true, + not_in: [0] + }, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"User verification that is required during validation. When set to `USER_VERIFICATION_REQUIREMENT_REQUIRED` the behaviour is for passkey authentication. Other values will mean U2F\""; + ref: "https://www.w3.org/TR/webauthn/#enum-userVerificationRequirement"; + } + ]; + } + + optional WebAuthN web_auth_n = 1; } message Challenges { - message Passkey { + message WebAuthN { google.protobuf.Struct public_key_credential_request_options = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { description: "Options for Assertion Generaration (dictionary PublicKeyCredentialRequestOptions). Generated helper methods transform the field to JSON, for use in a WebauthN client. See also: https://www.w3.org/TR/webauthn/#dictdef-publickeycredentialrequestoptions" @@ -22,5 +51,5 @@ message Challenges { ]; } - optional Passkey passkey = 1; + optional WebAuthN web_auth_n = 1; } diff --git a/proto/zitadel/session/v2alpha/session.proto b/proto/zitadel/session/v2alpha/session.proto index e6e69ca50d..37436ed2d4 100644 --- a/proto/zitadel/session/v2alpha/session.proto +++ b/proto/zitadel/session/v2alpha/session.proto @@ -39,17 +39,12 @@ message Session { description: "\"custom key value list\""; } ]; - string domain = 7 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "\"domain on which the session was created\""; - } - ]; } message Factors { UserFactor user = 1; PasswordFactor password = 2; - PasskeyFactor passkey = 3; + WebAuthNFactor web_auth_n = 3; IntentFactor intent = 4; } @@ -97,12 +92,13 @@ message IntentFactor { ]; } -message PasskeyFactor { +message WebAuthNFactor { google.protobuf.Timestamp verified_at = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { description: "\"time when the passkey challenge was last checked\""; } ]; + bool user_verified = 2; } message SearchQuery { diff --git a/proto/zitadel/session/v2alpha/session_service.proto b/proto/zitadel/session/v2alpha/session_service.proto index 2703176262..c201b5e368 100644 --- a/proto/zitadel/session/v2alpha/session_service.proto +++ b/proto/zitadel/session/v2alpha/session_service.proto @@ -244,12 +244,7 @@ message CreateSessionRequest{ description: "\"custom key value list to be stored on the session\""; } ]; - repeated ChallengeKind challenges = 3; - string domain = 4 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "\"Domain on which the session was created. Will be used for Passkey and U2F challenges.\""; - } - ]; + RequestChallenges challenges = 3; } message CreateSessionResponse{ @@ -296,7 +291,7 @@ message SetSessionRequest{ description: "\"custom key value list to be stored on the session\""; } ]; - repeated ChallengeKind challenges = 5; + RequestChallenges challenges = 5; } message SetSessionResponse{ @@ -306,7 +301,7 @@ message SetSessionResponse{ description: "\"token of the session, which is required for further updates of the session or the request other resources\""; } ]; - Challenges challenges = 3; + Challenges challenges = 3; } message DeleteSessionRequest{ @@ -341,9 +336,9 @@ message Checks { description: "\"Checks the password and updates the session on success. Requires that the user is already checked, either in the previous or the same request.\""; } ]; - optional CheckPasskey passkey = 3 [ + optional CheckWebAuthN web_auth_n = 3 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "\"Checks the public key credential issued by the passkey client. Requires that the user is already checked and a passkey challenge to be requested, in any previous request.\""; + description: "\"Checks the public key credential issued by the WebAuthN client. Requires that the user is already checked and a WebAuthN challenge to be requested, in any previous request.\""; } ]; optional CheckIntent intent = 4 [ @@ -385,12 +380,12 @@ message CheckPassword { ]; } -message CheckPasskey { +message CheckWebAuthN { google.protobuf.Struct credential_assertion_data = 1 [ (validate.rules).message.required = true, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "JSON representation of public key credential issued by the passkey client"; + description: "JSON representation of public key credential issued by the webAuthN client"; min_length: 55; max_length: 1048576; //1 MB }