mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-11 21:37:32 +00:00
feat(api): add otp (sms and email) checks in session api (#6422)
* feat: add otp (sms and email) checks in session api * implement sending * fix tests * add tests * add integration tests * fix merge main and add tests * put default OTP Email url into config --------- Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com>
This commit is contained in:
@@ -45,7 +45,10 @@ func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRe
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
challengeResponse, cmds := s.challengesToCommand(req.GetChallenges(), checks)
|
||||
challengeResponse, cmds, err := s.challengesToCommand(req.GetChallenges(), checks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
set, err := s.command.CreateSession(ctx, cmds, metadata)
|
||||
if err != nil {
|
||||
@@ -64,7 +67,10 @@ func (s *Server) SetSession(ctx context.Context, req *session.SetSessionRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
challengeResponse, cmds := s.challengesToCommand(req.GetChallenges(), checks)
|
||||
challengeResponse, cmds, err := s.challengesToCommand(req.GetChallenges(), checks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
set, err := s.command.UpdateSession(ctx, req.GetSessionId(), req.GetSessionToken(), cmds, req.GetMetadata())
|
||||
if err != nil {
|
||||
@@ -121,6 +127,8 @@ func factorsToPb(s *query.Session) *session.Factors {
|
||||
WebAuthN: webAuthNFactorToPb(s.WebAuthNFactor),
|
||||
Intent: intentFactorToPb(s.IntentFactor),
|
||||
Totp: totpFactorToPb(s.TOTPFactor),
|
||||
OtpSms: otpFactorToPb(s.OTPSMSFactor),
|
||||
OtpEmail: otpFactorToPb(s.OTPEmailFactor),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +169,15 @@ func totpFactorToPb(factor query.SessionTOTPFactor) *session.TOTPFactor {
|
||||
}
|
||||
}
|
||||
|
||||
func otpFactorToPb(factor query.SessionOTPFactor) *session.OTPFactor {
|
||||
if factor.OTPCheckedAt.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &session.OTPFactor{
|
||||
VerifiedAt: timestamppb.New(factor.OTPCheckedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func userFactorToPb(factor query.SessionUserFactor) *session.UserFactor {
|
||||
if factor.UserID == "" || factor.UserCheckedAt.IsZero() {
|
||||
return nil
|
||||
@@ -240,7 +257,7 @@ func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessionChecks := make([]command.SessionCommand, 0, 3)
|
||||
sessionChecks := make([]command.SessionCommand, 0, 7)
|
||||
if checkUser != nil {
|
||||
user, err := checkUser.search(ctx, s.query)
|
||||
if err != nil {
|
||||
@@ -260,12 +277,18 @@ func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([
|
||||
if totp := checks.GetTotp(); totp != nil {
|
||||
sessionChecks = append(sessionChecks, command.CheckTOTP(totp.GetTotp()))
|
||||
}
|
||||
if otp := checks.GetOtpSms(); otp != nil {
|
||||
sessionChecks = append(sessionChecks, command.CheckOTPSMS(otp.GetOtp()))
|
||||
}
|
||||
if otp := checks.GetOtpEmail(); otp != nil {
|
||||
sessionChecks = append(sessionChecks, command.CheckOTPEmail(otp.GetOtp()))
|
||||
}
|
||||
return sessionChecks, nil
|
||||
}
|
||||
|
||||
func (s *Server) challengesToCommand(challenges *session.RequestChallenges, cmds []command.SessionCommand) (*session.Challenges, []command.SessionCommand) {
|
||||
func (s *Server) challengesToCommand(challenges *session.RequestChallenges, cmds []command.SessionCommand) (*session.Challenges, []command.SessionCommand, error) {
|
||||
if challenges == nil {
|
||||
return nil, cmds
|
||||
return nil, cmds, nil
|
||||
}
|
||||
resp := new(session.Challenges)
|
||||
if req := challenges.GetWebAuthN(); req != nil {
|
||||
@@ -273,7 +296,20 @@ func (s *Server) challengesToCommand(challenges *session.RequestChallenges, cmds
|
||||
resp.WebAuthN = challenge
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
return resp, cmds
|
||||
if req := challenges.GetOtpSms(); req != nil {
|
||||
challenge, cmd := s.createOTPSMSChallengeCommand(req)
|
||||
resp.OtpSms = challenge
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
if req := challenges.GetOtpEmail(); req != nil {
|
||||
challenge, cmd, err := s.createOTPEmailChallengeCommand(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
resp.OtpEmail = challenge
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
return resp, cmds, nil
|
||||
}
|
||||
|
||||
func (s *Server) createWebAuthNChallengeCommand(req *session.RequestChallenges_WebAuthN) (*session.Challenges_WebAuthN, command.SessionCommand) {
|
||||
@@ -299,6 +335,34 @@ func userVerificationRequirementToDomain(req session.UserVerificationRequirement
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) createOTPSMSChallengeCommand(req *session.RequestChallenges_OTPSMS) (*string, command.SessionCommand) {
|
||||
if req.GetReturnCode() {
|
||||
challenge := new(string)
|
||||
return challenge, s.command.CreateOTPSMSChallengeReturnCode(challenge)
|
||||
}
|
||||
|
||||
return nil, s.command.CreateOTPSMSChallenge()
|
||||
|
||||
}
|
||||
|
||||
func (s *Server) createOTPEmailChallengeCommand(req *session.RequestChallenges_OTPEmail) (*string, command.SessionCommand, error) {
|
||||
switch t := req.GetDeliveryType().(type) {
|
||||
case *session.RequestChallenges_OTPEmail_SendCode_:
|
||||
cmd, err := s.command.CreateOTPEmailChallengeURLTemplate(t.SendCode.GetUrlTemplate())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return nil, cmd, nil
|
||||
case *session.RequestChallenges_OTPEmail_ReturnCode_:
|
||||
challenge := new(string)
|
||||
return challenge, s.command.CreateOTPEmailChallengeReturnCode(challenge), nil
|
||||
case nil:
|
||||
return nil, s.command.CreateOTPEmailChallenge(), nil
|
||||
default:
|
||||
return nil, nil, caos_errs.ThrowUnimplementedf(nil, "SESSION-k3ng0", "delivery_type oneOf %T in OTPEmailChallenge not implemented", t)
|
||||
}
|
||||
}
|
||||
|
||||
func userCheck(user *session.CheckUser) (userSearch, error) {
|
||||
if user == nil {
|
||||
return nil, nil
|
||||
|
@@ -39,6 +39,14 @@ func TestMain(m *testing.M) {
|
||||
|
||||
CTX, _ = Tester.WithAuthorization(ctx, integration.OrgOwner), errCtx
|
||||
User = Tester.CreateHumanUser(CTX)
|
||||
Tester.Client.UserV2.VerifyEmail(CTX, &user.VerifyEmailRequest{
|
||||
UserId: User.GetUserId(),
|
||||
VerificationCode: User.GetEmailCode(),
|
||||
})
|
||||
Tester.Client.UserV2.VerifyPhone(CTX, &user.VerifyPhoneRequest{
|
||||
UserId: User.GetUserId(),
|
||||
VerificationCode: User.GetPhoneCode(),
|
||||
})
|
||||
Tester.SetUserPassword(CTX, User.GetUserId(), integration.UserPassword)
|
||||
Tester.RegisterUserPasskey(CTX, User.GetUserId())
|
||||
return m.Run()
|
||||
@@ -75,6 +83,8 @@ const (
|
||||
wantWebAuthNFactorUserVerified
|
||||
wantTOTPFactor
|
||||
wantIntentFactor
|
||||
wantOTPSMSFactor
|
||||
wantOTPEmailFactor
|
||||
)
|
||||
|
||||
func verifyFactors(t testing.TB, factors *session.Factors, window time.Duration, want []wantFactor) {
|
||||
@@ -107,6 +117,14 @@ func verifyFactors(t testing.TB, factors *session.Factors, window time.Duration,
|
||||
pf := factors.GetIntent()
|
||||
assert.NotNil(t, pf)
|
||||
assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window))
|
||||
case wantOTPSMSFactor:
|
||||
pf := factors.GetOtpSms()
|
||||
assert.NotNil(t, pf)
|
||||
assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window))
|
||||
case wantOTPEmailFactor:
|
||||
pf := factors.GetOtpEmail()
|
||||
assert.NotNil(t, pf)
|
||||
assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,6 +380,20 @@ func registerTOTP(ctx context.Context, t *testing.T, userID string) (secret stri
|
||||
return secret
|
||||
}
|
||||
|
||||
func registerOTPSMS(ctx context.Context, t *testing.T, userID string) {
|
||||
_, err := Tester.Client.UserV2.AddOTPSMS(ctx, &user.AddOTPSMSRequest{
|
||||
UserId: userID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func registerOTPEmail(ctx context.Context, t *testing.T, userID string) {
|
||||
_, err := Tester.Client.UserV2.AddOTPEmail(ctx, &user.AddOTPEmailRequest{
|
||||
UserId: userID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestServer_SetSession_flow(t *testing.T) {
|
||||
// create new, empty session
|
||||
createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{})
|
||||
@@ -421,6 +453,8 @@ func TestServer_SetSession_flow(t *testing.T) {
|
||||
userAuthCtx := Tester.WithAuthorizationToken(CTX, sessionToken)
|
||||
Tester.RegisterUserU2F(userAuthCtx, User.GetUserId())
|
||||
totpSecret := registerTOTP(userAuthCtx, t, User.GetUserId())
|
||||
registerOTPSMS(userAuthCtx, t, User.GetUserId())
|
||||
registerOTPEmail(userAuthCtx, t, User.GetUserId())
|
||||
|
||||
t.Run("check webauthn, user not verified (U2F)", func(t *testing.T) {
|
||||
|
||||
@@ -478,6 +512,66 @@ func TestServer_SetSession_flow(t *testing.T) {
|
||||
sessionToken = resp.GetSessionToken()
|
||||
verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, wantUserFactor, wantWebAuthNFactor, wantTOTPFactor)
|
||||
})
|
||||
|
||||
t.Run("check OTP SMS", func(t *testing.T) {
|
||||
resp, err := Client.SetSession(CTX, &session.SetSessionRequest{
|
||||
SessionId: createResp.GetSessionId(),
|
||||
SessionToken: sessionToken,
|
||||
Challenges: &session.RequestChallenges{
|
||||
OtpSms: &session.RequestChallenges_OTPSMS{ReturnCode: true},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil)
|
||||
sessionToken = resp.GetSessionToken()
|
||||
|
||||
otp := resp.GetChallenges().GetOtpSms()
|
||||
require.NotEmpty(t, otp)
|
||||
|
||||
resp, err = Client.SetSession(CTX, &session.SetSessionRequest{
|
||||
SessionId: createResp.GetSessionId(),
|
||||
SessionToken: sessionToken,
|
||||
Checks: &session.Checks{
|
||||
OtpSms: &session.CheckOTP{
|
||||
Otp: otp,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
sessionToken = resp.GetSessionToken()
|
||||
verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, wantUserFactor, wantWebAuthNFactor, wantOTPSMSFactor)
|
||||
})
|
||||
|
||||
t.Run("check OTP Email", func(t *testing.T) {
|
||||
resp, err := Client.SetSession(CTX, &session.SetSessionRequest{
|
||||
SessionId: createResp.GetSessionId(),
|
||||
SessionToken: sessionToken,
|
||||
Challenges: &session.RequestChallenges{
|
||||
OtpEmail: &session.RequestChallenges_OTPEmail{
|
||||
DeliveryType: &session.RequestChallenges_OTPEmail_ReturnCode_{},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil)
|
||||
sessionToken = resp.GetSessionToken()
|
||||
|
||||
otp := resp.GetChallenges().GetOtpEmail()
|
||||
require.NotEmpty(t, otp)
|
||||
|
||||
resp, err = Client.SetSession(CTX, &session.SetSessionRequest{
|
||||
SessionId: createResp.GetSessionId(),
|
||||
SessionToken: sessionToken,
|
||||
Checks: &session.Checks{
|
||||
OtpEmail: &session.CheckOTP{
|
||||
Otp: otp,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
sessionToken = resp.GetSessionToken()
|
||||
verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, wantUserFactor, wantWebAuthNFactor, wantOTPEmailFactor)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_ZITADEL_API_missing_authentication(t *testing.T) {
|
||||
|
Reference in New Issue
Block a user