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]>
277 lines
8.3 KiB
Go
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
|
|
}
|