Files
zitadel/backend/v3/domain/session_challenge_otp_email.go
T
7c3d26b23b feat: rt sessions OTP email challenge (#11941)
# 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]>
2026-03-31 11:50:53 +00:00

277 lines
8.3 KiB
Go

package domain
import (
"context"
"io"
"text/template"
"time"
"golang.org/x/text/language"
"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"
)
var _ Commander = (*OTPEmailChallengeCommand)(nil)
var _ Transactional = (*OTPEmailChallengeCommand)(nil)
type SendCode struct {
URLTemplate string
}
type DeliveryType struct {
SendCode *SendCode
ReturnCode bool
}
type ChallengeTypeOTPEmail struct {
DeliveryType DeliveryType
}
type OTPEmailChallengeCommand struct {
ChallengeTypeOTPEmail *ChallengeTypeOTPEmail
SessionID string
InstanceID string
defaultSecretGeneratorConfig *crypto.GeneratorConfig
otpEncryptionAlgorithm crypto.EncryptionAlgorithm
newEmailCode newOTPCodeFunc
sessionChallengeOTPEmail *SessionChallengeOTPEmail // the generated OTP Email challenge that is stored in the session.
otpEmailChallenge *string // challenge to be set in the CreateSessionResponse
}
func NewOTPEmailChallengeCommand(
challengeTypeOTPEmail *ChallengeTypeOTPEmail,
sessionID string,
instanceID string,
secretGeneratorConfig *crypto.GeneratorConfig,
otpAlgorithm crypto.EncryptionAlgorithm,
newEmailCodeFn newOTPCodeFunc) *OTPEmailChallengeCommand {
if secretGeneratorConfig == nil {
secretGeneratorConfig = otpEmailSecretGeneratorConfig
}
if otpAlgorithm == nil {
otpAlgorithm = mfaEncryptionAlgo
}
if newEmailCodeFn == nil {
newEmailCodeFn = crypto.NewCode
}
return &OTPEmailChallengeCommand{
ChallengeTypeOTPEmail: challengeTypeOTPEmail,
SessionID: sessionID,
InstanceID: instanceID,
defaultSecretGeneratorConfig: secretGeneratorConfig,
otpEncryptionAlgorithm: otpAlgorithm,
newEmailCode: newEmailCodeFn,
}
}
// Validate implements [Commander].
// It validates that the session and user exist and that the user has email OTP enabled.
func (o *OTPEmailChallengeCommand) Validate(ctx context.Context, opts *InvokeOpts) (err error) {
if o.ChallengeTypeOTPEmail == 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-JArUai", objectTypeSession); err != nil {
return err
}
if retrievedSession.UserID == "" {
return zerrors.ThrowPreconditionFailed(nil, "DOM-wG2XoJ", "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-56MWkg", objectTypeUser); err != nil {
return err
}
// validate human user and user email
if retrievedUser.Human == nil || retrievedUser.Human.Email.Address == "" {
return zerrors.ThrowPreconditionFailed(nil, "DOM-7hG2d", "Errors.NotFound.User.Human.Email")
}
// validate email OTP is enabled
if retrievedUser.Human.Email.OTP.EnabledAt.IsZero() {
return zerrors.ThrowPreconditionFailed(nil, "DOM-9kL4q", "Errors.User.MFA.OTP.NotReady")
}
return nil
}
// Execute implements [Commander].
// It updates the session with the generated OTP email challenge.
func (o *OTPEmailChallengeCommand) Execute(ctx context.Context, opts *InvokeOpts) error {
if o.ChallengeTypeOTPEmail == nil {
return nil
}
// prepare the otp email challenge
sessionChallengeOTPEmail, challenge, err := o.prepareOTPEmailChallenge(ctx, opts)
if err != nil {
return err
}
// update the session with the otp email challenge
sessionRepo := opts.sessionRepo
updated, err := sessionRepo.Update(
ctx,
opts.DB(),
sessionRepo.PrimaryKeyCondition(o.InstanceID, o.SessionID),
sessionRepo.SetChallenge(sessionChallengeOTPEmail),
)
if err := handleUpdateError(err, expectedUpdatedRows, updated, "DOM-YfQIA3", objectTypeSession); err != nil {
return err
}
o.sessionChallengeOTPEmail = sessionChallengeOTPEmail
if o.ChallengeTypeOTPEmail.DeliveryType.ReturnCode { // only set when the delivery type is ReturnCode
o.otpEmailChallenge = &challenge
}
return nil
}
// Events implements [Commander].
// It creates the OTPEmailChallengedEvent if an OTP email challenge was requested.
func (o *OTPEmailChallengeCommand) Events(ctx context.Context, opts *InvokeOpts) ([]eventstore.Command, error) {
if o.ChallengeTypeOTPEmail == nil {
return nil, nil
}
return []eventstore.Command{
session.NewOTPEmailChallengedEvent(
ctx,
&session.NewAggregate(o.SessionID, o.InstanceID).Aggregate,
o.sessionChallengeOTPEmail.Code,
o.sessionChallengeOTPEmail.Expiry,
o.sessionChallengeOTPEmail.CodeReturned,
o.sessionChallengeOTPEmail.URLTemplate,
),
}, nil
}
func (o *OTPEmailChallengeCommand) GetOTPEmailChallenge() *string {
return o.otpEmailChallenge
}
// prepareOTPEmailChallenge generates the OTP email challenge based on the delivery type in the request.
func (o *OTPEmailChallengeCommand) prepareOTPEmailChallenge(ctx context.Context, opts *InvokeOpts) (*SessionChallengeOTPEmail, string, error) {
// generate email code
config, err := GetOTPCryptoGeneratorConfigWithDefault(ctx, o.InstanceID, opts, o.defaultSecretGeneratorConfig, OTPTypeEmail)
if err != nil {
return nil, "", err
}
codeGenerator := crypto.NewEncryptionGenerator(*config, o.otpEncryptionAlgorithm)
crypted, plain, err := o.newEmailCode(codeGenerator)
if err != nil {
return nil, "", err
}
challengeOTPEmail := &SessionChallengeOTPEmail{
LastChallengedAt: time.Now(),
Code: crypted,
Expiry: config.Expiry,
TriggeredAtOrigin: http.DomainContext(ctx).Origin(),
}
var otpEmailChallenge string
switch {
case o.ChallengeTypeOTPEmail.DeliveryType.SendCode != nil:
challengeOTPEmail.URLTemplate = o.ChallengeTypeOTPEmail.DeliveryType.SendCode.URLTemplate
case o.ChallengeTypeOTPEmail.DeliveryType.ReturnCode:
challengeOTPEmail.CodeReturned = true
otpEmailChallenge = plain
default:
// no additional action needed
}
return challengeOTPEmail, otpEmailChallenge, nil
}
// String implements [Commander].
func (o *OTPEmailChallengeCommand) String() string {
return "OTPEmailChallengeCommand"
}
// RequiresTransaction implements [Transactional].
func (o *OTPEmailChallengeCommand) RequiresTransaction() {}
// validateURLTemplate renders the given URL template with sample data to validate its correctness.
func validateURLTemplate(w io.Writer, tmpl string) error {
otpEmailURLData := &struct {
Code string
UserID string
LoginName string
DisplayName string
PreferredLanguage language.Tag
SessionID string
}{
Code: "code",
UserID: "userID",
LoginName: "loginName",
DisplayName: "displayName",
PreferredLanguage: language.English,
SessionID: "SessionID",
}
parsed, err := template.New("").Parse(tmpl)
if err != nil {
return zerrors.ThrowInvalidArgument(err, "DOM-wkDwQM", "Errors.Invalid.URLTemplate")
}
if err = parsed.Execute(w, otpEmailURLData); err != nil {
return zerrors.ThrowInvalidArgument(err, "DOM-F5Yv8l", "Errors.Invalid.URLTemplate")
}
return nil
}
func (o *OTPEmailChallengeCommand) validatePreConditions() error {
// validate required fields
if o.SessionID == "" {
return zerrors.ThrowPreconditionFailed(nil, "DOM-BQ5UgK", "Errors.Missing.SessionID")
}
if o.InstanceID == "" {
return zerrors.ThrowPreconditionFailed(nil, "DOM-kDnkDn", "Errors.Missing.InstanceID")
}
// validate that default secret generator config is set
if o.defaultSecretGeneratorConfig == nil {
return zerrors.ThrowInternal(nil, "DOM-nnB9MS", "missing default secret generator config")
}
// validate that otp encryption algorithm is set
if o.otpEncryptionAlgorithm == nil {
return zerrors.ThrowInternal(nil, "DOM-kuG75Q", "missing MFA encryption algorithm")
}
// validate the URL template
if sc := o.ChallengeTypeOTPEmail.DeliveryType.SendCode; sc != nil {
if sc.URLTemplate != "" {
if err := validateURLTemplate(io.Discard, sc.URLTemplate); err != nil {
return err
}
}
}
return nil
}