diff --git a/internal/api/grpc/session/v2/session.go b/internal/api/grpc/session/v2/session.go index ea61528fcb..54e01ae64c 100644 --- a/internal/api/grpc/session/v2/session.go +++ b/internal/api/grpc/session/v2/session.go @@ -120,6 +120,7 @@ func factorsToPb(s *query.Session) *session.Factors { Password: passwordFactorToPb(s.PasswordFactor), WebAuthN: webAuthNFactorToPb(s.WebAuthNFactor), Intent: intentFactorToPb(s.IntentFactor), + Totp: totpFactorToPb(s.TOTPFactor), } } @@ -151,6 +152,15 @@ func webAuthNFactorToPb(factor query.SessionWebAuthNFactor) *session.WebAuthNFac } } +func totpFactorToPb(factor query.SessionTOTPFactor) *session.TOTPFactor { + if factor.TOTPCheckedAt.IsZero() { + return nil + } + return &session.TOTPFactor{ + VerifiedAt: timestamppb.New(factor.TOTPCheckedAt), + } +} + func userFactorToPb(factor query.SessionUserFactor) *session.UserFactor { if factor.UserID == "" || factor.UserCheckedAt.IsZero() { return nil @@ -247,7 +257,9 @@ func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([ if passkey := checks.GetWebAuthN(); passkey != nil { sessionChecks = append(sessionChecks, s.command.CheckWebAuthN(passkey.GetCredentialAssertionData())) } - + if totp := checks.GetTotp(); totp != nil { + sessionChecks = append(sessionChecks, command.CheckTOTP(totp.GetTotp())) + } return sessionChecks, nil } diff --git a/internal/api/grpc/session/v2/session_integration_test.go b/internal/api/grpc/session/v2/session_integration_test.go index 7dd9355a71..14e99c6926 100644 --- a/internal/api/grpc/session/v2/session_integration_test.go +++ b/internal/api/grpc/session/v2/session_integration_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/muhlemmer/gu" + "github.com/pquerna/otp/totp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc/metadata" @@ -45,6 +46,7 @@ func TestMain(m *testing.M) { } func verifyCurrentSession(t testing.TB, id, token string, sequence uint64, window time.Duration, metadata map[string][]byte, factors ...wantFactor) *session.Session { + t.Helper() require.NotEmpty(t, id) require.NotEmpty(t, token) @@ -71,6 +73,7 @@ const ( wantPasswordFactor wantWebAuthNFactor wantWebAuthNFactorUserVerified + wantTOTPFactor wantIntentFactor ) @@ -90,12 +93,16 @@ func verifyFactors(t testing.TB, factors *session.Factors, window time.Duration, 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) + assert.False(t, pf.GetUserVerified()) 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) + assert.True(t, pf.GetUserVerified()) + case wantTOTPFactor: + pf := factors.GetTotp() + assert.NotNil(t, pf) + assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window)) case wantIntentFactor: pf := factors.GetIntent() assert.NotNil(t, pf) @@ -338,6 +345,23 @@ func TestServer_CreateSession_startedIntentFalseToken(t *testing.T) { require.Error(t, err) } +func registerTOTP(ctx context.Context, t *testing.T, userID string) (secret string) { + resp, err := Tester.Client.UserV2.RegisterTOTP(ctx, &user.RegisterTOTPRequest{ + UserId: userID, + }) + require.NoError(t, err) + secret = resp.GetSecret() + code, err := totp.GenerateCode(secret, time.Now()) + require.NoError(t, err) + + _, err = Tester.Client.UserV2.VerifyTOTPRegistration(ctx, &user.VerifyTOTPRegistrationRequest{ + UserId: userID, + Code: code, + }) + require.NoError(t, err) + return secret +} + func TestServer_SetSession_flow(t *testing.T) { // create new, empty session createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{}) @@ -346,7 +370,6 @@ func TestServer_SetSession_flow(t *testing.T) { verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, createResp.GetDetails().GetSequence(), time.Minute, nil) t.Run("check user", func(t *testing.T) { - wantFactors := []wantFactor{wantUserFactor} resp, err := Client.SetSession(CTX, &session.SetSessionRequest{ SessionId: createResp.GetSessionId(), SessionToken: sessionToken, @@ -360,7 +383,7 @@ func TestServer_SetSession_flow(t *testing.T) { }) require.NoError(t, err) sessionToken = resp.GetSessionToken() - verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, wantFactors...) + verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, wantUserFactor) }) t.Run("check webauthn, user verified (passkey)", func(t *testing.T) { @@ -378,7 +401,6 @@ func TestServer_SetSession_flow(t *testing.T) { verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil) sessionToken = resp.GetSessionToken() - wantFactors := []wantFactor{wantUserFactor, wantWebAuthNFactorUserVerified} assertionData, err := Tester.WebAuthN.CreateAssertionResponse(resp.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), true) require.NoError(t, err) @@ -393,14 +415,14 @@ func TestServer_SetSession_flow(t *testing.T) { }) require.NoError(t, err) sessionToken = resp.GetSessionToken() - verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, wantFactors...) + verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, wantUserFactor, wantWebAuthNFactorUserVerified) }) + userAuthCtx := Tester.WithAuthorizationToken(CTX, sessionToken) + Tester.RegisterUserU2F(userAuthCtx, User.GetUserId()) + totpSecret := registerTOTP(userAuthCtx, t, User.GetUserId()) + 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, @@ -421,7 +443,6 @@ func TestServer_SetSession_flow(t *testing.T) { 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) @@ -436,10 +457,27 @@ func TestServer_SetSession_flow(t *testing.T) { }) require.NoError(t, err) sessionToken = resp.GetSessionToken() - verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, wantFactors...) + verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, wantUserFactor, wantWebAuthNFactor) }) } }) + + t.Run("check TOTP", func(t *testing.T) { + code, err := totp.GenerateCode(totpSecret, time.Now()) + require.NoError(t, err) + resp, err := Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + SessionToken: sessionToken, + Checks: &session.Checks{ + Totp: &session.CheckTOTP{ + Totp: code, + }, + }, + }) + require.NoError(t, err) + sessionToken = resp.GetSessionToken() + verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, wantUserFactor, wantWebAuthNFactor, wantTOTPFactor) + }) } func Test_ZITADEL_API_missing_authentication(t *testing.T) { diff --git a/internal/api/grpc/session/v2/session_test.go b/internal/api/grpc/session/v2/session_test.go index 0f4d9b3c66..a7d725899c 100644 --- a/internal/api/grpc/session/v2/session_test.go +++ b/internal/api/grpc/session/v2/session_test.go @@ -91,6 +91,26 @@ func Test_sessionsToPb(t *testing.T) { }, Metadata: map[string][]byte{"hello": []byte("world")}, }, + { // totp factor + ID: "999", + CreationDate: now, + ChangeDate: now, + Sequence: 123, + State: domain.SessionStateActive, + ResourceOwner: "me", + Creator: "he", + UserFactor: query.SessionUserFactor{ + UserID: "345", + UserCheckedAt: past, + LoginName: "donald", + DisplayName: "donald duck", + ResourceOwner: "org1", + }, + TOTPFactor: query.SessionTOTPFactor{ + TOTPCheckedAt: past, + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, } want := []*session.Session{ @@ -157,6 +177,25 @@ func Test_sessionsToPb(t *testing.T) { }, Metadata: map[string][]byte{"hello": []byte("world")}, }, + { // totp factor + Id: "999", + CreationDate: timestamppb.New(now), + ChangeDate: timestamppb.New(now), + Sequence: 123, + Factors: &session.Factors{ + User: &session.UserFactor{ + VerifiedAt: timestamppb.New(past), + Id: "345", + LoginName: "donald", + DisplayName: "donald duck", + OrganisationId: "org1", + }, + Totp: &session.TOTPFactor{ + VerifiedAt: timestamppb.New(past), + }, + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, } out := sessionsToPb(sessions) diff --git a/internal/command/session.go b/internal/command/session.go index 6bcf9f044d..fead616c2f 100644 --- a/internal/command/session.go +++ b/internal/command/session.go @@ -26,11 +26,13 @@ type SessionCommands struct { sessionWriteModel *SessionWriteModel passwordWriteModel *HumanPasswordWriteModel intentWriteModel *IDPIntentWriteModel + totpWriteModel *HumanTOTPWriteModel eventstore *eventstore.Eventstore eventCommands []eventstore.Command hasher *crypto.PasswordHasher intentAlg crypto.EncryptionAlgorithm + totpAlg crypto.EncryptionAlgorithm createToken func(sessionID string) (id string, token string, err error) now func() time.Time } @@ -42,6 +44,7 @@ func (c *Commands) NewSessionCommands(cmds []SessionCommand, session *SessionWri eventstore: c.eventstore, hasher: c.userPasswordHasher, intentAlg: c.idpConfigEncryption, + totpAlg: c.multifactors.OTP.CryptoMFA, createToken: c.sessionTokenCreator, now: time.Now, } @@ -127,6 +130,28 @@ func CheckIntent(intentID, token string) SessionCommand { } } +func CheckTOTP(code string) SessionCommand { + return func(ctx context.Context, cmd *SessionCommands) (err error) { + if cmd.sessionWriteModel.UserID == "" { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Neil7", "Errors.User.UserIDMissing") + } + cmd.totpWriteModel = NewHumanTOTPWriteModel(cmd.sessionWriteModel.UserID, "") + err = cmd.eventstore.FilterToQueryReducer(ctx, cmd.totpWriteModel) + if err != nil { + return err + } + if cmd.totpWriteModel.State != domain.MFAStateReady { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-eej1U", "Errors.User.MFA.OTP.NotReady") + } + err = domain.VerifyTOTP(code, cmd.totpWriteModel.Secret, cmd.totpAlg) + if err != nil { + return err + } + cmd.TOTPChecked(ctx, cmd.now()) + return nil + } +} + // Exec will execute the commands specified and returns an error on the first occurrence func (s *SessionCommands) Exec(ctx context.Context) error { for _, cmd := range s.sessionCommands { @@ -175,6 +200,10 @@ func (s *SessionCommands) WebAuthNChecked(ctx context.Context, checkedAt time.Ti } } +func (s *SessionCommands) TOTPChecked(ctx context.Context, checkedAt time.Time) { + s.eventCommands = append(s.eventCommands, session.NewTOTPCheckedEvent(ctx, s.sessionWriteModel.aggregate, checkedAt)) +} + func (s *SessionCommands) SetToken(ctx context.Context, tokenID string) { s.eventCommands = append(s.eventCommands, session.NewTokenSetEvent(ctx, s.sessionWriteModel.aggregate, tokenID)) } diff --git a/internal/command/session_model.go b/internal/command/session_model.go index 7674da9bcc..373e5b96b4 100644 --- a/internal/command/session_model.go +++ b/internal/command/session_model.go @@ -35,6 +35,7 @@ type SessionWriteModel struct { PasswordCheckedAt time.Time IntentCheckedAt time.Time WebAuthNCheckedAt time.Time + TOTPCheckedAt time.Time WebAuthNUserVerified bool Metadata map[string][]byte State domain.SessionState @@ -70,6 +71,8 @@ func (wm *SessionWriteModel) Reduce() error { wm.reduceWebAuthNChallenged(e) case *session.WebAuthNCheckedEvent: wm.reduceWebAuthNChecked(e) + case *session.TOTPCheckedEvent: + wm.reduceTOTPChecked(e) case *session.TokenSetEvent: wm.reduceTokenSet(e) case *session.TerminateEvent: @@ -91,6 +94,7 @@ func (wm *SessionWriteModel) Query() *eventstore.SearchQueryBuilder { session.IntentCheckedType, session.WebAuthNChallengedType, session.WebAuthNCheckedType, + session.TOTPCheckedType, session.TokenSetType, session.MetadataSetType, session.TerminateType, @@ -135,6 +139,10 @@ func (wm *SessionWriteModel) reduceWebAuthNChecked(e *session.WebAuthNCheckedEve wm.WebAuthNUserVerified = e.UserVerified } +func (wm *SessionWriteModel) reduceTOTPChecked(e *session.TOTPCheckedEvent) { + wm.TOTPCheckedAt = e.CheckedAt +} + func (wm *SessionWriteModel) reduceTokenSet(e *session.TokenSetEvent) { wm.TokenID = e.TokenID } @@ -149,8 +157,8 @@ func (wm *SessionWriteModel) AuthenticationTime() time.Time { for _, check := range []time.Time{ wm.PasswordCheckedAt, wm.WebAuthNCheckedAt, + wm.TOTPCheckedAt, wm.IntentCheckedAt, - // 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) { @@ -176,12 +184,9 @@ func (wm *SessionWriteModel) AuthMethodTypes() []domain.UserAuthMethodType { if !wm.IntentCheckedAt.IsZero() { types = append(types, domain.UserAuthMethodTypeIDP) } - // TODO: add checks with https://github.com/zitadel/zitadel/issues/5477 - /* - if !wm.TOTPCheckedAt.IsZero() { - types = append(types, domain.UserAuthMethodTypeTOTP) - } - */ + if !wm.TOTPCheckedAt.IsZero() { + types = append(types, domain.UserAuthMethodTypeTOTP) + } // TODO: add checks with https://github.com/zitadel/zitadel/issues/6224 /* if !wm.TOTPFactor.OTPSMSCheckedAt.IsZero() { diff --git a/internal/command/session_test.go b/internal/command/session_test.go index 099740c101..57f3ba971c 100644 --- a/internal/command/session_test.go +++ b/internal/command/session_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/golang/mock/gomock" + "github.com/pquerna/otp/totp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/text/language" @@ -695,6 +696,138 @@ func TestCommands_updateSession(t *testing.T) { } } +func TestCheckTOTP(t *testing.T) { + ctx := authz.NewMockContext("", "org1", "user1") + + cryptoAlg := crypto.CreateMockEncryptionAlg(gomock.NewController(t)) + key, secret, err := domain.NewTOTPKey("example.com", "user1", cryptoAlg) + require.NoError(t, err) + + sessAgg := &session.NewAggregate("session1", "org1").Aggregate + userAgg := &user.NewAggregate("user1", "org1").Aggregate + + code, err := totp.GenerateCode(key.Secret(), testNow) + require.NoError(t, err) + + type fields struct { + sessionWriteModel *SessionWriteModel + eventstore func(*testing.T) *eventstore.Eventstore + } + + tests := []struct { + name string + code string + fields fields + wantEventCommands []eventstore.Command + wantErr error + }{ + { + name: "missing userID", + code: code, + fields: fields{ + sessionWriteModel: &SessionWriteModel{ + aggregate: sessAgg, + }, + eventstore: expectEventstore(), + }, + wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Neil7", "Errors.User.UserIDMissing"), + }, + { + name: "filter error", + code: code, + fields: fields{ + sessionWriteModel: &SessionWriteModel{ + UserID: "user1", + UserCheckedAt: testNow, + aggregate: sessAgg, + }, + eventstore: expectEventstore( + expectFilterError(io.ErrClosedPipe), + ), + }, + wantErr: io.ErrClosedPipe, + }, + { + name: "otp not ready error", + code: code, + fields: fields{ + sessionWriteModel: &SessionWriteModel{ + UserID: "user1", + UserCheckedAt: testNow, + aggregate: sessAgg, + }, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPAddedEvent(ctx, userAgg, secret), + ), + ), + ), + }, + wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-eej1U", "Errors.User.MFA.OTP.NotReady"), + }, + { + name: "otp verify error", + code: "foobar", + fields: fields{ + sessionWriteModel: &SessionWriteModel{ + UserID: "user1", + UserCheckedAt: testNow, + aggregate: sessAgg, + }, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPAddedEvent(ctx, userAgg, secret), + ), + eventFromEventPusher( + user.NewHumanOTPVerifiedEvent(ctx, userAgg, "agent1"), + ), + ), + ), + }, + wantErr: caos_errs.ThrowInvalidArgument(nil, "EVENT-8isk2", "Errors.User.MFA.OTP.InvalidCode"), + }, + { + name: "ok", + code: code, + fields: fields{ + sessionWriteModel: &SessionWriteModel{ + UserID: "user1", + UserCheckedAt: testNow, + aggregate: sessAgg, + }, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPAddedEvent(ctx, userAgg, secret), + ), + eventFromEventPusher( + user.NewHumanOTPVerifiedEvent(ctx, userAgg, "agent1"), + ), + ), + ), + }, + wantEventCommands: []eventstore.Command{ + session.NewTOTPCheckedEvent(ctx, sessAgg, testNow), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &SessionCommands{ + sessionWriteModel: tt.fields.sessionWriteModel, + eventstore: tt.fields.eventstore(t), + totpAlg: cryptoAlg, + now: func() time.Time { return testNow }, + } + err := CheckTOTP(tt.code)(ctx, cmd) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantEventCommands, cmd.eventCommands) + }) + } +} + func TestCommands_TerminateSession(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore diff --git a/internal/integration/integration.go b/internal/integration/integration.go index c645a188fe..d652311190 100644 --- a/internal/integration/integration.go +++ b/internal/integration/integration.go @@ -239,7 +239,12 @@ func (s *Tester) WithInstanceAuthorization(ctx context.Context, u UserType, inst } func (s *Tester) WithAuthorizationToken(ctx context.Context, token string) context.Context { - return metadata.AppendToOutgoingContext(ctx, "Authorization", fmt.Sprintf("Bearer %s", token)) + md, ok := metadata.FromOutgoingContext(ctx) + if !ok { + md = make(metadata.MD) + } + md.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + return metadata.NewOutgoingContext(ctx, md) } func (s *Tester) ensureSystemUser() { diff --git a/internal/query/projection/session.go b/internal/query/projection/session.go index afa48b1c01..654e804270 100644 --- a/internal/query/projection/session.go +++ b/internal/query/projection/session.go @@ -30,6 +30,7 @@ const ( SessionColumnIntentCheckedAt = "intent_checked_at" SessionColumnWebAuthNCheckedAt = "webauthn_checked_at" SessionColumnWebAuthNUserVerified = "webauthn_user_verified" + SessionColumnTOTPCheckedAt = "totp_checked_at" SessionColumnMetadata = "metadata" SessionColumnTokenID = "token_id" ) @@ -58,6 +59,7 @@ func newSessionProjection(ctx context.Context, config crdb.StatementHandlerConfi crdb.NewColumn(SessionColumnIntentCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()), crdb.NewColumn(SessionColumnWebAuthNCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()), crdb.NewColumn(SessionColumnWebAuthNUserVerified, crdb.ColumnTypeBool, crdb.Nullable()), + crdb.NewColumn(SessionColumnTOTPCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()), crdb.NewColumn(SessionColumnMetadata, crdb.ColumnTypeJSONB, crdb.Nullable()), crdb.NewColumn(SessionColumnTokenID, crdb.ColumnTypeText, crdb.Nullable()), }, @@ -93,6 +95,10 @@ func (p *sessionProjection) reducers() []handler.AggregateReducer { Event: session.WebAuthNCheckedType, Reduce: p.reduceWebAuthNChecked, }, + { + Event: session.TOTPCheckedType, + Reduce: p.reduceTOTPChecked, + }, { Event: session.TokenSetType, Reduce: p.reduceTokenSet, @@ -229,6 +235,26 @@ func (p *sessionProjection) reduceWebAuthNChecked(event eventstore.Event) (*hand ), nil } +func (p *sessionProjection) reduceTOTPChecked(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*session.TOTPCheckedEvent) + if !ok { + return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-Oqu8i", "reduce.wrong.event.type %s", session.TOTPCheckedType) + } + + return crdb.NewUpdateStatement( + e, + []handler.Column{ + handler.NewCol(SessionColumnChangeDate, e.CreationDate()), + handler.NewCol(SessionColumnSequence, e.Sequence()), + handler.NewCol(SessionColumnTOTPCheckedAt, e.CheckedAt), + }, + []handler.Condition{ + handler.NewCond(SessionColumnID, e.Aggregate().ID), + handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID), + }, + ), nil +} + func (p *sessionProjection) reduceTokenSet(event eventstore.Event) (*handler.Statement, error) { e, ok := event.(*session.TokenSetEvent) if !ok { diff --git a/internal/query/projection/session_test.go b/internal/query/projection/session_test.go index 5feb0d452c..c22310d620 100644 --- a/internal/query/projection/session_test.go +++ b/internal/query/projection/session_test.go @@ -191,6 +191,38 @@ func TestSessionProjection_reduces(t *testing.T) { }, }, }, + { + name: "instance reduceOTPChecked", + args: args{ + event: getEvent(testEvent( + session.AddedType, + session.AggregateType, + []byte(`{ + "checkedAt": "2023-05-04T00:00:00Z" + }`), + ), eventstore.GenericEventMapper[session.TOTPCheckedEvent]), + }, + reduce: (&sessionProjection{}).reduceTOTPChecked, + want: wantReduce{ + aggregateType: eventstore.AggregateType("session"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.sessions4 SET (change_date, sequence, totp_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedArgs: []interface{}{ + anyArg{}, + anyArg{}, + time.Date(2023, time.May, 4, 0, 0, 0, 0, time.UTC), + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, { name: "instance reduceTokenSet", args: args{ diff --git a/internal/query/session.go b/internal/query/session.go index 5746fe0592..0c7d67dbf8 100644 --- a/internal/query/session.go +++ b/internal/query/session.go @@ -34,6 +34,7 @@ type Session struct { PasswordFactor SessionPasswordFactor IntentFactor SessionIntentFactor WebAuthNFactor SessionWebAuthNFactor + TOTPFactor SessionTOTPFactor Metadata map[string][]byte } @@ -58,6 +59,10 @@ type SessionWebAuthNFactor struct { UserVerified bool } +type SessionTOTPFactor struct { + TOTPCheckedAt time.Time +} + type SessionsSearchQueries struct { SearchRequest Queries []SearchQuery @@ -132,6 +137,10 @@ var ( name: projection.SessionColumnWebAuthNUserVerified, table: sessionsTable, } + SessionColumnTOTPCheckedAt = Column{ + name: projection.SessionColumnTOTPCheckedAt, + table: sessionsTable, + } SessionColumnMetadata = Column{ name: projection.SessionColumnMetadata, table: sessionsTable, @@ -230,6 +239,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil SessionColumnIntentCheckedAt.identifier(), SessionColumnWebAuthNCheckedAt.identifier(), SessionColumnWebAuthNUserVerified.identifier(), + SessionColumnTOTPCheckedAt.identifier(), SessionColumnMetadata.identifier(), SessionColumnToken.identifier(), ).From(sessionsTable.identifier()). @@ -249,6 +259,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil intentCheckedAt sql.NullTime webAuthNCheckedAt sql.NullTime webAuthNUserPresent sql.NullBool + totpCheckedAt sql.NullTime metadata database.Map[[]byte] token sql.NullString ) @@ -270,6 +281,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil &intentCheckedAt, &webAuthNCheckedAt, &webAuthNUserPresent, + &totpCheckedAt, &metadata, &token, ) @@ -290,6 +302,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil session.IntentFactor.IntentCheckedAt = intentCheckedAt.Time session.WebAuthNFactor.WebAuthNCheckedAt = webAuthNCheckedAt.Time session.WebAuthNFactor.UserVerified = webAuthNUserPresent.Bool + session.TOTPFactor.TOTPCheckedAt = totpCheckedAt.Time session.Metadata = metadata return session, token.String, nil @@ -314,6 +327,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui SessionColumnIntentCheckedAt.identifier(), SessionColumnWebAuthNCheckedAt.identifier(), SessionColumnWebAuthNUserVerified.identifier(), + SessionColumnTOTPCheckedAt.identifier(), SessionColumnMetadata.identifier(), countColumn.identifier(), ).From(sessionsTable.identifier()). @@ -336,6 +350,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui intentCheckedAt sql.NullTime webAuthNCheckedAt sql.NullTime webAuthNUserPresent sql.NullBool + totpCheckedAt sql.NullTime metadata database.Map[[]byte] ) @@ -356,6 +371,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui &intentCheckedAt, &webAuthNCheckedAt, &webAuthNUserPresent, + &totpCheckedAt, &metadata, &sessions.Count, ) @@ -372,6 +388,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui session.IntentFactor.IntentCheckedAt = intentCheckedAt.Time session.WebAuthNFactor.WebAuthNCheckedAt = webAuthNCheckedAt.Time session.WebAuthNFactor.UserVerified = webAuthNUserPresent.Bool + session.TOTPFactor.TOTPCheckedAt = totpCheckedAt.Time session.Metadata = metadata sessions.Sessions = append(sessions.Sessions, session) diff --git a/internal/query/sessions_test.go b/internal/query/sessions_test.go index 92df908849..bc9e8bc6a0 100644 --- a/internal/query/sessions_test.go +++ b/internal/query/sessions_test.go @@ -33,6 +33,7 @@ var ( ` projections.sessions4.intent_checked_at,` + ` projections.sessions4.webauthn_checked_at,` + ` projections.sessions4.webauthn_user_verified,` + + ` projections.sessions4.totp_checked_at,` + ` projections.sessions4.metadata,` + ` projections.sessions4.token_id` + ` FROM projections.sessions4` + @@ -56,6 +57,7 @@ var ( ` projections.sessions4.intent_checked_at,` + ` projections.sessions4.webauthn_checked_at,` + ` projections.sessions4.webauthn_user_verified,` + + ` projections.sessions4.totp_checked_at,` + ` projections.sessions4.metadata,` + ` COUNT(*) OVER ()` + ` FROM projections.sessions4` + @@ -81,6 +83,7 @@ var ( "intent_checked_at", "webauthn_checked_at", "webauthn_user_verified", + "totp_checked_at", "metadata", "token", } @@ -102,6 +105,7 @@ var ( "intent_checked_at", "webauthn_checked_at", "webauthn_user_verified", + "totp_checked_at", "metadata", "count", } @@ -155,6 +159,7 @@ func Test_SessionsPrepare(t *testing.T) { testNow, testNow, true, + testNow, []byte(`{"key": "dmFsdWU="}`), }, }, @@ -190,6 +195,9 @@ func Test_SessionsPrepare(t *testing.T) { WebAuthNCheckedAt: testNow, UserVerified: true, }, + TOTPFactor: SessionTOTPFactor{ + TOTPCheckedAt: testNow, + }, Metadata: map[string][]byte{ "key": []byte("value"), }, @@ -222,6 +230,7 @@ func Test_SessionsPrepare(t *testing.T) { testNow, testNow, true, + testNow, []byte(`{"key": "dmFsdWU="}`), }, { @@ -241,6 +250,7 @@ func Test_SessionsPrepare(t *testing.T) { testNow, testNow, false, + testNow, []byte(`{"key": "dmFsdWU="}`), }, }, @@ -276,6 +286,9 @@ func Test_SessionsPrepare(t *testing.T) { WebAuthNCheckedAt: testNow, UserVerified: true, }, + TOTPFactor: SessionTOTPFactor{ + TOTPCheckedAt: testNow, + }, Metadata: map[string][]byte{ "key": []byte("value"), }, @@ -305,6 +318,9 @@ func Test_SessionsPrepare(t *testing.T) { WebAuthNCheckedAt: testNow, UserVerified: false, }, + TOTPFactor: SessionTOTPFactor{ + TOTPCheckedAt: testNow, + }, Metadata: map[string][]byte{ "key": []byte("value"), }, @@ -390,6 +406,7 @@ func Test_SessionPrepare(t *testing.T) { testNow, testNow, true, + testNow, []byte(`{"key": "dmFsdWU="}`), "tokenID", }, @@ -420,6 +437,9 @@ func Test_SessionPrepare(t *testing.T) { WebAuthNCheckedAt: testNow, UserVerified: true, }, + TOTPFactor: SessionTOTPFactor{ + TOTPCheckedAt: testNow, + }, Metadata: map[string][]byte{ "key": []byte("value"), }, diff --git a/internal/repository/session/eventstore.go b/internal/repository/session/eventstore.go index 89f6d775e4..efa52b6582 100644 --- a/internal/repository/session/eventstore.go +++ b/internal/repository/session/eventstore.go @@ -9,6 +9,7 @@ func RegisterEventMappers(es *eventstore.Eventstore) { RegisterFilterEventMapper(AggregateType, IntentCheckedType, IntentCheckedEventMapper). RegisterFilterEventMapper(AggregateType, WebAuthNChallengedType, eventstore.GenericEventMapper[WebAuthNChallengedEvent]). RegisterFilterEventMapper(AggregateType, WebAuthNCheckedType, eventstore.GenericEventMapper[WebAuthNCheckedEvent]). + RegisterFilterEventMapper(AggregateType, TOTPCheckedType, eventstore.GenericEventMapper[TOTPCheckedEvent]). 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 f2779fa503..556cd033c7 100644 --- a/internal/repository/session/session.go +++ b/internal/repository/session/session.go @@ -19,6 +19,7 @@ const ( IntentCheckedType = sessionEventPrefix + "intent.checked" WebAuthNChallengedType = sessionEventPrefix + "webAuthN.challenged" WebAuthNCheckedType = sessionEventPrefix + "webAuthN.checked" + TOTPCheckedType = sessionEventPrefix + "totp.checked" TokenSetType = sessionEventPrefix + "token.set" MetadataSetType = sessionEventPrefix + "metadata.set" TerminateType = sessionEventPrefix + "terminated" @@ -264,6 +265,39 @@ func NewWebAuthNCheckedEvent( } } +type TOTPCheckedEvent struct { + eventstore.BaseEvent `json:"-"` + + CheckedAt time.Time `json:"checkedAt"` +} + +func (e *TOTPCheckedEvent) Data() interface{} { + return e +} + +func (e *TOTPCheckedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *TOTPCheckedEvent) SetBaseEvent(base *eventstore.BaseEvent) { + e.BaseEvent = *base +} + +func NewTOTPCheckedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + checkedAt time.Time, +) *TOTPCheckedEvent { + return &TOTPCheckedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + TOTPCheckedType, + ), + CheckedAt: checkedAt, + } +} + type TokenSetEvent struct { eventstore.BaseEvent `json:"-"` diff --git a/proto/zitadel/session/v2alpha/session.proto b/proto/zitadel/session/v2alpha/session.proto index 37436ed2d4..44f337c0d6 100644 --- a/proto/zitadel/session/v2alpha/session.proto +++ b/proto/zitadel/session/v2alpha/session.proto @@ -46,6 +46,7 @@ message Factors { PasswordFactor password = 2; WebAuthNFactor web_auth_n = 3; IntentFactor intent = 4; + TOTPFactor totp = 5; } message UserFactor { @@ -101,6 +102,14 @@ message WebAuthNFactor { bool user_verified = 2; } +message TOTPFactor { + google.protobuf.Timestamp verified_at = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"time when the Time-based One-Time Password was last checked\""; + } + ]; +} + message SearchQuery { oneof query { option (validate.required) = true; diff --git a/proto/zitadel/session/v2alpha/session_service.proto b/proto/zitadel/session/v2alpha/session_service.proto index c201b5e368..693d6a7103 100644 --- a/proto/zitadel/session/v2alpha/session_service.proto +++ b/proto/zitadel/session/v2alpha/session_service.proto @@ -346,6 +346,11 @@ message Checks { description: "\"Checks the intent. Requires that the userlink is already checked and a successful intent.\""; } ]; + optional CheckTOTP totp = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"Checks the Time-based One-Time Password and updates the session on success. Requires that the user is already checked, either in the previous or the same request.\""; + } + ]; } message CheckUser { @@ -412,3 +417,14 @@ message CheckIntent { } ]; } + +message CheckTOTP { + string totp = 1 [ + (validate.rules).string = {min_len: 6, max_len: 6}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 6; + max_length: 6; + example: "\"323764\""; + } + ]; +} \ No newline at end of file