mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-12 03:47:33 +00:00
feat: user v2alpha email API (#5708)
* chore(proto): update versions
* change protoc plugin
* some cleanups
* define api for setting emails in new api
* implement user.SetEmail
* move SetEmail buisiness logic into command
* resuse newCryptoCode
* command: add ChangeEmail unit tests
Not complete, was not able to mock the generator.
* Revert "resuse newCryptoCode"
This reverts commit c89e90ae35
.
* undo change to crypto code generators
* command: use a generator so we can test properly
* command: reorganise ChangeEmail
improve test coverage
* implement VerifyEmail
including unit tests
* add URL template tests
* proto: change context to object
* remove old auth option
* remove old auth option
* fix linting errors
run gci on modified files
* add permission checks and fix some errors
* comments
* comments
---------
Co-authored-by: Livio Spring <livio.a@gmail.com>
Co-authored-by: Tim Möhlmann <tim+github@zitadel.com>
This commit is contained in:
@@ -29,6 +29,8 @@ import (
|
||||
type Commands struct {
|
||||
httpClient *http.Client
|
||||
|
||||
checkPermission permissionCheck
|
||||
|
||||
eventstore *eventstore.Eventstore
|
||||
static static.Storage
|
||||
idGenerator id.Generator
|
||||
@@ -59,7 +61,8 @@ type Commands struct {
|
||||
certificateLifetime time.Duration
|
||||
}
|
||||
|
||||
func StartCommands(es *eventstore.Eventstore,
|
||||
func StartCommands(
|
||||
es *eventstore.Eventstore,
|
||||
defaults sd.SystemDefaults,
|
||||
zitadelRoles []authz.RoleMapping,
|
||||
staticStore static.Storage,
|
||||
@@ -76,6 +79,7 @@ func StartCommands(es *eventstore.Eventstore,
|
||||
oidcEncryption,
|
||||
samlEncryption crypto.EncryptionAlgorithm,
|
||||
httpClient *http.Client,
|
||||
membershipsResolver authz.MembershipsResolver,
|
||||
) (repo *Commands, err error) {
|
||||
if externalDomain == "" {
|
||||
return nil, errors.ThrowInvalidArgument(nil, "COMMAND-Df21s", "no external domain specified")
|
||||
@@ -102,6 +106,9 @@ func StartCommands(es *eventstore.Eventstore,
|
||||
certificateAlgorithm: samlEncryption,
|
||||
webauthnConfig: webAuthN,
|
||||
httpClient: httpClient,
|
||||
checkPermission: func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) {
|
||||
return authz.CheckPermission(ctx, membershipsResolver, zitadelRoles, permission, orgID, resourceID, allowSelf)
|
||||
},
|
||||
}
|
||||
|
||||
instance_repo.RegisterEventMappers(repo.eventstore)
|
||||
|
@@ -10,6 +10,7 @@ import (
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/errors"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/eventstore/repository"
|
||||
"github.com/zitadel/zitadel/internal/eventstore/repository/mock"
|
||||
@@ -248,3 +249,15 @@ func (m *mockInstance) RequestedHost() string {
|
||||
func (m *mockInstance) SecurityPolicyAllowedOrigins() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newMockPermissionCheckAllowed() permissionCheck {
|
||||
return func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newMockPermissionCheckNotAllowed() permissionCheck {
|
||||
return func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) {
|
||||
return errors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")
|
||||
}
|
||||
}
|
||||
|
11
internal/command/permission.go
Normal file
11
internal/command/permission.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type permissionCheck func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error)
|
||||
|
||||
const (
|
||||
permissionUserWrite = "user.write"
|
||||
)
|
@@ -507,7 +507,7 @@ func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain.
|
||||
if human.Email != nil && human.EmailAddress != "" && human.IsEmailVerified {
|
||||
events = append(events, user.NewHumanEmailVerifiedEvent(ctx, userAgg))
|
||||
} else {
|
||||
emailCode, err := domain.NewEmailCode(emailCodeGenerator)
|
||||
emailCode, _, err := domain.NewEmailCode(emailCodeGenerator)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
@@ -41,7 +42,7 @@ func (c *Commands) ChangeHumanEmail(ctx context.Context, email *domain.Email, em
|
||||
if email.IsEmailVerified {
|
||||
events = append(events, user.NewHumanEmailVerifiedEvent(ctx, userAgg))
|
||||
} else {
|
||||
emailCode, err := domain.NewEmailCode(emailCodeGenerator)
|
||||
emailCode, _, err := domain.NewEmailCode(emailCodeGenerator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -113,7 +114,7 @@ func (c *Commands) CreateHumanEmailVerificationCode(ctx context.Context, userID,
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-3M9ds", "Errors.User.Email.AlreadyVerified")
|
||||
}
|
||||
userAgg := UserAggregateFromWriteModel(&existingEmail.WriteModel)
|
||||
emailCode, err := domain.NewEmailCode(emailCodeGenerator)
|
||||
emailCode, _, err := domain.NewEmailCode(emailCodeGenerator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@@ -4,10 +4,9 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
|
208
internal/command/user_v2_email.go
Normal file
208
internal/command/user_v2_email.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/repository/user"
|
||||
)
|
||||
|
||||
// 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, resourceOwner, email string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) {
|
||||
return c.changeUserEmailWithCode(ctx, userID, resourceOwner, 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, resourceOwner, 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, resourceOwner, 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, resourceOwner, email string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) {
|
||||
return c.changeUserEmailWithCode(ctx, userID, resourceOwner, email, 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, resourceOwner, email string) (*domain.Email, error) {
|
||||
cmd, err := c.NewUserEmailEvents(ctx, userID, resourceOwner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = c.checkPermission(ctx, permissionUserWrite, cmd.aggregate.ResourceOwner, userID, false); 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, resourceOwner, email string, alg crypto.EncryptionAlgorithm, returnCode bool, urlTmpl string) (*domain.Email, error) {
|
||||
config, err := secretGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyEmailCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gen := crypto.NewEncryptionGenerator(*config, alg)
|
||||
return c.changeUserEmailWithGenerator(ctx, userID, resourceOwner, email, gen, returnCode, urlTmpl)
|
||||
}
|
||||
|
||||
// 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, resourceOwner, email string, gen crypto.Generator, returnCode bool, urlTmpl string) (*domain.Email, error) {
|
||||
cmd, err := c.NewUserEmailEvents(ctx, userID, resourceOwner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = c.checkPermission(ctx, permissionUserWrite, cmd.aggregate.ResourceOwner, userID, true); 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.Push(ctx)
|
||||
}
|
||||
|
||||
func (c *Commands) VerifyUserEmail(ctx context.Context, userID, resourceOwner, code string, alg crypto.EncryptionAlgorithm) (*domain.ObjectDetails, error) {
|
||||
config, err := secretGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyEmailCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gen := crypto.NewEncryptionGenerator(*config, alg)
|
||||
return c.verifyUserEmailWithGenerator(ctx, userID, resourceOwner, code, gen)
|
||||
}
|
||||
|
||||
func (c *Commands) verifyUserEmailWithGenerator(ctx context.Context, userID, resourceOwner, code string, gen crypto.Generator) (*domain.ObjectDetails, error) {
|
||||
cmd, err := c.NewUserEmailEvents(ctx, userID, resourceOwner)
|
||||
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, resourceOwner string) (*UserEmailEvents, error) {
|
||||
if userID == "" {
|
||||
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-0Gzs3", "Errors.User.Email.IDMissing")
|
||||
}
|
||||
|
||||
model, err := c.emailWriteModel(ctx, userID, resourceOwner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if model.UserState == domain.UserStateUnspecified || model.UserState == domain.UserStateDeleted {
|
||||
return nil, caos_errs.ThrowNotFound(nil, "COMMAND-ieJ2e", "Errors.User.Email.NotFound")
|
||||
}
|
||||
if model.UserState == domain.UserStateInitial {
|
||||
return nil, caos_errs.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 caos_errs.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 {
|
||||
value, plain, err := crypto.NewCode(gen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.events = append(c.events, user.NewHumanEmailCodeAddedEventV2(ctx, c.aggregate, value, gen.Expiry(), urlTmpl, returnCode))
|
||||
if returnCode {
|
||||
c.plainCode = &plain
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *UserEmailEvents) VerifyCode(ctx context.Context, code string, gen crypto.Generator) error {
|
||||
if code == "" {
|
||||
return caos_errs.ThrowInvalidArgument(nil, "COMMAND-Fia4a", "Errors.User.Code.Empty")
|
||||
}
|
||||
|
||||
err := crypto.VerifyCode(c.model.CodeCreationDate, c.model.CodeExpiry, c.model.Code, code, gen)
|
||||
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 caos_errs.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
|
||||
}
|
1315
internal/command/user_v2_email_test.go
Normal file
1315
internal/command/user_v2_email_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user