mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-28 01:52:16 +00:00
# Which Problems Are Solved Add OTP Email challenge needed for Create/Set session in the RT model. # How the Problems Are Solved Introduce `OTPEmailChallengeCommand` (validate/execute/events) for session creation checks Add unit tests # Additional Changes Remove unused fields from `session_challenge_otp_sms.go` # Additional Context - Related to https://github.com/zitadel/zitadel/issues/11035 --------- Co-authored-by: Marco A. <[email protected]>
248 lines
7.7 KiB
Go
248 lines
7.7 KiB
Go
package domain
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/zitadel/zitadel/backend/v3/storage/database"
|
|
"github.com/zitadel/zitadel/internal/api/http"
|
|
"github.com/zitadel/zitadel/internal/crypto"
|
|
"github.com/zitadel/zitadel/internal/eventstore"
|
|
"github.com/zitadel/zitadel/internal/repository/session"
|
|
"github.com/zitadel/zitadel/internal/zerrors"
|
|
)
|
|
|
|
const (
|
|
expectedUpdatedRows = 1
|
|
objectTypeSession = "Session"
|
|
objectTypeUser = "User"
|
|
)
|
|
|
|
var _ Commander = (*OTPSMSChallengeCommand)(nil)
|
|
var _ Transactional = (*OTPSMSChallengeCommand)(nil)
|
|
|
|
type ChallengeTypeOTPSMS struct {
|
|
ReturnCode bool
|
|
}
|
|
|
|
// (@grvijayan) todo: to be implemented
|
|
// getActiveSMSProviderFn helps determine whether to generate an internal OTP code or use an external SMS provider.
|
|
// maybe temporary until we figure out how to integrate SMS providers from v1 API
|
|
type getActiveSMSProviderFn func(ctx context.Context, instanceID string) (string, error)
|
|
|
|
// OTPSMSChallengeCommand creates an OTP SMS challenge for a session.
|
|
type OTPSMSChallengeCommand struct {
|
|
ChallengeTypeOTPSMS *ChallengeTypeOTPSMS
|
|
|
|
SessionID string
|
|
InstanceID string
|
|
|
|
defaultSecretGeneratorConfig *crypto.GeneratorConfig
|
|
otpAlgorithm crypto.EncryptionAlgorithm
|
|
smsProvider getActiveSMSProviderFn
|
|
newPhoneCode newOTPCodeFunc
|
|
|
|
challengeOTPSMS *SessionChallengeOTPSMS // the generated OTP SMS challenge that is stored in the session.
|
|
otpSMSChallenge *string // challenge to be set in the CreateSessionResponse
|
|
}
|
|
|
|
// NewOTPSMSChallengeCommand creates a command to generate an OTP SMS challenge for a session.
|
|
func NewOTPSMSChallengeCommand(
|
|
challengeTypeOTPSMS *ChallengeTypeOTPSMS,
|
|
sessionID string,
|
|
instanceID string,
|
|
secretGeneratorConfig *crypto.GeneratorConfig,
|
|
otpAlgorithm crypto.EncryptionAlgorithm,
|
|
smsProvider getActiveSMSProviderFn,
|
|
newPhoneCodeFn newOTPCodeFunc) *OTPSMSChallengeCommand {
|
|
|
|
if secretGeneratorConfig == nil {
|
|
secretGeneratorConfig = otpSMSSecretGeneratorConfig
|
|
}
|
|
if otpAlgorithm == nil {
|
|
otpAlgorithm = mfaEncryptionAlgo
|
|
}
|
|
if newPhoneCodeFn == nil {
|
|
newPhoneCodeFn = crypto.NewCode
|
|
}
|
|
|
|
return &OTPSMSChallengeCommand{
|
|
ChallengeTypeOTPSMS: challengeTypeOTPSMS,
|
|
SessionID: sessionID,
|
|
InstanceID: instanceID,
|
|
defaultSecretGeneratorConfig: secretGeneratorConfig,
|
|
otpAlgorithm: otpAlgorithm,
|
|
smsProvider: smsProvider,
|
|
newPhoneCode: newPhoneCodeFn,
|
|
}
|
|
}
|
|
|
|
// Validate implements [Commander].
|
|
// It checks if the command has all required fields and fetches necessary data.
|
|
func (o *OTPSMSChallengeCommand) Validate(ctx context.Context, opts *InvokeOpts) (err error) {
|
|
if o.ChallengeTypeOTPSMS == nil {
|
|
return nil
|
|
}
|
|
|
|
err = o.validatePreConditions()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// get session
|
|
sessionRepo := opts.sessionRepo
|
|
retrievedSession, err := sessionRepo.Get(
|
|
ctx,
|
|
opts.DB(),
|
|
database.WithCondition(sessionRepo.PrimaryKeyCondition(o.InstanceID, o.SessionID)),
|
|
)
|
|
if err := handleGetError(err, "DOM-2aGWWE", objectTypeSession); err != nil {
|
|
return err
|
|
}
|
|
if retrievedSession.UserID == "" {
|
|
return zerrors.ThrowPreconditionFailed(nil, "DOM-Vi16Fs", "Errors.Missing.Session.UserID")
|
|
}
|
|
|
|
// get user
|
|
userRepo := opts.userRepo
|
|
retrievedUser, err := userRepo.Get(
|
|
ctx,
|
|
opts.DB(),
|
|
database.WithCondition(userRepo.PrimaryKeyCondition(o.InstanceID, retrievedSession.UserID)),
|
|
)
|
|
if err := handleGetError(err, "DOM-3aGHDs", objectTypeUser); err != nil {
|
|
return err
|
|
}
|
|
|
|
// validate human user and user phone
|
|
if retrievedUser.Human == nil || retrievedUser.Human.Phone == nil || retrievedUser.Human.Phone.Number == "" {
|
|
return zerrors.ThrowPreconditionFailed(nil, "DOM-7hG2w", "Errors.NotFound.User.Human.Phone")
|
|
}
|
|
// validate phone OTP is enabled
|
|
if retrievedUser.Human.Phone.OTP.EnabledAt.IsZero() {
|
|
return zerrors.ThrowPreconditionFailed(nil, "DOM-9kL4m", "Errors.User.MFA.OTP.NotReady")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Execute implements [Commander].
|
|
// It generates the OTP SMS challenge and updates the session.
|
|
func (o *OTPSMSChallengeCommand) Execute(ctx context.Context, opts *InvokeOpts) error {
|
|
if o.ChallengeTypeOTPSMS == nil {
|
|
return nil
|
|
}
|
|
|
|
// generate phone code
|
|
code, plain, generatorID, expiry, err := o.createPhoneCode(ctx, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// update the session with the otp sms challenge
|
|
challengeOTPSMS := &SessionChallengeOTPSMS{
|
|
LastChallengedAt: time.Now(),
|
|
Code: code,
|
|
Expiry: expiry,
|
|
CodeReturned: o.ChallengeTypeOTPSMS.ReturnCode,
|
|
GeneratorID: generatorID,
|
|
TriggeredAtOrigin: http.DomainContext(ctx).Origin(),
|
|
}
|
|
sessionRepo := opts.sessionRepo
|
|
updateCount, err := sessionRepo.Update(
|
|
ctx,
|
|
opts.DB(),
|
|
sessionRepo.PrimaryKeyCondition(o.InstanceID, o.SessionID),
|
|
sessionRepo.SetChallenge(challengeOTPSMS),
|
|
)
|
|
if err := handleUpdateError(err, expectedUpdatedRows, updateCount, "DOM-AigB0Z", objectTypeSession); err != nil {
|
|
return err
|
|
}
|
|
o.challengeOTPSMS = challengeOTPSMS
|
|
if o.ChallengeTypeOTPSMS.ReturnCode {
|
|
o.otpSMSChallenge = &plain
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Events implements [eventstore.Command].
|
|
// It returns an OTPSMSChallengedEvent.
|
|
func (o *OTPSMSChallengeCommand) Events(ctx context.Context, _ *InvokeOpts) ([]eventstore.Command, error) {
|
|
if o.ChallengeTypeOTPSMS == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
return []eventstore.Command{
|
|
session.NewOTPSMSChallengedEvent(
|
|
ctx,
|
|
&session.NewAggregate(o.SessionID, o.InstanceID).Aggregate,
|
|
o.challengeOTPSMS.Code,
|
|
o.challengeOTPSMS.Expiry,
|
|
o.challengeOTPSMS.CodeReturned,
|
|
o.challengeOTPSMS.GeneratorID,
|
|
),
|
|
}, nil
|
|
}
|
|
|
|
func (o *OTPSMSChallengeCommand) GetOTPSMSChallenge() *string {
|
|
return o.otpSMSChallenge
|
|
}
|
|
|
|
// String implements [Commander].
|
|
func (o *OTPSMSChallengeCommand) String() string {
|
|
return "OTPSMSChallengeCommand"
|
|
}
|
|
|
|
// RequiresTransaction implements [Transactional].
|
|
func (o *OTPSMSChallengeCommand) RequiresTransaction() {}
|
|
|
|
// createPhoneCode generates an OTP code or retrieves the external provider ID if an external SMS provider is active.
|
|
// In the case of an external provider, the code generation is skipped (e.g., when using Twilio verification API).
|
|
func (o *OTPSMSChallengeCommand) createPhoneCode(ctx context.Context, opts *InvokeOpts) (code *crypto.CryptoValue, plain string, externalID string, expiry time.Duration, err error) {
|
|
externalID, err = o.smsProvider(ctx, o.InstanceID)
|
|
if err != nil {
|
|
return nil, "", "", expiry, err
|
|
}
|
|
if externalID != "" {
|
|
return nil, "", externalID, expiry, nil
|
|
}
|
|
|
|
config, err := GetOTPCryptoGeneratorConfigWithDefault(ctx, o.InstanceID, opts, o.defaultSecretGeneratorConfig, OTPTypeSMS)
|
|
if err != nil {
|
|
return nil, "", "", expiry, err
|
|
}
|
|
codeGenerator := crypto.NewEncryptionGenerator(*config, o.otpAlgorithm)
|
|
crypted, plain, err := o.newPhoneCode(codeGenerator)
|
|
if err != nil {
|
|
return nil, "", "", expiry, err
|
|
}
|
|
return crypted, plain, "", config.Expiry, nil
|
|
}
|
|
|
|
func (o *OTPSMSChallengeCommand) validatePreConditions() error {
|
|
// validate required fields
|
|
if o.SessionID == "" {
|
|
return zerrors.ThrowPreconditionFailed(nil, "DOM-3XpM6A", "Errors.Missing.SessionID")
|
|
}
|
|
if o.InstanceID == "" {
|
|
return zerrors.ThrowPreconditionFailed(nil, "DOM-jNNJ9f", "Errors.Missing.InstanceID")
|
|
}
|
|
|
|
// validate that sms provider is set
|
|
if o.smsProvider == nil {
|
|
return zerrors.ThrowPreconditionFailed(nil, "DOM-1aGWeE", "missing sms provider")
|
|
}
|
|
|
|
// validate that default secret generator config is set
|
|
if o.defaultSecretGeneratorConfig == nil {
|
|
return zerrors.ThrowInternal(nil, "DOM-IDcOzP", "missing default secret generator config")
|
|
}
|
|
|
|
// validate that otp algorithm is set
|
|
if o.otpAlgorithm == nil {
|
|
return zerrors.ThrowInternal(nil, "DOM-VeBPmV", "missing MFA encryption algorithm")
|
|
}
|
|
return nil
|
|
}
|