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:
Livio Spring
2023-08-24 11:41:52 +02:00
committed by GitHub
parent 29fa3d417c
commit bb40e173bd
27 changed files with 2077 additions and 151 deletions

View File

@@ -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

View File

@@ -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) {