From 00220e953255a5d960666eb4bb7f407bf94a947e Mon Sep 17 00:00:00 2001 From: Livio Amstutz Date: Mon, 2 Aug 2021 15:24:58 +0200 Subject: [PATCH] feat: passwordless registration (#2103) * begin pw less registration * create pwless one time codes * send pwless link * separate send and add passwordless link * separate send and add passwordless link events * custom message text for passwordless registration * begin custom login texts for passwordless * i18n * i18n message * i18n message * custom message text * custom login text * org design and texts * create link in human import process * fix import human tests * begin passwordless init required step * passwordless init * passwordless init * do not return link in mgmt api * prompt * passwordless init only (no additional prompt) * cleanup * cleanup * add passwordless prompt to custom login text * increase init code complexity * fix grpc * cleanup * fix and add some cases for nextStep tests * fix tests * Update internal/notification/static/i18n/en.yaml * Update internal/notification/static/i18n/de.yaml * Update proto/zitadel/management.proto * Update internal/ui/login/static/i18n/de.yaml * Update internal/ui/login/static/i18n/de.yaml * Update internal/ui/login/static/i18n/de.yaml Co-authored-by: Fabi <38692350+fgerschwiler@users.noreply.github.com> --- cmd/zitadel/main.go | 2 +- cmd/zitadel/system-defaults.yaml | 8 + docs/docs/apis/proto/admin.md | 119 ++++++++- docs/docs/apis/proto/auth.md | 68 ++++- docs/docs/apis/proto/management.md | 218 +++++++++++++++- .../repository/eventsourcing/handler/user.go | 5 +- internal/api/grpc/admin/custom_text.go | 34 +++ .../api/grpc/admin/custom_text_converter.go | 18 ++ internal/api/grpc/auth/passwordless.go | 26 ++ internal/api/grpc/auth/server.go | 17 +- internal/api/grpc/management/custom_text.go | 48 ++++ .../grpc/management/custom_text_converter.go | 17 ++ internal/api/grpc/management/user.go | 35 ++- .../api/grpc/management/user_converter.go | 16 +- internal/api/grpc/text/custom_text.go | 132 +++++++--- internal/auth/repository/auth_request.go | 4 + .../eventsourcing/eventstore/auth_request.go | 37 ++- .../eventstore/auth_request_test.go | 85 ++++-- .../eventsourcing/eventstore/org.go | 16 +- .../repository/eventsourcing/handler/user.go | 5 +- internal/auth/repository/org.go | 4 +- internal/command/command.go | 2 + internal/command/custom_login_text.go | 78 ++++++ internal/command/custom_login_text_model.go | 180 +++++++++++++ internal/command/user_converter.go | 10 + internal/command/user_human.go | 51 ++-- internal/command/user_human_test.go | 247 ++++++++++++++++-- internal/command/user_human_webauthn.go | 135 +++++++++- internal/command/user_human_webauthn_model.go | 106 ++++++++ .../config/systemdefaults/system_defaults.go | 10 +- internal/domain/custom_login_text.go | 108 +++++--- internal/domain/custom_message_text.go | 38 +-- internal/domain/human.go | 4 +- internal/domain/human_web_auth_n.go | 29 ++ internal/domain/next_step.go | 11 +- .../iam/repository/view/model/custom_text.go | 66 ++++- .../repository/eventsourcing/handler/user.go | 7 +- .../eventsourcing/handler/notification.go | 49 ++++ internal/notification/static/i18n/de.yaml | 19 +- internal/notification/static/i18n/en.yaml | 19 +- .../types/passwordless_registration_link.go | 37 +++ internal/repository/user/eventstore.go | 5 + .../repository/user/human_mfa_passwordless.go | 239 ++++++++++++++++- .../handler/passwordless_login_handler.go | 13 +- .../handler/passwordless_prompt_handler.go | 40 +++ .../passwordless_registration_handler.go | 132 ++++++++++ internal/ui/login/handler/renderer.go | 71 ++--- internal/ui/login/handler/router.go | 5 + internal/ui/login/static/i18n/de.yaml | 21 ++ internal/ui/login/static/i18n/en.yaml | 23 +- .../static/templates/passwordless_prompt.html | 25 ++ .../templates/passwordless_registration.html | 52 ++++ .../passwordless_registration_done.html | 27 ++ internal/user/model/user_view.go | 58 ++-- internal/user/repository/view/model/user.go | 118 +++++---- .../cockroach/V1.57__passwordless_init.sql | 12 + proto/zitadel/admin.proto | 80 +++++- proto/zitadel/auth.proto | 53 +++- proto/zitadel/management.proto | 145 +++++++++- proto/zitadel/text.proto | 27 ++ 60 files changed, 2916 insertions(+), 350 deletions(-) create mode 100644 internal/notification/types/passwordless_registration_link.go create mode 100644 internal/ui/login/handler/passwordless_prompt_handler.go create mode 100644 internal/ui/login/handler/passwordless_registration_handler.go create mode 100644 internal/ui/login/static/templates/passwordless_prompt.html create mode 100644 internal/ui/login/static/templates/passwordless_registration.html create mode 100644 internal/ui/login/static/templates/passwordless_registration_done.html create mode 100644 migrations/cockroach/V1.57__passwordless_init.sql 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.Title"}}

+ {{ template "user-profile" . }} + +

{{t "PasswordlessPrompt.DescriptionInit"}}

+
+ +
+ + {{ .CSRF }} + + + +
+ + + + +
+ +
+ +{{template "main-bottom" .}} \ No newline at end of file diff --git a/internal/ui/login/static/templates/passwordless_registration.html b/internal/ui/login/static/templates/passwordless_registration.html new file mode 100644 index 0000000000..1d0320846f --- /dev/null +++ b/internal/ui/login/static/templates/passwordless_registration.html @@ -0,0 +1,52 @@ +{{template "main-top" .}} + +
+

{{t "PasswordlessRegistration.Title"}}

+ + {{ template "user-profile" . }} + +

{{t "PasswordlessRegistration.Description"}}

+
+ +
+ + {{ .CSRF }} + + + + + + + + + +
+ + {{if not .Disabled}} +
+ + +
+ {{end}} + +
+ + {{ template "error-message" .}} + +
+ + {{if not .Disabled}} + + {{end}} +
+
+ + + + + +{{template "main-bottom" .}} + \ No newline at end of file diff --git a/internal/ui/login/static/templates/passwordless_registration_done.html b/internal/ui/login/static/templates/passwordless_registration_done.html new file mode 100644 index 0000000000..7c6591af92 --- /dev/null +++ b/internal/ui/login/static/templates/passwordless_registration_done.html @@ -0,0 +1,27 @@ +{{template "main-top" .}} + +
+

{{t "PasswordlessRegistrationDone.Title"}}

+ + {{ template "user-profile" . }} + +

{{t "PasswordlessRegistrationDone.Description"}}

+
+ +
+ + {{ .CSRF }} + + + +
+ + {{t "PasswordlessRegistrationDone.CancelButtonText"}} + + + +
+
+ + +{{template "main-bottom" .}} \ No newline at end of file diff --git a/internal/user/model/user_view.go b/internal/user/model/user_view.go index 83a132a341..cba130064d 100644 --- a/internal/user/model/user_view.go +++ b/internal/user/model/user_view.go @@ -32,34 +32,36 @@ type UserView struct { } type HumanView struct { - PasswordSet bool - PasswordChangeRequired bool - UsernameChangeRequired bool - PasswordChanged time.Time - FirstName string - LastName string - NickName string - DisplayName string - AvatarKey string - AvatarURL string - PreSignedAvatar *url.URL - PreferredLanguage string - Gender Gender - Email string - IsEmailVerified bool - Phone string - IsPhoneVerified bool - Country string - Locality string - PostalCode string - Region string - StreetAddress string - OTPState MFAState - U2FTokens []*WebAuthNView - PasswordlessTokens []*WebAuthNView - MFAMaxSetUp req_model.MFALevel - MFAInitSkipped time.Time - InitRequired bool + PasswordSet bool + PasswordInitRequired bool + PasswordChangeRequired bool + UsernameChangeRequired bool + PasswordChanged time.Time + FirstName string + LastName string + NickName string + DisplayName string + AvatarKey string + AvatarURL string + PreSignedAvatar *url.URL + PreferredLanguage string + Gender Gender + Email string + IsEmailVerified bool + Phone string + IsPhoneVerified bool + Country string + Locality string + PostalCode string + Region string + StreetAddress string + OTPState MFAState + U2FTokens []*WebAuthNView + PasswordlessTokens []*WebAuthNView + MFAMaxSetUp req_model.MFALevel + MFAInitSkipped time.Time + InitRequired bool + PasswordlessInitRequired bool } type WebAuthNView struct { diff --git a/internal/user/repository/view/model/user.go b/internal/user/repository/view/model/user.go index 2d796bfa83..7277d2d73c 100644 --- a/internal/user/repository/view/model/user.go +++ b/internal/user/repository/view/model/user.go @@ -14,6 +14,7 @@ import ( "github.com/caos/zitadel/internal/eventstore/v1/models" iam_model "github.com/caos/zitadel/internal/iam/model" org_model "github.com/caos/zitadel/internal/org/model" + user_repo "github.com/caos/zitadel/internal/repository/user" "github.com/caos/zitadel/internal/user/model" es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model" ) @@ -69,33 +70,34 @@ const ( ) type HumanView struct { - FirstName string `json:"firstName" gorm:"column:first_name"` - LastName string `json:"lastName" gorm:"column:last_name"` - NickName string `json:"nickName" gorm:"column:nick_name"` - DisplayName string `json:"displayName" gorm:"column:display_name"` - PreferredLanguage string `json:"preferredLanguage" gorm:"column:preferred_language"` - Gender int32 `json:"gender" gorm:"column:gender"` - AvatarKey string `json:"storeKey" gorm:"column:avatar_key"` - Email string `json:"email" gorm:"column:email"` - IsEmailVerified bool `json:"-" gorm:"column:is_email_verified"` - Phone string `json:"phone" gorm:"column:phone"` - IsPhoneVerified bool `json:"-" gorm:"column:is_phone_verified"` - Country string `json:"country" gorm:"column:country"` - Locality string `json:"locality" gorm:"column:locality"` - PostalCode string `json:"postalCode" gorm:"column:postal_code"` - Region string `json:"region" gorm:"column:region"` - StreetAddress string `json:"streetAddress" gorm:"column:street_address"` - OTPState int32 `json:"-" gorm:"column:otp_state"` - U2FTokens WebAuthNTokens `json:"-" gorm:"column:u2f_tokens"` - MFAMaxSetUp int32 `json:"-" gorm:"column:mfa_max_set_up"` - MFAInitSkipped time.Time `json:"-" gorm:"column:mfa_init_skipped"` - InitRequired bool `json:"-" gorm:"column:init_required"` - - PasswordSet bool `json:"-" gorm:"column:password_set"` - PasswordChangeRequired bool `json:"-" gorm:"column:password_change_required"` - UsernameChangeRequired bool `json:"-" gorm:"column:username_change_required"` - PasswordChanged time.Time `json:"-" gorm:"column:password_change"` - PasswordlessTokens WebAuthNTokens `json:"-" gorm:"column:passwordless_tokens"` + FirstName string `json:"firstName" gorm:"column:first_name"` + LastName string `json:"lastName" gorm:"column:last_name"` + NickName string `json:"nickName" gorm:"column:nick_name"` + DisplayName string `json:"displayName" gorm:"column:display_name"` + PreferredLanguage string `json:"preferredLanguage" gorm:"column:preferred_language"` + Gender int32 `json:"gender" gorm:"column:gender"` + AvatarKey string `json:"storeKey" gorm:"column:avatar_key"` + Email string `json:"email" gorm:"column:email"` + IsEmailVerified bool `json:"-" gorm:"column:is_email_verified"` + Phone string `json:"phone" gorm:"column:phone"` + IsPhoneVerified bool `json:"-" gorm:"column:is_phone_verified"` + Country string `json:"country" gorm:"column:country"` + Locality string `json:"locality" gorm:"column:locality"` + PostalCode string `json:"postalCode" gorm:"column:postal_code"` + Region string `json:"region" gorm:"column:region"` + StreetAddress string `json:"streetAddress" gorm:"column:street_address"` + OTPState int32 `json:"-" gorm:"column:otp_state"` + U2FTokens WebAuthNTokens `json:"-" gorm:"column:u2f_tokens"` + MFAMaxSetUp int32 `json:"-" gorm:"column:mfa_max_set_up"` + MFAInitSkipped time.Time `json:"-" gorm:"column:mfa_init_skipped"` + InitRequired bool `json:"-" gorm:"column:init_required"` + PasswordlessInitRequired bool `json:"-" gorm:"column:passwordless_init_required"` + PasswordInitRequired bool `json:"-" gorm:"column:password_init_required"` + PasswordSet bool `json:"-" gorm:"column:password_set"` + PasswordChangeRequired bool `json:"-" gorm:"column:password_change_required"` + UsernameChangeRequired bool `json:"-" gorm:"column:username_change_required"` + PasswordChanged time.Time `json:"-" gorm:"column:password_change"` + PasswordlessTokens WebAuthNTokens `json:"-" gorm:"column:passwordless_tokens"` } type WebAuthNTokens []*WebAuthNView @@ -151,32 +153,34 @@ func UserToModel(user *UserView, prefixAvatarURL string) *model.UserView { } if !user.HumanView.IsZero() { userView.HumanView = &model.HumanView{ - PasswordSet: user.PasswordSet, - PasswordChangeRequired: user.PasswordChangeRequired, - PasswordChanged: user.PasswordChanged, - PasswordlessTokens: WebauthnTokensToModel(user.PasswordlessTokens), - U2FTokens: WebauthnTokensToModel(user.U2FTokens), - FirstName: user.FirstName, - LastName: user.LastName, - NickName: user.NickName, - DisplayName: user.DisplayName, - AvatarKey: user.AvatarKey, - AvatarURL: domain.AvatarURL(prefixAvatarURL, user.ResourceOwner, user.AvatarKey), - PreferredLanguage: user.PreferredLanguage, - Gender: model.Gender(user.Gender), - Email: user.Email, - IsEmailVerified: user.IsEmailVerified, - Phone: user.Phone, - IsPhoneVerified: user.IsPhoneVerified, - Country: user.Country, - Locality: user.Locality, - PostalCode: user.PostalCode, - Region: user.Region, - StreetAddress: user.StreetAddress, - OTPState: model.MFAState(user.OTPState), - MFAMaxSetUp: req_model.MFALevel(user.MFAMaxSetUp), - MFAInitSkipped: user.MFAInitSkipped, - InitRequired: user.InitRequired, + PasswordSet: user.PasswordSet, + PasswordInitRequired: user.PasswordInitRequired, + PasswordChangeRequired: user.PasswordChangeRequired, + PasswordChanged: user.PasswordChanged, + PasswordlessTokens: WebauthnTokensToModel(user.PasswordlessTokens), + U2FTokens: WebauthnTokensToModel(user.U2FTokens), + FirstName: user.FirstName, + LastName: user.LastName, + NickName: user.NickName, + DisplayName: user.DisplayName, + AvatarKey: user.AvatarKey, + AvatarURL: domain.AvatarURL(prefixAvatarURL, user.ResourceOwner, user.AvatarKey), + PreferredLanguage: user.PreferredLanguage, + Gender: model.Gender(user.Gender), + Email: user.Email, + IsEmailVerified: user.IsEmailVerified, + Phone: user.Phone, + IsPhoneVerified: user.IsPhoneVerified, + Country: user.Country, + Locality: user.Locality, + PostalCode: user.PostalCode, + Region: user.Region, + StreetAddress: user.StreetAddress, + OTPState: model.MFAState(user.OTPState), + MFAMaxSetUp: req_model.MFALevel(user.MFAMaxSetUp), + MFAInitSkipped: user.MFAInitSkipped, + InitRequired: user.InitRequired, + PasswordlessInitRequired: user.PasswordlessInitRequired, } } @@ -345,6 +349,12 @@ func (u *UserView) AppendEvent(event *models.Event) (err error) { err = u.setData(event) case es_model.HumanAvatarRemoved: u.AvatarKey = "" + case models.EventType(user_repo.HumanPasswordlessInitCodeAddedType), + models.EventType(user_repo.HumanPasswordlessInitCodeRequestedType): + if !u.PasswordSet { + u.PasswordlessInitRequired = true + u.PasswordInitRequired = false + } } u.ComputeObject() return err @@ -370,6 +380,7 @@ func (u *UserView) setPasswordData(event *models.Event) error { return caos_errs.ThrowInternal(nil, "MODEL-6jhsw", "could not unmarshal data") } u.PasswordSet = password.Secret != nil + u.PasswordInitRequired = !u.PasswordSet u.PasswordChangeRequired = password.ChangeRequired u.PasswordChanged = event.CreationDate return nil @@ -498,6 +509,7 @@ func (u *UserView) ComputeMFAMaxSetUp() { for _, token := range u.PasswordlessTokens { if token.State == int32(model.MFAStateReady) { u.MFAMaxSetUp = int32(req_model.MFALevelMultiFactor) + u.PasswordlessInitRequired = false return } } diff --git a/migrations/cockroach/V1.57__passwordless_init.sql b/migrations/cockroach/V1.57__passwordless_init.sql new file mode 100644 index 0000000000..7801e483db --- /dev/null +++ b/migrations/cockroach/V1.57__passwordless_init.sql @@ -0,0 +1,12 @@ +ALTER TABLE adminapi.users ADD COLUMN passwordless_init_required boolean; +ALTER TABLE auth.users ADD COLUMN passwordless_init_required boolean; +ALTER TABLE management.users ADD COLUMN passwordless_init_required boolean; + +ALTER TABLE adminapi.users ADD COLUMN password_init_required BOOLEAN; +UPDATE adminapi.users set password_init_required = NOT password_set; + +ALTER TABLE auth.users ADD COLUMN password_init_required BOOLEAN; +UPDATE auth.users set password_init_required = NOT password_set; + +ALTER TABLE management.users ADD COLUMN password_init_required BOOLEAN; +UPDATE management.users set password_init_required = NOT password_set; diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index 0d0b75adf4..c7462e7b2b 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -1692,12 +1692,49 @@ service AdminService { } //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}} rpc SetDefaultDomainClaimedMessageText(SetDefaultDomainClaimedMessageTextRequest) returns (SetDefaultDomainClaimedMessageTextResponse) { option (google.api.http) = { - put: "/text/message/verifyphone/{language}"; + put: "/text/message/domainclaimed/{language}"; + body: "*"; + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.policy.write"; + }; + } + + //Returns the default text for passwordless registration message (translation file) + rpc GetDefaultPasswordlessRegistrationMessageText(GetDefaultPasswordlessRegistrationMessageTextRequest) returns (GetDefaultPasswordlessRegistrationMessageTextResponse) { + option (google.api.http) = { + get: "/text/default/message/passwordless_registration/{language}"; + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.policy.read"; + }; + } + + //Returns the custom text for passwordless registration message (overwritten in eventstore) + rpc GetCustomPasswordlessRegistrationMessageText(GetCustomPasswordlessRegistrationMessageTextRequest) returns (GetCustomPasswordlessRegistrationMessageTextResponse) { + option (google.api.http) = { + get: "/text/message/passwordless_registration/{language}"; + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.policy.read"; + }; + } + + //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}} + rpc SetDefaultPasswordlessRegistrationMessageText(SetDefaultPasswordlessRegistrationMessageTextRequest) returns (SetDefaultPasswordlessRegistrationMessageTextResponse) { + option (google.api.http) = { + put: "/text/message/passwordless_registration/{language}"; body: "*"; }; @@ -3267,6 +3304,42 @@ message SetDefaultDomainClaimedMessageTextResponse { zitadel.v1.ObjectDetails details = 1; } +message GetDefaultPasswordlessRegistrationMessageTextRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message GetDefaultPasswordlessRegistrationMessageTextResponse { + zitadel.text.v1.MessageCustomText custom_text = 1; +} + +message GetCustomPasswordlessRegistrationMessageTextRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message GetCustomPasswordlessRegistrationMessageTextResponse { + zitadel.text.v1.MessageCustomText custom_text = 1; +} + +message SetDefaultPasswordlessRegistrationMessageTextRequest { + string language = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"de\"" + } + ]; + string title = 2 [(validate.rules).string = {max_len: 200}]; + string pre_header = 3 [(validate.rules).string = {max_len: 200}]; + string subject = 4 [(validate.rules).string = {max_len: 200}]; + string greeting = 5 [(validate.rules).string = {max_len: 200}]; + string text = 6 [(validate.rules).string = {max_len: 800}]; + string button_text = 7 [(validate.rules).string = {max_len: 200}]; + string footer_text = 8 [(validate.rules).string = {max_len: 200}]; +} + +message SetDefaultPasswordlessRegistrationMessageTextResponse { + zitadel.v1.ObjectDetails details = 1; +} + message GetDefaultLoginTextsRequest { string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; } @@ -3320,6 +3393,9 @@ message SetCustomLoginTextsRequest { zitadel.text.v1.SuccessLoginScreenText success_login_text = 29; zitadel.text.v1.LogoutDoneScreenText logout_text = 30; zitadel.text.v1.FooterText footer_text = 31; + zitadel.text.v1.PasswordlessPromptScreenText passwordless_prompt_text = 32; + zitadel.text.v1.PasswordlessRegistrationScreenText passwordless_registration_text = 33; + zitadel.text.v1.PasswordlessRegistrationDoneScreenText passwordless_registration_done_text = 34; } message SetCustomLoginTextsResponse { diff --git a/proto/zitadel/auth.proto b/proto/zitadel/auth.proto index b1d50daa2a..2a2c08a207 100644 --- a/proto/zitadel/auth.proto +++ b/proto/zitadel/auth.proto @@ -9,6 +9,7 @@ import "zitadel/policy.proto"; import "zitadel/idp.proto"; import "validate/validate.proto"; import "google/api/annotations.proto"; +import "google/protobuf/duration.proto"; import "google/protobuf/timestamp.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; @@ -76,7 +77,7 @@ service AuthService { option (google.api.http) = { post: "/users/me/changes/_search" }; - + option (zitadel.v1.auth_option) = { permission: "authenticated" }; @@ -132,7 +133,7 @@ service AuthService { put: "/users/me/username" body: "*" }; - + option (zitadel.v1.auth_option) = { permission: "authenticated" }; @@ -407,7 +408,7 @@ service AuthService { }; } - // Returns all configured passwordless authentications of the authorized user + // Returns all configured passwordless authenticators of the authorized user rpc ListMyPasswordless(ListMyPasswordlessRequest) returns (ListMyPasswordlessResponse) { option (google.api.http) = { post: "/users/me/passwordless/_search" @@ -417,7 +418,7 @@ service AuthService { }; } - // Adds a new passwordless authentications to the authorized user + // Adds a new passwordless authenticator to the authorized user // Multiple passwordless authentications can be configured rpc AddMyPasswordless(AddMyPasswordlessRequest) returns (AddMyPasswordlessResponse) { option (google.api.http) = { @@ -429,6 +430,32 @@ service AuthService { }; } + // 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 + rpc AddMyPasswordlessLink(AddMyPasswordlessLinkRequest) returns (AddMyPasswordlessLinkResponse) { + option (google.api.http) = { + post: "/users/me/passwordless/_link" + body: "*" + }; + option (zitadel.v1.auth_option) = { + permission: "authenticated" + }; + } + + // 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 + rpc SendMyPasswordlessLink(SendMyPasswordlessLinkRequest) returns (SendMyPasswordlessLinkResponse) { + option (google.api.http) = { + post: "/users/me/passwordless/_send_link" + body: "*" + }; + option (zitadel.v1.auth_option) = { + permission: "authenticated" + }; + } + // Verifies the last added passwordless configuration rpc VerifyMyPasswordless(VerifyMyPasswordlessRequest) returns (VerifyMyPasswordlessResponse) { option (google.api.http) = { @@ -775,7 +802,7 @@ message RemoveMyAuthFactorU2FResponse { message ListMyPasswordlessRequest {} message ListMyPasswordlessResponse { - repeated zitadel.user.v1.WebAuthNToken result = 1; + repeated zitadel.user.v1.WebAuthNToken result = 1; } //This is an empty request @@ -786,6 +813,22 @@ message AddMyPasswordlessResponse { zitadel.v1.ObjectDetails details = 2; } +//This is an empty request +message AddMyPasswordlessLinkRequest {} + +message AddMyPasswordlessLinkResponse { + zitadel.v1.ObjectDetails details = 1; + string link = 2; + google.protobuf.Duration expiration = 3; +} + +//This is an empty request +message SendMyPasswordlessLinkRequest {} + +message SendMyPasswordlessLinkResponse { + zitadel.v1.ObjectDetails details = 1; +} + message VerifyMyPasswordlessRequest { zitadel.user.v1.WebAuthNVerification verification = 1 [(validate.rules).message.required = true]; } diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 64ad710d4d..b57275fa99 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -485,7 +485,7 @@ service ManagementService { }; } - // Returns all configured passwordless authentications + // Returns all configured passwordless authenticators rpc ListHumanPasswordless(ListHumanPasswordlessRequest) returns (ListHumanPasswordlessResponse) { option (google.api.http) = { post: "/users/{user_id}/passwordless/_search" @@ -496,7 +496,20 @@ service ManagementService { }; } - // Removed a configured passwordless authentication + // 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 + rpc SendPasswordlessRegistration(SendPasswordlessRegistrationRequest) returns (SendPasswordlessRegistrationResponse) { + option (google.api.http) = { + post: "/users/{user_id}/passwordless/_send_link" + body: "*" + }; + option (zitadel.v1.auth_option) = { + permission: "authenticated" + }; + } + + // Removed a configured passwordless authenticator rpc RemoveHumanPasswordless(RemoveHumanPasswordlessRequest) returns (RemoveHumanPasswordlessResponse) { option (google.api.http) = { delete: "/users/{user_id}/passwordless/{token_id}" @@ -2175,8 +2188,7 @@ service ManagementService { }; } - //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}} rpc SetCustomInitMessageText(SetCustomInitMessageTextRequest) returns (SetCustomInitMessageTextResponse) { @@ -2224,8 +2236,7 @@ service ManagementService { }; } - //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}} rpc SetCustomPasswordResetMessageText(SetCustomPasswordResetMessageTextRequest) returns (SetCustomPasswordResetMessageTextResponse) { @@ -2274,8 +2285,7 @@ service ManagementService { }; } - //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}} rpc SetCustomVerifyEmailMessageText(SetCustomVerifyEmailMessageTextRequest) returns (SetCustomVerifyEmailMessageTextResponse) { @@ -2324,8 +2334,7 @@ service ManagementService { }; } - //Sets the default custom text for verify email message - // it impacts all organisations without customized verify email message text + // Sets the default 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}} rpc SetCustomVerifyPhoneMessageText(SetCustomVerifyPhoneMessageTextRequest) returns (SetCustomVerifyPhoneMessageTextResponse) { @@ -2374,8 +2383,7 @@ service ManagementService { }; } - // 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}} rpc SetCustomDomainClaimedMessageCustomText(SetCustomDomainClaimedMessageTextRequest) returns (SetCustomDomainClaimedMessageTextResponse) { @@ -2390,7 +2398,7 @@ service ManagementService { }; } - // 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 rpc ResetCustomDomainClaimedMessageTextToDefault(ResetCustomDomainClaimedMessageTextToDefaultRequest) returns (ResetCustomDomainClaimedMessageTextToDefaultResponse) { option (google.api.http) = { @@ -2402,6 +2410,55 @@ service ManagementService { }; } + //Returns the custom text for passwordless link message + rpc GetCustomPasswordlessRegistrationMessageText(GetCustomPasswordlessRegistrationMessageTextRequest) returns (GetCustomPasswordlessRegistrationMessageTextResponse) { + option (google.api.http) = { + get: "/text/message/passwordless_registration/{language}"; + }; + + option (zitadel.v1.auth_option) = { + permission: "policy.read"; + }; + } + + //Returns the custom text for passwordless link message + rpc GetDefaultPasswordlessRegistrationMessageText(GetDefaultPasswordlessRegistrationMessageTextRequest) returns (GetDefaultPasswordlessRegistrationMessageTextResponse) { + option (google.api.http) = { + get: "/text/default/message/passwordless_registration/{language}"; + }; + + option (zitadel.v1.auth_option) = { + permission: "policy.read"; + }; + } + + // 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}} + rpc SetCustomPasswordlessRegistrationMessageCustomText(SetCustomPasswordlessRegistrationMessageTextRequest) returns (SetCustomPasswordlessRegistrationMessageTextResponse) { + option (google.api.http) = { + put: "/text/message/passwordless_registration/{language}"; + body: "*"; + }; + + option (zitadel.v1.auth_option) = { + permission: "policy.write"; + feature: "custom_text" + }; + } + + // Removes the custom passwordless link message text of the organisation + // The default text of the IAM will trigger after + rpc ResetCustomPasswordlessRegistrationMessageTextToDefault(ResetCustomPasswordlessRegistrationMessageTextToDefaultRequest) returns (ResetCustomPasswordlessRegistrationMessageTextToDefaultResponse) { + option (google.api.http) = { + delete: "/text/message/passwordless_registration/{language}" + }; + + option (zitadel.v1.auth_option) = { + permission: "policy.delete" + }; + } + //Returns the custom texts for login ui rpc GetCustomLoginTexts(GetCustomLoginTextsRequest) returns (GetCustomLoginTextsResponse) { option (google.api.http) = { @@ -2695,11 +2752,18 @@ message ImportHumanUserRequest { Phone phone = 4; string password = 5; bool password_change_required = 6; + bool request_passwordless_registration = 7; } message ImportHumanUserResponse { + message PasswordlessRegistration { + string link = 1; + google.protobuf.Duration lifetime = 2; + } + string user_id = 1; zitadel.v1.ObjectDetails details = 2; + PasswordlessRegistration passwordless_registration = 3; } message AddMachineUserRequest { @@ -2934,6 +2998,14 @@ message ListHumanPasswordlessResponse { repeated zitadel.user.v1.WebAuthNToken result = 1; } +message SendPasswordlessRegistrationRequest { + string user_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message SendPasswordlessRegistrationResponse { + zitadel.v1.ObjectDetails details = 1; +} + message RemoveHumanPasswordlessRequest { string user_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; string token_id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; @@ -4354,6 +4426,9 @@ message SetCustomLoginTextsRequest { zitadel.text.v1.SuccessLoginScreenText success_login_text = 29; zitadel.text.v1.LogoutDoneScreenText logout_text = 30; zitadel.text.v1.FooterText footer_text = 31; + zitadel.text.v1.PasswordlessPromptScreenText passwordless_prompt_text = 32; + zitadel.text.v1.PasswordlessRegistrationScreenText passwordless_registration_text = 33; + zitadel.text.v1.PasswordlessRegistrationDoneScreenText passwordless_registration_done_text = 34; } message SetCustomLoginTextsResponse { @@ -4544,6 +4619,50 @@ message ResetCustomDomainClaimedMessageTextToDefaultResponse { zitadel.v1.ObjectDetails details = 1; } +message GetCustomPasswordlessRegistrationMessageTextRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message GetCustomPasswordlessRegistrationMessageTextResponse { + zitadel.text.v1.MessageCustomText custom_text = 1; +} + +message GetDefaultPasswordlessRegistrationMessageTextRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message GetDefaultPasswordlessRegistrationMessageTextResponse { + zitadel.text.v1.MessageCustomText custom_text = 1; +} + +message SetCustomPasswordlessRegistrationMessageTextRequest { + string language = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"de\"" + } + ]; + string title = 2 [(validate.rules).string = {max_len: 200}]; + string pre_header = 3 [(validate.rules).string = {max_len: 200}]; + string subject = 4 [(validate.rules).string = {max_len: 200}]; + string greeting = 5 [(validate.rules).string = {max_len: 200}]; + string text = 6 [(validate.rules).string = {max_len: 800}]; + string button_text = 7 [(validate.rules).string = {max_len: 200}]; + string footer_text = 8 [(validate.rules).string = {max_len: 200}]; +} + +message SetCustomPasswordlessRegistrationMessageTextResponse { + zitadel.v1.ObjectDetails details = 1; +} + +message ResetCustomPasswordlessRegistrationMessageTextToDefaultRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message ResetCustomPasswordlessRegistrationMessageTextToDefaultResponse { + zitadel.v1.ObjectDetails details = 1; +} + message GetOrgIDPByIDRequest { string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; } diff --git a/proto/zitadel/text.proto b/proto/zitadel/text.proto index efa154f3af..f038fd0d93 100644 --- a/proto/zitadel/text.proto +++ b/proto/zitadel/text.proto @@ -79,6 +79,9 @@ message LoginCustomText { SuccessLoginScreenText success_login_text = 29; LogoutDoneScreenText logout_text = 30; FooterText footer_text = 31; + PasswordlessPromptScreenText passwordless_prompt_text = 32; + PasswordlessRegistrationScreenText passwordless_registration_text = 33; + PasswordlessRegistrationDoneScreenText passwordless_registration_done_text = 34; } message SelectAccountScreenText { @@ -358,4 +361,28 @@ message FooterText { string privacy_policy = 3 [(validate.rules).string = {max_len: 200}]; string help = 5 [(validate.rules).string = {max_len: 200}]; string help_link = 6 [(validate.rules).string = {max_len: 500}]; +} + +message PasswordlessPromptScreenText { + string title = 1 [(validate.rules).string = {max_len: 200}]; + string description = 2 [(validate.rules).string = {max_len: 500}]; + string description_init = 3 [(validate.rules).string = {max_len: 500}]; + string passwordless_button_text = 4 [(validate.rules).string = {max_len: 100}]; + string next_button_text = 5 [(validate.rules).string = {max_len: 100}]; + string skip_button_text = 6 [(validate.rules).string = {max_len: 100}]; +} + +message PasswordlessRegistrationScreenText { + string title = 1 [(validate.rules).string = {max_len: 200}]; + string description = 2 [(validate.rules).string = {max_len: 500}]; + string token_name_label = 3 [(validate.rules).string = {max_len: 200}]; + string not_supported = 4 [(validate.rules).string = {max_len: 500}]; + string register_token_button_text = 5 [(validate.rules).string = {max_len: 100}]; + string error_retry = 6 [(validate.rules).string = {max_len: 500}]; +} + +message PasswordlessRegistrationDoneScreenText { + string title = 1 [(validate.rules).string = {max_len: 200}]; + string description = 2 [(validate.rules).string = {max_len: 500}]; + string next_button_text = 3 [(validate.rules).string = {max_len: 100}]; } \ No newline at end of file