diff --git a/cmd/zitadel/main.go b/cmd/zitadel/main.go
index 358cf3fa03..071b3499c0 100644
--- a/cmd/zitadel/main.go
+++ b/cmd/zitadel/main.go
@@ -216,7 +216,7 @@ func startAPI(ctx context.Context, conf *Config, verifier *internal_authz.TokenV
apis.RegisterServer(ctx, management.CreateServer(command, query, managementRepo, conf.SystemDefaults))
}
if *authEnabled {
- apis.RegisterServer(ctx, auth.CreateServer(command, query, authRepo))
+ apis.RegisterServer(ctx, auth.CreateServer(command, query, authRepo, conf.SystemDefaults))
}
if *oidcEnabled {
op := oidc.NewProvider(ctx, conf.API.OIDC, command, query, authRepo, conf.SystemDefaults.KeyConfig.EncryptionConfig, *localDevMode)
diff --git a/cmd/zitadel/system-defaults.yaml b/cmd/zitadel/system-defaults.yaml
index b8938827fd..19572bd067 100644
--- a/cmd/zitadel/system-defaults.yaml
+++ b/cmd/zitadel/system-defaults.yaml
@@ -44,6 +44,13 @@ SystemDefaults:
IncludeUpperLetters: true
IncludeDigits: true
IncludeSymbols: false
+ PasswordlessInitCode:
+ Length: 12
+ Expiry: '1h'
+ IncludeLowerLetters: true
+ IncludeUpperLetters: true
+ IncludeDigits: true
+ IncludeSymbols: false
MachineKeySize: 2048
ApplicationKeySize: 2048
Multifactors:
@@ -74,6 +81,7 @@ SystemDefaults:
PasswordReset: '$ZITADEL_ACCOUNTS/password/init?userID={{.UserID}}&code={{.Code}}'
VerifyEmail: '$ZITADEL_ACCOUNTS/mail/verification?userID={{.UserID}}&code={{.Code}}'
DomainClaimed: '$ZITADEL_ACCOUNTS/login'
+ PasswordlessRegistration: '$ZITADEL_ACCOUNTS/login/passwordless/init'
Providers:
Chat:
Url: $CHAT_URL
diff --git a/docs/docs/apis/proto/admin.md b/docs/docs/apis/proto/admin.md
index 9a2fa386f9..e08e1693db 100644
--- a/docs/docs/apis/proto/admin.md
+++ b/docs/docs/apis/proto/admin.md
@@ -851,13 +851,52 @@ Returns the custom text for domain claimed message (overwritten in eventstore)
[SetDefaultDomainClaimedMessageTextResponse](#setdefaultdomainclaimedmessagetextresponse)
Sets the default custom text for domain claimed phone message
-it impacts all organisations without customized verify phone message text
+it impacts all organisations without customized domain claimed message text
The Following Variables can be used:
{{.Domain}} {{.TempUsername}} {{.UserName}} {{.FirstName}} {{.LastName}} {{.NickName}} {{.DisplayName}} {{.LastEmail}} {{.VerifiedEmail}} {{.LastPhone}} {{.VerifiedPhone}} {{.PreferredLoginName}} {{.LoginNames}} {{.ChangeDate}}
- PUT: /text/message/verifyphone/{language}
+ PUT: /text/message/domainclaimed/{language}
+
+
+### GetDefaultPasswordlessRegistrationMessageText
+
+> **rpc** GetDefaultPasswordlessRegistrationMessageText([GetDefaultPasswordlessRegistrationMessageTextRequest](#getdefaultpasswordlessregistrationmessagetextrequest))
+[GetDefaultPasswordlessRegistrationMessageTextResponse](#getdefaultpasswordlessregistrationmessagetextresponse)
+
+Returns the default text for passwordless registration message (translation file)
+
+
+
+ GET: /text/default/message/passwordless_registration/{language}
+
+
+### GetCustomPasswordlessRegistrationMessageText
+
+> **rpc** GetCustomPasswordlessRegistrationMessageText([GetCustomPasswordlessRegistrationMessageTextRequest](#getcustompasswordlessregistrationmessagetextrequest))
+[GetCustomPasswordlessRegistrationMessageTextResponse](#getcustompasswordlessregistrationmessagetextresponse)
+
+Returns the custom text for passwordless registration message (overwritten in eventstore)
+
+
+
+ GET: /text/message/passwordless_registration/{language}
+
+
+### SetDefaultPasswordlessRegistrationMessageText
+
+> **rpc** SetDefaultPasswordlessRegistrationMessageText([SetDefaultPasswordlessRegistrationMessageTextRequest](#setdefaultpasswordlessregistrationmessagetextrequest))
+[SetDefaultPasswordlessRegistrationMessageTextResponse](#setdefaultpasswordlessregistrationmessagetextresponse)
+
+Sets the default custom text for passwordless registration message
+it impacts all organisations without customized passwordless registration message text
+The Following Variables can be used:
+{{.UserName}} {{.FirstName}} {{.LastName}} {{.NickName}} {{.DisplayName}} {{.LastEmail}} {{.VerifiedEmail}} {{.LastPhone}} {{.VerifiedPhone}} {{.PreferredLoginName}} {{.LoginNames}} {{.ChangeDate}}
+
+
+
+ PUT: /text/message/passwordless_registration/{language}
### GetDefaultLoginTexts
@@ -1366,6 +1405,28 @@ This is an empty response
+### GetCustomPasswordlessRegistrationMessageTextRequest
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| language | string | - | string.min_len: 1
string.max_len: 200
|
+
+
+
+
+### GetCustomPasswordlessRegistrationMessageTextResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| custom_text | zitadel.text.v1.MessageCustomText | - | |
+
+
+
+
### GetCustomVerifyEmailMessageTextRequest
@@ -1515,6 +1576,28 @@ This is an empty response
+### GetDefaultPasswordlessRegistrationMessageTextRequest
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| language | string | - | string.min_len: 1
string.max_len: 200
|
+
+
+
+
+### GetDefaultPasswordlessRegistrationMessageTextResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| custom_text | zitadel.text.v1.MessageCustomText | - | |
+
+
+
+
### GetDefaultVerifyEmailMessageTextRequest
@@ -2351,6 +2434,9 @@ This is an empty request
| success_login_text | zitadel.text.v1.SuccessLoginScreenText | - | |
| logout_text | zitadel.text.v1.LogoutDoneScreenText | - | |
| footer_text | zitadel.text.v1.FooterText | - | |
+| passwordless_prompt_text | zitadel.text.v1.PasswordlessPromptScreenText | - | |
+| passwordless_registration_text | zitadel.text.v1.PasswordlessRegistrationScreenText | - | |
+| passwordless_registration_done_text | zitadel.text.v1.PasswordlessRegistrationDoneScreenText | - | |
@@ -2490,6 +2576,35 @@ This is an empty request
+### SetDefaultPasswordlessRegistrationMessageTextRequest
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| language | string | - | string.min_len: 1
string.max_len: 200
|
+| title | string | - | string.max_len: 200
|
+| pre_header | string | - | string.max_len: 200
|
+| subject | string | - | string.max_len: 200
|
+| greeting | string | - | string.max_len: 200
|
+| text | string | - | string.max_len: 800
|
+| button_text | string | - | string.max_len: 200
|
+| footer_text | string | - | string.max_len: 200
|
+
+
+
+
+### SetDefaultPasswordlessRegistrationMessageTextResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| details | zitadel.v1.ObjectDetails | - | |
+
+
+
+
### SetDefaultVerifyEmailMessageTextRequest
diff --git a/docs/docs/apis/proto/auth.md b/docs/docs/apis/proto/auth.md
index 53467b240c..5e48a7eda9 100644
--- a/docs/docs/apis/proto/auth.md
+++ b/docs/docs/apis/proto/auth.md
@@ -401,7 +401,7 @@ Removes the U2F Authentication from the authorized user
> **rpc** ListMyPasswordless([ListMyPasswordlessRequest](#listmypasswordlessrequest))
[ListMyPasswordlessResponse](#listmypasswordlessresponse)
-Returns all configured passwordless authentications of the authorized user
+Returns all configured passwordless authenticators of the authorized user
@@ -413,7 +413,7 @@ Returns all configured passwordless authentications of the authorized user
> **rpc** AddMyPasswordless([AddMyPasswordlessRequest](#addmypasswordlessrequest))
[AddMyPasswordlessResponse](#addmypasswordlessresponse)
-Adds a new passwordless authentications to the authorized user
+Adds a new passwordless authenticator to the authorized user
Multiple passwordless authentications can be configured
@@ -421,6 +421,34 @@ Multiple passwordless authentications can be configured
POST: /users/me/passwordless
+### AddMyPasswordlessLink
+
+> **rpc** AddMyPasswordlessLink([AddMyPasswordlessLinkRequest](#addmypasswordlesslinkrequest))
+[AddMyPasswordlessLinkResponse](#addmypasswordlesslinkresponse)
+
+Adds a new passwordless authenticator link to the authorized user and returns it directly
+This link enables the user to register a new device if current passwordless devices are all platform authenticators
+e.g. User has already registered Windows Hello and wants to register FaceID on the iPhone
+
+
+
+ POST: /users/me/passwordless/_link
+
+
+### SendMyPasswordlessLink
+
+> **rpc** SendMyPasswordlessLink([SendMyPasswordlessLinkRequest](#sendmypasswordlesslinkrequest))
+[SendMyPasswordlessLinkResponse](#sendmypasswordlesslinkresponse)
+
+Adds a new passwordless authenticator link to the authorized user and sends it to the registered email address
+This link enables the user to register a new device if current passwordless devices are all platform authenticators
+e.g. User has already registered Windows Hello and wants to register FaceID on the iPhone
+
+
+
+ POST: /users/me/passwordless/_send_link
+
+
### VerifyMyPasswordless
> **rpc** VerifyMyPasswordless([VerifyMyPasswordlessRequest](#verifymypasswordlessrequest))
@@ -550,6 +578,25 @@ This is an empty request
+### AddMyPasswordlessLinkRequest
+This is an empty request
+
+
+
+
+### AddMyPasswordlessLinkResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| details | zitadel.v1.ObjectDetails | - | |
+| link | string | - | |
+| expiration | google.protobuf.Duration | - | |
+
+
+
+
### AddMyPasswordlessRequest
This is an empty request
@@ -1086,6 +1133,23 @@ This is an empty response
+### SendMyPasswordlessLinkRequest
+This is an empty request
+
+
+
+
+### SendMyPasswordlessLinkResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| details | zitadel.v1.ObjectDetails | - | |
+
+
+
+
### SetMyEmailRequest
diff --git a/docs/docs/apis/proto/management.md b/docs/docs/apis/proto/management.md
index 03dd21d279..328f4d2a36 100644
--- a/docs/docs/apis/proto/management.md
+++ b/docs/docs/apis/proto/management.md
@@ -450,19 +450,33 @@ The u2f (universial second factor) will be removed from the user
> **rpc** ListHumanPasswordless([ListHumanPasswordlessRequest](#listhumanpasswordlessrequest))
[ListHumanPasswordlessResponse](#listhumanpasswordlessresponse)
-Returns all configured passwordless authentications
+Returns all configured passwordless authenticators
POST: /users/{user_id}/passwordless/_search
+### SendPasswordlessRegistration
+
+> **rpc** SendPasswordlessRegistration([SendPasswordlessRegistrationRequest](#sendpasswordlessregistrationrequest))
+[SendPasswordlessRegistrationResponse](#sendpasswordlessregistrationresponse)
+
+Adds a new passwordless authenticator link to the user and sends it to the registered email address
+This link enables the user to register a new device if current passwordless devices are all platform authenticators
+e.g. User has already registered Windows Hello and wants to register FaceID on the iPhone
+
+
+
+ POST: /users/{user_id}/passwordless/_send_link
+
+
### RemoveHumanPasswordless
> **rpc** RemoveHumanPasswordless([RemoveHumanPasswordlessRequest](#removehumanpasswordlessrequest))
[RemoveHumanPasswordlessResponse](#removehumanpasswordlessresponse)
-Removed a configured passwordless authentication
+Removed a configured passwordless authenticator
@@ -2144,8 +2158,7 @@ Returns the default text for initial message
> **rpc** SetCustomInitMessageText([SetCustomInitMessageTextRequest](#setcustominitmessagetextrequest))
[SetCustomInitMessageTextResponse](#setcustominitmessagetextresponse)
-Sets the default custom text for initial message
-it impacts all organisations without customized initial message text
+Sets the custom text for initial message
The Following Variables can be used:
{{.Code}} {{.UserName}} {{.FirstName}} {{.LastName}} {{.NickName}} {{.DisplayName}} {{.LastEmail}} {{.VerifiedEmail}} {{.LastPhone}} {{.VerifiedPhone}} {{.PreferredLoginName}} {{.LoginNames}} {{.ChangeDate}}
@@ -2196,8 +2209,7 @@ Returns the default text for password reset message
> **rpc** SetCustomPasswordResetMessageText([SetCustomPasswordResetMessageTextRequest](#setcustompasswordresetmessagetextrequest))
[SetCustomPasswordResetMessageTextResponse](#setcustompasswordresetmessagetextresponse)
-Sets the default custom text for password reset message
-it impacts all organisations without customized password reset message text
+Sets the custom text for password reset message
The Following Variables can be used:
{{.Code}} {{.UserName}} {{.FirstName}} {{.LastName}} {{.NickName}} {{.DisplayName}} {{.LastEmail}} {{.VerifiedEmail}} {{.LastPhone}} {{.VerifiedPhone}} {{.PreferredLoginName}} {{.LoginNames}} {{.ChangeDate}}
@@ -2248,8 +2260,7 @@ Returns the default text for verify email message
> **rpc** SetCustomVerifyEmailMessageText([SetCustomVerifyEmailMessageTextRequest](#setcustomverifyemailmessagetextrequest))
[SetCustomVerifyEmailMessageTextResponse](#setcustomverifyemailmessagetextresponse)
-Sets the default custom text for verify email message
-it impacts all organisations without customized verify email message text
+Sets the custom text for verify email message
The Following Variables can be used:
{{.Code}} {{.UserName}} {{.FirstName}} {{.LastName}} {{.NickName}} {{.DisplayName}} {{.LastEmail}} {{.VerifiedEmail}} {{.LastPhone}} {{.VerifiedPhone}} {{.PreferredLoginName}} {{.LoginNames}} {{.ChangeDate}}
@@ -2301,7 +2312,6 @@ Returns the custom text for verify email message
[SetCustomVerifyPhoneMessageTextResponse](#setcustomverifyphonemessagetextresponse)
Sets the default custom text for verify email message
-it impacts all organisations without customized verify email message text
The Following Variables can be used:
{{.Code}} {{.UserName}} {{.FirstName}} {{.LastName}} {{.NickName}} {{.DisplayName}} {{.LastEmail}} {{.VerifiedEmail}} {{.LastPhone}} {{.VerifiedPhone}} {{.PreferredLoginName}} {{.LoginNames}} {{.ChangeDate}}
@@ -2352,8 +2362,7 @@ Returns the custom text for domain claimed message
> **rpc** SetCustomDomainClaimedMessageCustomText([SetCustomDomainClaimedMessageTextRequest](#setcustomdomainclaimedmessagetextrequest))
[SetCustomDomainClaimedMessageTextResponse](#setcustomdomainclaimedmessagetextresponse)
-Sets the default custom text for domain claimed message
-it impacts all organisations without customized domain claimed message text
+Sets the custom text for domain claimed message
The Following Variables can be used:
{{.Domain}} {{.TempUsername}} {{.UserName}} {{.FirstName}} {{.LastName}} {{.NickName}} {{.DisplayName}} {{.LastEmail}} {{.VerifiedEmail}} {{.LastPhone}} {{.VerifiedPhone}} {{.PreferredLoginName}} {{.LoginNames}} {{.ChangeDate}}
@@ -2367,7 +2376,7 @@ The Following Variables can be used:
> **rpc** ResetCustomDomainClaimedMessageTextToDefault([ResetCustomDomainClaimedMessageTextToDefaultRequest](#resetcustomdomainclaimedmessagetexttodefaultrequest))
[ResetCustomDomainClaimedMessageTextToDefaultResponse](#resetcustomdomainclaimedmessagetexttodefaultresponse)
-Removes the custom init message text of the organisation
+Removes the custom domain claimed message text of the organisation
The default text of the IAM will trigger after
@@ -2375,6 +2384,57 @@ The default text of the IAM will trigger after
DELETE: /text/message/domainclaimed/{language}
+### GetCustomPasswordlessRegistrationMessageText
+
+> **rpc** GetCustomPasswordlessRegistrationMessageText([GetCustomPasswordlessRegistrationMessageTextRequest](#getcustompasswordlessregistrationmessagetextrequest))
+[GetCustomPasswordlessRegistrationMessageTextResponse](#getcustompasswordlessregistrationmessagetextresponse)
+
+Returns the custom text for passwordless link message
+
+
+
+ GET: /text/message/passwordless_registration/{language}
+
+
+### GetDefaultPasswordlessRegistrationMessageText
+
+> **rpc** GetDefaultPasswordlessRegistrationMessageText([GetDefaultPasswordlessRegistrationMessageTextRequest](#getdefaultpasswordlessregistrationmessagetextrequest))
+[GetDefaultPasswordlessRegistrationMessageTextResponse](#getdefaultpasswordlessregistrationmessagetextresponse)
+
+Returns the custom text for passwordless link message
+
+
+
+ GET: /text/default/message/passwordless_registration/{language}
+
+
+### SetCustomPasswordlessRegistrationMessageCustomText
+
+> **rpc** SetCustomPasswordlessRegistrationMessageCustomText([SetCustomPasswordlessRegistrationMessageTextRequest](#setcustompasswordlessregistrationmessagetextrequest))
+[SetCustomPasswordlessRegistrationMessageTextResponse](#setcustompasswordlessregistrationmessagetextresponse)
+
+Sets the custom text for passwordless link message
+The Following Variables can be used:
+{{.UserName}} {{.FirstName}} {{.LastName}} {{.NickName}} {{.DisplayName}} {{.LastEmail}} {{.VerifiedEmail}} {{.LastPhone}} {{.VerifiedPhone}} {{.PreferredLoginName}} {{.LoginNames}} {{.ChangeDate}}
+
+
+
+ PUT: /text/message/passwordless_registration/{language}
+
+
+### ResetCustomPasswordlessRegistrationMessageTextToDefault
+
+> **rpc** ResetCustomPasswordlessRegistrationMessageTextToDefault([ResetCustomPasswordlessRegistrationMessageTextToDefaultRequest](#resetcustompasswordlessregistrationmessagetexttodefaultrequest))
+[ResetCustomPasswordlessRegistrationMessageTextToDefaultResponse](#resetcustompasswordlessregistrationmessagetexttodefaultresponse)
+
+Removes the custom passwordless link message text of the organisation
+The default text of the IAM will trigger after
+
+
+
+ DELETE: /text/message/passwordless_registration/{language}
+
+
### GetCustomLoginTexts
> **rpc** GetCustomLoginTexts([GetCustomLoginTextsRequest](#getcustomlogintextsrequest))
@@ -3598,6 +3658,28 @@ This is an empty request
+### GetCustomPasswordlessRegistrationMessageTextRequest
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| language | string | - | string.min_len: 1
string.max_len: 200
|
+
+
+
+
+### GetCustomPasswordlessRegistrationMessageTextResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| custom_text | zitadel.text.v1.MessageCustomText | - | |
+
+
+
+
### GetCustomVerifyEmailMessageTextRequest
@@ -3815,6 +3897,28 @@ This is an empty request
+### GetDefaultPasswordlessRegistrationMessageTextRequest
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| language | string | - | string.min_len: 1
string.max_len: 200
|
+
+
+
+
+### GetDefaultPasswordlessRegistrationMessageTextResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| custom_text | zitadel.text.v1.MessageCustomText | - | |
+
+
+
+
### GetDefaultPrivacyPolicyRequest
This is an empty request
@@ -4413,6 +4517,7 @@ This is an empty response
| phone | ImportHumanUserRequest.Phone | - | |
| password | string | - | |
| password_change_required | bool | - | |
+| request_passwordless_registration | bool | - | |
@@ -4465,6 +4570,19 @@ This is an empty response
| ----- | ---- | ----------- | ----------- |
| user_id | string | - | |
| details | zitadel.v1.ObjectDetails | - | |
+| passwordless_registration | ImportHumanUserResponse.PasswordlessRegistration | - | |
+
+
+
+
+### ImportHumanUserResponse.PasswordlessRegistration
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| link | string | - | |
+| lifetime | google.protobuf.Duration | - | |
@@ -6127,6 +6245,28 @@ This is an empty request
+### ResetCustomPasswordlessRegistrationMessageTextToDefaultRequest
+This is an empty request
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| language | string | - | string.min_len: 1
string.max_len: 200
|
+
+
+
+
+### ResetCustomPasswordlessRegistrationMessageTextToDefaultResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| details | zitadel.v1.ObjectDetails | - | |
+
+
+
+
### ResetCustomVerifyEmailMessageTextToDefaultRequest
@@ -6296,6 +6436,28 @@ This is an empty request
+### SendPasswordlessRegistrationRequest
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| user_id | string | - | string.min_len: 1
string.max_len: 200
|
+
+
+
+
+### SendPasswordlessRegistrationResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| details | zitadel.v1.ObjectDetails | - | |
+
+
+
+
### SetCustomDomainClaimedMessageTextRequest
@@ -6391,6 +6553,9 @@ This is an empty request
| success_login_text | zitadel.text.v1.SuccessLoginScreenText | - | |
| logout_text | zitadel.text.v1.LogoutDoneScreenText | - | |
| footer_text | zitadel.text.v1.FooterText | - | |
+| passwordless_prompt_text | zitadel.text.v1.PasswordlessPromptScreenText | - | |
+| passwordless_registration_text | zitadel.text.v1.PasswordlessRegistrationScreenText | - | |
+| passwordless_registration_done_text | zitadel.text.v1.PasswordlessRegistrationDoneScreenText | - | |
@@ -6435,6 +6600,35 @@ This is an empty request
+### SetCustomPasswordlessRegistrationMessageTextRequest
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| language | string | - | string.min_len: 1
string.max_len: 200
|
+| title | string | - | string.max_len: 200
|
+| pre_header | string | - | string.max_len: 200
|
+| subject | string | - | string.max_len: 200
|
+| greeting | string | - | string.max_len: 200
|
+| text | string | - | string.max_len: 800
|
+| button_text | string | - | string.max_len: 200
|
+| footer_text | string | - | string.max_len: 200
|
+
+
+
+
+### SetCustomPasswordlessRegistrationMessageTextResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| details | zitadel.v1.ObjectDetails | - | |
+
+
+
+
### SetCustomVerifyEmailMessageTextRequest
diff --git a/internal/admin/repository/eventsourcing/handler/user.go b/internal/admin/repository/eventsourcing/handler/user.go
index 4427c3e899..9498955192 100644
--- a/internal/admin/repository/eventsourcing/handler/user.go
+++ b/internal/admin/repository/eventsourcing/handler/user.go
@@ -19,6 +19,7 @@ import (
org_model "github.com/caos/zitadel/internal/org/model"
org_es_model "github.com/caos/zitadel/internal/org/repository/eventsourcing/model"
"github.com/caos/zitadel/internal/org/repository/view"
+ user_repo "github.com/caos/zitadel/internal/repository/user"
es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model"
view_model "github.com/caos/zitadel/internal/user/repository/view/model"
)
@@ -140,7 +141,9 @@ func (u *User) ProcessUser(event *es_models.Event) (err error) {
es_model.HumanPasswordlessTokenAdded,
es_model.HumanPasswordlessTokenVerified,
es_model.HumanPasswordlessTokenRemoved,
- es_model.MachineChanged:
+ es_model.MachineChanged,
+ es_models.EventType(user_repo.HumanPasswordlessInitCodeAddedType),
+ es_models.EventType(user_repo.HumanPasswordlessInitCodeRequestedType):
user, err = u.view.UserByID(event.AggregateID)
if err != nil {
return err
diff --git a/internal/api/grpc/admin/custom_text.go b/internal/api/grpc/admin/custom_text.go
index b7cc01b8c5..ae43a003fc 100644
--- a/internal/api/grpc/admin/custom_text.go
+++ b/internal/api/grpc/admin/custom_text.go
@@ -181,6 +181,40 @@ func (s *Server) SetDefaultDomainClaimedMessageText(ctx context.Context, req *ad
}, nil
}
+func (s *Server) GetDefaultPasswordlessRegistrationMessageText(ctx context.Context, req *admin_pb.GetDefaultPasswordlessRegistrationMessageTextRequest) (*admin_pb.GetDefaultPasswordlessRegistrationMessageTextResponse, error) {
+ msg, err := s.iam.GetDefaultMessageText(ctx, domain.PasswordlessRegistrationMessageType, req.Language)
+ if err != nil {
+ return nil, err
+ }
+ return &admin_pb.GetDefaultPasswordlessRegistrationMessageTextResponse{
+ CustomText: text_grpc.DomainCustomMsgTextToPb(msg),
+ }, nil
+}
+
+func (s *Server) GetCustomPasswordlessRegistrationMessageText(ctx context.Context, req *admin_pb.GetCustomPasswordlessRegistrationMessageTextRequest) (*admin_pb.GetCustomPasswordlessRegistrationMessageTextResponse, error) {
+ msg, err := s.iam.GetCustomMessageText(ctx, domain.PasswordlessRegistrationMessageType, req.Language)
+ if err != nil {
+ return nil, err
+ }
+ return &admin_pb.GetCustomPasswordlessRegistrationMessageTextResponse{
+ CustomText: text_grpc.DomainCustomMsgTextToPb(msg),
+ }, nil
+}
+
+func (s *Server) SetDefaultPasswordlessRegistrationMessageText(ctx context.Context, req *admin_pb.SetDefaultPasswordlessRegistrationMessageTextRequest) (*admin_pb.SetDefaultPasswordlessRegistrationMessageTextResponse, error) {
+ result, err := s.command.SetDefaultMessageText(ctx, SetPasswordlessRegistrationCustomTextToDomain(req))
+ if err != nil {
+ return nil, err
+ }
+ return &admin_pb.SetDefaultPasswordlessRegistrationMessageTextResponse{
+ Details: object.ChangeToDetailsPb(
+ result.Sequence,
+ result.EventDate,
+ result.ResourceOwner,
+ ),
+ }, nil
+}
+
func (s *Server) GetDefaultLoginTexts(ctx context.Context, req *admin_pb.GetDefaultLoginTextsRequest) (*admin_pb.GetDefaultLoginTextsResponse, error) {
msg, err := s.iam.GetDefaultLoginTexts(ctx, req.Language)
if err != nil {
diff --git a/internal/api/grpc/admin/custom_text_converter.go b/internal/api/grpc/admin/custom_text_converter.go
index 52986ce590..b8870183a8 100644
--- a/internal/api/grpc/admin/custom_text_converter.go
+++ b/internal/api/grpc/admin/custom_text_converter.go
@@ -83,6 +83,21 @@ func SetDomainClaimedCustomTextToDomain(msg *admin_pb.SetDefaultDomainClaimedMes
}
}
+func SetPasswordlessRegistrationCustomTextToDomain(msg *admin_pb.SetDefaultPasswordlessRegistrationMessageTextRequest) *domain.CustomMessageText {
+ langTag := language.Make(msg.Language)
+ return &domain.CustomMessageText{
+ MessageTextType: domain.PasswordlessRegistrationMessageType,
+ Language: langTag,
+ Title: msg.Title,
+ PreHeader: msg.PreHeader,
+ Subject: msg.Subject,
+ Greeting: msg.Greeting,
+ Text: msg.Text,
+ ButtonText: msg.ButtonText,
+ FooterText: msg.FooterText,
+ }
+}
+
func SetLoginTextToDomain(req *admin_pb.SetCustomLoginTextsRequest) *domain.CustomLoginText {
langTag := language.Make(req.Language)
result := &domain.CustomLoginText{
@@ -108,6 +123,9 @@ func SetLoginTextToDomain(req *admin_pb.SetCustomLoginTextsRequest) *domain.Cust
result.VerifyMFAOTP = text.VerifyMFAOTPScreenTextPbToDomain(req.VerifyMfaOtpText)
result.VerifyMFAU2F = text.VerifyMFAU2FScreenTextPbToDomain(req.VerifyMfaU2FText)
result.Passwordless = text.PasswordlessScreenTextPbToDomain(req.PasswordlessText)
+ result.PasswordlessPrompt = text.PasswordlessPromptScreenTextPbToDomain(req.PasswordlessPromptText)
+ result.PasswordlessRegistration = text.PasswordlessRegistrationScreenTextPbToDomain(req.PasswordlessRegistrationText)
+ result.PasswordlessRegistrationDone = text.PasswordlessRegistrationDoneScreenTextPbToDomain(req.PasswordlessRegistrationDoneText)
result.PasswordChange = text.PasswordChangeScreenTextPbToDomain(req.PasswordChangeText)
result.PasswordChangeDone = text.PasswordChangeDoneScreenTextPbToDomain(req.PasswordChangeDoneText)
result.PasswordResetDone = text.PasswordResetDoneScreenTextPbToDomain(req.PasswordResetDoneText)
diff --git a/internal/api/grpc/auth/passwordless.go b/internal/api/grpc/auth/passwordless.go
index 42d7b0f37f..c5177fb9e2 100644
--- a/internal/api/grpc/auth/passwordless.go
+++ b/internal/api/grpc/auth/passwordless.go
@@ -3,6 +3,8 @@ package auth
import (
"context"
+ "google.golang.org/protobuf/types/known/durationpb"
+
"github.com/caos/zitadel/internal/api/authz"
"github.com/caos/zitadel/internal/api/grpc/object"
user_grpc "github.com/caos/zitadel/internal/api/grpc/user"
@@ -38,6 +40,30 @@ func (s *Server) AddMyPasswordless(ctx context.Context, _ *auth_pb.AddMyPassword
}, nil
}
+func (s *Server) AddMyPasswordlessLink(ctx context.Context, _ *auth_pb.AddMyPasswordlessLinkRequest) (*auth_pb.AddMyPasswordlessLinkResponse, error) {
+ ctxData := authz.GetCtxData(ctx)
+ initCode, err := s.command.HumanAddPasswordlessInitCode(ctx, ctxData.UserID, ctxData.ResourceOwner)
+ if err != nil {
+ return nil, err
+ }
+ return &auth_pb.AddMyPasswordlessLinkResponse{
+ Details: object.AddToDetailsPb(initCode.Sequence, initCode.ChangeDate, initCode.ResourceOwner),
+ Link: initCode.Link(s.defaults.Notifications.Endpoints.PasswordlessRegistration),
+ Expiration: durationpb.New(initCode.Expiration),
+ }, nil
+}
+
+func (s *Server) SendMyPasswordlessLink(ctx context.Context, _ *auth_pb.SendMyPasswordlessLinkRequest) (*auth_pb.SendMyPasswordlessLinkResponse, error) {
+ ctxData := authz.GetCtxData(ctx)
+ initCode, err := s.command.HumanSendPasswordlessInitCode(ctx, ctxData.UserID, ctxData.ResourceOwner)
+ if err != nil {
+ return nil, err
+ }
+ return &auth_pb.SendMyPasswordlessLinkResponse{
+ Details: object.AddToDetailsPb(initCode.Sequence, initCode.ChangeDate, initCode.ResourceOwner),
+ }, nil
+}
+
func (s *Server) VerifyMyPasswordless(ctx context.Context, req *auth_pb.VerifyMyPasswordlessRequest) (*auth_pb.VerifyMyPasswordlessResponse, error) {
ctxData := authz.GetCtxData(ctx)
objectDetails, err := s.command.HumanHumanPasswordlessSetup(ctx, ctxData.UserID, ctxData.ResourceOwner, req.Verification.TokenName, "", req.Verification.PublicKeyCredential)
diff --git a/internal/api/grpc/auth/server.go b/internal/api/grpc/auth/server.go
index 183b7b3dc0..96c03109cc 100644
--- a/internal/api/grpc/auth/server.go
+++ b/internal/api/grpc/auth/server.go
@@ -8,6 +8,7 @@ import (
"github.com/caos/zitadel/internal/auth/repository"
"github.com/caos/zitadel/internal/auth/repository/eventsourcing"
"github.com/caos/zitadel/internal/command"
+ "github.com/caos/zitadel/internal/config/systemdefaults"
"github.com/caos/zitadel/internal/query"
"github.com/caos/zitadel/pkg/grpc/auth"
)
@@ -20,20 +21,22 @@ const (
type Server struct {
auth.UnimplementedAuthServiceServer
- command *command.Commands
- query *query.Queries
- repo repository.Repository
+ command *command.Commands
+ query *query.Queries
+ repo repository.Repository
+ defaults systemdefaults.SystemDefaults
}
type Config struct {
Repository eventsourcing.Config
}
-func CreateServer(command *command.Commands, query *query.Queries, authRepo repository.Repository) *Server {
+func CreateServer(command *command.Commands, query *query.Queries, authRepo repository.Repository, defaults systemdefaults.SystemDefaults) *Server {
return &Server{
- command: command,
- query: query,
- repo: authRepo,
+ command: command,
+ query: query,
+ repo: authRepo,
+ defaults: defaults,
}
}
diff --git a/internal/api/grpc/management/custom_text.go b/internal/api/grpc/management/custom_text.go
index 85fd71ae7f..f737af0726 100644
--- a/internal/api/grpc/management/custom_text.go
+++ b/internal/api/grpc/management/custom_text.go
@@ -252,6 +252,54 @@ func (s *Server) ResetCustomDomainClaimedMessageTextToDefault(ctx context.Contex
}, nil
}
+func (s *Server) GetCustomPasswordlessRegistrationMessageText(ctx context.Context, req *mgmt_pb.GetCustomPasswordlessRegistrationMessageTextRequest) (*mgmt_pb.GetCustomPasswordlessRegistrationMessageTextResponse, error) {
+ msg, err := s.org.GetMessageText(ctx, authz.GetCtxData(ctx).OrgID, domain.PasswordlessRegistrationMessageType, req.Language)
+ if err != nil {
+ return nil, err
+ }
+ return &mgmt_pb.GetCustomPasswordlessRegistrationMessageTextResponse{
+ CustomText: text_grpc.DomainCustomMsgTextToPb(msg),
+ }, nil
+}
+
+func (s *Server) GetDefaultPasswordlessRegistrationMessageText(ctx context.Context, req *mgmt_pb.GetDefaultPasswordlessRegistrationMessageTextRequest) (*mgmt_pb.GetDefaultPasswordlessRegistrationMessageTextResponse, error) {
+ msg, err := s.org.GetDefaultMessageText(ctx, domain.PasswordlessRegistrationMessageType, req.Language)
+ if err != nil {
+ return nil, err
+ }
+ return &mgmt_pb.GetDefaultPasswordlessRegistrationMessageTextResponse{
+ CustomText: text_grpc.DomainCustomMsgTextToPb(msg),
+ }, nil
+}
+
+func (s *Server) SetCustomPasswordlessRegistrationMessageCustomText(ctx context.Context, req *mgmt_pb.SetCustomPasswordlessRegistrationMessageTextRequest) (*mgmt_pb.SetCustomPasswordlessRegistrationMessageTextResponse, error) {
+ result, err := s.command.SetOrgMessageText(ctx, authz.GetCtxData(ctx).OrgID, SetPasswordlessRegistrationCustomTextToDomain(req))
+ if err != nil {
+ return nil, err
+ }
+ return &mgmt_pb.SetCustomPasswordlessRegistrationMessageTextResponse{
+ Details: object.ChangeToDetailsPb(
+ result.Sequence,
+ result.EventDate,
+ result.ResourceOwner,
+ ),
+ }, nil
+}
+
+func (s *Server) ResetCustomPasswordlessRegistrationMessageTextToDefault(ctx context.Context, req *mgmt_pb.ResetCustomPasswordlessRegistrationMessageTextToDefaultRequest) (*mgmt_pb.ResetCustomPasswordlessRegistrationMessageTextToDefaultResponse, error) {
+ result, err := s.command.RemoveOrgMessageTexts(ctx, authz.GetCtxData(ctx).OrgID, domain.PasswordlessRegistrationMessageType, language.Make(req.Language))
+ if err != nil {
+ return nil, err
+ }
+ return &mgmt_pb.ResetCustomPasswordlessRegistrationMessageTextToDefaultResponse{
+ Details: object.ChangeToDetailsPb(
+ result.Sequence,
+ result.EventDate,
+ result.ResourceOwner,
+ ),
+ }, nil
+}
+
func (s *Server) GetCustomLoginTexts(ctx context.Context, req *mgmt_pb.GetCustomLoginTextsRequest) (*mgmt_pb.GetCustomLoginTextsResponse, error) {
msg, err := s.org.GetLoginTexts(ctx, authz.GetCtxData(ctx).OrgID, req.Language)
if err != nil {
diff --git a/internal/api/grpc/management/custom_text_converter.go b/internal/api/grpc/management/custom_text_converter.go
index 3ed95367b1..91bf484f63 100644
--- a/internal/api/grpc/management/custom_text_converter.go
+++ b/internal/api/grpc/management/custom_text_converter.go
@@ -83,6 +83,21 @@ func SetDomainClaimedCustomTextToDomain(msg *mgmt_pb.SetCustomDomainClaimedMessa
}
}
+func SetPasswordlessRegistrationCustomTextToDomain(msg *mgmt_pb.SetCustomPasswordlessRegistrationMessageTextRequest) *domain.CustomMessageText {
+ langTag := language.Make(msg.Language)
+ return &domain.CustomMessageText{
+ MessageTextType: domain.PasswordlessRegistrationMessageType,
+ Language: langTag,
+ Title: msg.Title,
+ PreHeader: msg.PreHeader,
+ Subject: msg.Subject,
+ Greeting: msg.Greeting,
+ Text: msg.Text,
+ ButtonText: msg.ButtonText,
+ FooterText: msg.FooterText,
+ }
+}
+
func SetLoginCustomTextToDomain(req *mgmt_pb.SetCustomLoginTextsRequest) *domain.CustomLoginText {
langTag := language.Make(req.Language)
result := &domain.CustomLoginText{
@@ -107,6 +122,8 @@ func SetLoginCustomTextToDomain(req *mgmt_pb.SetCustomLoginTextsRequest) *domain
result.VerifyMFAOTP = text.VerifyMFAOTPScreenTextPbToDomain(req.VerifyMfaOtpText)
result.VerifyMFAU2F = text.VerifyMFAU2FScreenTextPbToDomain(req.VerifyMfaU2FText)
result.Passwordless = text.PasswordlessScreenTextPbToDomain(req.PasswordlessText)
+ result.PasswordlessRegistration = text.PasswordlessRegistrationScreenTextPbToDomain(req.PasswordlessRegistrationText)
+ result.PasswordlessRegistrationDone = text.PasswordlessRegistrationDoneScreenTextPbToDomain(req.PasswordlessRegistrationDoneText)
result.PasswordChange = text.PasswordChangeScreenTextPbToDomain(req.PasswordChangeText)
result.PasswordChangeDone = text.PasswordChangeDoneScreenTextPbToDomain(req.PasswordChangeDoneText)
result.PasswordResetDone = text.PasswordResetDoneScreenTextPbToDomain(req.PasswordResetDoneText)
diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go
index 24e3920b9d..35aea50da2 100644
--- a/internal/api/grpc/management/user.go
+++ b/internal/api/grpc/management/user.go
@@ -3,6 +3,8 @@ package management
import (
"context"
+ "google.golang.org/protobuf/types/known/durationpb"
+
"github.com/caos/zitadel/internal/api/authz"
"github.com/caos/zitadel/internal/api/grpc/authn"
change_grpc "github.com/caos/zitadel/internal/api/grpc/change"
@@ -92,18 +94,26 @@ func (s *Server) AddHumanUser(ctx context.Context, req *mgmt_pb.AddHumanUserRequ
}
func (s *Server) ImportHumanUser(ctx context.Context, req *mgmt_pb.ImportHumanUserRequest) (*mgmt_pb.ImportHumanUserResponse, error) {
- human, err := s.command.ImportHuman(ctx, authz.GetCtxData(ctx).OrgID, ImportHumanUserRequestToDomain(req))
+ human, passwordless := ImportHumanUserRequestToDomain(req)
+ addedHuman, code, err := s.command.ImportHuman(ctx, authz.GetCtxData(ctx).OrgID, human, passwordless)
if err != nil {
return nil, err
}
- return &mgmt_pb.ImportHumanUserResponse{
- UserId: human.AggregateID,
+ resp := &mgmt_pb.ImportHumanUserResponse{
+ UserId: addedHuman.AggregateID,
Details: obj_grpc.AddToDetailsPb(
- human.Sequence,
- human.ChangeDate,
- human.ResourceOwner,
+ addedHuman.Sequence,
+ addedHuman.ChangeDate,
+ addedHuman.ResourceOwner,
),
- }, nil
+ }
+ if code != nil {
+ resp.PasswordlessRegistration = &mgmt_pb.ImportHumanUserResponse_PasswordlessRegistration{
+ Link: code.Link(s.systemDefaults.Notifications.Endpoints.PasswordlessRegistration),
+ Lifetime: durationpb.New(code.Expiration),
+ }
+ }
+ return resp, nil
}
func (s *Server) AddMachineUser(ctx context.Context, req *mgmt_pb.AddMachineUserRequest) (*mgmt_pb.AddMachineUserResponse, error) {
@@ -408,6 +418,17 @@ func (s *Server) ListHumanPasswordless(ctx context.Context, req *mgmt_pb.ListHum
}, nil
}
+func (s *Server) SendPasswordlessRegistration(ctx context.Context, req *mgmt_pb.SendPasswordlessRegistrationRequest) (*mgmt_pb.SendPasswordlessRegistrationResponse, error) {
+ ctxData := authz.GetCtxData(ctx)
+ initCode, err := s.command.HumanSendPasswordlessInitCode(ctx, req.UserId, ctxData.OrgID)
+ if err != nil {
+ return nil, err
+ }
+ return &mgmt_pb.SendPasswordlessRegistrationResponse{
+ Details: object.AddToDetailsPb(initCode.Sequence, initCode.ChangeDate, initCode.ResourceOwner),
+ }, nil
+}
+
func (s *Server) RemoveHumanPasswordless(ctx context.Context, req *mgmt_pb.RemoveHumanPasswordlessRequest) (*mgmt_pb.RemoveHumanPasswordlessResponse, error) {
objectDetails, err := s.command.HumanRemovePasswordless(ctx, req.UserId, req.TokenId, authz.GetCtxData(ctx).OrgID)
if err != nil {
diff --git a/internal/api/grpc/management/user_converter.go b/internal/api/grpc/management/user_converter.go
index e2ffb2c5f8..d06f33c594 100644
--- a/internal/api/grpc/management/user_converter.go
+++ b/internal/api/grpc/management/user_converter.go
@@ -69,13 +69,13 @@ func AddHumanUserRequestToDomain(req *mgmt_pb.AddHumanUserRequest) *domain.Human
return h
}
-func ImportHumanUserRequestToDomain(req *mgmt_pb.ImportHumanUserRequest) *domain.Human {
- h := &domain.Human{
+func ImportHumanUserRequestToDomain(req *mgmt_pb.ImportHumanUserRequest) (human *domain.Human, passwordless bool) {
+ human = &domain.Human{
Username: req.UserName,
}
preferredLanguage, err := language.Parse(req.Profile.PreferredLanguage)
logging.Log("MANAG-3GUFJ").OnError(err).Debug("language malformed")
- h.Profile = &domain.Profile{
+ human.Profile = &domain.Profile{
FirstName: req.Profile.FirstName,
LastName: req.Profile.LastName,
NickName: req.Profile.NickName,
@@ -83,22 +83,22 @@ func ImportHumanUserRequestToDomain(req *mgmt_pb.ImportHumanUserRequest) *domain
PreferredLanguage: preferredLanguage,
Gender: user_grpc.GenderToDomain(req.Profile.Gender),
}
- h.Email = &domain.Email{
+ human.Email = &domain.Email{
EmailAddress: req.Email.Email,
IsEmailVerified: req.Email.IsEmailVerified,
}
if req.Phone != nil {
- h.Phone = &domain.Phone{
+ human.Phone = &domain.Phone{
PhoneNumber: req.Phone.Phone,
IsPhoneVerified: req.Phone.IsPhoneVerified,
}
}
if req.Password != "" {
- h.Password = &domain.Password{SecretString: req.Password}
- h.Password.ChangeRequired = req.PasswordChangeRequired
+ human.Password = &domain.Password{SecretString: req.Password}
+ human.Password.ChangeRequired = req.PasswordChangeRequired
}
- return h
+ return human, req.RequestPasswordlessRegistration
}
func AddMachineUserRequestToDomain(req *mgmt_pb.AddMachineUserRequest) *domain.Machine {
diff --git a/internal/api/grpc/text/custom_text.go b/internal/api/grpc/text/custom_text.go
index fb1db7370a..43ab517699 100644
--- a/internal/api/grpc/text/custom_text.go
+++ b/internal/api/grpc/text/custom_text.go
@@ -32,36 +32,39 @@ func CustomLoginTextToPb(text *domain.CustomLoginText) *text_pb.LoginCustomText
text.ChangeDate,
text.AggregateID,
),
- SelectAccountText: SelectAccountScreenToPb(text.SelectAccount),
- LoginText: LoginScreenTextToPb(text.Login),
- PasswordText: PasswordScreenTextToPb(text.Password),
- UsernameChangeText: UsernameChangeScreenTextToPb(text.UsernameChange),
- UsernameChangeDoneText: UsernameChangeDoneScreenTextToPb(text.UsernameChangeDone),
- InitPasswordText: InitPasswordScreenTextToPb(text.InitPassword),
- InitPasswordDoneText: InitPasswordDoneScreenTextToPb(text.InitPasswordDone),
- EmailVerificationText: EmailVerificationScreenTextToPb(text.EmailVerification),
- EmailVerificationDoneText: EmailVerificationDoneScreenTextToPb(text.EmailVerificationDone),
- InitializeUserText: InitializeUserScreenTextToPb(text.InitUser),
- InitializeDoneText: InitializeUserDoneScreenTextToPb(text.InitUserDone),
- InitMfaPromptText: InitMFAPromptScreenTextToPb(text.InitMFAPrompt),
- InitMfaOtpText: InitMFAOTPScreenTextToPb(text.InitMFAOTP),
- InitMfaU2FText: InitMFAU2FScreenTextToPb(text.InitMFAU2F),
- InitMfaDoneText: InitMFADoneScreenTextToPb(text.InitMFADone),
- MfaProvidersText: MFAProvidersTextToPb(text.MFAProvider),
- VerifyMfaOtpText: VerifyMFAOTPScreenTextToPb(text.VerifyMFAOTP),
- VerifyMfaU2FText: VerifyMFAU2FScreenTextToPb(text.VerifyMFAU2F),
- PasswordlessText: PasswordlessScreenTextToPb(text.Passwordless),
- PasswordChangeText: PasswordChangeScreenTextToPb(text.PasswordChange),
- PasswordChangeDoneText: PasswordChangeDoneScreenTextToPb(text.PasswordChangeDone),
- PasswordResetDoneText: PasswordResetDoneScreenTextToPb(text.PasswordResetDone),
- RegistrationOptionText: RegistrationOptionScreenTextToPb(text.RegisterOption),
- RegistrationUserText: RegistrationUserScreenTextToPb(text.RegistrationUser),
- RegistrationOrgText: RegistrationOrgScreenTextToPb(text.RegistrationOrg),
- LinkingUserDoneText: LinkingUserDoneScreenTextToPb(text.LinkingUsersDone),
- ExternalUserNotFoundText: ExternalUserNotFoundScreenTextToPb(text.ExternalNotFoundOption),
- SuccessLoginText: SuccessLoginScreenTextToPb(text.LoginSuccess),
- LogoutText: LogoutDoneScreenTextToPb(text.LogoutDone),
- FooterText: FooterTextToPb(text.Footer),
+ SelectAccountText: SelectAccountScreenToPb(text.SelectAccount),
+ LoginText: LoginScreenTextToPb(text.Login),
+ PasswordText: PasswordScreenTextToPb(text.Password),
+ UsernameChangeText: UsernameChangeScreenTextToPb(text.UsernameChange),
+ UsernameChangeDoneText: UsernameChangeDoneScreenTextToPb(text.UsernameChangeDone),
+ InitPasswordText: InitPasswordScreenTextToPb(text.InitPassword),
+ InitPasswordDoneText: InitPasswordDoneScreenTextToPb(text.InitPasswordDone),
+ EmailVerificationText: EmailVerificationScreenTextToPb(text.EmailVerification),
+ EmailVerificationDoneText: EmailVerificationDoneScreenTextToPb(text.EmailVerificationDone),
+ InitializeUserText: InitializeUserScreenTextToPb(text.InitUser),
+ InitializeDoneText: InitializeUserDoneScreenTextToPb(text.InitUserDone),
+ InitMfaPromptText: InitMFAPromptScreenTextToPb(text.InitMFAPrompt),
+ InitMfaOtpText: InitMFAOTPScreenTextToPb(text.InitMFAOTP),
+ InitMfaU2FText: InitMFAU2FScreenTextToPb(text.InitMFAU2F),
+ InitMfaDoneText: InitMFADoneScreenTextToPb(text.InitMFADone),
+ MfaProvidersText: MFAProvidersTextToPb(text.MFAProvider),
+ VerifyMfaOtpText: VerifyMFAOTPScreenTextToPb(text.VerifyMFAOTP),
+ VerifyMfaU2FText: VerifyMFAU2FScreenTextToPb(text.VerifyMFAU2F),
+ PasswordlessText: PasswordlessScreenTextToPb(text.Passwordless),
+ PasswordlessPromptText: PasswordlessPromptScreenTextToPb(text.PasswordlessPrompt),
+ PasswordlessRegistrationText: PasswordlessRegistrationScreenTextToPb(text.PasswordlessRegistration),
+ PasswordlessRegistrationDoneText: PasswordlessRegistrationDoneScreenTextToPb(text.PasswordlessRegistrationDone),
+ PasswordChangeText: PasswordChangeScreenTextToPb(text.PasswordChange),
+ PasswordChangeDoneText: PasswordChangeDoneScreenTextToPb(text.PasswordChangeDone),
+ PasswordResetDoneText: PasswordResetDoneScreenTextToPb(text.PasswordResetDone),
+ RegistrationOptionText: RegistrationOptionScreenTextToPb(text.RegisterOption),
+ RegistrationUserText: RegistrationUserScreenTextToPb(text.RegistrationUser),
+ RegistrationOrgText: RegistrationOrgScreenTextToPb(text.RegistrationOrg),
+ LinkingUserDoneText: LinkingUserDoneScreenTextToPb(text.LinkingUsersDone),
+ ExternalUserNotFoundText: ExternalUserNotFoundScreenTextToPb(text.ExternalNotFoundOption),
+ SuccessLoginText: SuccessLoginScreenTextToPb(text.LoginSuccess),
+ LogoutText: LogoutDoneScreenTextToPb(text.LogoutDone),
+ FooterText: FooterTextToPb(text.Footer),
}
}
@@ -272,6 +275,36 @@ func PasswordlessScreenTextToPb(text domain.PasswordlessScreenText) *text_pb.Pas
}
}
+func PasswordlessPromptScreenTextToPb(text domain.PasswordlessPromptScreenText) *text_pb.PasswordlessPromptScreenText {
+ return &text_pb.PasswordlessPromptScreenText{
+ Title: text.Title,
+ Description: text.Description,
+ DescriptionInit: text.DescriptionInit,
+ PasswordlessButtonText: text.PasswordlessButtonText,
+ NextButtonText: text.NextButtonText,
+ SkipButtonText: text.SkipButtonText,
+ }
+}
+
+func PasswordlessRegistrationScreenTextToPb(text domain.PasswordlessRegistrationScreenText) *text_pb.PasswordlessRegistrationScreenText {
+ return &text_pb.PasswordlessRegistrationScreenText{
+ Title: text.Title,
+ Description: text.Description,
+ RegisterTokenButtonText: text.RegisterTokenButtonText,
+ TokenNameLabel: text.TokenNameLabel,
+ NotSupported: text.NotSupported,
+ ErrorRetry: text.ErrorRetry,
+ }
+}
+
+func PasswordlessRegistrationDoneScreenTextToPb(text domain.PasswordlessRegistrationDoneScreenText) *text_pb.PasswordlessRegistrationDoneScreenText {
+ return &text_pb.PasswordlessRegistrationDoneScreenText{
+ Title: text.Title,
+ Description: text.Description,
+ NextButtonText: text.NextButtonText,
+ }
+}
+
func PasswordChangeScreenTextToPb(text domain.PasswordChangeScreenText) *text_pb.PasswordChangeScreenText {
return &text_pb.PasswordChangeScreenText{
Title: text.Title,
@@ -660,6 +693,45 @@ func PasswordlessScreenTextPbToDomain(text *text_pb.PasswordlessScreenText) doma
}
}
+func PasswordlessPromptScreenTextPbToDomain(text *text_pb.PasswordlessPromptScreenText) domain.PasswordlessPromptScreenText {
+ if text == nil {
+ return domain.PasswordlessPromptScreenText{}
+ }
+ return domain.PasswordlessPromptScreenText{
+ Title: text.Title,
+ Description: text.Description,
+ DescriptionInit: text.DescriptionInit,
+ PasswordlessButtonText: text.PasswordlessButtonText,
+ NextButtonText: text.NextButtonText,
+ SkipButtonText: text.SkipButtonText,
+ }
+}
+
+func PasswordlessRegistrationScreenTextPbToDomain(text *text_pb.PasswordlessRegistrationScreenText) domain.PasswordlessRegistrationScreenText {
+ if text == nil {
+ return domain.PasswordlessRegistrationScreenText{}
+ }
+ return domain.PasswordlessRegistrationScreenText{
+ Title: text.Title,
+ Description: text.Description,
+ RegisterTokenButtonText: text.RegisterTokenButtonText,
+ TokenNameLabel: text.TokenNameLabel,
+ NotSupported: text.NotSupported,
+ ErrorRetry: text.ErrorRetry,
+ }
+}
+
+func PasswordlessRegistrationDoneScreenTextPbToDomain(text *text_pb.PasswordlessRegistrationDoneScreenText) domain.PasswordlessRegistrationDoneScreenText {
+ if text == nil {
+ return domain.PasswordlessRegistrationDoneScreenText{}
+ }
+ return domain.PasswordlessRegistrationDoneScreenText{
+ Title: text.Title,
+ Description: text.Description,
+ NextButtonText: text.NextButtonText,
+ }
+}
+
func PasswordChangeScreenTextPbToDomain(text *text_pb.PasswordChangeScreenText) domain.PasswordChangeScreenText {
if text == nil {
return domain.PasswordChangeScreenText{}
diff --git a/internal/auth/repository/auth_request.go b/internal/auth/repository/auth_request.go
index bd67df7d65..fe6b09ed9c 100644
--- a/internal/auth/repository/auth_request.go
+++ b/internal/auth/repository/auth_request.go
@@ -22,6 +22,10 @@ type AuthRequestRepository interface {
VerifyMFAOTP(ctx context.Context, authRequestID, userID, resourceOwner, code, userAgentID string, info *domain.BrowserInfo) error
BeginMFAU2FLogin(ctx context.Context, userID, resourceOwner, authRequestID, userAgentID string) (*domain.WebAuthNLogin, error)
VerifyMFAU2F(ctx context.Context, userID, resourceOwner, authRequestID, userAgentID string, credentialData []byte, info *domain.BrowserInfo) error
+ BeginPasswordlessSetup(ctx context.Context, userID, resourceOwner string) (login *domain.WebAuthNToken, err error)
+ VerifyPasswordlessSetup(ctx context.Context, userID, resourceOwner, userAgentID, tokenName string, credentialData []byte) (err error)
+ BeginPasswordlessInitCodeSetup(ctx context.Context, userID, resourceOwner, codeID, verificationCode string) (login *domain.WebAuthNToken, err error)
+ VerifyPasswordlessInitCodeSetup(ctx context.Context, userID, resourceOwner, userAgentID, tokenName, codeID, verificationCode string, credentialData []byte) (err error)
BeginPasswordlessLogin(ctx context.Context, userID, resourceOwner, authRequestID, userAgentID string) (*domain.WebAuthNLogin, error)
VerifyPasswordless(ctx context.Context, userID, resourceOwner, authRequestID, userAgentID string, credentialData []byte, info *domain.BrowserInfo) error
diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go
index e3c3133742..0e73c4d882 100644
--- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go
+++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go
@@ -296,6 +296,32 @@ func (repo *AuthRequestRepo) VerifyMFAU2F(ctx context.Context, userID, resourceO
return repo.Command.HumanFinishU2FLogin(ctx, userID, resourceOwner, credentialData, request, true)
}
+func (repo *AuthRequestRepo) BeginPasswordlessSetup(ctx context.Context, userID, resourceOwner string) (login *domain.WebAuthNToken, err error) {
+ ctx, span := tracing.NewSpan(ctx)
+ defer func() { span.EndWithError(err) }()
+ return repo.Command.HumanAddPasswordlessSetup(ctx, userID, resourceOwner, true)
+}
+
+func (repo *AuthRequestRepo) VerifyPasswordlessSetup(ctx context.Context, userID, resourceOwner, userAgentID, tokenName string, credentialData []byte) (err error) {
+ ctx, span := tracing.NewSpan(ctx)
+ defer func() { span.EndWithError(err) }()
+ _, err = repo.Command.HumanHumanPasswordlessSetup(ctx, userID, resourceOwner, tokenName, userAgentID, credentialData)
+ return err
+}
+
+func (repo *AuthRequestRepo) BeginPasswordlessInitCodeSetup(ctx context.Context, userID, resourceOwner, codeID, verificationCode string) (login *domain.WebAuthNToken, err error) {
+ ctx, span := tracing.NewSpan(ctx)
+ defer func() { span.EndWithError(err) }()
+ return repo.Command.HumanAddPasswordlessSetupInitCode(ctx, userID, resourceOwner, codeID, verificationCode)
+}
+
+func (repo *AuthRequestRepo) VerifyPasswordlessInitCodeSetup(ctx context.Context, userID, resourceOwner, userAgentID, tokenName, codeID, verificationCode string, credentialData []byte) (err error) {
+ ctx, span := tracing.NewSpan(ctx)
+ defer func() { span.EndWithError(err) }()
+ _, err = repo.Command.HumanPasswordlessSetupInitCode(ctx, userID, resourceOwner, userAgentID, tokenName, codeID, verificationCode, credentialData)
+ return err
+}
+
func (repo *AuthRequestRepo) BeginPasswordlessLogin(ctx context.Context, userID, resourceOwner, authRequestID, userAgentID string) (login *domain.WebAuthNLogin, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
@@ -610,7 +636,6 @@ func (repo *AuthRequestRepo) nextSteps(ctx context.Context, request *domain.Auth
if request.LinkingUsers != nil && len(request.LinkingUsers) != 0 {
return append(steps, &domain.LinkUsersStep{}), nil
-
}
//PLANNED: consent step
@@ -657,10 +682,16 @@ func (repo *AuthRequestRepo) firstFactorChecked(request *domain.AuthRequest, use
request.AuthTime = userSession.PasswordlessVerification
return nil
}
- step = &domain.PasswordlessStep{}
+ step = &domain.PasswordlessStep{
+ PasswordSet: user.PasswordSet,
+ }
}
- if !user.PasswordSet {
+ if user.PasswordlessInitRequired {
+ return &domain.PasswordlessRegistrationPromptStep{}
+ }
+
+ if user.PasswordInitRequired {
return &domain.InitPasswordStep{}
}
diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go
index 637dbfb9c7..233381daeb 100644
--- a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go
+++ b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go
@@ -6,6 +6,7 @@ import (
"testing"
"time"
+ "github.com/caos/zitadel/internal/crypto"
"github.com/stretchr/testify/assert"
"github.com/caos/zitadel/internal/auth/repository/eventsourcing/view"
@@ -131,14 +132,16 @@ func (m *mockEventErrUser) BulkAddExternalIDPs(ctx context.Context, userID strin
}
type mockViewUser struct {
- InitRequired bool
- PasswordSet bool
- PasswordChangeRequired bool
- IsEmailVerified bool
- OTPState int32
- MFAMaxSetUp int32
- MFAInitSkipped time.Time
- PasswordlessTokens user_view_model.WebAuthNTokens
+ InitRequired bool
+ PasswordInitRequired bool
+ PasswordSet bool
+ PasswordChangeRequired bool
+ IsEmailVerified bool
+ OTPState int32
+ MFAMaxSetUp int32
+ MFAInitSkipped time.Time
+ PasswordlessInitRequired bool
+ PasswordlessTokens user_view_model.WebAuthNTokens
}
type mockLoginPolicy struct {
@@ -154,15 +157,17 @@ func (m *mockViewUser) UserByID(string) (*user_view_model.UserView, error) {
State: int32(user_model.UserStateActive),
UserName: "UserName",
HumanView: &user_view_model.HumanView{
- FirstName: "FirstName",
- InitRequired: m.InitRequired,
- PasswordSet: m.PasswordSet,
- PasswordChangeRequired: m.PasswordChangeRequired,
- IsEmailVerified: m.IsEmailVerified,
- OTPState: m.OTPState,
- MFAMaxSetUp: m.MFAMaxSetUp,
- MFAInitSkipped: m.MFAInitSkipped,
- PasswordlessTokens: m.PasswordlessTokens,
+ FirstName: "FirstName",
+ InitRequired: m.InitRequired,
+ PasswordInitRequired: m.PasswordInitRequired,
+ PasswordSet: m.PasswordSet,
+ PasswordChangeRequired: m.PasswordChangeRequired,
+ IsEmailVerified: m.IsEmailVerified,
+ OTPState: m.OTPState,
+ MFAMaxSetUp: m.MFAMaxSetUp,
+ MFAInitSkipped: m.MFAInitSkipped,
+ PasswordlessInitRequired: m.PasswordlessInitRequired,
+ PasswordlessTokens: m.PasswordlessTokens,
},
}, nil
}
@@ -486,7 +491,37 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
nil,
},
{
- "passwordless not verified, passwordless check step",
+ "passwordless not initialised, passwordless prompt step",
+ fields{
+ userSessionViewProvider: &mockViewUserSession{},
+ userViewProvider: &mockViewUser{
+ PasswordlessInitRequired: true,
+ },
+ userEventProvider: &mockEventUser{},
+ orgViewProvider: &mockViewOrg{State: org_model.OrgStateActive},
+ MultiFactorCheckLifeTime: 10 * time.Hour,
+ },
+ args{&domain.AuthRequest{UserID: "UserID", LoginPolicy: &domain.LoginPolicy{PasswordlessType: domain.PasswordlessTypeAllowed}}, false},
+ []domain.NextStep{&domain.PasswordlessRegistrationPromptStep{}},
+ nil,
+ },
+ {
+ "passwordless not verified, no password set, passwordless check step",
+ fields{
+ userSessionViewProvider: &mockViewUserSession{},
+ userViewProvider: &mockViewUser{
+ PasswordlessTokens: user_view_model.WebAuthNTokens{&user_view_model.WebAuthNView{ID: "id", State: int32(user_model.MFAStateReady)}},
+ },
+ userEventProvider: &mockEventUser{},
+ orgViewProvider: &mockViewOrg{State: org_model.OrgStateActive},
+ MultiFactorCheckLifeTime: 10 * time.Hour,
+ },
+ args{&domain.AuthRequest{UserID: "UserID", LoginPolicy: &domain.LoginPolicy{PasswordlessType: domain.PasswordlessTypeAllowed}}, false},
+ []domain.NextStep{&domain.PasswordlessStep{}},
+ nil,
+ },
+ {
+ "passwordless not verified, passwordless check step, downgrade possible",
fields{
userSessionViewProvider: &mockViewUserSession{},
userViewProvider: &mockViewUser{
@@ -498,7 +533,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
MultiFactorCheckLifeTime: 10 * time.Hour,
},
args{&domain.AuthRequest{UserID: "UserID", LoginPolicy: &domain.LoginPolicy{PasswordlessType: domain.PasswordlessTypeAllowed}}, false},
- []domain.NextStep{&domain.PasswordlessStep{}},
+ []domain.NextStep{&domain.PasswordlessStep{PasswordSet: true}},
nil,
},
{
@@ -533,9 +568,11 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
"password not set, init password step",
fields{
userSessionViewProvider: &mockViewUserSession{},
- userViewProvider: &mockViewUser{},
- userEventProvider: &mockEventUser{},
- orgViewProvider: &mockViewOrg{State: org_model.OrgStateActive},
+ userViewProvider: &mockViewUser{
+ PasswordInitRequired: true,
+ },
+ userEventProvider: &mockEventUser{},
+ orgViewProvider: &mockViewOrg{State: org_model.OrgStateActive},
},
args{&domain.AuthRequest{UserID: "UserID", LoginPolicy: &domain.LoginPolicy{}}, false},
[]domain.NextStep{&domain.InitPasswordStep{}},
@@ -1510,6 +1547,7 @@ func Test_userByID(t *testing.T) {
"new user events, new view model state",
args{
viewProvider: &mockViewUser{
+ PasswordSet: true,
PasswordChangeRequired: true,
},
eventProvider: &mockEventUser{
@@ -1518,7 +1556,7 @@ func Test_userByID(t *testing.T) {
Type: user_es_model.UserPasswordChanged,
CreationDate: time.Now().UTC().Round(1 * time.Second),
Data: func() []byte {
- data, _ := json.Marshal(user_es_model.Password{ChangeRequired: false})
+ data, _ := json.Marshal(user_es_model.Password{ChangeRequired: false, Secret: &crypto.CryptoValue{}})
return data
}(),
},
@@ -1529,6 +1567,7 @@ func Test_userByID(t *testing.T) {
State: user_model.UserStateActive,
UserName: "UserName",
HumanView: &user_model.HumanView{
+ PasswordSet: true,
PasswordChangeRequired: false,
PasswordChanged: time.Now().UTC().Round(1 * time.Second),
FirstName: "FirstName",
diff --git a/internal/auth/repository/eventsourcing/eventstore/org.go b/internal/auth/repository/eventsourcing/eventstore/org.go
index 84692e3042..d42408f5d6 100644
--- a/internal/auth/repository/eventsourcing/eventstore/org.go
+++ b/internal/auth/repository/eventsourcing/eventstore/org.go
@@ -105,7 +105,7 @@ func (repo *OrgRepository) GetMyPasswordComplexityPolicy(ctx context.Context) (*
return iam_view_model.PasswordComplexityViewToModel(policy), err
}
-func (repo *OrgRepository) GetLabelPolicy(ctx context.Context, orgID string) (*iam_model.LabelPolicyView, error) {
+func (repo *OrgRepository) GetLabelPolicy(ctx context.Context, orgID string) (*domain.LabelPolicy, error) {
orgPolicy, err := repo.View.LabelPolicyByAggregateIDAndState(orgID, int32(domain.LabelPolicyStateActive))
if errors.IsNotFound(err) {
orgPolicy, err = repo.View.LabelPolicyByAggregateIDAndState(repo.SystemDefaults.IamID, int32(domain.LabelPolicyStateActive))
@@ -113,7 +113,19 @@ func (repo *OrgRepository) GetLabelPolicy(ctx context.Context, orgID string) (*i
if err != nil {
return nil, err
}
- return iam_view_model.LabelPolicyViewToModel(orgPolicy), nil
+ return orgPolicy.ToDomain(), nil
+}
+
+func (repo *OrgRepository) GetLoginText(ctx context.Context, orgID string) ([]*domain.CustomText, error) {
+ loginTexts, err := repo.View.CustomTextsByAggregateIDAndTemplate(domain.IAMID, domain.LoginCustomText)
+ if err != nil {
+ return nil, err
+ }
+ orgLoginTexts, err := repo.View.CustomTextsByAggregateIDAndTemplate(orgID, domain.LoginCustomText)
+ if err != nil {
+ return nil, err
+ }
+ return append(iam_view_model.CustomTextViewsToDomain(loginTexts), iam_view_model.CustomTextViewsToDomain(orgLoginTexts)...), nil
}
func (repo *OrgRepository) GetDefaultPrivacyPolicy(ctx context.Context) (*iam_model.PrivacyPolicyView, error) {
diff --git a/internal/auth/repository/eventsourcing/handler/user.go b/internal/auth/repository/eventsourcing/handler/user.go
index d3e88e0422..90f5e04802 100644
--- a/internal/auth/repository/eventsourcing/handler/user.go
+++ b/internal/auth/repository/eventsourcing/handler/user.go
@@ -16,6 +16,7 @@ import (
org_model "github.com/caos/zitadel/internal/org/model"
org_es_model "github.com/caos/zitadel/internal/org/repository/eventsourcing/model"
"github.com/caos/zitadel/internal/org/repository/view"
+ user_repo "github.com/caos/zitadel/internal/repository/user"
es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model"
view_model "github.com/caos/zitadel/internal/user/repository/view/model"
)
@@ -142,7 +143,9 @@ func (u *User) ProcessUser(event *es_models.Event) (err error) {
es_model.HumanPasswordlessTokenRemoved,
es_model.HumanMFAInitSkipped,
es_model.MachineChanged,
- es_model.HumanPasswordChanged:
+ es_model.HumanPasswordChanged,
+ es_models.EventType(user_repo.HumanPasswordlessInitCodeAddedType),
+ es_models.EventType(user_repo.HumanPasswordlessInitCodeRequestedType):
user, err = u.view.UserByID(event.AggregateID)
if err != nil {
return err
diff --git a/internal/auth/repository/org.go b/internal/auth/repository/org.go
index 934c4ef653..f420f81371 100644
--- a/internal/auth/repository/org.go
+++ b/internal/auth/repository/org.go
@@ -2,6 +2,7 @@ package repository
import (
"context"
+ "github.com/caos/zitadel/internal/domain"
iam_model "github.com/caos/zitadel/internal/iam/model"
org_model "github.com/caos/zitadel/internal/org/model"
)
@@ -12,6 +13,7 @@ type OrgRepository interface {
GetDefaultOrgIAMPolicy(ctx context.Context) (*iam_model.OrgIAMPolicyView, error)
GetIDPConfigByID(ctx context.Context, idpConfigID string) (*iam_model.IDPConfigView, error)
GetMyPasswordComplexityPolicy(ctx context.Context) (*iam_model.PasswordComplexityPolicyView, error)
- GetLabelPolicy(ctx context.Context, orgID string) (*iam_model.LabelPolicyView, error)
+ GetLabelPolicy(ctx context.Context, orgID string) (*domain.LabelPolicy, error)
+ GetLoginText(ctx context.Context, orgID string) ([]*domain.CustomText, error)
GetDefaultPrivacyPolicy(ctx context.Context) (*iam_model.PrivacyPolicyView, error)
}
diff --git a/internal/command/command.go b/internal/command/command.go
index 3e0ef4011a..55fb4f2e89 100644
--- a/internal/command/command.go
+++ b/internal/command/command.go
@@ -39,6 +39,7 @@ type Commands struct {
emailVerificationCode crypto.Generator
phoneVerificationCode crypto.Generator
passwordVerificationCode crypto.Generator
+ passwordlessInitCode crypto.Generator
machineKeyAlg crypto.EncryptionAlgorithm
machineKeySize int
applicationKeySize int
@@ -90,6 +91,7 @@ func StartCommands(eventstore *eventstore.Eventstore, defaults sd.SystemDefaults
repo.emailVerificationCode = crypto.NewEncryptionGenerator(defaults.SecretGenerators.EmailVerificationCode, userEncryptionAlgorithm)
repo.phoneVerificationCode = crypto.NewEncryptionGenerator(defaults.SecretGenerators.PhoneVerificationCode, userEncryptionAlgorithm)
repo.passwordVerificationCode = crypto.NewEncryptionGenerator(defaults.SecretGenerators.PasswordVerificationCode, userEncryptionAlgorithm)
+ repo.passwordlessInitCode = crypto.NewEncryptionGenerator(defaults.SecretGenerators.PasswordlessInitCode, userEncryptionAlgorithm)
repo.userPasswordAlg = crypto.NewBCrypt(defaults.SecretGenerators.PasswordSaltCost)
repo.machineKeyAlg = userEncryptionAlgorithm
repo.machineKeySize = int(defaults.SecretGenerators.MachineKeySize)
diff --git a/internal/command/custom_login_text.go b/internal/command/custom_login_text.go
index 1fe435f176..fc4821f2d2 100644
--- a/internal/command/custom_login_text.go
+++ b/internal/command/custom_login_text.go
@@ -32,6 +32,9 @@ func (c *Commands) createAllLoginTextEvents(ctx context.Context, agg *eventstore
events = append(events, c.createVerifyMFAOTPEvents(ctx, agg, existingText, text, defaultText)...)
events = append(events, c.createVerifyMFAU2FEvents(ctx, agg, existingText, text, defaultText)...)
events = append(events, c.createPasswordlessEvents(ctx, agg, existingText, text, defaultText)...)
+ events = append(events, c.createPasswordlessPromptEvents(ctx, agg, existingText, text, defaultText)...)
+ events = append(events, c.createPasswordlessRegistrationEvents(ctx, agg, existingText, text, defaultText)...)
+ events = append(events, c.createPasswordlessRegistrationDoneEvents(ctx, agg, existingText, text, defaultText)...)
events = append(events, c.createPasswordChangeEvents(ctx, agg, existingText, text, defaultText)...)
events = append(events, c.createPasswordChangeDoneEvents(ctx, agg, existingText, text, defaultText)...)
events = append(events, c.createPasswordResetDoneEvents(ctx, agg, existingText, text, defaultText)...)
@@ -589,6 +592,81 @@ func (c *Commands) createPasswordlessEvents(ctx context.Context, agg *eventstore
return events
}
+func (c *Commands) createPasswordlessRegistrationEvents(ctx context.Context, agg *eventstore.Aggregate, existingText *CustomLoginTextReadModel, text *domain.CustomLoginText, defaultText bool) []eventstore.EventPusher {
+ events := make([]eventstore.EventPusher, 0)
+ event := c.createCustomLoginTextEvent(ctx, agg, domain.LoginKeyPasswordlessRegistrationTitle, existingText.PasswordlessRegistrationTitle, text.PasswordlessRegistration.Title, text.Language, defaultText)
+ if event != nil {
+ events = append(events, event)
+ }
+ event = c.createCustomLoginTextEvent(ctx, agg, domain.LoginKeyPasswordlessRegistrationDescription, existingText.PasswordlessRegistrationDescription, text.PasswordlessRegistration.Description, text.Language, defaultText)
+ if event != nil {
+ events = append(events, event)
+ }
+ event = c.createCustomLoginTextEvent(ctx, agg, domain.LoginKeyPasswordlessRegistrationRegisterTokenButtonText, existingText.PasswordlessRegistrationRegisterTokenButtonText, text.PasswordlessRegistration.RegisterTokenButtonText, text.Language, defaultText)
+ if event != nil {
+ events = append(events, event)
+ }
+ event = c.createCustomLoginTextEvent(ctx, agg, domain.LoginKeyPasswordlessRegistrationTokenNameLabel, existingText.PasswordlessRegistrationTokenNameLabel, text.PasswordlessRegistration.TokenNameLabel, text.Language, defaultText)
+ if event != nil {
+ events = append(events, event)
+ }
+ event = c.createCustomLoginTextEvent(ctx, agg, domain.LoginKeyPasswordlessRegistrationNotSupported, existingText.PasswordlessRegistrationNotSupported, text.PasswordlessRegistration.NotSupported, text.Language, defaultText)
+ if event != nil {
+ events = append(events, event)
+ }
+ event = c.createCustomLoginTextEvent(ctx, agg, domain.LoginKeyPasswordlessRegistrationErrorRetry, existingText.PasswordlessRegistrationErrorRetry, text.PasswordlessRegistration.ErrorRetry, text.Language, defaultText)
+ if event != nil {
+ events = append(events, event)
+ }
+ return events
+}
+
+func (c *Commands) createPasswordlessPromptEvents(ctx context.Context, agg *eventstore.Aggregate, existingText *CustomLoginTextReadModel, text *domain.CustomLoginText, defaultText bool) []eventstore.EventPusher {
+ events := make([]eventstore.EventPusher, 0)
+ event := c.createCustomLoginTextEvent(ctx, agg, domain.LoginKeyPasswordlessPromptTitle, existingText.PasswordlessPromptTitle, text.PasswordlessPrompt.Title, text.Language, defaultText)
+ if event != nil {
+ events = append(events, event)
+ }
+ event = c.createCustomLoginTextEvent(ctx, agg, domain.LoginKeyPasswordlessPromptDescription, existingText.PasswordlessPromptDescription, text.PasswordlessPrompt.Description, text.Language, defaultText)
+ if event != nil {
+ events = append(events, event)
+ }
+ event = c.createCustomLoginTextEvent(ctx, agg, domain.LoginKeyPasswordlessPromptDescriptionInit, existingText.PasswordlessPromptDescriptionInit, text.PasswordlessPrompt.DescriptionInit, text.Language, defaultText)
+ if event != nil {
+ events = append(events, event)
+ }
+ event = c.createCustomLoginTextEvent(ctx, agg, domain.LoginKeyPasswordlessPromptPasswordlessButtonText, existingText.PasswordlessPromptPasswordlessButtonText, text.PasswordlessPrompt.PasswordlessButtonText, text.Language, defaultText)
+ if event != nil {
+ events = append(events, event)
+ }
+ event = c.createCustomLoginTextEvent(ctx, agg, domain.LoginKeyPasswordlessPromptNextButtonText, existingText.PasswordlessPromptNextButtonText, text.PasswordlessPrompt.NextButtonText, text.Language, defaultText)
+ if event != nil {
+ events = append(events, event)
+ }
+ event = c.createCustomLoginTextEvent(ctx, agg, domain.LoginKeyPasswordlessPromptSkipButtonText, existingText.PasswordlessPromptSkipButtonText, text.PasswordlessPrompt.SkipButtonText, text.Language, defaultText)
+ if event != nil {
+ events = append(events, event)
+ }
+ return events
+}
+
+func (c *Commands) createPasswordlessRegistrationDoneEvents(ctx context.Context, agg *eventstore.Aggregate, existingText *CustomLoginTextReadModel, text *domain.CustomLoginText, defaultText bool) []eventstore.EventPusher {
+ events := make([]eventstore.EventPusher, 0)
+ event := c.createCustomLoginTextEvent(ctx, agg, domain.LoginKeyPasswordlessRegistrationDoneTitle, existingText.PasswordlessRegistrationDoneTitle, text.PasswordlessRegistrationDone.Title, text.Language, defaultText)
+ if event != nil {
+ events = append(events, event)
+ }
+ event = c.createCustomLoginTextEvent(ctx, agg, domain.LoginKeyPasswordlessRegistrationDoneDescription, existingText.PasswordlessRegistrationDoneDescription, text.PasswordlessRegistrationDone.Description, text.Language, defaultText)
+ if event != nil {
+ events = append(events, event)
+ }
+ event = c.createCustomLoginTextEvent(ctx, agg, domain.LoginKeyPasswordlessRegistrationDoneNextButtonText, existingText.PasswordlessRegistrationDoneNextButtonText, text.PasswordlessRegistrationDone.NextButtonText, text.Language, defaultText)
+ if event != nil {
+ events = append(events, event)
+ }
+ return events
+}
+
func (c *Commands) createPasswordChangeEvents(ctx context.Context, agg *eventstore.Aggregate, existingText *CustomLoginTextReadModel, text *domain.CustomLoginText, defaultText bool) []eventstore.EventPusher {
events := make([]eventstore.EventPusher, 0)
event := c.createCustomLoginTextEvent(ctx, agg, domain.LoginKeyPasswordChangeTitle, existingText.PasswordChangeTitle, text.PasswordChange.Title, text.Language, defaultText)
diff --git a/internal/command/custom_login_text_model.go b/internal/command/custom_login_text_model.go
index 87fe9fd292..69b6db2cec 100644
--- a/internal/command/custom_login_text_model.go
+++ b/internal/command/custom_login_text_model.go
@@ -147,6 +147,24 @@ type CustomLoginTextReadModel struct {
PasswordlessNotSupported string
PasswordlessErrorRetry string
+ PasswordlessPromptTitle string
+ PasswordlessPromptDescription string
+ PasswordlessPromptDescriptionInit string
+ PasswordlessPromptPasswordlessButtonText string
+ PasswordlessPromptNextButtonText string
+ PasswordlessPromptSkipButtonText string
+
+ PasswordlessRegistrationTitle string
+ PasswordlessRegistrationDescription string
+ PasswordlessRegistrationRegisterTokenButtonText string
+ PasswordlessRegistrationTokenNameLabel string
+ PasswordlessRegistrationNotSupported string
+ PasswordlessRegistrationErrorRetry string
+
+ PasswordlessRegistrationDoneTitle string
+ PasswordlessRegistrationDoneDescription string
+ PasswordlessRegistrationDoneNextButtonText string
+
PasswordChangeTitle string
PasswordChangeDescription string
PasswordChangeOldPasswordLabel string
@@ -314,6 +332,18 @@ func (wm *CustomLoginTextReadModel) Reduce() error {
wm.handlePasswordlessScreenSetEvent(e)
continue
}
+ if strings.HasPrefix(e.Key, domain.LoginKeyPasswordlessPrompt) {
+ wm.handlePasswordlessPromptScreenSetEvent(e)
+ continue
+ }
+ if strings.HasPrefix(e.Key, domain.LoginKeyPasswordlessRegistration) {
+ wm.handlePasswordlessRegistrationScreenSetEvent(e)
+ continue
+ }
+ if strings.HasPrefix(e.Key, domain.LoginKeyPasswordlessRegistrationDone) {
+ wm.handlePasswordlessRegistrationDoneScreenSetEvent(e)
+ continue
+ }
if strings.HasPrefix(e.Key, domain.LoginKeyPasswordChange) {
wm.handlePasswordChangeScreenSetEvent(e)
continue
@@ -438,6 +468,18 @@ func (wm *CustomLoginTextReadModel) Reduce() error {
wm.handlePasswordlessScreenRemoveEvent(e)
continue
}
+ if strings.HasPrefix(e.Key, domain.LoginKeyPasswordlessPrompt) {
+ wm.handlePasswordlessPromptScreenRemoveEvent(e)
+ continue
+ }
+ if strings.HasPrefix(e.Key, domain.LoginKeyPasswordlessRegistration) {
+ wm.handlePasswordlessRegistrationScreenRemoveEvent(e)
+ continue
+ }
+ if strings.HasPrefix(e.Key, domain.LoginKeyPasswordlessRegistrationDone) {
+ wm.handlePasswordlessRegistrationDoneScreenRemoveEvent(e)
+ continue
+ }
if strings.HasPrefix(e.Key, domain.LoginKeyPasswordChange) {
wm.handlePasswordChangeScreenRemoveEvent(e)
continue
@@ -1489,6 +1531,144 @@ func (wm *CustomLoginTextReadModel) handlePasswordlessScreenRemoveEvent(e *polic
}
}
+func (wm *CustomLoginTextReadModel) handlePasswordlessPromptScreenSetEvent(e *policy.CustomTextSetEvent) {
+ if e.Key == domain.LoginKeyPasswordlessPromptTitle {
+ wm.PasswordlessPromptTitle = e.Text
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessPromptDescription {
+ wm.PasswordlessPromptDescription = e.Text
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessPromptDescriptionInit {
+ wm.PasswordlessPromptDescriptionInit = e.Text
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessPromptPasswordlessButtonText {
+ wm.PasswordlessPromptPasswordlessButtonText = e.Text
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessPromptNextButtonText {
+ wm.PasswordlessPromptNextButtonText = e.Text
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessPromptSkipButtonText {
+ wm.PasswordlessPromptSkipButtonText = e.Text
+ return
+ }
+}
+
+func (wm *CustomLoginTextReadModel) handlePasswordlessPromptScreenRemoveEvent(e *policy.CustomTextRemovedEvent) {
+ if e.Key == domain.LoginKeyPasswordlessPromptTitle {
+ wm.PasswordlessPromptTitle = ""
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessPromptDescription {
+ wm.PasswordlessPromptDescription = ""
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessPromptDescriptionInit {
+ wm.PasswordlessPromptDescriptionInit = ""
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessPromptPasswordlessButtonText {
+ wm.PasswordlessPromptPasswordlessButtonText = ""
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessPromptNextButtonText {
+ wm.PasswordlessPromptNextButtonText = ""
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessPromptSkipButtonText {
+ wm.PasswordlessPromptSkipButtonText = ""
+ return
+ }
+}
+
+func (wm *CustomLoginTextReadModel) handlePasswordlessRegistrationScreenSetEvent(e *policy.CustomTextSetEvent) {
+ if e.Key == domain.LoginKeyPasswordlessRegistrationTitle {
+ wm.PasswordlessRegistrationTitle = e.Text
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessRegistrationDescription {
+ wm.PasswordlessRegistrationDescription = e.Text
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessRegistrationRegisterTokenButtonText {
+ wm.PasswordlessRegistrationRegisterTokenButtonText = e.Text
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessRegistrationTokenNameLabel {
+ wm.PasswordlessRegistrationTokenNameLabel = e.Text
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessRegistrationNotSupported {
+ wm.PasswordlessRegistrationNotSupported = e.Text
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessRegistrationErrorRetry {
+ wm.PasswordlessRegistrationErrorRetry = e.Text
+ return
+ }
+}
+
+func (wm *CustomLoginTextReadModel) handlePasswordlessRegistrationScreenRemoveEvent(e *policy.CustomTextRemovedEvent) {
+ if e.Key == domain.LoginKeyPasswordlessRegistrationTitle {
+ wm.PasswordlessRegistrationTitle = ""
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessRegistrationDescription {
+ wm.PasswordlessRegistrationDescription = ""
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessRegistrationRegisterTokenButtonText {
+ wm.PasswordlessRegistrationRegisterTokenButtonText = ""
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessRegistrationTokenNameLabel {
+ wm.PasswordlessRegistrationTokenNameLabel = ""
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessRegistrationNotSupported {
+ wm.PasswordlessRegistrationNotSupported = ""
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessRegistrationErrorRetry {
+ wm.PasswordlessRegistrationErrorRetry = ""
+ return
+ }
+}
+
+func (wm *CustomLoginTextReadModel) handlePasswordlessRegistrationDoneScreenSetEvent(e *policy.CustomTextSetEvent) {
+ if e.Key == domain.LoginKeyPasswordlessRegistrationDoneTitle {
+ wm.PasswordlessRegistrationDoneTitle = e.Text
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessRegistrationDoneDescription {
+ wm.PasswordlessRegistrationDoneDescription = e.Text
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessRegistrationDoneNextButtonText {
+ wm.PasswordlessRegistrationDoneNextButtonText = e.Text
+ return
+ }
+}
+
+func (wm *CustomLoginTextReadModel) handlePasswordlessRegistrationDoneScreenRemoveEvent(e *policy.CustomTextRemovedEvent) {
+ if e.Key == domain.LoginKeyPasswordlessRegistrationDoneTitle {
+ wm.PasswordlessRegistrationDoneTitle = ""
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessRegistrationDoneDescription {
+ wm.PasswordlessRegistrationDoneDescription = ""
+ return
+ }
+ if e.Key == domain.LoginKeyPasswordlessRegistrationDoneNextButtonText {
+ wm.PasswordlessRegistrationDoneNextButtonText = ""
+ return
+ }
+}
+
func (wm *CustomLoginTextReadModel) handlePasswordChangeScreenSetEvent(e *policy.CustomTextSetEvent) {
if e.Key == domain.LoginKeyPasswordChangeTitle {
wm.PasswordChangeTitle = e.Text
diff --git a/internal/command/user_converter.go b/internal/command/user_converter.go
index 2832a2be0c..6cb28deba6 100644
--- a/internal/command/user_converter.go
+++ b/internal/command/user_converter.go
@@ -143,3 +143,13 @@ func authRequestDomainToAuthRequestInfo(authRequest *domain.AuthRequest) *user.A
}
return info
}
+
+func writeModelToPasswordlessInitCode(initCodeModel *HumanPasswordlessInitCodeWriteModel, code string) *domain.PasswordlessInitCode {
+ return &domain.PasswordlessInitCode{
+ ObjectRoot: writeModelToObjectRoot(initCodeModel.WriteModel),
+ CodeID: initCodeModel.CodeID,
+ Code: code,
+ Expiration: initCodeModel.Expiration,
+ State: initCodeModel.State,
+ }
+}
diff --git a/internal/command/user_human.go b/internal/command/user_human.go
index a674ec0c79..b33150aa7f 100644
--- a/internal/command/user_human.go
+++ b/internal/command/user_human.go
@@ -50,33 +50,40 @@ func (c *Commands) AddHuman(ctx context.Context, orgID string, human *domain.Hum
return writeModelToHuman(addedHuman), nil
}
-func (c *Commands) ImportHuman(ctx context.Context, orgID string, human *domain.Human) (*domain.Human, error) {
+func (c *Commands) ImportHuman(ctx context.Context, orgID string, human *domain.Human, passwordless bool) (_ *domain.Human, passwordlessCode *domain.PasswordlessInitCode, err error) {
if orgID == "" {
- return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-5N8fs", "Errors.ResourceOwnerMissing")
+ return nil, nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-5N8fs", "Errors.ResourceOwnerMissing")
}
orgIAMPolicy, err := c.getOrgIAMPolicy(ctx, orgID)
if err != nil {
- return nil, caos_errs.ThrowPreconditionFailed(err, "COMMAND-2N9fs", "Errors.Org.OrgIAMPolicy.NotFound")
+ return nil, nil, caos_errs.ThrowPreconditionFailed(err, "COMMAND-2N9fs", "Errors.Org.OrgIAMPolicy.NotFound")
}
pwPolicy, err := c.getOrgPasswordComplexityPolicy(ctx, orgID)
if err != nil {
- return nil, caos_errs.ThrowPreconditionFailed(err, "COMMAND-4N8gs", "Errors.Org.PasswordComplexity.NotFound")
+ return nil, nil, caos_errs.ThrowPreconditionFailed(err, "COMMAND-4N8gs", "Errors.Org.PasswordComplexity.NotFound")
}
- events, addedHuman, err := c.importHuman(ctx, orgID, human, orgIAMPolicy, pwPolicy)
+ events, addedHuman, addedCode, code, err := c.importHuman(ctx, orgID, human, passwordless, orgIAMPolicy, pwPolicy)
if err != nil {
- return nil, err
+ return nil, nil, err
}
pushedEvents, err := c.eventstore.PushEvents(ctx, events...)
if err != nil {
- return nil, err
+ return nil, nil, err
}
err = AppendAndReduce(addedHuman, pushedEvents...)
if err != nil {
- return nil, err
+ return nil, nil, err
+ }
+ if addedCode != nil {
+ err = AppendAndReduce(addedCode, pushedEvents...)
+ if err != nil {
+ return nil, nil, err
+ }
+ passwordlessCode = writeModelToPasswordlessInitCode(addedCode, code)
}
- return writeModelToHuman(addedHuman), nil
+ return writeModelToHuman(addedHuman), passwordlessCode, nil
}
func (c *Commands) addHuman(ctx context.Context, orgID string, human *domain.Human, orgIAMPolicy *domain.OrgIAMPolicy, pwPolicy *domain.PasswordComplexityPolicy) ([]eventstore.EventPusher, *HumanWriteModel, error) {
@@ -86,14 +93,26 @@ func (c *Commands) addHuman(ctx context.Context, orgID string, human *domain.Hum
if human.Password != nil && human.SecretString != "" {
human.ChangeRequired = true
}
- return c.createHuman(ctx, orgID, human, nil, false, orgIAMPolicy, pwPolicy)
+ return c.createHuman(ctx, orgID, human, nil, false, false, orgIAMPolicy, pwPolicy)
}
-func (c *Commands) importHuman(ctx context.Context, orgID string, human *domain.Human, orgIAMPolicy *domain.OrgIAMPolicy, pwPolicy *domain.PasswordComplexityPolicy) ([]eventstore.EventPusher, *HumanWriteModel, error) {
+func (c *Commands) importHuman(ctx context.Context, orgID string, human *domain.Human, passwordless bool, orgIAMPolicy *domain.OrgIAMPolicy, pwPolicy *domain.PasswordComplexityPolicy) (events []eventstore.EventPusher, humanWriteModel *HumanWriteModel, passwordlessCodeWriteModel *HumanPasswordlessInitCodeWriteModel, code string, err error) {
if orgID == "" || !human.IsValid() {
- return nil, nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-00p2b", "Errors.User.Invalid")
+ return nil, nil, nil, "", caos_errs.ThrowInvalidArgument(nil, "COMMAND-00p2b", "Errors.User.Invalid")
}
- return c.createHuman(ctx, orgID, human, nil, false, orgIAMPolicy, pwPolicy)
+ events, humanWriteModel, err = c.createHuman(ctx, orgID, human, nil, false, passwordless, orgIAMPolicy, pwPolicy)
+ if err != nil {
+ return nil, nil, nil, "", err
+ }
+ if passwordless {
+ var codeEvent eventstore.EventPusher
+ codeEvent, passwordlessCodeWriteModel, code, err = c.humanAddPasswordlessInitCode(ctx, human.AggregateID, orgID, true)
+ if err != nil {
+ return nil, nil, nil, "", err
+ }
+ events = append(events, codeEvent)
+ }
+ return events, humanWriteModel, passwordlessCodeWriteModel, code, nil
}
func (c *Commands) RegisterHuman(ctx context.Context, orgID string, human *domain.Human, externalIDP *domain.ExternalIDP, orgMemberRoles []string) (*domain.Human, error) {
@@ -149,10 +168,10 @@ func (c *Commands) registerHuman(ctx context.Context, orgID string, human *domai
if human.Password != nil && human.SecretString != "" {
human.ChangeRequired = false
}
- return c.createHuman(ctx, orgID, human, externalIDP, true, orgIAMPolicy, pwPolicy)
+ return c.createHuman(ctx, orgID, human, externalIDP, true, false, orgIAMPolicy, pwPolicy)
}
-func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain.Human, externalIDP *domain.ExternalIDP, selfregister bool, orgIAMPolicy *domain.OrgIAMPolicy, pwPolicy *domain.PasswordComplexityPolicy) ([]eventstore.EventPusher, *HumanWriteModel, error) {
+func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain.Human, externalIDP *domain.ExternalIDP, selfregister, passwordless bool, orgIAMPolicy *domain.OrgIAMPolicy, pwPolicy *domain.PasswordComplexityPolicy) ([]eventstore.EventPusher, *HumanWriteModel, error) {
if err := human.CheckOrgIAMPolicy(orgIAMPolicy); err != nil {
return nil, nil, err
}
@@ -187,7 +206,7 @@ func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain.
events = append(events, event)
}
- if human.IsInitialState() {
+ if human.IsInitialState(passwordless) {
initCode, err := domain.NewInitUserCode(c.initializeUserCode)
if err != nil {
return nil, nil, err
diff --git a/internal/command/user_human_test.go b/internal/command/user_human_test.go
index 0e7d1f6d41..d24e104f17 100644
--- a/internal/command/user_human_test.go
+++ b/internal/command/user_human_test.go
@@ -652,19 +652,22 @@ func TestCommandSide_AddHuman(t *testing.T) {
func TestCommandSide_ImportHuman(t *testing.T) {
type fields struct {
- eventstore *eventstore.Eventstore
- idGenerator id.Generator
- secretGenerator crypto.Generator
- userPasswordAlg crypto.HashAlgorithm
+ eventstore *eventstore.Eventstore
+ idGenerator id.Generator
+ secretGenerator crypto.Generator
+ userPasswordAlg crypto.HashAlgorithm
+ passwordlessInitCode crypto.Generator
}
type args struct {
- ctx context.Context
- orgID string
- human *domain.Human
+ ctx context.Context
+ orgID string
+ human *domain.Human
+ passwordless bool
}
type res struct {
- want *domain.Human
- err func(error) bool
+ wantHuman *domain.Human
+ wantCode *domain.PasswordlessInitCode
+ err func(error) bool
}
tests := []struct {
name string
@@ -869,7 +872,7 @@ func TestCommandSide_ImportHuman(t *testing.T) {
},
},
res: res{
- want: &domain.Human{
+ wantHuman: &domain.Human{
ObjectRoot: models.ObjectRoot{
AggregateID: "user1",
ResourceOwner: "org1",
@@ -950,7 +953,7 @@ func TestCommandSide_ImportHuman(t *testing.T) {
},
},
res: res{
- want: &domain.Human{
+ wantHuman: &domain.Human{
ObjectRoot: models.ObjectRoot{
AggregateID: "user1",
ResourceOwner: "org1",
@@ -970,6 +973,218 @@ func TestCommandSide_ImportHuman(t *testing.T) {
},
},
},
+ {
+ name: "add human email verified passwordless only, ok",
+ fields: fields{
+ eventstore: eventstoreExpect(
+ t,
+ expectFilter(
+ eventFromEventPusher(
+ org.NewOrgIAMPolicyAddedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ true,
+ ),
+ ),
+ ),
+ expectFilter(
+ eventFromEventPusher(
+ org.NewPasswordComplexityPolicyAddedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ 1,
+ false,
+ false,
+ false,
+ false,
+ ),
+ ),
+ ),
+ expectFilter(),
+ expectPush(
+ []*repository.Event{
+ eventFromEventPusher(
+ newAddHumanEvent("", false, ""),
+ ),
+ eventFromEventPusher(
+ user.NewHumanEmailVerifiedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate),
+ ),
+ eventFromEventPusher(
+ user.NewHumanPasswordlessInitCodeAddedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "code1",
+ &crypto.CryptoValue{
+ CryptoType: crypto.TypeEncryption,
+ Algorithm: "enc",
+ KeyID: "id",
+ Crypted: []byte("a"),
+ },
+ time.Hour,
+ ),
+ ),
+ },
+ uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)),
+ ),
+ ),
+ idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1", "code1"),
+ secretGenerator: GetMockSecretGenerator(t),
+ userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
+ passwordlessInitCode: GetMockSecretGenerator(t),
+ },
+ args: args{
+ ctx: context.Background(),
+ orgID: "org1",
+ human: &domain.Human{
+ Username: "username",
+ Profile: &domain.Profile{
+ FirstName: "firstname",
+ LastName: "lastname",
+ },
+ Email: &domain.Email{
+ EmailAddress: "email@test.ch",
+ IsEmailVerified: true,
+ },
+ },
+ passwordless: true,
+ },
+ res: res{
+ wantHuman: &domain.Human{
+ ObjectRoot: models.ObjectRoot{
+ AggregateID: "user1",
+ ResourceOwner: "org1",
+ },
+ Username: "username",
+ Profile: &domain.Profile{
+ FirstName: "firstname",
+ LastName: "lastname",
+ DisplayName: "firstname lastname",
+ PreferredLanguage: language.Und,
+ },
+ Email: &domain.Email{
+ EmailAddress: "email@test.ch",
+ IsEmailVerified: true,
+ },
+ State: domain.UserStateActive,
+ },
+ wantCode: &domain.PasswordlessInitCode{
+ ObjectRoot: models.ObjectRoot{
+ AggregateID: "user1",
+ ResourceOwner: "org1",
+ },
+ Expiration: time.Hour,
+ CodeID: "code1",
+ Code: "a",
+ State: domain.PasswordlessInitCodeStateActive,
+ },
+ },
+ },
+ {
+ name: "add human email verified passwordless and password change not required, ok",
+ fields: fields{
+ eventstore: eventstoreExpect(
+ t,
+ expectFilter(
+ eventFromEventPusher(
+ org.NewOrgIAMPolicyAddedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ true,
+ ),
+ ),
+ ),
+ expectFilter(
+ eventFromEventPusher(
+ org.NewPasswordComplexityPolicyAddedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ 1,
+ false,
+ false,
+ false,
+ false,
+ ),
+ ),
+ ),
+ expectFilter(),
+ expectPush(
+ []*repository.Event{
+ eventFromEventPusher(
+ newAddHumanEvent("password", false, ""),
+ ),
+ eventFromEventPusher(
+ user.NewHumanEmailVerifiedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate),
+ ),
+ eventFromEventPusher(
+ user.NewHumanPasswordlessInitCodeAddedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "code1",
+ &crypto.CryptoValue{
+ CryptoType: crypto.TypeEncryption,
+ Algorithm: "enc",
+ KeyID: "id",
+ Crypted: []byte("a"),
+ },
+ time.Hour,
+ ),
+ ),
+ },
+ uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)),
+ ),
+ ),
+ idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1", "code1"),
+ secretGenerator: GetMockSecretGenerator(t),
+ userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
+ passwordlessInitCode: GetMockSecretGenerator(t),
+ },
+ args: args{
+ ctx: context.Background(),
+ orgID: "org1",
+ human: &domain.Human{
+ Username: "username",
+ Password: &domain.Password{
+ SecretString: "password",
+ ChangeRequired: false,
+ },
+ Profile: &domain.Profile{
+ FirstName: "firstname",
+ LastName: "lastname",
+ },
+ Email: &domain.Email{
+ EmailAddress: "email@test.ch",
+ IsEmailVerified: true,
+ },
+ },
+ passwordless: true,
+ },
+ res: res{
+ wantHuman: &domain.Human{
+ ObjectRoot: models.ObjectRoot{
+ AggregateID: "user1",
+ ResourceOwner: "org1",
+ },
+ Username: "username",
+ Profile: &domain.Profile{
+ FirstName: "firstname",
+ LastName: "lastname",
+ DisplayName: "firstname lastname",
+ PreferredLanguage: language.Und,
+ },
+ Email: &domain.Email{
+ EmailAddress: "email@test.ch",
+ IsEmailVerified: true,
+ },
+ State: domain.UserStateActive,
+ },
+ wantCode: &domain.PasswordlessInitCode{
+ ObjectRoot: models.ObjectRoot{
+ AggregateID: "user1",
+ ResourceOwner: "org1",
+ },
+ Expiration: time.Hour,
+ CodeID: "code1",
+ Code: "a",
+ State: domain.PasswordlessInitCodeStateActive,
+ },
+ },
+ },
{
name: "add human (with phone), ok",
fields: fields{
@@ -1052,7 +1267,7 @@ func TestCommandSide_ImportHuman(t *testing.T) {
},
},
res: res{
- want: &domain.Human{
+ wantHuman: &domain.Human{
ObjectRoot: models.ObjectRoot{
AggregateID: "user1",
ResourceOwner: "org1",
@@ -1151,7 +1366,7 @@ func TestCommandSide_ImportHuman(t *testing.T) {
},
},
res: res{
- want: &domain.Human{
+ wantHuman: &domain.Human{
ObjectRoot: models.ObjectRoot{
AggregateID: "user1",
ResourceOwner: "org1",
@@ -1182,8 +1397,9 @@ func TestCommandSide_ImportHuman(t *testing.T) {
initializeUserCode: tt.fields.secretGenerator,
phoneVerificationCode: tt.fields.secretGenerator,
userPasswordAlg: tt.fields.userPasswordAlg,
+ passwordlessInitCode: tt.fields.passwordlessInitCode,
}
- got, err := r.ImportHuman(tt.args.ctx, tt.args.orgID, tt.args.human)
+ gotHuman, gotCode, err := r.ImportHuman(tt.args.ctx, tt.args.orgID, tt.args.human, tt.args.passwordless)
if tt.res.err == nil {
assert.NoError(t, err)
}
@@ -1191,7 +1407,8 @@ func TestCommandSide_ImportHuman(t *testing.T) {
t.Errorf("got wrong err: %v ", err)
}
if tt.res.err == nil {
- assert.Equal(t, tt.res.want, got)
+ assert.Equal(t, tt.res.wantHuman, gotHuman)
+ assert.Equal(t, tt.res.wantCode, gotCode)
}
})
}
diff --git a/internal/command/user_human_webauthn.go b/internal/command/user_human_webauthn.go
index e44fa3ff60..004810e785 100644
--- a/internal/command/user_human_webauthn.go
+++ b/internal/command/user_human_webauthn.go
@@ -2,9 +2,11 @@ package command
import (
"context"
+ "time"
"github.com/caos/logging"
+ "github.com/caos/zitadel/internal/crypto"
"github.com/caos/zitadel/internal/domain"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/eventstore"
@@ -127,8 +129,16 @@ func (c *Commands) HumanAddPasswordlessSetup(ctx context.Context, userID, resour
return createdWebAuthN, nil
}
+func (c *Commands) HumanAddPasswordlessSetupInitCode(ctx context.Context, userID, resourceowner, codeID, verificationCode string) (*domain.WebAuthNToken, error) {
+ err := c.humanVerifyPasswordlessInitCode(ctx, userID, resourceowner, codeID, verificationCode)
+ if err != nil {
+ return nil, err
+ }
+ return c.HumanAddPasswordlessSetup(ctx, userID, resourceowner, true)
+}
+
func (c *Commands) addHumanWebAuthN(ctx context.Context, userID, resourceowner string, isLoginUI bool, tokens []*domain.WebAuthNToken) (*HumanWebAuthNWriteModel, *eventstore.Aggregate, *domain.WebAuthNToken, error) {
- if userID == "" || resourceowner == "" {
+ if userID == "" {
return nil, nil, nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-3M0od", "Errors.IDMissing")
}
user, err := c.getHuman(ctx, userID, resourceowner)
@@ -198,7 +208,24 @@ func (c *Commands) HumanVerifyU2FSetup(ctx context.Context, userID, resourceowne
return writeModelToObjectDetails(&verifyWebAuthN.WriteModel), nil
}
+func (c *Commands) HumanPasswordlessSetupInitCode(ctx context.Context, userID, resourceowner, tokenName, userAgentID, codeID, verificationCode string, credentialData []byte) (*domain.ObjectDetails, error) {
+ err := c.humanVerifyPasswordlessInitCode(ctx, userID, resourceowner, codeID, verificationCode)
+ if err != nil {
+ return nil, err
+ }
+ succeededEvent := func(userAgg *eventstore.Aggregate) *usr_repo.HumanPasswordlessInitCodeCheckSucceededEvent {
+ return usr_repo.NewHumanPasswordlessInitCodeCheckSucceededEvent(ctx, userAgg, codeID)
+ }
+ return c.humanHumanPasswordlessSetup(ctx, userID, resourceowner, tokenName, userAgentID, credentialData, succeededEvent)
+}
+
func (c *Commands) HumanHumanPasswordlessSetup(ctx context.Context, userID, resourceowner, tokenName, userAgentID string, credentialData []byte) (*domain.ObjectDetails, error) {
+ return c.humanHumanPasswordlessSetup(ctx, userID, resourceowner, tokenName, userAgentID, credentialData, nil)
+}
+
+func (c *Commands) humanHumanPasswordlessSetup(ctx context.Context, userID, resourceowner, tokenName, userAgentID string, credentialData []byte,
+ codeCheckEvent func(*eventstore.Aggregate) *usr_repo.HumanPasswordlessInitCodeCheckSucceededEvent) (*domain.ObjectDetails, error) {
+
u2fTokens, err := c.getHumanPasswordlessTokens(ctx, userID, resourceowner)
if err != nil {
return nil, err
@@ -208,7 +235,7 @@ func (c *Commands) HumanHumanPasswordlessSetup(ctx context.Context, userID, reso
return nil, err
}
- pushedEvents, err := c.eventstore.PushEvents(ctx,
+ events := []eventstore.EventPusher{
usr_repo.NewHumanPasswordlessVerifiedEvent(
ctx,
userAgg,
@@ -221,7 +248,11 @@ func (c *Commands) HumanHumanPasswordlessSetup(ctx context.Context, userID, reso
webAuthN.SignCount,
userAgentID,
),
- )
+ }
+ if codeCheckEvent != nil {
+ events = append(events, codeCheckEvent(userAgg))
+ }
+ pushedEvents, err := c.eventstore.PushEvents(ctx, events...)
if err != nil {
return nil, err
}
@@ -233,7 +264,7 @@ func (c *Commands) HumanHumanPasswordlessSetup(ctx context.Context, userID, reso
}
func (c *Commands) verifyHumanWebAuthN(ctx context.Context, userID, resourceowner, tokenName, userAgentID string, credentialData []byte, tokens []*domain.WebAuthNToken) (*eventstore.Aggregate, *domain.WebAuthNToken, *HumanWebAuthNWriteModel, error) {
- if userID == "" || resourceowner == "" {
+ if userID == "" {
return nil, nil, nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-3M0od", "Errors.IDMissing")
}
user, err := c.getHuman(ctx, userID, resourceowner)
@@ -452,6 +483,102 @@ func (c *Commands) HumanRemovePasswordless(ctx context.Context, userID, webAuthN
return c.removeHumanWebAuthN(ctx, userID, webAuthNID, resourceOwner, event)
}
+func (c *Commands) HumanAddPasswordlessInitCode(ctx context.Context, userID, resourceOwner string) (*domain.PasswordlessInitCode, error) {
+ codeEvent, initCode, code, err := c.humanAddPasswordlessInitCode(ctx, userID, resourceOwner, true)
+ pushedEvents, err := c.eventstore.PushEvents(ctx, codeEvent)
+ if err != nil {
+ return nil, err
+ }
+ err = AppendAndReduce(initCode, pushedEvents...)
+ if err != nil {
+ return nil, err
+ }
+ return writeModelToPasswordlessInitCode(initCode, code), nil
+}
+
+func (c *Commands) HumanSendPasswordlessInitCode(ctx context.Context, userID, resourceOwner string) (*domain.PasswordlessInitCode, error) {
+ codeEvent, initCode, code, err := c.humanAddPasswordlessInitCode(ctx, userID, resourceOwner, true)
+ pushedEvents, err := c.eventstore.PushEvents(ctx, codeEvent)
+ if err != nil {
+ return nil, err
+ }
+ err = AppendAndReduce(initCode, pushedEvents...)
+ if err != nil {
+ return nil, err
+ }
+ return writeModelToPasswordlessInitCode(initCode, code), nil
+}
+
+func (c *Commands) humanAddPasswordlessInitCode(ctx context.Context, userID, resourceOwner string, direct bool) (eventstore.EventPusher, *HumanPasswordlessInitCodeWriteModel, string, error) {
+ if userID == "" {
+ return nil, nil, "", caos_errs.ThrowPreconditionFailed(nil, "COMMAND-GVfg3", "Errors.IDMissing")
+ }
+
+ codeID, err := c.idGenerator.Next()
+ if err != nil {
+ return nil, nil, "", err
+ }
+ initCode := NewHumanPasswordlessInitCodeWriteModel(userID, codeID, resourceOwner)
+ err = c.eventstore.FilterToQueryReducer(ctx, initCode)
+ if err != nil {
+ return nil, nil, "", err
+ }
+
+ cryptoCode, code, err := crypto.NewCode(c.passwordlessInitCode)
+ if err != nil {
+ return nil, nil, "", err
+ }
+ codeEventCreator := func(ctx context.Context, agg *eventstore.Aggregate, id string, cryptoCode *crypto.CryptoValue, exp time.Duration) eventstore.EventPusher {
+ return usr_repo.NewHumanPasswordlessInitCodeAddedEvent(ctx, agg, id, cryptoCode, exp)
+ }
+ if !direct {
+ codeEventCreator = func(ctx context.Context, agg *eventstore.Aggregate, id string, cryptoCode *crypto.CryptoValue, exp time.Duration) eventstore.EventPusher {
+ return usr_repo.NewHumanPasswordlessInitCodeRequestedEvent(ctx, agg, id, cryptoCode, exp)
+ }
+ }
+ codeEvent := codeEventCreator(ctx, UserAggregateFromWriteModel(&initCode.WriteModel), codeID, cryptoCode, c.passwordlessInitCode.Expiry())
+ return codeEvent, initCode, code, nil
+}
+
+func (c *Commands) HumanPasswordlessInitCodeSent(ctx context.Context, userID, resourceOwner, codeID string) error {
+ if userID == "" || codeID == "" {
+ return caos_errs.ThrowInvalidArgument(nil, "COMMAND-ADggh", "Errors.IDMissing")
+ }
+ initCode := NewHumanPasswordlessInitCodeWriteModel(userID, codeID, resourceOwner)
+ err := c.eventstore.FilterToQueryReducer(ctx, initCode)
+ if err != nil {
+ return err
+ }
+
+ if initCode.State != domain.PasswordlessInitCodeStateRequested {
+ return caos_errs.ThrowNotFound(nil, "COMMAND-Gdfg3", "Errors.User.Code.NotFound")
+ }
+
+ _, err = c.eventstore.PushEvents(ctx,
+ usr_repo.NewHumanPasswordlessInitCodeSentEvent(ctx, UserAggregateFromWriteModel(&initCode.WriteModel), codeID),
+ )
+ return err
+}
+
+func (c *Commands) humanVerifyPasswordlessInitCode(ctx context.Context, userID, resourceOwner, codeID, verificationCode string) error {
+ if userID == "" || codeID == "" {
+ return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-GVfg3", "Errors.IDMissing")
+ }
+ initCode := NewHumanPasswordlessInitCodeWriteModel(userID, codeID, resourceOwner)
+ err := c.eventstore.FilterToQueryReducer(ctx, initCode)
+ if err != nil {
+ return err
+ }
+ err = crypto.VerifyCode(initCode.ChangeDate, initCode.Expiration, initCode.CryptoCode, verificationCode, c.passwordlessInitCode)
+ if err != nil || initCode.State != domain.PasswordlessInitCodeStateActive {
+ userAgg := UserAggregateFromWriteModel(&initCode.WriteModel)
+ _, err = c.eventstore.PushEvents(ctx, usr_repo.NewHumanPasswordlessInitCodeCheckFailedEvent(ctx, userAgg, codeID))
+ logging.LogWithFields("COMMAND-Gkuud", "userID", userAgg.ID).OnError(err).Error("NewHumanPasswordlessInitCodeCheckFailedEvent push failed")
+ return caos_errs.ThrowInvalidArgument(err, "COMMAND-Dhz8i", "Errors.User.Code.Invalid")
+ }
+ return nil
+}
+
func (c *Commands) removeHumanWebAuthN(ctx context.Context, userID, webAuthNID, resourceOwner string, preparedEvent func(*eventstore.Aggregate) eventstore.EventPusher) (*domain.ObjectDetails, error) {
if userID == "" || webAuthNID == "" {
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-6M9de", "Errors.IDMissing")
diff --git a/internal/command/user_human_webauthn_model.go b/internal/command/user_human_webauthn_model.go
index 472ee0a5f4..069fca44d9 100644
--- a/internal/command/user_human_webauthn_model.go
+++ b/internal/command/user_human_webauthn_model.go
@@ -1,6 +1,9 @@
package command
import (
+ "time"
+
+ "github.com/caos/zitadel/internal/crypto"
"github.com/caos/zitadel/internal/domain"
"github.com/caos/zitadel/internal/eventstore"
"github.com/caos/zitadel/internal/repository/user"
@@ -430,3 +433,106 @@ func (rm *HumanPasswordlessLoginReadModel) Query() *eventstore.SearchQueryBuilde
Builder()
}
+
+type HumanPasswordlessInitCodeWriteModel struct {
+ eventstore.WriteModel
+
+ CodeID string
+ Attempts uint8
+ CryptoCode *crypto.CryptoValue
+ Expiration time.Duration
+ State domain.PasswordlessInitCodeState
+}
+
+func NewHumanPasswordlessInitCodeWriteModel(userID, codeID, resourceOwner string) *HumanPasswordlessInitCodeWriteModel {
+ return &HumanPasswordlessInitCodeWriteModel{
+ WriteModel: eventstore.WriteModel{
+ AggregateID: userID,
+ ResourceOwner: resourceOwner,
+ },
+ CodeID: codeID,
+ }
+}
+
+func (wm *HumanPasswordlessInitCodeWriteModel) AppendEvents(events ...eventstore.EventReader) {
+ for _, event := range events {
+ switch e := event.(type) {
+ case *user.HumanPasswordlessInitCodeAddedEvent:
+ if wm.CodeID == e.ID {
+ wm.WriteModel.AppendEvents(e)
+ }
+ case *user.HumanPasswordlessInitCodeRequestedEvent:
+ if wm.CodeID == e.ID {
+ wm.WriteModel.AppendEvents(e)
+ }
+ case *user.HumanPasswordlessInitCodeSentEvent:
+ if wm.CodeID == e.ID {
+ wm.WriteModel.AppendEvents(e)
+ }
+ case *user.HumanPasswordlessInitCodeCheckFailedEvent:
+ if wm.CodeID == e.ID {
+ wm.WriteModel.AppendEvents(e)
+ }
+ case *user.HumanPasswordlessInitCodeCheckSucceededEvent:
+ if wm.CodeID == e.ID {
+ wm.WriteModel.AppendEvents(e)
+ }
+ case *user.UserRemovedEvent:
+ wm.WriteModel.AppendEvents(e)
+ }
+ }
+}
+
+func (wm *HumanPasswordlessInitCodeWriteModel) Reduce() error {
+ for _, event := range wm.Events {
+ switch e := event.(type) {
+ case *user.HumanPasswordlessInitCodeAddedEvent:
+ wm.appendAddedEvent(e)
+ case *user.HumanPasswordlessInitCodeRequestedEvent:
+ wm.appendRequestedEvent(e)
+ case *user.HumanPasswordlessInitCodeSentEvent:
+ wm.State = domain.PasswordlessInitCodeStateActive
+ case *user.HumanPasswordlessInitCodeCheckFailedEvent:
+ wm.appendCheckFailedEvent(e)
+ case *user.HumanPasswordlessInitCodeCheckSucceededEvent:
+ wm.State = domain.PasswordlessInitCodeStateRemoved
+ case *user.UserRemovedEvent:
+ wm.State = domain.PasswordlessInitCodeStateRemoved
+ }
+ }
+ return wm.WriteModel.Reduce()
+}
+
+func (wm *HumanPasswordlessInitCodeWriteModel) appendAddedEvent(e *user.HumanPasswordlessInitCodeAddedEvent) {
+ wm.CryptoCode = e.Code
+ wm.Expiration = e.Expiry
+ wm.State = domain.PasswordlessInitCodeStateActive
+}
+
+func (wm *HumanPasswordlessInitCodeWriteModel) appendRequestedEvent(e *user.HumanPasswordlessInitCodeRequestedEvent) {
+ wm.CryptoCode = e.Code
+ wm.Expiration = e.Expiry
+ wm.State = domain.PasswordlessInitCodeStateRequested
+}
+
+func (wm *HumanPasswordlessInitCodeWriteModel) appendCheckFailedEvent(e *user.HumanPasswordlessInitCodeCheckFailedEvent) {
+ wm.Attempts++
+ if wm.Attempts == 3 { //TODO: config?
+ wm.State = domain.PasswordlessInitCodeStateRemoved
+ }
+}
+
+func (wm *HumanPasswordlessInitCodeWriteModel) Query() *eventstore.SearchQueryBuilder {
+ return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
+ ResourceOwner(wm.ResourceOwner).
+ AddQuery().
+ AggregateTypes(user.AggregateType).
+ AggregateIDs(wm.AggregateID).
+ EventTypes(user.HumanPasswordlessInitCodeAddedType,
+ user.HumanPasswordlessInitCodeRequestedType,
+ user.HumanPasswordlessInitCodeSentType,
+ user.HumanPasswordlessInitCodeCheckFailedType,
+ user.HumanPasswordlessInitCodeCheckSucceededType,
+ user.UserRemovedType).
+ Builder()
+}
diff --git a/internal/config/systemdefaults/system_defaults.go b/internal/config/systemdefaults/system_defaults.go
index 02653688a4..c4d8e06079 100644
--- a/internal/config/systemdefaults/system_defaults.go
+++ b/internal/config/systemdefaults/system_defaults.go
@@ -39,6 +39,7 @@ type SecretGenerators struct {
EmailVerificationCode crypto.GeneratorConfig
PhoneVerificationCode crypto.GeneratorConfig
PasswordVerificationCode crypto.GeneratorConfig
+ PasswordlessInitCode crypto.GeneratorConfig
MachineKeySize uint32
ApplicationKeySize uint32
}
@@ -73,10 +74,11 @@ type Notifications struct {
}
type Endpoints struct {
- InitCode string
- PasswordReset string
- VerifyEmail string
- DomainClaimed string
+ InitCode string
+ PasswordReset string
+ VerifyEmail string
+ DomainClaimed string
+ PasswordlessRegistration string
}
type Providers struct {
diff --git a/internal/domain/custom_login_text.go b/internal/domain/custom_login_text.go
index c15fc5dd45..a39c1b425e 100644
--- a/internal/domain/custom_login_text.go
+++ b/internal/domain/custom_login_text.go
@@ -159,6 +159,27 @@ const (
LoginKeyPasswordlessNotSupported = LoginKeyPasswordless + "NotSupported"
LoginKeyPasswordlessErrorRetry = LoginKeyPasswordless + "ErrorRetry"
+ LoginKeyPasswordlessPrompt = "PasswordlessPrompt."
+ LoginKeyPasswordlessPromptTitle = LoginKeyPasswordlessPrompt + "Title"
+ LoginKeyPasswordlessPromptDescription = LoginKeyPasswordlessPrompt + "Description"
+ LoginKeyPasswordlessPromptDescriptionInit = LoginKeyPasswordlessPrompt + "DescriptionInit"
+ LoginKeyPasswordlessPromptPasswordlessButtonText = LoginKeyPasswordlessPrompt + "PasswordlessButtonText"
+ LoginKeyPasswordlessPromptNextButtonText = LoginKeyPasswordlessPrompt + "NextButtonText"
+ LoginKeyPasswordlessPromptSkipButtonText = LoginKeyPasswordlessPrompt + "SkipButtonText"
+
+ LoginKeyPasswordlessRegistration = "PasswordlessRegistration."
+ LoginKeyPasswordlessRegistrationTitle = LoginKeyPasswordlessRegistration + "Title"
+ LoginKeyPasswordlessRegistrationDescription = LoginKeyPasswordlessRegistration + "Description"
+ LoginKeyPasswordlessRegistrationRegisterTokenButtonText = LoginKeyPasswordlessRegistration + "RegisterTokenButtonText"
+ LoginKeyPasswordlessRegistrationTokenNameLabel = LoginKeyPasswordlessRegistration + "TokenNameLabel"
+ LoginKeyPasswordlessRegistrationNotSupported = LoginKeyPasswordlessRegistration + "NotSupported"
+ LoginKeyPasswordlessRegistrationErrorRetry = LoginKeyPasswordlessRegistration + "ErrorRetry"
+
+ LoginKeyPasswordlessRegistrationDone = "PasswordlessRegistrationDone."
+ LoginKeyPasswordlessRegistrationDoneTitle = LoginKeyPasswordlessRegistrationDone + "Title"
+ LoginKeyPasswordlessRegistrationDoneDescription = LoginKeyPasswordlessRegistrationDone + "Description"
+ LoginKeyPasswordlessRegistrationDoneNextButtonText = LoginKeyPasswordlessRegistrationDone + "NextButtonText"
+
LoginKeyPasswordChange = "PasswordChange."
LoginKeyPasswordChangeTitle = LoginKeyPasswordChange + "Title"
LoginKeyPasswordChangeDescription = LoginKeyPasswordChange + "Description"
@@ -258,36 +279,39 @@ type CustomLoginText struct {
Default bool
Language language.Tag
- SelectAccount SelectAccountScreenText
- Login LoginScreenText
- Password PasswordScreenText
- UsernameChange UsernameChangeScreenText
- UsernameChangeDone UsernameChangeDoneScreenText
- InitPassword InitPasswordScreenText
- InitPasswordDone InitPasswordDoneScreenText
- EmailVerification EmailVerificationScreenText
- EmailVerificationDone EmailVerificationDoneScreenText
- InitUser InitializeUserScreenText
- InitUserDone InitializeUserDoneScreenText
- InitMFAPrompt InitMFAPromptScreenText
- InitMFAOTP InitMFAOTPScreenText
- InitMFAU2F InitMFAU2FScreenText
- InitMFADone InitMFADoneScreenText
- MFAProvider MFAProvidersText
- VerifyMFAOTP VerifyMFAOTPScreenText
- VerifyMFAU2F VerifyMFAU2FScreenText
- Passwordless PasswordlessScreenText
- PasswordChange PasswordChangeScreenText
- PasswordChangeDone PasswordChangeDoneScreenText
- PasswordResetDone PasswordResetDoneScreenText
- RegisterOption RegistrationOptionScreenText
- RegistrationUser RegistrationUserScreenText
- RegistrationOrg RegistrationOrgScreenText
- LinkingUsersDone LinkingUserDoneScreenText
- ExternalNotFoundOption ExternalUserNotFoundScreenText
- LoginSuccess SuccessLoginScreenText
- LogoutDone LogoutDoneScreenText
- Footer FooterText
+ SelectAccount SelectAccountScreenText
+ Login LoginScreenText
+ Password PasswordScreenText
+ UsernameChange UsernameChangeScreenText
+ UsernameChangeDone UsernameChangeDoneScreenText
+ InitPassword InitPasswordScreenText
+ InitPasswordDone InitPasswordDoneScreenText
+ EmailVerification EmailVerificationScreenText
+ EmailVerificationDone EmailVerificationDoneScreenText
+ InitUser InitializeUserScreenText
+ InitUserDone InitializeUserDoneScreenText
+ InitMFAPrompt InitMFAPromptScreenText
+ InitMFAOTP InitMFAOTPScreenText
+ InitMFAU2F InitMFAU2FScreenText
+ InitMFADone InitMFADoneScreenText
+ MFAProvider MFAProvidersText
+ VerifyMFAOTP VerifyMFAOTPScreenText
+ VerifyMFAU2F VerifyMFAU2FScreenText
+ Passwordless PasswordlessScreenText
+ PasswordlessPrompt PasswordlessPromptScreenText
+ PasswordlessRegistration PasswordlessRegistrationScreenText
+ PasswordlessRegistrationDone PasswordlessRegistrationDoneScreenText
+ PasswordChange PasswordChangeScreenText
+ PasswordChangeDone PasswordChangeDoneScreenText
+ PasswordResetDone PasswordResetDoneScreenText
+ RegisterOption RegistrationOptionScreenText
+ RegistrationUser RegistrationUserScreenText
+ RegistrationOrg RegistrationOrgScreenText
+ LinkingUsersDone LinkingUserDoneScreenText
+ ExternalNotFoundOption ExternalUserNotFoundScreenText
+ LoginSuccess SuccessLoginScreenText
+ LogoutDone LogoutDoneScreenText
+ Footer FooterText
}
func (m *CustomLoginText) IsValid() bool {
@@ -564,3 +588,27 @@ type FooterText struct {
Help string
HelpLink string
}
+
+type PasswordlessPromptScreenText struct {
+ Title string
+ Description string
+ DescriptionInit string
+ PasswordlessButtonText string
+ NextButtonText string
+ SkipButtonText string
+}
+
+type PasswordlessRegistrationScreenText struct {
+ Title string
+ Description string
+ RegisterTokenButtonText string
+ TokenNameLabel string
+ NotSupported string
+ ErrorRetry string
+}
+
+type PasswordlessRegistrationDoneScreenText struct {
+ Title string
+ Description string
+ NextButtonText string
+}
diff --git a/internal/domain/custom_message_text.go b/internal/domain/custom_message_text.go
index ed457d2aaf..0309d5f745 100644
--- a/internal/domain/custom_message_text.go
+++ b/internal/domain/custom_message_text.go
@@ -7,26 +7,28 @@ import (
)
const (
- InitCodeMessageType = "InitCode"
- PasswordResetMessageType = "PasswordReset"
- VerifyEmailMessageType = "VerifyEmail"
- VerifyPhoneMessageType = "VerifyPhone"
- DomainClaimedMessageType = "DomainClaimed"
- MessageTitle = "Title"
- MessagePreHeader = "PreHeader"
- MessageSubject = "Subject"
- MessageGreeting = "Greeting"
- MessageText = "Text"
- MessageButtonText = "ButtonText"
- MessageFooterText = "Footer"
+ InitCodeMessageType = "InitCode"
+ PasswordResetMessageType = "PasswordReset"
+ VerifyEmailMessageType = "VerifyEmail"
+ VerifyPhoneMessageType = "VerifyPhone"
+ DomainClaimedMessageType = "DomainClaimed"
+ PasswordlessRegistrationMessageType = "PasswordlessRegistration"
+ MessageTitle = "Title"
+ MessagePreHeader = "PreHeader"
+ MessageSubject = "Subject"
+ MessageGreeting = "Greeting"
+ MessageText = "Text"
+ MessageButtonText = "ButtonText"
+ MessageFooterText = "Footer"
)
type MessageTexts struct {
- InitCode CustomMessageText
- PasswordReset CustomMessageText
- VerifyEmail CustomMessageText
- VerifyPhone CustomMessageText
- DomainClaimed CustomMessageText
+ InitCode CustomMessageText
+ PasswordReset CustomMessageText
+ VerifyEmail CustomMessageText
+ VerifyPhone CustomMessageText
+ DomainClaimed CustomMessageText
+ PasswordlessRegistration CustomMessageText
}
type CustomMessageText struct {
@@ -61,6 +63,8 @@ func (m *MessageTexts) GetMessageTextByType(msgType string) *CustomMessageText {
return &m.VerifyPhone
case DomainClaimedMessageType:
return &m.DomainClaimed
+ case PasswordlessRegistrationMessageType:
+ return &m.PasswordlessRegistration
}
return nil
}
diff --git a/internal/domain/human.go b/internal/domain/human.go
index f9e0ba372a..bf109fe3df 100644
--- a/internal/domain/human.go
+++ b/internal/domain/human.go
@@ -79,8 +79,8 @@ func (u *Human) HashPasswordIfExisting(policy *PasswordComplexityPolicy, passwor
return nil
}
-func (u *Human) IsInitialState() bool {
- return u.Email == nil || !u.IsEmailVerified || (u.ExternalIDPs == nil || len(u.ExternalIDPs) == 0) && (u.Password == nil || u.SecretString == "")
+func (u *Human) IsInitialState(passwordless bool) bool {
+ return u.Email == nil || !u.IsEmailVerified || (u.ExternalIDPs == nil || len(u.ExternalIDPs) == 0) && !passwordless && (u.Password == nil || u.SecretString == "")
}
func NewInitUserCode(generator crypto.Generator) (*InitUserCode, error) {
diff --git a/internal/domain/human_web_auth_n.go b/internal/domain/human_web_auth_n.go
index 6c571e4388..1c3551d076 100644
--- a/internal/domain/human_web_auth_n.go
+++ b/internal/domain/human_web_auth_n.go
@@ -2,6 +2,9 @@ package domain
import (
"bytes"
+ "fmt"
+ "time"
+
es_models "github.com/caos/zitadel/internal/eventstore/v1/models"
)
@@ -65,3 +68,29 @@ func GetTokenByKeyID(tokens []*WebAuthNToken, keyID []byte) (int, *WebAuthNToken
}
return -1, nil
}
+
+type PasswordlessInitCodeState int32
+
+const (
+ PasswordlessInitCodeStateUnspecified PasswordlessInitCodeState = iota
+ PasswordlessInitCodeStateRequested
+ PasswordlessInitCodeStateActive
+ PasswordlessInitCodeStateRemoved
+)
+
+type PasswordlessInitCode struct {
+ es_models.ObjectRoot
+
+ CodeID string
+ Code string
+ Expiration time.Duration
+ State PasswordlessInitCodeState
+}
+
+func (p *PasswordlessInitCode) Link(baseURL string) string {
+ return PasswordlessInitCodeLink(baseURL, p.AggregateID, p.ResourceOwner, p.CodeID, p.Code)
+}
+
+func PasswordlessInitCodeLink(baseURL, userID, resourceOwner, codeID, code string) string {
+ return fmt.Sprintf("%s?userID=%s&orgID=%s&codeID=%s&code=%s", baseURL, userID, resourceOwner, codeID, code)
+}
diff --git a/internal/domain/next_step.go b/internal/domain/next_step.go
index 6e812e7ac6..1d5c201f0d 100644
--- a/internal/domain/next_step.go
+++ b/internal/domain/next_step.go
@@ -24,6 +24,7 @@ const (
NextStepExternalLogin
NextStepGrantRequired
NextStepPasswordless
+ NextStepPasswordlessRegistrationPrompt
NextStepRegistration
)
@@ -93,12 +94,20 @@ func (s *ExternalLoginStep) Type() NextStepType {
return NextStepExternalLogin
}
-type PasswordlessStep struct{}
+type PasswordlessStep struct {
+ PasswordSet bool
+}
func (s *PasswordlessStep) Type() NextStepType {
return NextStepPasswordless
}
+type PasswordlessRegistrationPromptStep struct{}
+
+func (s *PasswordlessRegistrationPromptStep) Type() NextStepType {
+ return NextStepPasswordlessRegistrationPrompt
+}
+
type ChangePasswordStep struct{}
func (s *ChangePasswordStep) Type() NextStepType {
diff --git a/internal/iam/repository/view/model/custom_text.go b/internal/iam/repository/view/model/custom_text.go
index 272ea49c4c..fd63822940 100644
--- a/internal/iam/repository/view/model/custom_text.go
+++ b/internal/iam/repository/view/model/custom_text.go
@@ -93,7 +93,8 @@ func (r *CustomTextView) IsMessageTemplate() bool {
r.Template == domain.PasswordResetMessageType ||
r.Template == domain.VerifyEmailMessageType ||
r.Template == domain.VerifyPhoneMessageType ||
- r.Template == domain.DomainClaimedMessageType
+ r.Template == domain.DomainClaimedMessageType ||
+ r.Template == domain.PasswordlessRegistrationMessageType
}
func CustomTextViewsToMessageDomain(aggregateID, lang string, texts []*CustomTextView) *domain.CustomMessageText {
@@ -208,6 +209,15 @@ func CustomTextViewsToLoginDomain(aggregateID, lang string, texts []*CustomTextV
if strings.HasPrefix(text.Key, domain.LoginKeyPasswordless) {
passwordlessKeyToDomain(text, result)
}
+ if strings.HasPrefix(text.Key, domain.LoginKeyPasswordlessPrompt) {
+ passwordlessPromptKeyToDomain(text, result)
+ }
+ if strings.HasPrefix(text.Key, domain.LoginKeyPasswordlessRegistration) {
+ passwordlessRegistrationKeyToDomain(text, result)
+ }
+ if strings.HasPrefix(text.Key, domain.LoginKeyPasswordlessRegistrationDone) {
+ passwordlessRegistrationDoneKeyToDomain(text, result)
+ }
if strings.HasPrefix(text.Key, domain.LoginKeyPasswordChange) {
passwordChangeKeyToDomain(text, result)
}
@@ -638,6 +648,60 @@ func passwordlessKeyToDomain(text *CustomTextView, result *domain.CustomLoginTex
}
}
+func passwordlessPromptKeyToDomain(text *CustomTextView, result *domain.CustomLoginText) {
+ if text.Key == domain.LoginKeyPasswordlessPromptTitle {
+ result.PasswordlessPrompt.Title = text.Text
+ }
+ if text.Key == domain.LoginKeyPasswordlessPromptDescription {
+ result.PasswordlessPrompt.Description = text.Text
+ }
+ if text.Key == domain.LoginKeyPasswordlessPromptDescriptionInit {
+ result.PasswordlessPrompt.DescriptionInit = text.Text
+ }
+ if text.Key == domain.LoginKeyPasswordlessPromptPasswordlessButtonText {
+ result.PasswordlessPrompt.PasswordlessButtonText = text.Text
+ }
+ if text.Key == domain.LoginKeyPasswordlessPromptNextButtonText {
+ result.PasswordlessPrompt.NextButtonText = text.Text
+ }
+ if text.Key == domain.LoginKeyPasswordlessPromptSkipButtonText {
+ result.PasswordlessPrompt.SkipButtonText = text.Text
+ }
+}
+
+func passwordlessRegistrationKeyToDomain(text *CustomTextView, result *domain.CustomLoginText) {
+ if text.Key == domain.LoginKeyPasswordlessRegistrationTitle {
+ result.PasswordlessRegistration.Title = text.Text
+ }
+ if text.Key == domain.LoginKeyPasswordlessRegistrationDescription {
+ result.PasswordlessRegistration.Description = text.Text
+ }
+ if text.Key == domain.LoginKeyPasswordlessRegistrationRegisterTokenButtonText {
+ result.PasswordlessRegistration.RegisterTokenButtonText = text.Text
+ }
+ if text.Key == domain.LoginKeyPasswordlessRegistrationTokenNameLabel {
+ result.PasswordlessRegistration.TokenNameLabel = text.Text
+ }
+ if text.Key == domain.LoginKeyPasswordlessRegistrationNotSupported {
+ result.PasswordlessRegistration.NotSupported = text.Text
+ }
+ if text.Key == domain.LoginKeyPasswordlessRegistrationErrorRetry {
+ result.PasswordlessRegistration.ErrorRetry = text.Text
+ }
+}
+
+func passwordlessRegistrationDoneKeyToDomain(text *CustomTextView, result *domain.CustomLoginText) {
+ if text.Key == domain.LoginKeyPasswordlessRegistrationDoneTitle {
+ result.PasswordlessRegistrationDone.Title = text.Text
+ }
+ if text.Key == domain.LoginKeyPasswordlessRegistrationDoneDescription {
+ result.PasswordlessRegistrationDone.Description = text.Text
+ }
+ if text.Key == domain.LoginKeyPasswordlessRegistrationDoneNextButtonText {
+ result.PasswordlessRegistrationDone.NextButtonText = text.Text
+ }
+}
+
func passwordChangeKeyToDomain(text *CustomTextView, result *domain.CustomLoginText) {
if text.Key == domain.LoginKeyPasswordChangeTitle {
result.PasswordChange.Title = text.Text
diff --git a/internal/management/repository/eventsourcing/handler/user.go b/internal/management/repository/eventsourcing/handler/user.go
index b832e70790..77f911b556 100644
--- a/internal/management/repository/eventsourcing/handler/user.go
+++ b/internal/management/repository/eventsourcing/handler/user.go
@@ -2,7 +2,9 @@ package handler
import (
"context"
+
"github.com/caos/logging"
+
"github.com/caos/zitadel/internal/domain"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/eventstore/v1"
@@ -16,6 +18,7 @@ import (
org_model "github.com/caos/zitadel/internal/org/model"
org_es_model "github.com/caos/zitadel/internal/org/repository/eventsourcing/model"
"github.com/caos/zitadel/internal/org/repository/view"
+ user_repo "github.com/caos/zitadel/internal/repository/user"
es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model"
view_model "github.com/caos/zitadel/internal/user/repository/view/model"
)
@@ -139,7 +142,9 @@ func (u *User) ProcessUser(event *es_models.Event) (err error) {
es_model.HumanPasswordlessTokenAdded,
es_model.HumanPasswordlessTokenVerified,
es_model.HumanPasswordlessTokenRemoved,
- es_model.MachineChanged:
+ es_model.MachineChanged,
+ es_models.EventType(user_repo.HumanPasswordlessInitCodeAddedType),
+ es_models.EventType(user_repo.HumanPasswordlessInitCodeRequestedType):
user, err = u.view.UserByID(event.AggregateID)
if err != nil {
return err
diff --git a/internal/notification/repository/eventsourcing/handler/notification.go b/internal/notification/repository/eventsourcing/handler/notification.go
index e260ab4d8c..130d21d596 100644
--- a/internal/notification/repository/eventsourcing/handler/notification.go
+++ b/internal/notification/repository/eventsourcing/handler/notification.go
@@ -23,6 +23,7 @@ import (
iam_model "github.com/caos/zitadel/internal/iam/model"
iam_es_model "github.com/caos/zitadel/internal/iam/repository/view/model"
"github.com/caos/zitadel/internal/notification/types"
+ user_repo "github.com/caos/zitadel/internal/repository/user"
es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model"
"github.com/caos/zitadel/internal/user/repository/view"
"github.com/caos/zitadel/internal/user/repository/view/model"
@@ -124,6 +125,8 @@ func (n *Notification) Reduce(event *models.Event) (err error) {
err = n.handlePasswordCode(event)
case es_model.DomainClaimed:
err = n.handleDomainClaimed(event)
+ case models.EventType(user_repo.HumanPasswordlessInitCodeRequestedType):
+ err = n.handlePasswordlessRegistrationLink(event)
}
if err != nil {
return err
@@ -312,6 +315,52 @@ func (n *Notification) handleDomainClaimed(event *models.Event) (err error) {
return n.command.UserDomainClaimedSent(ctx, event.ResourceOwner, event.AggregateID)
}
+func (n *Notification) handlePasswordlessRegistrationLink(event *models.Event) (err error) {
+ addedEvent := new(user_repo.HumanPasswordlessInitCodeRequestedEvent)
+ if err := json.Unmarshal(event.Data, addedEvent); err != nil {
+ return err
+ }
+ events, err := n.getUserEvents(event.AggregateID, event.Sequence)
+ if err != nil {
+ return err
+ }
+ for _, e := range events {
+ if e.Type == models.EventType(user_repo.HumanPasswordlessInitCodeSentType) {
+ sentEvent := new(user_repo.HumanPasswordlessInitCodeSentEvent)
+ if err := json.Unmarshal(e.Data, sentEvent); err != nil {
+ return err
+ }
+ if sentEvent.ID == addedEvent.ID {
+ return nil
+ }
+ }
+ }
+ user, err := n.getUserByID(event.AggregateID)
+ if err != nil {
+ return err
+ }
+ ctx := getSetNotifyContextData(event.ResourceOwner)
+ colors, err := n.getLabelPolicy(ctx)
+ if err != nil {
+ return err
+ }
+
+ template, err := n.getMailTemplate(ctx)
+ if err != nil {
+ return err
+ }
+
+ translator, err := n.getTranslatorWithOrgTexts(user.ResourceOwner, domain.PasswordlessRegistrationMessageType)
+ if err != nil {
+ return err
+ }
+ err = types.SendPasswordlessRegistrationLink(string(template.Template), translator, user, addedEvent, n.systemDefaults, n.AesCrypto, colors, n.apiDomain)
+ if err != nil {
+ return err
+ }
+ return n.command.HumanPasswordlessInitCodeSent(ctx, event.AggregateID, event.ResourceOwner, addedEvent.ID)
+}
+
func (n *Notification) checkIfCodeAlreadyHandledOrExpired(event *models.Event, expiry time.Duration, eventTypes ...models.EventType) (bool, error) {
if event.CreationDate.Add(expiry).Before(time.Now().UTC()) {
return true, nil
diff --git a/internal/notification/static/i18n/de.yaml b/internal/notification/static/i18n/de.yaml
index 001c572dfb..0d432259ca 100644
--- a/internal/notification/static/i18n/de.yaml
+++ b/internal/notification/static/i18n/de.yaml
@@ -1,35 +1,42 @@
InitCode:
- Title: Zitadel - User initialisieren
+ Title: ZITADEL - User initialisieren
PreHeader: User initialisieren
Subject: User initialisieren
Greeting: Hallo {{.FirstName}} {{.LastName}},
- Text: Dieser Benutzer wurde soeben im Zitadel erstellt. Mit dem Benutzernamen <br><strong>{{.PreferredLoginName}}</strong><br> kannst du dich anmelden. Nutze den untenstehenden Button, um die Initialisierung abzuschliessen <br>(Code <strong>{{.Code}}</strong>).<br> Falls du dieses Mail nicht angefordert hast, kannst du es einfach ignorieren.
+ Text: Dieser Benutzer wurde soeben in ZITADEL erstellt. Mit dem Benutzernamen <br><strong>{{.PreferredLoginName}}</strong><br> kannst du dich anmelden. Nutze den untenstehenden Button, um die Initialisierung abzuschliessen <br>(Code <strong>{{.Code}}</strong>).<br> Falls du dieses Mail nicht angefordert hast, kannst du es einfach ignorieren.
ButtonText: Initialisierung abschliessen
PasswordReset:
- Title: Zitadel - Passwort zurücksetzen
+ Title: ZITADEL - Passwort zurücksetzen
PreHeader: Passwort zurücksetzen
Subject: Passwort zurücksetzen
Greeting: Hallo {{.FirstName}} {{.LastName}},
Text: Wir haben eine Anfrage für das Zurücksetzen deines Passwortes bekommen. Du kannst den untenstehenden Button verwenden, um dein Passwort zurückzusetzen <br>(Code <strong>{{.Code}}</strong>).<br> Falls du dieses Mail nicht angefordert hast, kannst du es ignorieren.
ButtonText: Passwort zurücksetzen
VerifyEmail:
- Title: Zitadel - Email verifizieren
+ Title: ZITADEL - Email verifizieren
PreHeader: Email verifizieren
Subject: Email verifizieren
Greeting: Hallo {{.FirstName}} {{.LastName}},
Text: Eine neue E-Mail Adresse wurde hinzugefügt. Bitte verwende den untenstehenden Button um diese zu verifizieren <br>(Code <strong>{{.Code}}</strong>).<br> Falls du deine E-Mail Adresse nicht selber hinzugefügt hast, kannst du dieses E-Mail ignorieren.
ButtonText: Email verifizieren
VerifyPhone:
- Title: Zitadel - Telefonnummer verifizieren
+ Title: ZITADEL - Telefonnummer verifizieren
PreHeader: Telefonnummer verifizieren
Subject: Telefonnummer verifizieren
Greeting: Hallo {{.FirstName}} {{.LastName}},
Text: Eine Telefonnummer wurde hinzugefügt. Bitte verifiziere diese in dem du folgenden Code eingibst<br>(Code <strong>{{.Code}}</strong>).<br>
ButtonText: Telefon verifizieren
DomainClaimed:
- Title: Zitadel - Domain wurde beansprucht
+ Title: ZITADEL - Domain wurde beansprucht
PreHeader: Email / Username ändern
Subject: Domain wurde beansprucht
Greeting: Hallo {{.FirstName}} {{.LastName}},
Text: Die Domain {{.Domain}} wurde von einer Organisation beansprucht. Dein derzeitiger User {{.Username}} ist nicht Teil dieser Organisation. Daher musst du beim nächsten Login eine neue Email hinterlegen. Für diesen Login haben wir dir einen temporären Usernamen ({{.TempUsername}}) erstellt.
ButtonText: Login
+PasswordlessRegistration:
+ Title: ZITADEL - Passwortlosen Login hinzufügen
+ PreHeader: Passwortlosen Login hinzufügen
+ Subject: Passwortlosen Login hinzufügen
+ Greeting: Hallo {{.FirstName}} {{.LastName}},
+ Text: Wir haben eine Anfrage für das Hinzufügen eines Token für den passwortlosen Login erhalten. Du kannst den untenstehenden Button verwenden, um dein Token oder Gerät hinzuzufügen.
+ ButtonText: Passwortlosen Login hinzufügen
diff --git a/internal/notification/static/i18n/en.yaml b/internal/notification/static/i18n/en.yaml
index 3a373e35e5..26cf60266a 100644
--- a/internal/notification/static/i18n/en.yaml
+++ b/internal/notification/static/i18n/en.yaml
@@ -1,35 +1,42 @@
InitCode:
- Title: Zitadel - Initialize User
+ Title: ZITADEL - Initialize User
PreHeader: Initialize User
Subject: Initialize User
Greeting: Hello {{.FirstName}} {{.LastName}},
- Text: This user was created in Zitadel. Use the username {{.PreferredLoginName}} to login. Please click the button below to finish the initialization process. (Code {{.Code}}) If you didn't ask for this mail, please ignore it.
+ Text: This user was created in ZITADEL. Use the username {{.PreferredLoginName}} to login. Please click the button below to finish the initialization process. (Code {{.Code}}) If you didn't ask for this mail, please ignore it.
ButtonText: Finish initialization
PasswordReset:
- Title: Zitadel - Reset password
+ Title: ZITADEL - Reset password
PreHeader: Reset password
Subject: Reset password
Greeting: Hello {{.FirstName}} {{.LastName}},
Text: We received a password reset request. Please use the button below to reset your password. (Code {{.Code}}) If you didn't ask for this mail, please ignore it.
ButtonText: Reset password
VerifyEmail:
- Title: Zitadel - Verify email
+ Title: ZITADEL - Verify email
PreHeader: Verify email
Subject: Verify email
Greeting: Hello {{.FirstName}} {{.LastName}},
Text: A new email has been added. Please use the button below to verify your mail. (Code {{.Code}}) If you din't add a new email, please ignore this email.
ButtonText: Verify email
VerifyPhone:
- Title: Zitadel - Verify phone
+ Title: ZITADEL - Verify phone
PreHeader: Verify phone
Subject: Verify phone
Greeting: Hello {{.FirstName}} {{.LastName}},
Text: A new phonenumber has been added. Please use the following code to verify it {{.Code}}
ButtonText: Verify phone
DomainClaimed:
- Title: Zitadel - Domain has been claimed
+ Title: ZITADEL - Domain has been claimed
PreHeader: Change email / username
Subject: Domain has been claimed
Greeting: Hello {{.FirstName}} {{.LastName}},
Text: The domain {{.Domain}} has been claimed by an organisation. Your current user {{.Username}} is not part of this organisation. Therefore you'll have to change your email when you login. We have created a temporary username ({{.TempUsername}}) for this login.
ButtonText: Login
+PasswordlessRegistration:
+ Title: ZITADEL - Add Passwordless Login
+ PreHeader: Add Passwordless Login
+ Subject: Add Passwordless Login
+ Greeting: Hello {{.FirstName}} {{.LastName}},
+ Text: We received a request to add a token for passwordless login. Please use the button below to add your token or device for passwordless login.
+ ButtonText: Add Passwordless Login
diff --git a/internal/notification/types/passwordless_registration_link.go b/internal/notification/types/passwordless_registration_link.go
new file mode 100644
index 0000000000..2485e654bc
--- /dev/null
+++ b/internal/notification/types/passwordless_registration_link.go
@@ -0,0 +1,37 @@
+package types
+
+import (
+ "github.com/caos/zitadel/internal/config/systemdefaults"
+ "github.com/caos/zitadel/internal/crypto"
+ "github.com/caos/zitadel/internal/domain"
+ "github.com/caos/zitadel/internal/i18n"
+ iam_model "github.com/caos/zitadel/internal/iam/model"
+ "github.com/caos/zitadel/internal/notification/templates"
+ "github.com/caos/zitadel/internal/repository/user"
+ view_model "github.com/caos/zitadel/internal/user/repository/view/model"
+)
+
+type PasswordlessRegistrationLinkData struct {
+ templates.TemplateData
+ URL string
+}
+
+func SendPasswordlessRegistrationLink(mailhtml string, translator *i18n.Translator, user *view_model.NotifyUser, code *user.HumanPasswordlessInitCodeRequestedEvent, systemDefaults systemdefaults.SystemDefaults, alg crypto.EncryptionAlgorithm, colors *iam_model.LabelPolicyView, apiDomain string) error {
+ codeString, err := crypto.DecryptString(code.Code, alg)
+ if err != nil {
+ return err
+ }
+ url := domain.PasswordlessInitCodeLink(systemDefaults.Notifications.Endpoints.PasswordlessRegistration, user.ID, user.ResourceOwner, code.ID, codeString)
+ var args = mapNotifyUserToArgs(user)
+
+ emailCodeData := &PasswordlessRegistrationLinkData{
+ TemplateData: templates.GetTemplateData(translator, args, apiDomain, url, domain.PasswordlessRegistrationMessageType, user.PreferredLanguage, colors),
+ URL: url,
+ }
+
+ template, err := templates.GetParsedTemplate(mailhtml, emailCodeData)
+ if err != nil {
+ return err
+ }
+ return generateEmail(user, emailCodeData.Subject, template, systemDefaults.Notifications, true)
+}
diff --git a/internal/repository/user/eventstore.go b/internal/repository/user/eventstore.go
index ec4ca6037d..61b8b42287 100644
--- a/internal/repository/user/eventstore.go
+++ b/internal/repository/user/eventstore.go
@@ -96,6 +96,11 @@ func RegisterEventMappers(es *eventstore.Eventstore) {
RegisterFilterEventMapper(HumanPasswordlessTokenBeginLoginType, HumanPasswordlessBeginLoginEventMapper).
RegisterFilterEventMapper(HumanPasswordlessTokenCheckSucceededType, HumanPasswordlessCheckSucceededEventMapper).
RegisterFilterEventMapper(HumanPasswordlessTokenCheckFailedType, HumanPasswordlessCheckFailedEventMapper).
+ RegisterFilterEventMapper(HumanPasswordlessInitCodeAddedType, HumanPasswordlessInitCodeAddedEventMapper).
+ RegisterFilterEventMapper(HumanPasswordlessInitCodeRequestedType, HumanPasswordlessInitCodeRequestedEventMapper).
+ RegisterFilterEventMapper(HumanPasswordlessInitCodeSentType, HumanPasswordlessInitCodeSentEventMapper).
+ RegisterFilterEventMapper(HumanPasswordlessInitCodeCheckFailedType, HumanPasswordlessInitCodeCodeCheckFailedEventMapper).
+ RegisterFilterEventMapper(HumanPasswordlessInitCodeCheckSucceededType, HumanPasswordlessInitCodeCodeCheckSucceededEventMapper).
RegisterFilterEventMapper(HumanRefreshTokenAddedType, HumanRefreshTokenAddedEventMapper).
RegisterFilterEventMapper(HumanRefreshTokenRenewedType, HumanRefreshTokenRenewedEventEventMapper).
RegisterFilterEventMapper(HumanRefreshTokenRemovedType, HumanRefreshTokenRemovedEventEventMapper).
diff --git a/internal/repository/user/human_mfa_passwordless.go b/internal/repository/user/human_mfa_passwordless.go
index 0e0233e182..fa354b32f1 100644
--- a/internal/repository/user/human_mfa_passwordless.go
+++ b/internal/repository/user/human_mfa_passwordless.go
@@ -2,21 +2,32 @@ package user
import (
"context"
+ "encoding/json"
+ "time"
+ "github.com/caos/zitadel/internal/crypto"
"github.com/caos/zitadel/internal/domain"
+ "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/eventstore"
"github.com/caos/zitadel/internal/eventstore/repository"
)
const (
- passwordlessEventPrefix = humanEventPrefix + "passwordless.token."
- HumanPasswordlessTokenAddedType = passwordlessEventPrefix + "added"
- HumanPasswordlessTokenVerifiedType = passwordlessEventPrefix + "verified"
- HumanPasswordlessTokenSignCountChangedType = passwordlessEventPrefix + "signcount.changed"
- HumanPasswordlessTokenRemovedType = passwordlessEventPrefix + "removed"
- HumanPasswordlessTokenBeginLoginType = passwordlessEventPrefix + "begin.login"
- HumanPasswordlessTokenCheckSucceededType = passwordlessEventPrefix + "check.succeeded"
- HumanPasswordlessTokenCheckFailedType = passwordlessEventPrefix + "check.failed"
+ passwordlessEventPrefix = humanEventPrefix + "passwordless."
+ humanPasswordlessTokenEventPrefix = passwordlessEventPrefix + "token."
+ HumanPasswordlessTokenAddedType = humanPasswordlessTokenEventPrefix + "added"
+ HumanPasswordlessTokenVerifiedType = humanPasswordlessTokenEventPrefix + "verified"
+ HumanPasswordlessTokenSignCountChangedType = humanPasswordlessTokenEventPrefix + "signcount.changed"
+ HumanPasswordlessTokenRemovedType = humanPasswordlessTokenEventPrefix + "removed"
+ HumanPasswordlessTokenBeginLoginType = humanPasswordlessTokenEventPrefix + "begin.login"
+ HumanPasswordlessTokenCheckSucceededType = humanPasswordlessTokenEventPrefix + "check.succeeded"
+ HumanPasswordlessTokenCheckFailedType = humanPasswordlessTokenEventPrefix + "check.failed"
+ humanPasswordlessInitCodePrefix = passwordlessEventPrefix + "initialization.code."
+ HumanPasswordlessInitCodeAddedType = humanPasswordlessInitCodePrefix + "added"
+ HumanPasswordlessInitCodeRequestedType = humanPasswordlessInitCodePrefix + "requested"
+ HumanPasswordlessInitCodeSentType = humanPasswordlessInitCodePrefix + "sent"
+ HumanPasswordlessInitCodeCheckFailedType = humanPasswordlessInitCodePrefix + "check.failed"
+ HumanPasswordlessInitCodeCheckSucceededType = humanPasswordlessInitCodePrefix + "check.succeeded"
)
type HumanPasswordlessAddedEvent struct {
@@ -254,3 +265,215 @@ func HumanPasswordlessCheckFailedEventMapper(event *repository.Event) (eventstor
return &HumanPasswordlessCheckFailedEvent{HumanWebAuthNCheckFailedEvent: *e.(*HumanWebAuthNCheckFailedEvent)}, nil
}
+
+type HumanPasswordlessInitCodeAddedEvent struct {
+ eventstore.BaseEvent `json:"-"`
+
+ ID string `json:"id"`
+ Code *crypto.CryptoValue `json:"code"`
+ Expiry time.Duration `json:"expiry"`
+}
+
+func (e *HumanPasswordlessInitCodeAddedEvent) Data() interface{} {
+ return e
+}
+
+func (e *HumanPasswordlessInitCodeAddedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
+ return nil
+}
+
+func NewHumanPasswordlessInitCodeAddedEvent(
+ ctx context.Context,
+ aggregate *eventstore.Aggregate,
+ id string,
+ code *crypto.CryptoValue,
+ expiry time.Duration,
+) *HumanPasswordlessInitCodeAddedEvent {
+ return &HumanPasswordlessInitCodeAddedEvent{
+ BaseEvent: *eventstore.NewBaseEventForPush(
+ ctx,
+ aggregate,
+ HumanPasswordlessInitCodeAddedType,
+ ),
+ ID: id,
+ Code: code,
+ Expiry: expiry,
+ }
+}
+
+func HumanPasswordlessInitCodeAddedEventMapper(event *repository.Event) (eventstore.EventReader, error) {
+ webAuthNAdded := &HumanPasswordlessInitCodeAddedEvent{
+ BaseEvent: *eventstore.BaseEventFromRepo(event),
+ }
+ err := json.Unmarshal(event.Data, webAuthNAdded)
+ if err != nil {
+ return nil, errors.ThrowInternal(err, "USER-BDf32", "unable to unmarshal human passwordless code added")
+ }
+ return webAuthNAdded, nil
+}
+
+type HumanPasswordlessInitCodeRequestedEvent struct {
+ eventstore.BaseEvent `json:"-"`
+
+ ID string `json:"id"`
+ Code *crypto.CryptoValue `json:"code"`
+ Expiry time.Duration `json:"expiry"`
+}
+
+func (e *HumanPasswordlessInitCodeRequestedEvent) Data() interface{} {
+ return e
+}
+
+func (e *HumanPasswordlessInitCodeRequestedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
+ return nil
+}
+
+func NewHumanPasswordlessInitCodeRequestedEvent(
+ ctx context.Context,
+ aggregate *eventstore.Aggregate,
+ id string,
+ code *crypto.CryptoValue,
+ expiry time.Duration,
+) *HumanPasswordlessInitCodeRequestedEvent {
+ return &HumanPasswordlessInitCodeRequestedEvent{
+ BaseEvent: *eventstore.NewBaseEventForPush(
+ ctx,
+ aggregate,
+ HumanPasswordlessInitCodeRequestedType,
+ ),
+ ID: id,
+ Code: code,
+ Expiry: expiry,
+ }
+}
+
+func HumanPasswordlessInitCodeRequestedEventMapper(event *repository.Event) (eventstore.EventReader, error) {
+ webAuthNAdded := &HumanPasswordlessInitCodeRequestedEvent{
+ BaseEvent: *eventstore.BaseEventFromRepo(event),
+ }
+ err := json.Unmarshal(event.Data, webAuthNAdded)
+ if err != nil {
+ return nil, errors.ThrowInternal(err, "USER-VGfg3", "unable to unmarshal human passwordless code delivery added")
+ }
+ return webAuthNAdded, nil
+}
+
+type HumanPasswordlessInitCodeSentEvent struct {
+ eventstore.BaseEvent `json:"-"`
+
+ ID string `json:"id"`
+}
+
+func (e *HumanPasswordlessInitCodeSentEvent) Data() interface{} {
+ return e
+}
+
+func (e *HumanPasswordlessInitCodeSentEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
+ return nil
+}
+
+func NewHumanPasswordlessInitCodeSentEvent(
+ ctx context.Context,
+ aggregate *eventstore.Aggregate,
+ id string,
+) *HumanPasswordlessInitCodeSentEvent {
+ return &HumanPasswordlessInitCodeSentEvent{
+ BaseEvent: *eventstore.NewBaseEventForPush(
+ ctx,
+ aggregate,
+ HumanPasswordlessInitCodeSentType,
+ ),
+ ID: id,
+ }
+}
+
+func HumanPasswordlessInitCodeSentEventMapper(event *repository.Event) (eventstore.EventReader, error) {
+ webAuthNAdded := &HumanPasswordlessInitCodeSentEvent{
+ BaseEvent: *eventstore.BaseEventFromRepo(event),
+ }
+ err := json.Unmarshal(event.Data, webAuthNAdded)
+ if err != nil {
+ return nil, errors.ThrowInternal(err, "USER-Gtg4j", "unable to unmarshal human passwordless code sent")
+ }
+ return webAuthNAdded, nil
+}
+
+type HumanPasswordlessInitCodeCheckFailedEvent struct {
+ eventstore.BaseEvent `json:"-"`
+
+ ID string `json:"id"`
+}
+
+func (e *HumanPasswordlessInitCodeCheckFailedEvent) Data() interface{} {
+ return e
+}
+
+func (e *HumanPasswordlessInitCodeCheckFailedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
+ return nil
+}
+
+func NewHumanPasswordlessInitCodeCheckFailedEvent(
+ ctx context.Context,
+ aggregate *eventstore.Aggregate,
+ id string,
+) *HumanPasswordlessInitCodeCheckFailedEvent {
+ return &HumanPasswordlessInitCodeCheckFailedEvent{
+ BaseEvent: *eventstore.NewBaseEventForPush(
+ ctx,
+ aggregate,
+ HumanPasswordlessInitCodeCheckFailedType,
+ ),
+ ID: id,
+ }
+}
+
+func HumanPasswordlessInitCodeCodeCheckFailedEventMapper(event *repository.Event) (eventstore.EventReader, error) {
+ webAuthNAdded := &HumanPasswordlessInitCodeCheckFailedEvent{
+ BaseEvent: *eventstore.BaseEventFromRepo(event),
+ }
+ err := json.Unmarshal(event.Data, webAuthNAdded)
+ if err != nil {
+ return nil, errors.ThrowInternal(err, "USER-Gtg4j", "unable to unmarshal human passwordless code check failed")
+ }
+ return webAuthNAdded, nil
+}
+
+type HumanPasswordlessInitCodeCheckSucceededEvent struct {
+ eventstore.BaseEvent `json:"-"`
+
+ ID string `json:"id"`
+}
+
+func (e *HumanPasswordlessInitCodeCheckSucceededEvent) Data() interface{} {
+ return e
+}
+
+func (e *HumanPasswordlessInitCodeCheckSucceededEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
+ return nil
+}
+
+func NewHumanPasswordlessInitCodeCheckSucceededEvent(
+ ctx context.Context,
+ aggregate *eventstore.Aggregate,
+ id string,
+) *HumanPasswordlessInitCodeCheckSucceededEvent {
+ return &HumanPasswordlessInitCodeCheckSucceededEvent{
+ BaseEvent: *eventstore.NewBaseEventForPush(
+ ctx,
+ aggregate,
+ HumanPasswordlessInitCodeCheckSucceededType,
+ ),
+ ID: id,
+ }
+}
+
+func HumanPasswordlessInitCodeCodeCheckSucceededEventMapper(event *repository.Event) (eventstore.EventReader, error) {
+ webAuthNAdded := &HumanPasswordlessInitCodeCheckSucceededEvent{
+ BaseEvent: *eventstore.BaseEventFromRepo(event),
+ }
+ err := json.Unmarshal(event.Data, webAuthNAdded)
+ if err != nil {
+ return nil, errors.ThrowInternal(err, "USER-Gtg4j", "unable to unmarshal human passwordless code check succeeded")
+ }
+ return webAuthNAdded, nil
+}
diff --git a/internal/ui/login/handler/passwordless_login_handler.go b/internal/ui/login/handler/passwordless_login_handler.go
index 7216239e4b..851a2c1480 100644
--- a/internal/ui/login/handler/passwordless_login_handler.go
+++ b/internal/ui/login/handler/passwordless_login_handler.go
@@ -22,7 +22,7 @@ type passwordlessFormData struct {
PasswordLogin bool `schema:"passwordlogin"`
}
-func (l *Login) renderPasswordlessVerification(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) {
+func (l *Login) renderPasswordlessVerification(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, passwordSet bool, err error) {
var errID, errMessage, credentialData string
var webAuthNLogin *domain.WebAuthNLogin
if err == nil {
@@ -35,16 +35,15 @@ func (l *Login) renderPasswordlessVerification(w http.ResponseWriter, r *http.Re
if webAuthNLogin != nil {
credentialData = base64.RawURLEncoding.EncodeToString(webAuthNLogin.CredentialAssertionData)
}
- var passwordLogin bool
- if authReq.LoginPolicy != nil {
- passwordLogin = authReq.LoginPolicy.AllowUsernamePassword
+ if passwordSet && authReq.LoginPolicy != nil {
+ passwordSet = authReq.LoginPolicy.AllowUsernamePassword
}
data := &passwordlessData{
webAuthNData{
userData: l.getUserData(r, authReq, "Login Passwordless", errID, errMessage),
CredentialCreationData: credentialData,
},
- passwordLogin,
+ passwordSet,
}
l.renderer.RenderTemplate(w, r, l.getTranslator(authReq), l.renderer.Templates[tmplPasswordlessVerification], data, nil)
}
@@ -62,13 +61,13 @@ func (l *Login) handlePasswordlessVerification(w http.ResponseWriter, r *http.Re
}
credData, err := base64.URLEncoding.DecodeString(formData.CredentialData)
if err != nil {
- l.renderPasswordlessVerification(w, r, authReq, err)
+ l.renderPasswordlessVerification(w, r, authReq, formData.PasswordLogin, err)
return
}
userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context())
err = l.authRepo.VerifyPasswordless(setContext(r.Context(), authReq.UserOrgID), authReq.UserID, authReq.UserOrgID, authReq.ID, userAgentID, credData, domain.BrowserInfoFromRequest(r))
if err != nil {
- l.renderPasswordlessVerification(w, r, authReq, err)
+ l.renderPasswordlessVerification(w, r, authReq, formData.PasswordLogin, err)
return
}
l.renderNextStep(w, r, authReq)
diff --git a/internal/ui/login/handler/passwordless_prompt_handler.go b/internal/ui/login/handler/passwordless_prompt_handler.go
new file mode 100644
index 0000000000..e0a4ea87c9
--- /dev/null
+++ b/internal/ui/login/handler/passwordless_prompt_handler.go
@@ -0,0 +1,40 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/caos/zitadel/internal/domain"
+)
+
+const (
+ tmplPasswordlessPrompt = "passwordlessprompt"
+)
+
+type passwordlessPromptData struct {
+ userData
+}
+
+type passwordlessPromptFormData struct{}
+
+func (l *Login) handlePasswordlessPrompt(w http.ResponseWriter, r *http.Request) {
+ data := new(passwordlessPromptFormData)
+ authReq, err := l.getAuthRequestAndParseData(r, data)
+ if err != nil {
+ l.renderError(w, r, authReq, err)
+ return
+ }
+ l.renderPasswordlessRegistration(w, r, authReq, "", "", "", "", nil)
+}
+
+func (l *Login) renderPasswordlessPrompt(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) {
+ var errID, errMessage string
+ if err != nil {
+ errID, errMessage = l.getErrorMessage(r, err)
+ }
+ data := &passwordlessPromptData{
+ userData: l.getUserData(r, authReq, "Passwordless Prompt", errID, errMessage),
+ }
+
+ translator := l.getTranslator(authReq)
+ l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplPasswordlessPrompt], data, nil)
+}
diff --git a/internal/ui/login/handler/passwordless_registration_handler.go b/internal/ui/login/handler/passwordless_registration_handler.go
new file mode 100644
index 0000000000..6ea321f71c
--- /dev/null
+++ b/internal/ui/login/handler/passwordless_registration_handler.go
@@ -0,0 +1,132 @@
+package handler
+
+import (
+ "encoding/base64"
+ "net/http"
+
+ http_mw "github.com/caos/zitadel/internal/api/http/middleware"
+ "github.com/caos/zitadel/internal/domain"
+)
+
+const (
+ tmplPasswordlessRegistration = "passwordlessregistration"
+ tmplPasswordlessRegistrationDone = "passwordlessregistrationdone"
+ queryPasswordlessRegistrationCode = "code"
+ queryPasswordlessRegistrationCodeID = "codeID"
+ queryPasswordlessRegistrationUserID = "userID"
+ queryPasswordlessRegistrationOrgID = "orgID"
+)
+
+type passwordlessRegistrationData struct {
+ webAuthNData
+ Code string
+ CodeID string
+ UserID string
+ OrgID string
+ Disabled bool
+}
+
+type passwordlessRegistrationFormData struct {
+ webAuthNFormData
+ Code string `schema:"code"`
+ CodeID string `schema:"codeID"`
+ UserID string `schema:"userID"`
+ OrgID string `schema:"orgID"`
+ TokenName string `schema:"name"`
+}
+
+func (l *Login) handlePasswordlessRegistration(w http.ResponseWriter, r *http.Request) {
+ userID := r.FormValue(queryPasswordlessRegistrationUserID)
+ orgID := r.FormValue(queryPasswordlessRegistrationOrgID)
+ codeID := r.FormValue(queryPasswordlessRegistrationCodeID)
+ code := r.FormValue(queryPasswordlessRegistrationCode)
+ l.renderPasswordlessRegistration(w, r, nil, userID, orgID, codeID, code, nil)
+}
+
+func (l *Login) renderPasswordlessRegistration(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, userID, orgID, codeID, code string, err error) {
+ var errID, errMessage, credentialData string
+ var disabled bool
+ if authReq != nil {
+ userID = authReq.UserID
+ orgID = authReq.UserOrgID
+ }
+ var webAuthNToken *domain.WebAuthNToken
+ if err == nil {
+ if authReq != nil {
+ webAuthNToken, err = l.authRepo.BeginPasswordlessSetup(setContext(r.Context(), authReq.UserOrgID), userID, authReq.UserOrgID)
+ } else {
+ webAuthNToken, err = l.authRepo.BeginPasswordlessInitCodeSetup(setContext(r.Context(), orgID), userID, orgID, codeID, code)
+ }
+ }
+ if err != nil {
+ errID, errMessage = l.getErrorMessage(r, err)
+ disabled = true
+ }
+ if webAuthNToken != nil {
+ credentialData = base64.RawURLEncoding.EncodeToString(webAuthNToken.CredentialCreationData)
+ }
+ data := &passwordlessRegistrationData{
+ webAuthNData{
+ userData: l.getUserData(r, authReq, "Login Passwordless", errID, errMessage),
+ CredentialCreationData: credentialData,
+ },
+ code,
+ codeID,
+ userID,
+ orgID,
+ disabled,
+ }
+ translator := l.getTranslator(authReq)
+ if authReq == nil {
+ policy, err := l.authRepo.GetLabelPolicy(r.Context(), orgID)
+ if err != nil {
+
+ }
+ data.LabelPolicy = policy
+ texts, err := l.authRepo.GetLoginText(r.Context(), orgID)
+ if err != nil {
+
+ }
+ translator, _ = l.renderer.NewTranslator()
+ l.addLoginTranslations(translator, texts)
+ }
+ l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplPasswordlessRegistration], data, nil)
+}
+
+func (l *Login) handlePasswordlessRegistrationCheck(w http.ResponseWriter, r *http.Request) {
+ formData := new(passwordlessRegistrationFormData)
+ authReq, err := l.getAuthRequestAndParseData(r, formData)
+ if err != nil {
+ l.renderError(w, r, authReq, err)
+ return
+ }
+ l.checkPasswordlessRegistration(w, r, authReq, formData, nil)
+}
+
+func (l *Login) checkPasswordlessRegistration(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, formData *passwordlessRegistrationFormData, err error) {
+ credData, err := base64.URLEncoding.DecodeString(formData.CredentialData)
+ if err != nil {
+ l.renderPasswordlessRegistration(w, r, authReq, formData.UserID, formData.OrgID, formData.CodeID, formData.Code, err)
+ return
+ }
+ userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context())
+ if authReq != nil {
+ err = l.authRepo.VerifyPasswordlessSetup(setContext(r.Context(), authReq.UserOrgID), formData.UserID, authReq.UserOrgID, userAgentID, formData.TokenName, credData)
+ } else {
+ err = l.authRepo.VerifyPasswordlessInitCodeSetup(setContext(r.Context(), formData.OrgID), formData.UserID, formData.OrgID, userAgentID, formData.TokenName, formData.CodeID, formData.Code, credData)
+ }
+ if err != nil {
+ l.renderPasswordlessRegistration(w, r, authReq, formData.UserID, formData.OrgID, formData.CodeID, formData.Code, err)
+ return
+ }
+ l.renderPasswordlessRegistrationDone(w, r, authReq, nil)
+}
+
+func (l *Login) renderPasswordlessRegistrationDone(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) {
+ var errID, errMessage string
+ if err != nil {
+ errID, errMessage = l.getErrorMessage(r, err)
+ }
+ data := l.getUserData(r, authReq, "Passwordless Registration Done", errID, errMessage)
+ l.renderer.RenderTemplate(w, r, l.getTranslator(authReq), l.renderer.Templates[tmplPasswordlessRegistrationDone], data, nil)
+}
diff --git a/internal/ui/login/handler/renderer.go b/internal/ui/login/handler/renderer.go
index b6b264d361..930139a168 100644
--- a/internal/ui/login/handler/renderer.go
+++ b/internal/ui/login/handler/renderer.go
@@ -37,35 +37,38 @@ func CreateRenderer(pathPrefix string, staticDir http.FileSystem, staticStorage
staticStorage: staticStorage,
}
tmplMapping := map[string]string{
- tmplError: "error.html",
- tmplLogin: "login.html",
- tmplUserSelection: "select_user.html",
- tmplPassword: "password.html",
- tmplPasswordlessVerification: "passwordless.html",
- tmplMFAVerify: "mfa_verify_otp.html",
- tmplMFAPrompt: "mfa_prompt.html",
- tmplMFAInitVerify: "mfa_init_otp.html",
- tmplMFAU2FInit: "mfa_init_u2f.html",
- tmplU2FVerification: "mfa_verification_u2f.html",
- tmplMFAInitDone: "mfa_init_done.html",
- tmplMailVerification: "mail_verification.html",
- tmplMailVerified: "mail_verified.html",
- tmplInitPassword: "init_password.html",
- tmplInitPasswordDone: "init_password_done.html",
- tmplInitUser: "init_user.html",
- tmplInitUserDone: "init_user_done.html",
- tmplPasswordResetDone: "password_reset_done.html",
- tmplChangePassword: "change_password.html",
- tmplChangePasswordDone: "change_password_done.html",
- tmplRegisterOption: "register_option.html",
- tmplRegister: "register.html",
- tmplLogoutDone: "logout_done.html",
- tmplRegisterOrg: "register_org.html",
- tmplChangeUsername: "change_username.html",
- tmplChangeUsernameDone: "change_username_done.html",
- tmplLinkUsersDone: "link_users_done.html",
- tmplExternalNotFoundOption: "external_not_found_option.html",
- tmplLoginSuccess: "login_success.html",
+ tmplError: "error.html",
+ tmplLogin: "login.html",
+ tmplUserSelection: "select_user.html",
+ tmplPassword: "password.html",
+ tmplPasswordlessVerification: "passwordless.html",
+ tmplPasswordlessRegistration: "passwordless_registration.html",
+ tmplPasswordlessRegistrationDone: "passwordless_registration_done.html",
+ tmplPasswordlessPrompt: "passwordless_prompt.html",
+ tmplMFAVerify: "mfa_verify_otp.html",
+ tmplMFAPrompt: "mfa_prompt.html",
+ tmplMFAInitVerify: "mfa_init_otp.html",
+ tmplMFAU2FInit: "mfa_init_u2f.html",
+ tmplU2FVerification: "mfa_verification_u2f.html",
+ tmplMFAInitDone: "mfa_init_done.html",
+ tmplMailVerification: "mail_verification.html",
+ tmplMailVerified: "mail_verified.html",
+ tmplInitPassword: "init_password.html",
+ tmplInitPasswordDone: "init_password_done.html",
+ tmplInitUser: "init_user.html",
+ tmplInitUserDone: "init_user_done.html",
+ tmplPasswordResetDone: "password_reset_done.html",
+ tmplChangePassword: "change_password.html",
+ tmplChangePasswordDone: "change_password_done.html",
+ tmplRegisterOption: "register_option.html",
+ tmplRegister: "register.html",
+ tmplLogoutDone: "logout_done.html",
+ tmplRegisterOrg: "register_org.html",
+ tmplChangeUsername: "change_username.html",
+ tmplChangeUsernameDone: "change_username_done.html",
+ tmplLinkUsersDone: "link_users_done.html",
+ tmplExternalNotFoundOption: "external_not_found_option.html",
+ tmplLoginSuccess: "login_success.html",
}
funcs := map[string]interface{}{
"resourceUrl": func(file string) string {
@@ -127,6 +130,12 @@ func CreateRenderer(pathPrefix string, staticDir http.FileSystem, staticStorage
"passwordLessVerificationUrl": func() string {
return path.Join(r.pathPrefix, EndpointPasswordlessLogin)
},
+ "passwordLessRegistrationUrl": func() string {
+ return path.Join(r.pathPrefix, EndpointPasswordlessRegistration)
+ },
+ "passwordlessPromptUrl": func() string {
+ return path.Join(r.pathPrefix, EndpointPasswordlessPrompt)
+ },
"passwordResetUrl": func(id string) string {
return path.Join(r.pathPrefix, fmt.Sprintf("%s?%s=%s", EndpointPasswordReset, queryAuthRequestID, id))
},
@@ -246,7 +255,9 @@ func (l *Login) chooseNextStep(w http.ResponseWriter, r *http.Request, authReq *
case *domain.PasswordStep:
l.renderPassword(w, r, authReq, nil)
case *domain.PasswordlessStep:
- l.renderPasswordlessVerification(w, r, authReq, nil)
+ l.renderPasswordlessVerification(w, r, authReq, step.PasswordSet, nil)
+ case *domain.PasswordlessRegistrationPromptStep:
+ l.renderPasswordlessPrompt(w, r, authReq, nil)
case *domain.MFAVerificationStep:
l.renderMFAVerify(w, r, authReq, step, err)
case *domain.RedirectToCallbackStep:
diff --git a/internal/ui/login/handler/router.go b/internal/ui/login/handler/router.go
index e5464cbb6b..5a501ad435 100644
--- a/internal/ui/login/handler/router.go
+++ b/internal/ui/login/handler/router.go
@@ -14,6 +14,8 @@ const (
EndpointExternalLogin = "/login/externalidp"
EndpointExternalLoginCallback = "/login/externalidp/callback"
EndpointPasswordlessLogin = "/login/passwordless"
+ EndpointPasswordlessRegistration = "/login/passwordless/init"
+ EndpointPasswordlessPrompt = "/login/passwordless/prompt"
EndpointLoginName = "/loginname"
EndpointUserSelection = "/userselection"
EndpointChangeUsername = "/username/change"
@@ -52,6 +54,9 @@ func CreateRouter(login *Login, staticDir http.FileSystem, interceptors ...mux.M
router.HandleFunc(EndpointExternalLogin, login.handleExternalLogin).Methods(http.MethodGet)
router.HandleFunc(EndpointExternalLoginCallback, login.handleExternalLoginCallback).Methods(http.MethodGet)
router.HandleFunc(EndpointPasswordlessLogin, login.handlePasswordlessVerification).Methods(http.MethodPost)
+ router.HandleFunc(EndpointPasswordlessRegistration, login.handlePasswordlessRegistration).Methods(http.MethodGet)
+ router.HandleFunc(EndpointPasswordlessRegistration, login.handlePasswordlessRegistrationCheck).Methods(http.MethodPost)
+ router.HandleFunc(EndpointPasswordlessPrompt, login.handlePasswordlessPrompt).Methods(http.MethodPost)
router.HandleFunc(EndpointLoginName, login.handleLoginName).Methods(http.MethodGet)
router.HandleFunc(EndpointLoginName, login.handleLoginNameCheck).Methods(http.MethodPost)
router.HandleFunc(EndpointUserSelection, login.handleSelectUser).Methods(http.MethodPost)
diff --git a/internal/ui/login/static/i18n/de.yaml b/internal/ui/login/static/i18n/de.yaml
index 285808051e..028e684c7e 100644
--- a/internal/ui/login/static/i18n/de.yaml
+++ b/internal/ui/login/static/i18n/de.yaml
@@ -134,6 +134,27 @@ Passwordless:
LoginWithPwButtonText: Mit Passwort anmelden
ValidateTokenButtonText: Token validieren
+PasswordlessPrompt:
+ Title: Passwortloser Login hinzufügen
+ Description: Möchtest du einen passwortlosen Login hinzufügen?
+ DescriptionInit: Du musst zuerst den Passwortlosen Login hinzufügen. Nutze dazu den Link, den du erhalten hast um dein Gerät zu registrieren.
+ PasswordlessButtonText: Werde Passwortlos
+ NextButtonText: weiter
+ SkipButtonText: überspringen
+
+PasswordlessRegistration:
+ Title: Passwortloser Login hinzufügen
+ Description: Füge dein Token hinzu, indem du einen Namen eingibst und den 'Token registrieren' Button drückst.
+ TokenNameLabel: Name des Tokens / Geräts
+ NotSupported: WebAuthN wird durch deinen Browser nicht unterstützt. Stelle sicher, dass du die aktuelle Version installiert hast oder nutze einen anderen (z.B. Chrome, Safari, Firefox)
+ RegisterTokenButtonText: Token registrieren
+ ErrorRetry: Versuche es erneut, erstelle eine neue Abfrage oder wähle eine andere Methode.
+
+PasswordlessRegistrationDone:
+ Title: Passwortloser Login erstellt
+ Description: Token für passwortlosen Login erfolgreich hinzugefügt.
+ NextButtonText: weiter
+
PasswordChange:
Title: Passwort ändern
Description: Ändere dein Password in dem du dein altes und dann dein neuen Passwort eingibst.
diff --git a/internal/ui/login/static/i18n/en.yaml b/internal/ui/login/static/i18n/en.yaml
index a3921a8e99..fc1f35b3b0 100644
--- a/internal/ui/login/static/i18n/en.yaml
+++ b/internal/ui/login/static/i18n/en.yaml
@@ -97,7 +97,7 @@ InitMFAOTP:
InitMFAU2F:
Title: Multifactor Setup U2F / WebAuthN
Description: Add your Token by providing a name and then clicking on the 'Register Token' button below.
- TokenNameLabel: Name of the tokens / machine
+ TokenNameLabel: Name of the token / machine
NotSupported: WebAuthN is not supported by your browser. Please ensure it is up to date or use a different one (e.g. Chrome, Safari, Firefox)
RegisterTokenButtonText: Register Token
ErrorRetry: Retry, create a new challenge or choose a different method.
@@ -134,6 +134,27 @@ Passwordless:
LoginWithPwButtonText: Login with password
ValidateTokenButtonText: Validate Token
+PasswordlessPrompt:
+ Title: Passwordless setup
+ Description: Would you like to setup passwordless login?
+ DescriptionInit: You need to set up passwordless login. Use the link you were given to register your device.
+ PasswordlessButtonText: Go passwordless
+ NextButtonText: next
+ SkipButtonText: skip
+
+PasswordlessRegistration:
+ Title: Passwordless setup
+ Description: Add your Token by providing a name and then clicking on the 'Register Token' button below.
+ TokenNameLabel: Name of the token / machine
+ NotSupported: WebAuthN is not supported by your browser. Please ensure it is up to date or use a different one (e.g. Chrome, Safari, Firefox)
+ RegisterTokenButtonText: Register Token
+ ErrorRetry: Retry, create a new challenge or choose a different method.
+
+PasswordlessRegistrationDone:
+ Title: Passwordless set up
+ Description: Token for passwordless successfully added.
+ NextButtonText: next
+
PasswordChange:
Title: Change Password
Description: Change your password. Enter your old and new password.
diff --git a/internal/ui/login/static/templates/passwordless_prompt.html b/internal/ui/login/static/templates/passwordless_prompt.html
new file mode 100644
index 0000000000..6763846b20
--- /dev/null
+++ b/internal/ui/login/static/templates/passwordless_prompt.html
@@ -0,0 +1,25 @@
+{{template "main-top" .}}
+
+
{{t "PasswordlessPrompt.DescriptionInit"}}
+{{t "PasswordlessRegistration.Description"}}
+{{t "PasswordlessRegistrationDone.Description"}}
+