zitadel/internal/command/user_v3_model.go
Livio Spring 14e2aba1bc
feat: Add Twilio Verification Service (#8678)
# Which Problems Are Solved
Twilio supports a robust, multi-channel verification service that
notably supports multi-region SMS sender numbers required for our use
case. Currently, Zitadel does much of the work of the Twilio Verify (eg.
localization, code generation, messaging) but doesn't support the pool
of sender numbers that Twilio Verify does.

# How the Problems Are Solved
To support this API, we need to be able to store the Twilio Service ID
and send that in a verification request where appropriate: phone number
verification and SMS 2FA code paths.

This PR does the following: 
- Adds the ability to use Twilio Verify of standard messaging through
Twilio
- Adds support for international numbers and more reliable verification
messages sent from multiple numbers
- Adds a new Twilio configuration option to support Twilio Verify in the
admin console
- Sends verification SMS messages through Twilio Verify
- Implements Twilio Verification Checks for codes generated through the
same

# Additional Changes

# Additional Context
- base was implemented by @zhirschtritt in
https://github.com/zitadel/zitadel/pull/8268 ❤️
- closes https://github.com/zitadel/zitadel/issues/8581

---------

Co-authored-by: Zachary Hirschtritt <zachary.hirschtritt@klaviyo.com>
Co-authored-by: Joey Biscoglia <joey.biscoglia@klaviyo.com>
2024-09-26 09:14:33 +02:00

684 lines
19 KiB
Go

package command
import (
"bytes"
"context"
"encoding/json"
"time"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
domain_schema "github.com/zitadel/zitadel/internal/domain/schema"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/user/schemauser"
"github.com/zitadel/zitadel/internal/zerrors"
)
type UserV3WriteModel struct {
eventstore.WriteModel
PhoneWM bool
EmailWM bool
DataWM bool
SchemaID string
SchemaRevision uint64
Email string
IsEmailVerified bool
EmailVerifiedFailedCount int
EmailCode *VerifyCode
Phone string
IsPhoneVerified bool
PhoneVerifiedFailedCount int
PhoneCode *VerifyCode
Data json.RawMessage
Locked bool
State domain.UserState
checkPermission domain.PermissionCheck
writePermissionCheck bool
}
func (wm *UserV3WriteModel) GetWriteModel() *eventstore.WriteModel {
return &wm.WriteModel
}
type VerifyCode struct {
Code *crypto.CryptoValue
CreationDate time.Time
Expiry time.Duration
}
func NewExistsUserV3WriteModel(resourceOwner, userID string, checkPermission domain.PermissionCheck) *UserV3WriteModel {
return &UserV3WriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: userID,
ResourceOwner: resourceOwner,
},
PhoneWM: false,
EmailWM: false,
DataWM: false,
checkPermission: checkPermission,
}
}
func NewUserV3WriteModel(resourceOwner, userID string, checkPermission domain.PermissionCheck) *UserV3WriteModel {
return &UserV3WriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: userID,
ResourceOwner: resourceOwner,
},
PhoneWM: true,
EmailWM: true,
DataWM: true,
checkPermission: checkPermission,
}
}
func NewUserV3EmailWriteModel(resourceOwner, userID string, checkPermission domain.PermissionCheck) *UserV3WriteModel {
return &UserV3WriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: userID,
ResourceOwner: resourceOwner,
},
EmailWM: true,
checkPermission: checkPermission,
}
}
func NewUserV3PhoneWriteModel(resourceOwner, userID string, checkPermission domain.PermissionCheck) *UserV3WriteModel {
return &UserV3WriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: userID,
ResourceOwner: resourceOwner,
},
PhoneWM: true,
checkPermission: checkPermission,
}
}
func (wm *UserV3WriteModel) Reduce() error {
for _, event := range wm.Events {
switch e := event.(type) {
case *schemauser.CreatedEvent:
wm.SchemaID = e.SchemaID
wm.SchemaRevision = e.SchemaRevision
wm.Data = e.Data
wm.Locked = false
wm.State = domain.UserStateActive
case *schemauser.UpdatedEvent:
if e.SchemaID != nil {
wm.SchemaID = *e.SchemaID
}
if e.SchemaRevision != nil {
wm.SchemaRevision = *e.SchemaRevision
}
if len(e.Data) > 0 {
wm.Data = e.Data
}
case *schemauser.DeletedEvent:
wm.State = domain.UserStateDeleted
case *schemauser.EmailUpdatedEvent:
wm.Email = string(e.EmailAddress)
wm.IsEmailVerified = false
wm.EmailVerifiedFailedCount = 0
wm.EmailCode = nil
case *schemauser.EmailCodeAddedEvent:
wm.IsEmailVerified = false
wm.EmailVerifiedFailedCount = 0
wm.EmailCode = &VerifyCode{
Code: e.Code,
CreationDate: e.CreationDate(),
Expiry: e.Expiry,
}
case *schemauser.EmailVerifiedEvent:
wm.IsEmailVerified = true
wm.EmailVerifiedFailedCount = 0
wm.EmailCode = nil
case *schemauser.EmailVerificationFailedEvent:
wm.EmailVerifiedFailedCount += 1
case *schemauser.PhoneUpdatedEvent:
wm.Phone = string(e.PhoneNumber)
wm.IsPhoneVerified = false
wm.PhoneVerifiedFailedCount = 0
wm.EmailCode = nil
case *schemauser.PhoneCodeAddedEvent:
wm.IsPhoneVerified = false
wm.PhoneVerifiedFailedCount = 0
wm.PhoneCode = &VerifyCode{
Code: e.Code,
CreationDate: e.CreationDate(),
Expiry: e.Expiry,
}
case *schemauser.PhoneVerifiedEvent:
wm.PhoneVerifiedFailedCount = 0
wm.IsPhoneVerified = true
wm.PhoneCode = nil
case *schemauser.PhoneVerificationFailedEvent:
wm.PhoneVerifiedFailedCount += 1
case *schemauser.LockedEvent:
wm.Locked = true
case *schemauser.UnlockedEvent:
wm.Locked = false
case *schemauser.DeactivatedEvent:
wm.State = domain.UserStateInactive
case *schemauser.ActivatedEvent:
wm.State = domain.UserStateActive
}
}
return wm.WriteModel.Reduce()
}
func (wm *UserV3WriteModel) Query() *eventstore.SearchQueryBuilder {
builder := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent)
if wm.ResourceOwner != "" {
builder = builder.ResourceOwner(wm.ResourceOwner)
}
eventtypes := []eventstore.EventType{
schemauser.CreatedType,
schemauser.DeletedType,
schemauser.ActivatedType,
schemauser.DeactivatedType,
schemauser.LockedType,
schemauser.UnlockedType,
}
if wm.DataWM {
eventtypes = append(eventtypes,
schemauser.UpdatedType,
)
}
if wm.EmailWM {
eventtypes = append(eventtypes,
schemauser.EmailUpdatedType,
schemauser.EmailVerifiedType,
schemauser.EmailCodeAddedType,
schemauser.EmailVerificationFailedType,
)
}
if wm.PhoneWM {
eventtypes = append(eventtypes,
schemauser.PhoneUpdatedType,
schemauser.PhoneVerifiedType,
schemauser.PhoneCodeAddedType,
schemauser.PhoneVerificationFailedType,
)
}
return builder.AddQuery().
AggregateTypes(schemauser.AggregateType).
AggregateIDs(wm.AggregateID).
EventTypes(eventtypes...).Builder()
}
func (wm *UserV3WriteModel) NewCreated(
ctx context.Context,
schemaID string,
schemaRevision uint64,
data json.RawMessage,
email *Email,
phone *Phone,
emailCode func(context.Context) (*EncryptedCode, error),
phoneCode func(context.Context) (*EncryptedCode, string, error),
) (_ []eventstore.Command, codeEmail string, codePhone string, err error) {
if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil {
return nil, "", "", err
}
if wm.Exists() {
return nil, "", "", zerrors.ThrowPreconditionFailed(nil, "COMMAND-Nn8CRVlkeZ", "Errors.User.AlreadyExists")
}
events := []eventstore.Command{
schemauser.NewCreatedEvent(ctx,
UserV3AggregateFromWriteModel(&wm.WriteModel),
schemaID, schemaRevision, data,
),
}
if email != nil {
emailEvents, plainCodeEmail, err := wm.NewEmailCreate(ctx,
email,
emailCode,
)
if err != nil {
return nil, "", "", err
}
if plainCodeEmail != "" {
codeEmail = plainCodeEmail
}
events = append(events, emailEvents...)
}
if phone != nil {
phoneEvents, plainCodePhone, err := wm.NewPhoneCreate(ctx,
phone,
phoneCode,
)
if err != nil {
return nil, "", "", err
}
if plainCodePhone != "" {
codePhone = plainCodePhone
}
events = append(events, phoneEvents...)
}
return events, codeEmail, codePhone, nil
}
func (wm *UserV3WriteModel) getSchemaRoleForWrite(ctx context.Context, resourceOwner, userID string) (domain_schema.Role, error) {
if userID == authz.GetCtxData(ctx).UserID {
return domain_schema.RoleSelf, nil
}
if err := wm.checkPermission(ctx, domain.PermissionUserWrite, resourceOwner, userID); err != nil {
return domain_schema.RoleUnspecified, err
}
return domain_schema.RoleOwner, nil
}
func (wm *UserV3WriteModel) validateData(ctx context.Context, data []byte, schemaWM *UserSchemaWriteModel) (string, uint64, error) {
// get role for permission check in schema through extension
role, err := wm.getSchemaRoleForWrite(ctx, wm.ResourceOwner, wm.AggregateID)
if err != nil {
return "", 0, err
}
schema, err := domain_schema.NewSchema(role, bytes.NewReader(schemaWM.Schema))
if err != nil {
return "", 0, err
}
// if data not changed but a new schema or revision should be used
if data == nil {
data = wm.Data
}
var v interface{}
if err := json.Unmarshal(data, &v); err != nil {
return "", 0, zerrors.ThrowInvalidArgument(nil, "COMMAND-7o3ZGxtXUz", "Errors.User.Invalid")
}
if err := schema.Validate(v); err != nil {
return "", 0, zerrors.ThrowPreconditionFailed(nil, "COMMAND-SlKXqLSeL6", "Errors.UserSchema.Data.Invalid")
}
return schemaWM.AggregateID, schemaWM.SchemaRevision, nil
}
func (wm *UserV3WriteModel) NewUpdate(
ctx context.Context,
schemaWM *UserSchemaWriteModel,
user *SchemaUser,
email *Email,
phone *Phone,
emailCode func(context.Context) (*EncryptedCode, error),
phoneCode func(context.Context) (*EncryptedCode, string, error),
) (_ []eventstore.Command, codeEmail string, codePhone string, err error) {
if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil {
return nil, "", "", err
}
if !wm.Exists() {
return nil, "", "", zerrors.ThrowPreconditionFailed(nil, "COMMAND-Nn8CRVlkeZ", "Errors.User.NotFound")
}
events := make([]eventstore.Command, 0)
if user != nil {
schemaID, schemaRevision, err := wm.validateData(ctx, user.Data, schemaWM)
if err != nil {
return nil, "", "", err
}
userEvents := wm.newUpdatedEvents(ctx,
schemaID,
schemaRevision,
user.Data,
)
events = append(events, userEvents...)
}
if email != nil {
emailEvents, plainCodeEmail, err := wm.NewEmailUpdate(ctx,
email,
emailCode,
)
if err != nil {
return nil, "", "", err
}
if plainCodeEmail != "" {
codeEmail = plainCodeEmail
}
events = append(events, emailEvents...)
}
if phone != nil {
phoneEvents, plainCodePhone, err := wm.NewPhoneCreate(ctx,
phone,
phoneCode,
)
if err != nil {
return nil, "", "", err
}
if plainCodePhone != "" {
codePhone = plainCodePhone
}
events = append(events, phoneEvents...)
}
return events, codeEmail, codePhone, nil
}
func (wm *UserV3WriteModel) newUpdatedEvents(
ctx context.Context,
schemaID string,
schemaRevision uint64,
data json.RawMessage,
) []eventstore.Command {
changes := make([]schemauser.Changes, 0)
if wm.SchemaID != schemaID {
changes = append(changes, schemauser.ChangeSchemaID(schemaID))
}
if wm.SchemaRevision != schemaRevision {
changes = append(changes, schemauser.ChangeSchemaRevision(schemaRevision))
}
if data != nil && !bytes.Equal(wm.Data, data) {
changes = append(changes, schemauser.ChangeData(data))
}
if len(changes) == 0 {
return nil
}
return []eventstore.Command{schemauser.NewUpdatedEvent(ctx, UserV3AggregateFromWriteModel(&wm.WriteModel), changes)}
}
func (wm *UserV3WriteModel) NewDelete(
ctx context.Context,
) (_ []eventstore.Command, err error) {
if !wm.Exists() {
return nil, zerrors.ThrowNotFound(nil, "COMMAND-syHyCsGmvM", "Errors.User.NotFound")
}
if err := wm.checkPermissionDelete(ctx, wm.ResourceOwner, wm.AggregateID); err != nil {
return nil, err
}
return []eventstore.Command{schemauser.NewDeletedEvent(ctx, UserV3AggregateFromWriteModel(&wm.WriteModel))}, nil
}
func UserV3AggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggregate {
return &eventstore.Aggregate{
ID: wm.AggregateID,
Type: schemauser.AggregateType,
ResourceOwner: wm.ResourceOwner,
InstanceID: wm.InstanceID,
Version: schemauser.AggregateVersion,
}
}
func (wm *UserV3WriteModel) Exists() bool {
return wm.State != domain.UserStateDeleted && wm.State != domain.UserStateUnspecified
}
func (wm *UserV3WriteModel) checkPermissionWrite(
ctx context.Context,
resourceOwner string,
userID string,
) error {
if wm.writePermissionCheck {
return nil
}
if userID != "" && userID == authz.GetCtxData(ctx).UserID {
return nil
}
if err := wm.checkPermission(ctx, domain.PermissionUserWrite, resourceOwner, userID); err != nil {
return err
}
wm.writePermissionCheck = true
return nil
}
func (wm *UserV3WriteModel) checkPermissionDelete(
ctx context.Context,
resourceOwner string,
userID string,
) error {
if userID != "" && userID == authz.GetCtxData(ctx).UserID {
return nil
}
return wm.checkPermission(ctx, domain.PermissionUserDelete, resourceOwner, userID)
}
func (wm *UserV3WriteModel) NewEmailCreate(
ctx context.Context,
email *Email,
code func(context.Context) (*EncryptedCode, error),
) (_ []eventstore.Command, plainCode string, err error) {
if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil {
return nil, "", err
}
if email == nil || wm.Email == string(email.Address) {
return nil, "", nil
}
events := []eventstore.Command{
schemauser.NewEmailUpdatedEvent(ctx,
UserV3AggregateFromWriteModel(&wm.WriteModel),
email.Address,
),
}
if email.Verified {
events = append(events, wm.newEmailVerifiedEvent(ctx))
} else {
codeEvent, code, err := wm.newEmailCodeAddedEvent(ctx, code, email.URLTemplate, email.ReturnCode)
if err != nil {
return nil, "", err
}
events = append(events, codeEvent)
if code != "" {
plainCode = code
}
}
return events, plainCode, nil
}
func (wm *UserV3WriteModel) NewEmailUpdate(
ctx context.Context,
email *Email,
code func(context.Context) (*EncryptedCode, error),
) (_ []eventstore.Command, plainCode string, err error) {
if !wm.EmailWM {
return nil, "", nil
}
if !wm.Exists() {
return nil, "", zerrors.ThrowNotFound(nil, "COMMAND-nJ0TQFuRmP", "Errors.User.NotFound")
}
return wm.NewEmailCreate(ctx, email, code)
}
func (wm *UserV3WriteModel) NewEmailVerify(
ctx context.Context,
verify func(creationDate time.Time, expiry time.Duration, cryptoCode *crypto.CryptoValue) error,
) ([]eventstore.Command, error) {
if !wm.EmailWM {
return nil, nil
}
if !wm.Exists() {
return nil, zerrors.ThrowNotFound(nil, "COMMAND-qbGyMPvjvj", "Errors.User.NotFound")
}
if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil {
return nil, err
}
if wm.EmailCode == nil {
return nil, nil
}
if err := verify(wm.EmailCode.CreationDate, wm.EmailCode.Expiry, wm.EmailCode.Code); err != nil {
return nil, err
}
return []eventstore.Command{wm.newEmailVerifiedEvent(ctx)}, nil
}
func (wm *UserV3WriteModel) newEmailVerifiedEvent(
ctx context.Context,
) *schemauser.EmailVerifiedEvent {
return schemauser.NewEmailVerifiedEvent(ctx, UserV3AggregateFromWriteModel(&wm.WriteModel))
}
func (wm *UserV3WriteModel) NewResendEmailCode(
ctx context.Context,
code func(context.Context) (*EncryptedCode, error),
urlTemplate string,
isReturnCode bool,
) (_ []eventstore.Command, plainCode string, err error) {
if !wm.EmailWM {
return nil, "", nil
}
if !wm.Exists() {
return nil, "", zerrors.ThrowNotFound(nil, "COMMAND-EajeF6ypOV", "Errors.User.NotFound")
}
if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil {
return nil, "", err
}
if wm.EmailCode == nil {
return nil, "", zerrors.ThrowPreconditionFailed(err, "COMMAND-QRkNTBwF8q", "Errors.User.Code.Empty")
}
event, plainCode, err := wm.newEmailCodeAddedEvent(ctx, code, urlTemplate, isReturnCode)
if err != nil {
return nil, "", err
}
return []eventstore.Command{event}, plainCode, nil
}
func (wm *UserV3WriteModel) newEmailCodeAddedEvent(
ctx context.Context,
code func(context.Context) (*EncryptedCode, error),
urlTemplate string,
isReturnCode bool,
) (_ *schemauser.EmailCodeAddedEvent, plainCode string, err error) {
cryptoCode, err := code(ctx)
if err != nil {
return nil, "", err
}
if isReturnCode {
plainCode = cryptoCode.Plain
}
return schemauser.NewEmailCodeAddedEvent(ctx,
UserV3AggregateFromWriteModel(&wm.WriteModel),
cryptoCode.Crypted,
cryptoCode.Expiry,
urlTemplate,
isReturnCode,
), plainCode, nil
}
func (wm *UserV3WriteModel) NewPhoneCreate(
ctx context.Context,
phone *Phone,
code func(context.Context) (*EncryptedCode, string, error),
) (_ []eventstore.Command, plainCode string, err error) {
if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil {
return nil, "", err
}
if phone == nil || wm.Phone == string(phone.Number) {
return nil, "", nil
}
events := []eventstore.Command{
schemauser.NewPhoneUpdatedEvent(ctx,
UserV3AggregateFromWriteModel(&wm.WriteModel),
phone.Number,
),
}
if phone.Verified {
events = append(events, wm.newPhoneVerifiedEvent(ctx))
} else {
codeEvent, code, err := wm.newPhoneCodeAddedEvent(ctx, code, phone.ReturnCode)
if err != nil {
return nil, "", err
}
events = append(events, codeEvent)
if code != "" {
plainCode = code
}
}
return events, plainCode, nil
}
func (wm *UserV3WriteModel) NewPhoneUpdate(
ctx context.Context,
phone *Phone,
code func(context.Context) (*EncryptedCode, string, error),
) (_ []eventstore.Command, plainCode string, err error) {
if !wm.PhoneWM {
return nil, "", nil
}
if !wm.Exists() {
return nil, "", zerrors.ThrowNotFound(nil, "COMMAND-b33QAVgel6", "Errors.User.NotFound")
}
return wm.NewPhoneCreate(ctx, phone, code)
}
func (wm *UserV3WriteModel) NewPhoneVerify(
ctx context.Context,
verify func(creationDate time.Time, expiry time.Duration, cryptoCode *crypto.CryptoValue) error,
) ([]eventstore.Command, error) {
if !wm.PhoneWM {
return nil, nil
}
if !wm.Exists() {
return nil, zerrors.ThrowNotFound(nil, "COMMAND-bx2OLtgGNS", "Errors.User.NotFound")
}
if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil {
return nil, err
}
if wm.PhoneCode == nil {
return nil, nil
}
if err := verify(wm.PhoneCode.CreationDate, wm.PhoneCode.Expiry, wm.PhoneCode.Code); err != nil {
return nil, err
}
return []eventstore.Command{wm.newPhoneVerifiedEvent(ctx)}, nil
}
func (wm *UserV3WriteModel) newPhoneVerifiedEvent(
ctx context.Context,
) *schemauser.PhoneVerifiedEvent {
return schemauser.NewPhoneVerifiedEvent(ctx, UserV3AggregateFromWriteModel(&wm.WriteModel))
}
func (wm *UserV3WriteModel) NewResendPhoneCode(
ctx context.Context,
code func(context.Context) (*EncryptedCode, string, error),
isReturnCode bool,
) (_ []eventstore.Command, plainCode string, err error) {
if !wm.PhoneWM {
return nil, "", nil
}
if !wm.Exists() {
return nil, "", zerrors.ThrowNotFound(nil, "COMMAND-z8Bu9vuL9s", "Errors.User.NotFound")
}
if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil {
return nil, "", err
}
if wm.PhoneCode == nil {
return nil, "", zerrors.ThrowPreconditionFailed(err, "COMMAND-fEsHdqECzb", "Errors.User.Code.Empty")
}
event, plainCode, err := wm.newPhoneCodeAddedEvent(ctx, code, isReturnCode)
if err != nil {
return nil, "", err
}
return []eventstore.Command{event}, plainCode, nil
}
func (wm *UserV3WriteModel) newPhoneCodeAddedEvent(
ctx context.Context,
code func(context.Context) (*EncryptedCode, string, error),
isReturnCode bool,
) (_ *schemauser.PhoneCodeAddedEvent, plainCode string, err error) {
cryptoCode, generatorID, err := code(ctx)
if err != nil {
return nil, "", err
}
if isReturnCode {
plainCode = cryptoCode.Plain
}
return schemauser.NewPhoneCodeAddedEvent(ctx,
UserV3AggregateFromWriteModel(&wm.WriteModel),
cryptoCode.CryptedCode(),
cryptoCode.CodeExpiry(),
isReturnCode,
generatorID,
), plainCode, nil
}