feat(api/v2): implement TOTP session check (#6362)

* feat(api/v2): implement TOTP session check

* add integration test

* correct typo in projection test

* fix event type typos

---------

Co-authored-by: Livio Spring <livio.a@gmail.com>
This commit is contained in:
Tim Möhlmann 2023-08-15 12:50:42 +03:00 committed by GitHub
parent 8953353210
commit 0017542aa2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 437 additions and 21 deletions

View File

@ -120,6 +120,7 @@ func factorsToPb(s *query.Session) *session.Factors {
Password: passwordFactorToPb(s.PasswordFactor), Password: passwordFactorToPb(s.PasswordFactor),
WebAuthN: webAuthNFactorToPb(s.WebAuthNFactor), WebAuthN: webAuthNFactorToPb(s.WebAuthNFactor),
Intent: intentFactorToPb(s.IntentFactor), 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 { func userFactorToPb(factor query.SessionUserFactor) *session.UserFactor {
if factor.UserID == "" || factor.UserCheckedAt.IsZero() { if factor.UserID == "" || factor.UserCheckedAt.IsZero() {
return nil return nil
@ -247,7 +257,9 @@ func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([
if passkey := checks.GetWebAuthN(); passkey != nil { if passkey := checks.GetWebAuthN(); passkey != nil {
sessionChecks = append(sessionChecks, s.command.CheckWebAuthN(passkey.GetCredentialAssertionData())) sessionChecks = append(sessionChecks, s.command.CheckWebAuthN(passkey.GetCredentialAssertionData()))
} }
if totp := checks.GetTotp(); totp != nil {
sessionChecks = append(sessionChecks, command.CheckTOTP(totp.GetTotp()))
}
return sessionChecks, nil return sessionChecks, nil
} }

View File

@ -10,6 +10,7 @@ import (
"time" "time"
"github.com/muhlemmer/gu" "github.com/muhlemmer/gu"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"google.golang.org/grpc/metadata" "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 { 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, id)
require.NotEmpty(t, token) require.NotEmpty(t, token)
@ -71,6 +73,7 @@ const (
wantPasswordFactor wantPasswordFactor
wantWebAuthNFactor wantWebAuthNFactor
wantWebAuthNFactorUserVerified wantWebAuthNFactorUserVerified
wantTOTPFactor
wantIntentFactor wantIntentFactor
) )
@ -90,12 +93,16 @@ func verifyFactors(t testing.TB, factors *session.Factors, window time.Duration,
pf := factors.GetWebAuthN() pf := factors.GetWebAuthN()
assert.NotNil(t, pf) assert.NotNil(t, pf)
assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window)) 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: case wantWebAuthNFactorUserVerified:
pf := factors.GetWebAuthN() pf := factors.GetWebAuthN()
assert.NotNil(t, pf) assert.NotNil(t, pf)
assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window)) 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: case wantIntentFactor:
pf := factors.GetIntent() pf := factors.GetIntent()
assert.NotNil(t, pf) assert.NotNil(t, pf)
@ -338,6 +345,23 @@ func TestServer_CreateSession_startedIntentFalseToken(t *testing.T) {
require.Error(t, err) 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) { func TestServer_SetSession_flow(t *testing.T) {
// create new, empty session // create new, empty session
createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{}) 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) verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, createResp.GetDetails().GetSequence(), time.Minute, nil)
t.Run("check user", func(t *testing.T) { t.Run("check user", func(t *testing.T) {
wantFactors := []wantFactor{wantUserFactor}
resp, err := Client.SetSession(CTX, &session.SetSessionRequest{ resp, err := Client.SetSession(CTX, &session.SetSessionRequest{
SessionId: createResp.GetSessionId(), SessionId: createResp.GetSessionId(),
SessionToken: sessionToken, SessionToken: sessionToken,
@ -360,7 +383,7 @@ func TestServer_SetSession_flow(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
sessionToken = resp.GetSessionToken() 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) { 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) verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil)
sessionToken = resp.GetSessionToken() sessionToken = resp.GetSessionToken()
wantFactors := []wantFactor{wantUserFactor, wantWebAuthNFactorUserVerified}
assertionData, err := Tester.WebAuthN.CreateAssertionResponse(resp.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), true) assertionData, err := Tester.WebAuthN.CreateAssertionResponse(resp.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), true)
require.NoError(t, err) require.NoError(t, err)
@ -393,14 +415,14 @@ func TestServer_SetSession_flow(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
sessionToken = resp.GetSessionToken() 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) { 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{ for _, userVerificationRequirement := range []session.UserVerificationRequirement{
session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_PREFERRED, 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) verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil)
sessionToken = resp.GetSessionToken() sessionToken = resp.GetSessionToken()
wantFactors := []wantFactor{wantUserFactor, wantWebAuthNFactor}
assertionData, err := Tester.WebAuthN.CreateAssertionResponse(resp.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), false) assertionData, err := Tester.WebAuthN.CreateAssertionResponse(resp.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), false)
require.NoError(t, err) require.NoError(t, err)
@ -436,10 +457,27 @@ func TestServer_SetSession_flow(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
sessionToken = resp.GetSessionToken() 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) { func Test_ZITADEL_API_missing_authentication(t *testing.T) {

View File

@ -91,6 +91,26 @@ func Test_sessionsToPb(t *testing.T) {
}, },
Metadata: map[string][]byte{"hello": []byte("world")}, 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{ want := []*session.Session{
@ -157,6 +177,25 @@ func Test_sessionsToPb(t *testing.T) {
}, },
Metadata: map[string][]byte{"hello": []byte("world")}, 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) out := sessionsToPb(sessions)

View File

@ -26,11 +26,13 @@ type SessionCommands struct {
sessionWriteModel *SessionWriteModel sessionWriteModel *SessionWriteModel
passwordWriteModel *HumanPasswordWriteModel passwordWriteModel *HumanPasswordWriteModel
intentWriteModel *IDPIntentWriteModel intentWriteModel *IDPIntentWriteModel
totpWriteModel *HumanTOTPWriteModel
eventstore *eventstore.Eventstore eventstore *eventstore.Eventstore
eventCommands []eventstore.Command eventCommands []eventstore.Command
hasher *crypto.PasswordHasher hasher *crypto.PasswordHasher
intentAlg crypto.EncryptionAlgorithm intentAlg crypto.EncryptionAlgorithm
totpAlg crypto.EncryptionAlgorithm
createToken func(sessionID string) (id string, token string, err error) createToken func(sessionID string) (id string, token string, err error)
now func() time.Time now func() time.Time
} }
@ -42,6 +44,7 @@ func (c *Commands) NewSessionCommands(cmds []SessionCommand, session *SessionWri
eventstore: c.eventstore, eventstore: c.eventstore,
hasher: c.userPasswordHasher, hasher: c.userPasswordHasher,
intentAlg: c.idpConfigEncryption, intentAlg: c.idpConfigEncryption,
totpAlg: c.multifactors.OTP.CryptoMFA,
createToken: c.sessionTokenCreator, createToken: c.sessionTokenCreator,
now: time.Now, 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 // Exec will execute the commands specified and returns an error on the first occurrence
func (s *SessionCommands) Exec(ctx context.Context) error { func (s *SessionCommands) Exec(ctx context.Context) error {
for _, cmd := range s.sessionCommands { 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) { func (s *SessionCommands) SetToken(ctx context.Context, tokenID string) {
s.eventCommands = append(s.eventCommands, session.NewTokenSetEvent(ctx, s.sessionWriteModel.aggregate, tokenID)) s.eventCommands = append(s.eventCommands, session.NewTokenSetEvent(ctx, s.sessionWriteModel.aggregate, tokenID))
} }

View File

@ -35,6 +35,7 @@ type SessionWriteModel struct {
PasswordCheckedAt time.Time PasswordCheckedAt time.Time
IntentCheckedAt time.Time IntentCheckedAt time.Time
WebAuthNCheckedAt time.Time WebAuthNCheckedAt time.Time
TOTPCheckedAt time.Time
WebAuthNUserVerified bool WebAuthNUserVerified bool
Metadata map[string][]byte Metadata map[string][]byte
State domain.SessionState State domain.SessionState
@ -70,6 +71,8 @@ func (wm *SessionWriteModel) Reduce() error {
wm.reduceWebAuthNChallenged(e) wm.reduceWebAuthNChallenged(e)
case *session.WebAuthNCheckedEvent: case *session.WebAuthNCheckedEvent:
wm.reduceWebAuthNChecked(e) wm.reduceWebAuthNChecked(e)
case *session.TOTPCheckedEvent:
wm.reduceTOTPChecked(e)
case *session.TokenSetEvent: case *session.TokenSetEvent:
wm.reduceTokenSet(e) wm.reduceTokenSet(e)
case *session.TerminateEvent: case *session.TerminateEvent:
@ -91,6 +94,7 @@ func (wm *SessionWriteModel) Query() *eventstore.SearchQueryBuilder {
session.IntentCheckedType, session.IntentCheckedType,
session.WebAuthNChallengedType, session.WebAuthNChallengedType,
session.WebAuthNCheckedType, session.WebAuthNCheckedType,
session.TOTPCheckedType,
session.TokenSetType, session.TokenSetType,
session.MetadataSetType, session.MetadataSetType,
session.TerminateType, session.TerminateType,
@ -135,6 +139,10 @@ func (wm *SessionWriteModel) reduceWebAuthNChecked(e *session.WebAuthNCheckedEve
wm.WebAuthNUserVerified = e.UserVerified wm.WebAuthNUserVerified = e.UserVerified
} }
func (wm *SessionWriteModel) reduceTOTPChecked(e *session.TOTPCheckedEvent) {
wm.TOTPCheckedAt = e.CheckedAt
}
func (wm *SessionWriteModel) reduceTokenSet(e *session.TokenSetEvent) { func (wm *SessionWriteModel) reduceTokenSet(e *session.TokenSetEvent) {
wm.TokenID = e.TokenID wm.TokenID = e.TokenID
} }
@ -149,8 +157,8 @@ func (wm *SessionWriteModel) AuthenticationTime() time.Time {
for _, check := range []time.Time{ for _, check := range []time.Time{
wm.PasswordCheckedAt, wm.PasswordCheckedAt,
wm.WebAuthNCheckedAt, wm.WebAuthNCheckedAt,
wm.TOTPCheckedAt,
wm.IntentCheckedAt, 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 // TODO: add OTP (sms and email) check https://github.com/zitadel/zitadel/issues/6224
} { } {
if check.After(authTime) { if check.After(authTime) {
@ -176,12 +184,9 @@ func (wm *SessionWriteModel) AuthMethodTypes() []domain.UserAuthMethodType {
if !wm.IntentCheckedAt.IsZero() { if !wm.IntentCheckedAt.IsZero() {
types = append(types, domain.UserAuthMethodTypeIDP) types = append(types, domain.UserAuthMethodTypeIDP)
} }
// TODO: add checks with https://github.com/zitadel/zitadel/issues/5477
/*
if !wm.TOTPCheckedAt.IsZero() { if !wm.TOTPCheckedAt.IsZero() {
types = append(types, domain.UserAuthMethodTypeTOTP) types = append(types, domain.UserAuthMethodTypeTOTP)
} }
*/
// TODO: add checks with https://github.com/zitadel/zitadel/issues/6224 // TODO: add checks with https://github.com/zitadel/zitadel/issues/6224
/* /*
if !wm.TOTPFactor.OTPSMSCheckedAt.IsZero() { if !wm.TOTPFactor.OTPSMSCheckedAt.IsZero() {

View File

@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/golang/mock/gomock" "github.com/golang/mock/gomock"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/text/language" "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) { func TestCommands_TerminateSession(t *testing.T) {
type fields struct { type fields struct {
eventstore *eventstore.Eventstore eventstore *eventstore.Eventstore

View File

@ -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 { 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() { func (s *Tester) ensureSystemUser() {

View File

@ -30,6 +30,7 @@ const (
SessionColumnIntentCheckedAt = "intent_checked_at" SessionColumnIntentCheckedAt = "intent_checked_at"
SessionColumnWebAuthNCheckedAt = "webauthn_checked_at" SessionColumnWebAuthNCheckedAt = "webauthn_checked_at"
SessionColumnWebAuthNUserVerified = "webauthn_user_verified" SessionColumnWebAuthNUserVerified = "webauthn_user_verified"
SessionColumnTOTPCheckedAt = "totp_checked_at"
SessionColumnMetadata = "metadata" SessionColumnMetadata = "metadata"
SessionColumnTokenID = "token_id" SessionColumnTokenID = "token_id"
) )
@ -58,6 +59,7 @@ func newSessionProjection(ctx context.Context, config crdb.StatementHandlerConfi
crdb.NewColumn(SessionColumnIntentCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()), crdb.NewColumn(SessionColumnIntentCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()),
crdb.NewColumn(SessionColumnWebAuthNCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()), crdb.NewColumn(SessionColumnWebAuthNCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()),
crdb.NewColumn(SessionColumnWebAuthNUserVerified, crdb.ColumnTypeBool, 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(SessionColumnMetadata, crdb.ColumnTypeJSONB, crdb.Nullable()),
crdb.NewColumn(SessionColumnTokenID, crdb.ColumnTypeText, crdb.Nullable()), crdb.NewColumn(SessionColumnTokenID, crdb.ColumnTypeText, crdb.Nullable()),
}, },
@ -93,6 +95,10 @@ func (p *sessionProjection) reducers() []handler.AggregateReducer {
Event: session.WebAuthNCheckedType, Event: session.WebAuthNCheckedType,
Reduce: p.reduceWebAuthNChecked, Reduce: p.reduceWebAuthNChecked,
}, },
{
Event: session.TOTPCheckedType,
Reduce: p.reduceTOTPChecked,
},
{ {
Event: session.TokenSetType, Event: session.TokenSetType,
Reduce: p.reduceTokenSet, Reduce: p.reduceTokenSet,
@ -229,6 +235,26 @@ func (p *sessionProjection) reduceWebAuthNChecked(event eventstore.Event) (*hand
), nil ), 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) { func (p *sessionProjection) reduceTokenSet(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*session.TokenSetEvent) e, ok := event.(*session.TokenSetEvent)
if !ok { if !ok {

View File

@ -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", name: "instance reduceTokenSet",
args: args{ args: args{

View File

@ -34,6 +34,7 @@ type Session struct {
PasswordFactor SessionPasswordFactor PasswordFactor SessionPasswordFactor
IntentFactor SessionIntentFactor IntentFactor SessionIntentFactor
WebAuthNFactor SessionWebAuthNFactor WebAuthNFactor SessionWebAuthNFactor
TOTPFactor SessionTOTPFactor
Metadata map[string][]byte Metadata map[string][]byte
} }
@ -58,6 +59,10 @@ type SessionWebAuthNFactor struct {
UserVerified bool UserVerified bool
} }
type SessionTOTPFactor struct {
TOTPCheckedAt time.Time
}
type SessionsSearchQueries struct { type SessionsSearchQueries struct {
SearchRequest SearchRequest
Queries []SearchQuery Queries []SearchQuery
@ -132,6 +137,10 @@ var (
name: projection.SessionColumnWebAuthNUserVerified, name: projection.SessionColumnWebAuthNUserVerified,
table: sessionsTable, table: sessionsTable,
} }
SessionColumnTOTPCheckedAt = Column{
name: projection.SessionColumnTOTPCheckedAt,
table: sessionsTable,
}
SessionColumnMetadata = Column{ SessionColumnMetadata = Column{
name: projection.SessionColumnMetadata, name: projection.SessionColumnMetadata,
table: sessionsTable, table: sessionsTable,
@ -230,6 +239,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil
SessionColumnIntentCheckedAt.identifier(), SessionColumnIntentCheckedAt.identifier(),
SessionColumnWebAuthNCheckedAt.identifier(), SessionColumnWebAuthNCheckedAt.identifier(),
SessionColumnWebAuthNUserVerified.identifier(), SessionColumnWebAuthNUserVerified.identifier(),
SessionColumnTOTPCheckedAt.identifier(),
SessionColumnMetadata.identifier(), SessionColumnMetadata.identifier(),
SessionColumnToken.identifier(), SessionColumnToken.identifier(),
).From(sessionsTable.identifier()). ).From(sessionsTable.identifier()).
@ -249,6 +259,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil
intentCheckedAt sql.NullTime intentCheckedAt sql.NullTime
webAuthNCheckedAt sql.NullTime webAuthNCheckedAt sql.NullTime
webAuthNUserPresent sql.NullBool webAuthNUserPresent sql.NullBool
totpCheckedAt sql.NullTime
metadata database.Map[[]byte] metadata database.Map[[]byte]
token sql.NullString token sql.NullString
) )
@ -270,6 +281,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil
&intentCheckedAt, &intentCheckedAt,
&webAuthNCheckedAt, &webAuthNCheckedAt,
&webAuthNUserPresent, &webAuthNUserPresent,
&totpCheckedAt,
&metadata, &metadata,
&token, &token,
) )
@ -290,6 +302,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil
session.IntentFactor.IntentCheckedAt = intentCheckedAt.Time session.IntentFactor.IntentCheckedAt = intentCheckedAt.Time
session.WebAuthNFactor.WebAuthNCheckedAt = webAuthNCheckedAt.Time session.WebAuthNFactor.WebAuthNCheckedAt = webAuthNCheckedAt.Time
session.WebAuthNFactor.UserVerified = webAuthNUserPresent.Bool session.WebAuthNFactor.UserVerified = webAuthNUserPresent.Bool
session.TOTPFactor.TOTPCheckedAt = totpCheckedAt.Time
session.Metadata = metadata session.Metadata = metadata
return session, token.String, nil return session, token.String, nil
@ -314,6 +327,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui
SessionColumnIntentCheckedAt.identifier(), SessionColumnIntentCheckedAt.identifier(),
SessionColumnWebAuthNCheckedAt.identifier(), SessionColumnWebAuthNCheckedAt.identifier(),
SessionColumnWebAuthNUserVerified.identifier(), SessionColumnWebAuthNUserVerified.identifier(),
SessionColumnTOTPCheckedAt.identifier(),
SessionColumnMetadata.identifier(), SessionColumnMetadata.identifier(),
countColumn.identifier(), countColumn.identifier(),
).From(sessionsTable.identifier()). ).From(sessionsTable.identifier()).
@ -336,6 +350,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui
intentCheckedAt sql.NullTime intentCheckedAt sql.NullTime
webAuthNCheckedAt sql.NullTime webAuthNCheckedAt sql.NullTime
webAuthNUserPresent sql.NullBool webAuthNUserPresent sql.NullBool
totpCheckedAt sql.NullTime
metadata database.Map[[]byte] metadata database.Map[[]byte]
) )
@ -356,6 +371,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui
&intentCheckedAt, &intentCheckedAt,
&webAuthNCheckedAt, &webAuthNCheckedAt,
&webAuthNUserPresent, &webAuthNUserPresent,
&totpCheckedAt,
&metadata, &metadata,
&sessions.Count, &sessions.Count,
) )
@ -372,6 +388,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui
session.IntentFactor.IntentCheckedAt = intentCheckedAt.Time session.IntentFactor.IntentCheckedAt = intentCheckedAt.Time
session.WebAuthNFactor.WebAuthNCheckedAt = webAuthNCheckedAt.Time session.WebAuthNFactor.WebAuthNCheckedAt = webAuthNCheckedAt.Time
session.WebAuthNFactor.UserVerified = webAuthNUserPresent.Bool session.WebAuthNFactor.UserVerified = webAuthNUserPresent.Bool
session.TOTPFactor.TOTPCheckedAt = totpCheckedAt.Time
session.Metadata = metadata session.Metadata = metadata
sessions.Sessions = append(sessions.Sessions, session) sessions.Sessions = append(sessions.Sessions, session)

View File

@ -33,6 +33,7 @@ var (
` projections.sessions4.intent_checked_at,` + ` projections.sessions4.intent_checked_at,` +
` projections.sessions4.webauthn_checked_at,` + ` projections.sessions4.webauthn_checked_at,` +
` projections.sessions4.webauthn_user_verified,` + ` projections.sessions4.webauthn_user_verified,` +
` projections.sessions4.totp_checked_at,` +
` projections.sessions4.metadata,` + ` projections.sessions4.metadata,` +
` projections.sessions4.token_id` + ` projections.sessions4.token_id` +
` FROM projections.sessions4` + ` FROM projections.sessions4` +
@ -56,6 +57,7 @@ var (
` projections.sessions4.intent_checked_at,` + ` projections.sessions4.intent_checked_at,` +
` projections.sessions4.webauthn_checked_at,` + ` projections.sessions4.webauthn_checked_at,` +
` projections.sessions4.webauthn_user_verified,` + ` projections.sessions4.webauthn_user_verified,` +
` projections.sessions4.totp_checked_at,` +
` projections.sessions4.metadata,` + ` projections.sessions4.metadata,` +
` COUNT(*) OVER ()` + ` COUNT(*) OVER ()` +
` FROM projections.sessions4` + ` FROM projections.sessions4` +
@ -81,6 +83,7 @@ var (
"intent_checked_at", "intent_checked_at",
"webauthn_checked_at", "webauthn_checked_at",
"webauthn_user_verified", "webauthn_user_verified",
"totp_checked_at",
"metadata", "metadata",
"token", "token",
} }
@ -102,6 +105,7 @@ var (
"intent_checked_at", "intent_checked_at",
"webauthn_checked_at", "webauthn_checked_at",
"webauthn_user_verified", "webauthn_user_verified",
"totp_checked_at",
"metadata", "metadata",
"count", "count",
} }
@ -155,6 +159,7 @@ func Test_SessionsPrepare(t *testing.T) {
testNow, testNow,
testNow, testNow,
true, true,
testNow,
[]byte(`{"key": "dmFsdWU="}`), []byte(`{"key": "dmFsdWU="}`),
}, },
}, },
@ -190,6 +195,9 @@ func Test_SessionsPrepare(t *testing.T) {
WebAuthNCheckedAt: testNow, WebAuthNCheckedAt: testNow,
UserVerified: true, UserVerified: true,
}, },
TOTPFactor: SessionTOTPFactor{
TOTPCheckedAt: testNow,
},
Metadata: map[string][]byte{ Metadata: map[string][]byte{
"key": []byte("value"), "key": []byte("value"),
}, },
@ -222,6 +230,7 @@ func Test_SessionsPrepare(t *testing.T) {
testNow, testNow,
testNow, testNow,
true, true,
testNow,
[]byte(`{"key": "dmFsdWU="}`), []byte(`{"key": "dmFsdWU="}`),
}, },
{ {
@ -241,6 +250,7 @@ func Test_SessionsPrepare(t *testing.T) {
testNow, testNow,
testNow, testNow,
false, false,
testNow,
[]byte(`{"key": "dmFsdWU="}`), []byte(`{"key": "dmFsdWU="}`),
}, },
}, },
@ -276,6 +286,9 @@ func Test_SessionsPrepare(t *testing.T) {
WebAuthNCheckedAt: testNow, WebAuthNCheckedAt: testNow,
UserVerified: true, UserVerified: true,
}, },
TOTPFactor: SessionTOTPFactor{
TOTPCheckedAt: testNow,
},
Metadata: map[string][]byte{ Metadata: map[string][]byte{
"key": []byte("value"), "key": []byte("value"),
}, },
@ -305,6 +318,9 @@ func Test_SessionsPrepare(t *testing.T) {
WebAuthNCheckedAt: testNow, WebAuthNCheckedAt: testNow,
UserVerified: false, UserVerified: false,
}, },
TOTPFactor: SessionTOTPFactor{
TOTPCheckedAt: testNow,
},
Metadata: map[string][]byte{ Metadata: map[string][]byte{
"key": []byte("value"), "key": []byte("value"),
}, },
@ -390,6 +406,7 @@ func Test_SessionPrepare(t *testing.T) {
testNow, testNow,
testNow, testNow,
true, true,
testNow,
[]byte(`{"key": "dmFsdWU="}`), []byte(`{"key": "dmFsdWU="}`),
"tokenID", "tokenID",
}, },
@ -420,6 +437,9 @@ func Test_SessionPrepare(t *testing.T) {
WebAuthNCheckedAt: testNow, WebAuthNCheckedAt: testNow,
UserVerified: true, UserVerified: true,
}, },
TOTPFactor: SessionTOTPFactor{
TOTPCheckedAt: testNow,
},
Metadata: map[string][]byte{ Metadata: map[string][]byte{
"key": []byte("value"), "key": []byte("value"),
}, },

View File

@ -9,6 +9,7 @@ func RegisterEventMappers(es *eventstore.Eventstore) {
RegisterFilterEventMapper(AggregateType, IntentCheckedType, IntentCheckedEventMapper). RegisterFilterEventMapper(AggregateType, IntentCheckedType, IntentCheckedEventMapper).
RegisterFilterEventMapper(AggregateType, WebAuthNChallengedType, eventstore.GenericEventMapper[WebAuthNChallengedEvent]). RegisterFilterEventMapper(AggregateType, WebAuthNChallengedType, eventstore.GenericEventMapper[WebAuthNChallengedEvent]).
RegisterFilterEventMapper(AggregateType, WebAuthNCheckedType, eventstore.GenericEventMapper[WebAuthNCheckedEvent]). RegisterFilterEventMapper(AggregateType, WebAuthNCheckedType, eventstore.GenericEventMapper[WebAuthNCheckedEvent]).
RegisterFilterEventMapper(AggregateType, TOTPCheckedType, eventstore.GenericEventMapper[TOTPCheckedEvent]).
RegisterFilterEventMapper(AggregateType, TokenSetType, TokenSetEventMapper). RegisterFilterEventMapper(AggregateType, TokenSetType, TokenSetEventMapper).
RegisterFilterEventMapper(AggregateType, MetadataSetType, MetadataSetEventMapper). RegisterFilterEventMapper(AggregateType, MetadataSetType, MetadataSetEventMapper).
RegisterFilterEventMapper(AggregateType, TerminateType, TerminateEventMapper) RegisterFilterEventMapper(AggregateType, TerminateType, TerminateEventMapper)

View File

@ -19,6 +19,7 @@ const (
IntentCheckedType = sessionEventPrefix + "intent.checked" IntentCheckedType = sessionEventPrefix + "intent.checked"
WebAuthNChallengedType = sessionEventPrefix + "webAuthN.challenged" WebAuthNChallengedType = sessionEventPrefix + "webAuthN.challenged"
WebAuthNCheckedType = sessionEventPrefix + "webAuthN.checked" WebAuthNCheckedType = sessionEventPrefix + "webAuthN.checked"
TOTPCheckedType = sessionEventPrefix + "totp.checked"
TokenSetType = sessionEventPrefix + "token.set" TokenSetType = sessionEventPrefix + "token.set"
MetadataSetType = sessionEventPrefix + "metadata.set" MetadataSetType = sessionEventPrefix + "metadata.set"
TerminateType = sessionEventPrefix + "terminated" 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 { type TokenSetEvent struct {
eventstore.BaseEvent `json:"-"` eventstore.BaseEvent `json:"-"`

View File

@ -46,6 +46,7 @@ message Factors {
PasswordFactor password = 2; PasswordFactor password = 2;
WebAuthNFactor web_auth_n = 3; WebAuthNFactor web_auth_n = 3;
IntentFactor intent = 4; IntentFactor intent = 4;
TOTPFactor totp = 5;
} }
message UserFactor { message UserFactor {
@ -101,6 +102,14 @@ message WebAuthNFactor {
bool user_verified = 2; 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 { message SearchQuery {
oneof query { oneof query {
option (validate.required) = true; option (validate.required) = true;

View File

@ -346,6 +346,11 @@ message Checks {
description: "\"Checks the intent. Requires that the userlink is already checked and a successful intent.\""; 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 { 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\"";
}
];
}