zitadel/internal/command/user_v2_invite.go
Livio Spring a07b2f4677
feat: invite user link (#8578)
# Which Problems Are Solved

As an administrator I want to be able to invite users to my application
with the API V2, some user data I will already prefil, the user should
add the authentication method themself (password, passkey, sso).

# How the Problems Are Solved

- A user can now be created with a email explicitly set to false.
- If a user has no verified email and no authentication method, an
`InviteCode` can be created through the User V2 API.
  - the code can be returned or sent through email
- additionally `URLTemplate` and an `ApplicatioName` can provided for
the email
- The code can be resent and verified through the User V2 API
- The V1 login allows users to verify and resend the code and set a
password (analog user initialization)
- The message text for the user invitation can be customized

# Additional Changes

- `verifyUserPasskeyCode` directly uses `crypto.VerifyCode` (instead of
`verifyEncryptedCode`)
- `verifyEncryptedCode` is removed (unnecessarily queried for the code
generator)

# Additional Context

- closes #8310
- TODO: login V2 will have to implement invite flow:
https://github.com/zitadel/typescript/issues/166
2024-09-11 10:53:55 +00:00

194 lines
6.4 KiB
Go

package command
import (
"context"
"strings"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"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/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
)
type CreateUserInvite struct {
UserID string
URLTemplate string
ReturnCode bool
ApplicationName string
}
func (c *Commands) CreateInviteCode(ctx context.Context, invite *CreateUserInvite) (details *domain.ObjectDetails, returnCode *string, err error) {
invite.UserID = strings.TrimSpace(invite.UserID)
if invite.UserID == "" {
return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4jio3", "Errors.User.UserIDMissing")
}
wm, err := c.userInviteCodeWriteModel(ctx, invite.UserID, "")
if err != nil {
return nil, nil, err
}
if err := c.checkPermission(ctx, domain.PermissionUserWrite, wm.ResourceOwner, wm.AggregateID); err != nil {
return nil, nil, err
}
if !wm.UserState.Exists() {
return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wgvn4", "Errors.User.NotFound")
}
if !wm.CreationAllowed() {
return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-EF34g", "Errors.User.AlreadyInitialised")
}
code, err := c.newUserInviteCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint
if err != nil {
return nil, nil, err
}
err = c.pushAppendAndReduce(ctx, wm, user.NewHumanInviteCodeAddedEvent(
ctx,
UserAggregateFromWriteModelCtx(ctx, &wm.WriteModel),
code.Crypted,
code.Expiry,
invite.URLTemplate,
invite.ReturnCode,
invite.ApplicationName,
"",
))
if err != nil {
return nil, nil, err
}
if invite.ReturnCode {
returnCode = &code.Plain
}
return writeModelToObjectDetails(&wm.WriteModel), returnCode, nil
}
// ResendInviteCode resends the invite mail with a new code and an optional authRequestID.
// It will reuse the applicationName from the previous code.
func (c *Commands) ResendInviteCode(ctx context.Context, userID, resourceOwner, authRequestID string) (objectDetails *domain.ObjectDetails, err error) {
if userID == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-2n8vs", "Errors.User.UserIDMissing")
}
existingCode, err := c.userInviteCodeWriteModel(ctx, userID, resourceOwner)
if err != nil {
return nil, err
}
if authz.GetCtxData(ctx).UserID != userID {
if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingCode.ResourceOwner, userID); err != nil {
return nil, err
}
}
if !existingCode.UserState.Exists() {
return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-H3b2a", "Errors.User.NotFound")
}
if !existingCode.CreationAllowed() {
return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Gg42s", "Errors.User.AlreadyInitialised")
}
if existingCode.InviteCode == nil || existingCode.CodeReturned {
return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wr3gq", "Errors.User.Code.NotFound")
}
code, err := c.newUserInviteCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint
if err != nil {
return nil, err
}
if authRequestID == "" {
authRequestID = existingCode.AuthRequestID
}
err = c.pushAppendAndReduce(ctx, existingCode,
user.NewHumanInviteCodeAddedEvent(
ctx,
UserAggregateFromWriteModelCtx(ctx, &existingCode.WriteModel),
code.Crypted,
code.Expiry,
existingCode.URLTemplate,
false,
existingCode.ApplicationName,
authRequestID,
))
if err != nil {
return nil, err
}
return writeModelToObjectDetails(&existingCode.WriteModel), nil
}
func (c *Commands) InviteCodeSent(ctx context.Context, userID, orgID string) (err error) {
if userID == "" {
return zerrors.ThrowInvalidArgument(nil, "COMMAND-Sgf31", "Errors.User.UserIDMissing")
}
existingCode, err := c.userInviteCodeWriteModel(ctx, userID, orgID)
if err != nil {
return err
}
if !existingCode.UserState.Exists() {
return zerrors.ThrowPreconditionFailed(nil, "COMMAND-HN34a", "Errors.User.NotFound")
}
if existingCode.InviteCode == nil || existingCode.CodeReturned {
return zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wr3gq", "Errors.User.Code.NotFound")
}
userAgg := UserAggregateFromWriteModelCtx(ctx, &existingCode.WriteModel)
_, err = c.eventstore.Push(ctx, user.NewHumanInviteCodeSentEvent(ctx, userAgg))
return err
}
func (c *Commands) VerifyInviteCode(ctx context.Context, userID, code string) (details *domain.ObjectDetails, err error) {
return c.VerifyInviteCodeSetPassword(ctx, userID, code, "", "")
}
func (c *Commands) VerifyInviteCodeSetPassword(ctx context.Context, userID, code, password, userAgentID string) (details *domain.ObjectDetails, err error) {
if userID == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Gk3f2", "Errors.User.UserIDMissing")
}
wm, err := c.userInviteCodeWriteModel(ctx, userID, "")
if err != nil {
return nil, err
}
if !wm.UserState.Exists() {
return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-F5g2h", "Errors.User.NotFound")
}
userAgg := UserAggregateFromWriteModelCtx(ctx, &wm.WriteModel)
err = crypto.VerifyCode(wm.InviteCodeCreationDate, wm.InviteCodeExpiry, wm.InviteCode, code, c.userEncryption)
if err != nil {
_, err = c.eventstore.Push(ctx, user.NewHumanInviteCheckFailedEvent(ctx, userAgg))
logging.WithFields("userID", userAgg.ID).OnError(err).Error("NewHumanInviteCheckFailedEvent push failed")
return nil, zerrors.ThrowInvalidArgument(err, "COMMAND-Wgn4q", "Errors.User.Code.Invalid")
}
commands := []eventstore.Command{
user.NewHumanInviteCheckSucceededEvent(ctx, userAgg),
user.NewHumanEmailVerifiedEvent(ctx, userAgg),
}
if password != "" {
passwordCommand, err := c.setPasswordCommand(
ctx,
userAgg,
wm.UserState,
password,
"",
userAgentID,
false,
nil,
)
if err != nil {
return nil, err
}
commands = append(commands, passwordCommand)
}
err = c.pushAppendAndReduce(ctx, wm, commands...)
if err != nil {
return nil, err
}
return writeModelToObjectDetails(&wm.WriteModel), nil
}
func (c *Commands) userInviteCodeWriteModel(ctx context.Context, userID, orgID string) (writeModel *UserV2InviteWriteModel, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
writeModel = newUserV2InviteWriteModel(userID, orgID)
err = c.eventstore.FilterToQueryReducer(ctx, writeModel)
if err != nil {
return nil, err
}
return writeModel, nil
}