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:
Livio Spring
2024-09-11 12:53:55 +02:00
committed by GitHub
parent 02c78a19c6
commit a07b2f4677
114 changed files with 3898 additions and 293 deletions

View File

@@ -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 {

View File

@@ -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{

View File

@@ -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
}