zitadel/internal/command/user_v2_email.go
Stefan Benz 8ec099ae28
fix: restructure resend email code to send email code (#9099)
# Which Problems Are Solved

There is currently no endpoint to send an email code for verification of
the email if you don't change the email itself.

# How the Problems Are Solved

Endpoint HasEmailCode to get the information that an email code is
existing, used by the new login.
Endpoint SendEmailCode, if no code is existing to replace
ResendEmailCode as there is a check that a code has to be there, before
it can be resend.

# Additional Changes

None

# Additional Context

Closes #9096

---------

Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com>
2024-12-27 16:34:38 +01:00

316 lines
13 KiB
Go

package command
import (
"context"
"io"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/zerrors"
)
// ChangeUserEmail sets a user's email address, generates a code
// and triggers a notification e-mail with the default confirmation URL format.
func (c *Commands) ChangeUserEmail(ctx context.Context, userID, email string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) {
return c.changeUserEmailWithCode(ctx, userID, email, alg, false, "")
}
// ChangeUserEmailURLTemplate sets a user's email address, generates a code
// and triggers a notification e-mail with the confirmation URL rendered from the passed urlTmpl.
// urlTmpl must be a valid [tmpl.Template].
func (c *Commands) ChangeUserEmailURLTemplate(ctx context.Context, userID, email string, alg crypto.EncryptionAlgorithm, urlTmpl string) (*domain.Email, error) {
if err := domain.RenderConfirmURLTemplate(io.Discard, urlTmpl, userID, "code", "orgID"); err != nil {
return nil, err
}
return c.changeUserEmailWithCode(ctx, userID, email, alg, false, urlTmpl)
}
// ChangeUserEmailReturnCode sets a user's email address, generates a code and does not send a notification email.
// The generated plain text code will be set in the returned Email object.
func (c *Commands) ChangeUserEmailReturnCode(ctx context.Context, userID, email string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) {
return c.changeUserEmailWithCode(ctx, userID, email, alg, true, "")
}
// ResendUserEmailCode generates a new code if there is a code existing
// and triggers a notification e-mail with the default confirmation URL format.
func (c *Commands) ResendUserEmailCode(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) {
return c.resendUserEmailCode(ctx, userID, alg, false, "")
}
// ResendUserEmailCodeURLTemplate generates a new code if there is a code existing
// and triggers a notification e-mail with the confirmation URL rendered from the passed urlTmpl.
// urlTmpl must be a valid [tmpl.Template].
func (c *Commands) ResendUserEmailCodeURLTemplate(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm, urlTmpl string) (*domain.Email, error) {
if err := domain.RenderConfirmURLTemplate(io.Discard, urlTmpl, userID, "code", "orgID"); err != nil {
return nil, err
}
return c.resendUserEmailCode(ctx, userID, alg, false, urlTmpl)
}
// ResendUserEmailReturnCode generates a new code if there is a code existing and does not send a notification email.
// The generated plain text code will be set in the returned Email object.
func (c *Commands) ResendUserEmailReturnCode(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) {
return c.resendUserEmailCode(ctx, userID, alg, true, "")
}
// SendUserEmailCode generates a new code
// and triggers a notification e-mail with the default confirmation URL format.
func (c *Commands) SendUserEmailCode(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) {
return c.sendUserEmailCode(ctx, userID, alg, false, "")
}
// SendUserEmailCodeURLTemplate generates a new code
// and triggers a notification e-mail with the confirmation URL rendered from the passed urlTmpl.
// urlTmpl must be a valid [tmpl.Template].
func (c *Commands) SendUserEmailCodeURLTemplate(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm, urlTmpl string) (*domain.Email, error) {
if err := domain.RenderConfirmURLTemplate(io.Discard, urlTmpl, userID, "code", "orgID"); err != nil {
return nil, err
}
return c.sendUserEmailCode(ctx, userID, alg, false, urlTmpl)
}
// SendUserEmailReturnCode generates a new code and does not send a notification email.
// The generated plain text code will be set in the returned Email object.
func (c *Commands) SendUserEmailReturnCode(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) {
return c.sendUserEmailCode(ctx, userID, alg, true, "")
}
// ChangeUserEmailVerified sets a user's email address and marks it is verified.
// No code is generated and no confirmation e-mail is send.
func (c *Commands) ChangeUserEmailVerified(ctx context.Context, userID, email string) (*domain.Email, error) {
cmd, err := c.NewUserEmailEvents(ctx, userID)
if err != nil {
return nil, err
}
if err = c.checkPermission(ctx, domain.PermissionUserWrite, cmd.aggregate.ResourceOwner, userID); err != nil {
return nil, err
}
if err = cmd.Change(ctx, domain.EmailAddress(email)); err != nil {
return nil, err
}
cmd.SetVerified(ctx)
return cmd.Push(ctx)
}
func (c *Commands) changeUserEmailWithCode(ctx context.Context, userID, email string, alg crypto.EncryptionAlgorithm, returnCode bool, urlTmpl string) (*domain.Email, error) {
config, err := cryptoGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyEmailCode) //nolint:staticcheck
if err != nil {
return nil, err
}
gen := crypto.NewEncryptionGenerator(*config, alg)
return c.changeUserEmailWithGenerator(ctx, userID, email, gen, returnCode, urlTmpl)
}
func (c *Commands) resendUserEmailCode(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm, returnCode bool, urlTmpl string) (*domain.Email, error) {
config, err := cryptoGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyEmailCode) //nolint:staticcheck
if err != nil {
return nil, err
}
gen := crypto.NewEncryptionGenerator(*config, alg)
return c.sendUserEmailCodeWithGenerator(ctx, userID, gen, returnCode, urlTmpl, true)
}
func (c *Commands) sendUserEmailCode(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm, returnCode bool, urlTmpl string) (*domain.Email, error) {
config, err := cryptoGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyEmailCode) //nolint:staticcheck
if err != nil {
return nil, err
}
gen := crypto.NewEncryptionGenerator(*config, alg)
return c.sendUserEmailCodeWithGenerator(ctx, userID, gen, returnCode, urlTmpl, false)
}
// changeUserEmailWithGenerator set a user's email address.
// returnCode controls if the plain text version of the code will be set in the return object.
// When the plain text code is returned, no notification e-mail will be send to the user.
// urlTmpl allows changing the target URL that is used by the e-mail and should be a validated Go template, if used.
func (c *Commands) changeUserEmailWithGenerator(ctx context.Context, userID, email string, gen crypto.Generator, returnCode bool, urlTmpl string) (*domain.Email, error) {
cmd, err := c.changeUserEmailWithGeneratorEvents(ctx, userID, email, gen, returnCode, urlTmpl)
if err != nil {
return nil, err
}
return cmd.Push(ctx)
}
func (c *Commands) sendUserEmailCodeWithGenerator(ctx context.Context, userID string, gen crypto.Generator, returnCode bool, urlTmpl string, existingCheck bool) (*domain.Email, error) {
cmd, err := c.sendUserEmailCodeWithGeneratorEvents(ctx, userID, gen, returnCode, urlTmpl, existingCheck)
if err != nil {
return nil, err
}
return cmd.Push(ctx)
}
func (c *Commands) changeUserEmailWithGeneratorEvents(ctx context.Context, userID, email string, gen crypto.Generator, returnCode bool, urlTmpl string) (*UserEmailEvents, error) {
cmd, err := c.NewUserEmailEvents(ctx, userID)
if err != nil {
return nil, err
}
if err = c.checkPermissionUpdateUser(ctx, cmd.aggregate.ResourceOwner, userID); err != nil {
return nil, err
}
if err = cmd.Change(ctx, domain.EmailAddress(email)); err != nil {
return nil, err
}
if err = cmd.AddGeneratedCode(ctx, gen, urlTmpl, returnCode); err != nil {
return nil, err
}
return cmd, nil
}
func (c *Commands) sendUserEmailCodeWithGeneratorEvents(ctx context.Context, userID string, gen crypto.Generator, returnCode bool, urlTmpl string, existingCheck bool) (*UserEmailEvents, error) {
cmd, err := c.NewUserEmailEvents(ctx, userID)
if err != nil {
return nil, err
}
if err = c.checkPermissionUpdateUser(ctx, cmd.aggregate.ResourceOwner, userID); err != nil {
return nil, err
}
if existingCheck && cmd.model.Code == nil {
return nil, zerrors.ThrowPreconditionFailed(err, "EMAIL-5w5ilin4yt", "Errors.User.Code.Empty")
}
if err = cmd.AddGeneratedCode(ctx, gen, urlTmpl, returnCode); err != nil {
return nil, err
}
return cmd, nil
}
func (c *Commands) VerifyUserEmail(ctx context.Context, userID, code string, alg crypto.EncryptionAlgorithm) (*domain.ObjectDetails, error) {
config, err := cryptoGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyEmailCode) //nolint:staticcheck
if err != nil {
return nil, err
}
gen := crypto.NewEncryptionGenerator(*config, alg)
return c.verifyUserEmailWithGenerator(ctx, userID, code, gen)
}
func (c *Commands) verifyUserEmailWithGenerator(ctx context.Context, userID, code string, gen crypto.Generator) (*domain.ObjectDetails, error) {
cmd, err := c.NewUserEmailEvents(ctx, userID)
if err != nil {
return nil, err
}
err = cmd.VerifyCode(ctx, code, gen)
if err != nil {
return nil, err
}
if _, err = cmd.Push(ctx); err != nil {
return nil, err
}
return writeModelToObjectDetails(&cmd.model.WriteModel), nil
}
// UserEmailEvents allows step-by-step additions of events,
// operating on the Human Email Model.
type UserEmailEvents struct {
eventstore *eventstore.Eventstore
aggregate *eventstore.Aggregate
events []eventstore.Command
model *HumanEmailWriteModel
plainCode *string
}
// NewUserEmailEvents constructs a UserEmailEvents with a Human Email Write Model,
// filtered by userID and resourceOwner.
// If a model cannot be found, or it's state is invalid and error is returned.
func (c *Commands) NewUserEmailEvents(ctx context.Context, userID string) (*UserEmailEvents, error) {
if userID == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-0Gzs3", "Errors.User.Email.IDMissing")
}
model, err := c.emailWriteModel(ctx, userID, "")
if err != nil {
return nil, err
}
if model.UserState == domain.UserStateUnspecified || model.UserState == domain.UserStateDeleted {
return nil, zerrors.ThrowNotFound(nil, "COMMAND-ieJ2e", "Errors.User.Email.NotFound")
}
if model.UserState == domain.UserStateInitial {
return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-uz0Uu", "Errors.User.NotInitialised")
}
return &UserEmailEvents{
eventstore: c.eventstore,
aggregate: UserAggregateFromWriteModel(&model.WriteModel),
model: model,
}, nil
}
// Change sets a new email address.
// The generated event unsets any previously generated code and verified flag.
func (c *UserEmailEvents) Change(ctx context.Context, email domain.EmailAddress) error {
if err := email.Validate(); err != nil {
return err
}
event, hasChanged := c.model.NewChangedEvent(ctx, c.aggregate, email)
if !hasChanged {
return zerrors.ThrowPreconditionFailed(nil, "COMMAND-Uch5e", "Errors.User.Email.NotChanged")
}
c.events = append(c.events, event)
return nil
}
// SetVerified sets the email address to verified.
func (c *UserEmailEvents) SetVerified(ctx context.Context) {
c.events = append(c.events, user.NewHumanEmailVerifiedEvent(ctx, c.aggregate))
}
// AddGeneratedCode generates a new encrypted code and sets it to the email address.
// When returnCode a plain text of the code will be returned from Push.
func (c *UserEmailEvents) AddGeneratedCode(ctx context.Context, gen crypto.Generator, urlTmpl string, returnCode bool) error {
cmd, code, err := generateCodeCommand(ctx, c.aggregate, gen, urlTmpl, returnCode)
if err != nil {
return err
}
c.events = append(c.events, cmd)
if returnCode {
c.plainCode = &code
}
return nil
}
func generateCodeCommand(ctx context.Context, agg *eventstore.Aggregate, gen crypto.Generator, urlTmpl string, returnCode bool) (eventstore.Command, string, error) {
value, plain, err := crypto.NewCode(gen)
if err != nil {
return nil, "", err
}
cmd := user.NewHumanEmailCodeAddedEventV2(ctx, agg, value, gen.Expiry(), urlTmpl, returnCode, "")
if returnCode {
return cmd, plain, nil
}
return cmd, "", nil
}
func (c *UserEmailEvents) VerifyCode(ctx context.Context, code string, gen crypto.Generator) error {
if code == "" {
return zerrors.ThrowInvalidArgument(nil, "COMMAND-Fia4a", "Errors.User.Code.Empty")
}
err := crypto.VerifyCode(c.model.CodeCreationDate, c.model.CodeExpiry, c.model.Code, code, gen.Alg())
if err == nil {
c.events = append(c.events, user.NewHumanEmailVerifiedEvent(ctx, c.aggregate))
return nil
}
_, err = c.eventstore.Push(ctx, user.NewHumanEmailVerificationFailedEvent(ctx, c.aggregate))
logging.WithFields("id", "COMMAND-Zoo6b", "userID", c.aggregate.ID).OnError(err).Error("NewHumanEmailVerificationFailedEvent push failed")
return zerrors.ThrowInvalidArgument(err, "COMMAND-eis9R", "Errors.User.Code.Invalid")
}
// Push all events to the eventstore and Reduce them into the Model.
func (c *UserEmailEvents) Push(ctx context.Context) (*domain.Email, error) {
pushedEvents, err := c.eventstore.Push(ctx, c.events...)
if err != nil {
return nil, err
}
err = AppendAndReduce(c.model, pushedEvents...)
if err != nil {
return nil, err
}
email := writeModelToEmail(c.model)
email.PlainCode = c.plainCode
return email, nil
}