mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-11 19:17:32 +00:00
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
This commit is contained in:
@@ -106,6 +106,7 @@ type idpUserLinksProvider interface {
|
||||
type userEventProvider interface {
|
||||
UserEventsByID(ctx context.Context, id string, changeDate time.Time, eventTypes []eventstore.EventType) ([]eventstore.Event, error)
|
||||
PasswordCodeExists(ctx context.Context, userID string) (exists bool, err error)
|
||||
InviteCodeExists(ctx context.Context, userID string) (exists bool, err error)
|
||||
}
|
||||
|
||||
type userCommandProvider interface {
|
||||
@@ -1254,8 +1255,18 @@ func (repo *AuthRequestRepo) firstFactorChecked(ctx context.Context, request *do
|
||||
|
||||
if user.PasswordInitRequired {
|
||||
if !user.IsEmailVerified {
|
||||
// If the user was created through the user resource API,
|
||||
// they can either have an invite code...
|
||||
exists, err := repo.UserEventProvider.InviteCodeExists(ctx, user.ID)
|
||||
logging.WithFields("userID", user.ID).OnError(err).Error("unable to check if invite code exists")
|
||||
if err == nil && exists {
|
||||
return &domain.VerifyInviteStep{}
|
||||
}
|
||||
// or were created with an explicit email verification mail
|
||||
return &domain.VerifyEMailStep{InitPassword: true}
|
||||
}
|
||||
// If they were created with a verified mail, they might have never received mail to set their password,
|
||||
// e.g. when created through a user resource API. In this case we'll just create and send one now.
|
||||
exists, err := repo.UserEventProvider.PasswordCodeExists(ctx, user.ID)
|
||||
logging.WithFields("userID", user.ID).OnError(err).Error("unable to check if password code exists")
|
||||
if err == nil && !exists {
|
||||
|
@@ -110,8 +110,9 @@ func (m *mockViewNoUser) UserByID(context.Context, string, string) (*user_view_m
|
||||
}
|
||||
|
||||
type mockEventUser struct {
|
||||
Events []eventstore.Event
|
||||
CodeExists bool
|
||||
Events []eventstore.Event
|
||||
PwCodeExists bool
|
||||
InvitationCodeExists bool
|
||||
}
|
||||
|
||||
func (m *mockEventUser) UserEventsByID(ctx context.Context, id string, changeDate time.Time, types []eventstore.EventType) ([]eventstore.Event, error) {
|
||||
@@ -119,7 +120,11 @@ func (m *mockEventUser) UserEventsByID(ctx context.Context, id string, changeDat
|
||||
}
|
||||
|
||||
func (m *mockEventUser) PasswordCodeExists(ctx context.Context, userID string) (bool, error) {
|
||||
return m.CodeExists, nil
|
||||
return m.PwCodeExists, nil
|
||||
}
|
||||
|
||||
func (m *mockEventUser) InviteCodeExists(ctx context.Context, userID string) (bool, error) {
|
||||
return m.InvitationCodeExists, nil
|
||||
}
|
||||
|
||||
func (m *mockEventUser) GetLatestUserSessionSequence(ctx context.Context, instanceID string) (*query.CurrentState, error) {
|
||||
@@ -140,6 +145,10 @@ func (m *mockEventErrUser) PasswordCodeExists(ctx context.Context, userID string
|
||||
return false, zerrors.ThrowInternal(nil, "id", "internal error")
|
||||
}
|
||||
|
||||
func (m *mockEventErrUser) InviteCodeExists(ctx context.Context, userID string) (bool, error) {
|
||||
return false, zerrors.ThrowInternal(nil, "id", "internal error")
|
||||
}
|
||||
|
||||
type mockViewUser struct {
|
||||
InitRequired bool
|
||||
PasswordInitRequired bool
|
||||
@@ -1019,6 +1028,36 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
|
||||
[]domain.NextStep{&domain.VerifyEMailStep{}},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"password not set (email not verified), invite code exists, invite step",
|
||||
fields{
|
||||
userSessionViewProvider: &mockViewUserSession{},
|
||||
userViewProvider: &mockViewUser{
|
||||
PasswordInitRequired: true,
|
||||
},
|
||||
userEventProvider: &mockEventUser{
|
||||
InvitationCodeExists: true,
|
||||
},
|
||||
lockoutPolicyProvider: &mockLockoutPolicy{
|
||||
policy: &query.LockoutPolicy{
|
||||
ShowFailures: true,
|
||||
},
|
||||
},
|
||||
orgViewProvider: &mockViewOrg{State: domain.OrgStateActive},
|
||||
idpUserLinksProvider: &mockIDPUserLinks{},
|
||||
},
|
||||
args{
|
||||
&domain.AuthRequest{
|
||||
UserID: "UserID",
|
||||
LoginPolicy: &domain.LoginPolicy{
|
||||
AllowUsernamePassword: true,
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
[]domain.NextStep{&domain.VerifyInviteStep{}},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"password not set (email not verified), init password step",
|
||||
fields{
|
||||
@@ -1056,7 +1095,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
|
||||
IsEmailVerified: true,
|
||||
},
|
||||
userEventProvider: &mockEventUser{
|
||||
CodeExists: true,
|
||||
PwCodeExists: true,
|
||||
},
|
||||
lockoutPolicyProvider: &mockLockoutPolicy{
|
||||
policy: &query.LockoutPolicy{
|
||||
@@ -1088,7 +1127,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
|
||||
IsEmailVerified: true,
|
||||
},
|
||||
userEventProvider: &mockEventUser{
|
||||
CodeExists: false,
|
||||
PwCodeExists: false,
|
||||
},
|
||||
lockoutPolicyProvider: &mockLockoutPolicy{
|
||||
policy: &query.LockoutPolicy{
|
||||
|
@@ -93,3 +93,41 @@ func (repo *UserRepo) PasswordCodeExists(ctx context.Context, userID string) (ex
|
||||
}
|
||||
return model.exists, nil
|
||||
}
|
||||
|
||||
type inviteCodeCheck struct {
|
||||
userID string
|
||||
|
||||
exists bool
|
||||
events int
|
||||
}
|
||||
|
||||
func (p *inviteCodeCheck) Reduce() error {
|
||||
p.exists = p.events > 0
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *inviteCodeCheck) AppendEvents(events ...eventstore.Event) {
|
||||
p.events += len(events)
|
||||
}
|
||||
|
||||
func (p *inviteCodeCheck) Query() *eventstore.SearchQueryBuilder {
|
||||
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
|
||||
AddQuery().
|
||||
AggregateTypes(user.AggregateType).
|
||||
AggregateIDs(p.userID).
|
||||
EventTypes(
|
||||
user.HumanInviteCodeAddedType,
|
||||
user.HumanInviteCodeSentType).
|
||||
Builder()
|
||||
}
|
||||
|
||||
func (repo *UserRepo) InviteCodeExists(ctx context.Context, userID string) (exists bool, err error) {
|
||||
model := &inviteCodeCheck{
|
||||
userID: userID,
|
||||
}
|
||||
err = repo.Eventstore.FilterToQueryReducer(ctx, model)
|
||||
if err != nil {
|
||||
return false, zerrors.ThrowPermissionDenied(err, "EVENT-GJ2os", "Errors.Internal")
|
||||
}
|
||||
return model.exists, nil
|
||||
}
|
||||
|
Reference in New Issue
Block a user