feat: invite user link (#8578)

# Which Problems Are Solved

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

# How the Problems Are Solved

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

# Additional Changes

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

# Additional Context

- closes #8310
- TODO: login V2 will have to implement invite flow:
https://github.com/zitadel/typescript/issues/166
This commit is contained in:
Livio Spring
2024-09-11 12:53:55 +02:00
committed by GitHub
parent 02c78a19c6
commit a07b2f4677
114 changed files with 3898 additions and 293 deletions

View File

@@ -0,0 +1,154 @@
package login
import (
"net/http"
"net/url"
http_mw "github.com/zitadel/zitadel/internal/api/http/middleware"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/zerrors"
)
const (
queryInviteUserCode = "code"
queryInviteUserUserID = "userID"
queryInviteUserLoginName = "loginname"
tmplInviteUser = "inviteuser"
)
type inviteUserFormData struct {
Code string `schema:"code"`
LoginName string `schema:"loginname"`
Password string `schema:"password"`
PasswordConfirm string `schema:"passwordconfirm"`
UserID string `schema:"userID"`
OrgID string `schema:"orgID"`
Resend bool `schema:"resend"`
}
type inviteUserData struct {
baseData
profileData
Code string
LoginName string
UserID string
MinLength uint64
HasUppercase string
HasLowercase string
HasNumber string
HasSymbol string
}
func InviteUserLink(origin, userID, loginName, code, orgID string, authRequestID string) string {
v := url.Values{}
v.Set(queryInviteUserUserID, userID)
v.Set(queryInviteUserLoginName, loginName)
v.Set(queryInviteUserCode, code)
v.Set(queryOrgID, orgID)
v.Set(QueryAuthRequestID, authRequestID)
return externalLink(origin) + EndpointInviteUser + "?" + v.Encode()
}
func (l *Login) handleInviteUser(w http.ResponseWriter, r *http.Request) {
authReq := l.checkOptionalAuthRequestOfEmailLinks(r)
userID := r.FormValue(queryInviteUserUserID)
orgID := r.FormValue(queryOrgID)
code := r.FormValue(queryInviteUserCode)
loginName := r.FormValue(queryInviteUserLoginName)
l.renderInviteUser(w, r, authReq, userID, orgID, loginName, code, nil)
}
func (l *Login) handleInviteUserCheck(w http.ResponseWriter, r *http.Request) {
data := new(inviteUserFormData)
authReq, err := l.getAuthRequestAndParseData(r, data)
if err != nil {
l.renderError(w, r, nil, err)
return
}
if data.Resend {
l.resendUserInvite(w, r, authReq, data.UserID, data.OrgID, data.LoginName)
return
}
l.checkUserInviteCode(w, r, authReq, data)
}
func (l *Login) checkUserInviteCode(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *inviteUserFormData) {
if data.Password != data.PasswordConfirm {
err := zerrors.ThrowInvalidArgument(nil, "VIEW-KJS3h", "Errors.User.Password.ConfirmationWrong")
l.renderInviteUser(w, r, authReq, data.UserID, data.OrgID, data.LoginName, data.Code, err)
return
}
userOrgID := ""
if authReq != nil {
userOrgID = authReq.UserOrgID
}
userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context())
_, err := l.command.VerifyInviteCodeSetPassword(setUserContext(r.Context(), data.UserID, userOrgID), data.UserID, data.Code, data.Password, userAgentID)
if err != nil {
l.renderInviteUser(w, r, authReq, data.UserID, data.OrgID, data.LoginName, "", err)
return
}
if authReq == nil {
l.defaultRedirect(w, r)
return
}
l.renderNextStep(w, r, authReq)
}
func (l *Login) resendUserInvite(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, userID, orgID, loginName string) {
var userOrgID, authRequestID string
if authReq != nil {
userOrgID = authReq.UserOrgID
authRequestID = authReq.ID
}
_, err := l.command.ResendInviteCode(setUserContext(r.Context(), userID, userOrgID), userID, userOrgID, authRequestID)
l.renderInviteUser(w, r, authReq, userID, orgID, loginName, "", err)
}
func (l *Login) renderInviteUser(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, userID, orgID, loginName string, code string, err error) {
var errID, errMessage string
if err != nil {
errID, errMessage = l.getErrorMessage(r, err)
}
if authReq != nil {
userID = authReq.UserID
orgID = authReq.UserOrgID
}
translator := l.getTranslator(r.Context(), authReq)
data := inviteUserData{
baseData: l.getBaseData(r, authReq, translator, "InviteUser.Title", "InviteUser.Description", errID, errMessage),
profileData: l.getProfileData(authReq),
UserID: userID,
Code: code,
}
// if the user clicked on the link in the mail, we need to make sure the loginName is rendered
if authReq == nil {
data.LoginName = loginName
data.UserName = loginName
}
policy := l.getPasswordComplexityPolicyByUserID(r, userID)
if policy != nil {
data.MinLength = policy.MinLength
if policy.HasUppercase {
data.HasUppercase = UpperCaseRegex
}
if policy.HasLowercase {
data.HasLowercase = LowerCaseRegex
}
if policy.HasSymbol {
data.HasSymbol = SymbolRegex
}
if policy.HasNumber {
data.HasNumber = NumberRegex
}
}
if authReq == nil {
if err == nil {
l.customTexts(r.Context(), translator, orgID)
}
}
l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplInviteUser], data, nil)
}

View File

@@ -68,6 +68,7 @@ func CreateRenderer(pathPrefix string, staticStorage static.Storage, cookieName
tmplInitPasswordDone: "init_password_done.html",
tmplInitUser: "init_user.html",
tmplInitUserDone: "init_user_done.html",
tmplInviteUser: "invite_user.html",
tmplPasswordResetDone: "password_reset_done.html",
tmplChangePassword: "change_password.html",
tmplChangePasswordDone: "change_password_done.html",
@@ -193,6 +194,9 @@ func CreateRenderer(pathPrefix string, staticStorage static.Storage, cookieName
"initUserUrl": func() string {
return path.Join(r.pathPrefix, EndpointInitUser)
},
"inviteUserUrl": func() string {
return path.Join(r.pathPrefix, EndpointInviteUser)
},
"changePasswordUrl": func() string {
return path.Join(r.pathPrefix, EndpointChangePassword)
},
@@ -329,6 +333,8 @@ func (l *Login) chooseNextStep(w http.ResponseWriter, r *http.Request, authReq *
l.renderInternalError(w, r, authReq, zerrors.ThrowPreconditionFailed(nil, "APP-asb43", "Errors.User.GrantRequired"))
case *domain.ProjectRequiredStep:
l.renderInternalError(w, r, authReq, zerrors.ThrowPreconditionFailed(nil, "APP-m92d", "Errors.User.ProjectRequired"))
case *domain.VerifyInviteStep:
l.renderInviteUser(w, r, authReq, "", "", "", "", nil)
default:
l.renderInternalError(w, r, authReq, zerrors.ThrowInternal(nil, "APP-ds3QF", "step no possible"))
}

View File

@@ -30,6 +30,7 @@ const (
EndpointChangePassword = "/password/change"
EndpointPasswordReset = "/password/reset"
EndpointInitUser = "/user/init"
EndpointInviteUser = "/user/invite"
EndpointMFAVerify = "/mfa/verify"
EndpointMFAPrompt = "/mfa/prompt"
EndpointMFAInitVerify = "/mfa/init/verify"
@@ -94,6 +95,8 @@ func CreateRouter(login *Login, interceptors ...mux.MiddlewareFunc) *mux.Router
router.HandleFunc(EndpointPasswordReset, login.handlePasswordReset).Methods(http.MethodGet)
router.HandleFunc(EndpointInitUser, login.handleInitUser).Methods(http.MethodGet)
router.HandleFunc(EndpointInitUser, login.handleInitUserCheck).Methods(http.MethodPost)
router.HandleFunc(EndpointInviteUser, login.handleInviteUser).Methods(http.MethodGet)
router.HandleFunc(EndpointInviteUser, login.handleInviteUserCheck).Methods(http.MethodPost)
router.HandleFunc(EndpointMFAVerify, login.handleMFAVerify).Methods(http.MethodPost)
router.HandleFunc(EndpointMFAPrompt, login.handleMFAPromptSelection).Methods(http.MethodGet)
router.HandleFunc(EndpointMFAPrompt, login.handleMFAPrompt).Methods(http.MethodPost)

View File

@@ -81,6 +81,14 @@ InitUserDone:
Description: Имейлът е потвърден и паролата е успешно зададена
NextButtonText: следващия
CancelButtonText: анулиране
InviteUser:
Title: Активиране на потребителя
Description: Проверете своя имейл с кода по-долу и задайте паролата си.
CodeLabel: Код
NewPasswordLabel: Нова парола
NewPasswordConfirm: Потвърди парола
NextButtonText: Напред
ResendButtonText: Изпрати отново код
InitMFAPrompt:
Title: 2-факторна настройка
Description: >-

View File

@@ -86,6 +86,15 @@ InitUserDone:
NextButtonText: Další
CancelButtonText: Zrušit
InviteUser:
Title: Aktivace uživatele
Description: Ověřte svůj e-mail pomocí níže uvedeného kódu a nastavte si heslo.
CodeLabel: Kód
NewPasswordLabel: Nové heslo
NewPasswordConfirm: Potvrďte heslo
NextButtonText: Další
ResendButtonText: Odeslat kód znovu
InitMFAPrompt:
Title: Nastavení 2-faktorové autentizace
Description: 2-faktorová autentizace vám poskytuje další zabezpečení pro váš uživatelský účet. Tím je zajištěno, že k vašemu účtu máte přístup pouze vy.

View File

@@ -86,6 +86,15 @@ InitUserDone:
NextButtonText: Weiter
CancelButtonText: Abbrechen
InviteUser:
Title: Benutzer aktivieren
Description: Bestätige deine E-Mail-Adresse mit dem unten stehenden Code und lege dein Passwort fest.
CodeLabel: Code
NewPasswordLabel: Neues Passwort
NewPasswordConfirm: Passwort bestätigen
NextButtonText: Weiter
ResendButtonText: Code erneut senden
InitMFAPrompt:
Title: Zweitfaktor hinzufügen
Description: Die Zwei-Faktor-Authentifizierung gibt dir eine zusätzliche Sicherheit für dein Benutzerkonto. Damit stellst du sicher, dass nur du Zugriff auf dein Konto hast.

View File

@@ -86,6 +86,15 @@ InitUserDone:
NextButtonText: Next
CancelButtonText: Cancel
InviteUser:
Title: Activate User
Description: Verify your e-mail with the code below and set your password.
CodeLabel: Code
NewPasswordLabel: New Password
NewPasswordConfirm: Confirm Password
NextButtonText: Next
ResendButtonText: Resend Code
InitMFAPrompt:
Title: 2-Factor Setup
Description: 2-factor authentication gives you an additional security for your user account. This ensures that only you have access to your account.

View File

@@ -86,6 +86,15 @@ InitUserDone:
NextButtonText: siguiente
CancelButtonText: cancelar
InviteUser:
Title: Activar usuario
Description: Verifica tu email con el siguiente código y establece tu contraseña.
CodeLabel: Código
NewPasswordLabel: Nueva contraseña
NewPasswordConfirm: Confirmar contraseña
NextButtonText: siguiente
ResendButtonText: reenviar código
InitMFAPrompt:
Title: Configuración de doble factor
Description: La autenticación de doble factor te proporciona seguridad adicional para tu cuenta de usuario. Ésta asegura que solo tú tienes acceso a tu cuenta.

View File

@@ -86,6 +86,15 @@ InitUserDone:
NextButtonText: Suivant
CancelButtonText: Annuler
InviteUser:
Title: Activer l'utilisateur
Description: Vérifiez votre e-mail avec le code ci-dessous et définissez votre mot de passe.
CodeLabel: Code
NewPasswordLabel: Nouveau mot de passe
NewPasswordConfirm: Confirmer le mot de passe
NextButtonText: Suivant
ResendButtonText: Renvoyer le code
InitMFAPrompt:
Title: Configuration authentification à 2 facteurs
Description: L'authentification authentification à 2 facteurs vous offre une sécurité supplémentaire pour votre compte d'utilisateur. Vous êtes ainsi assuré d'être le seul à avoir accès à votre compte.

View File

@@ -76,6 +76,14 @@ InitUserDone:
Description: Email terverifikasi dan Kata Sandi berhasil ditetapkan
NextButtonText: Berikutnya
CancelButtonText: Membatalkan
InviteUser:
Title: Aktifkan Pengguna
Description: Verifikasi email Anda dengan kode di bawah ini dan atur kata sandi Anda.
CodeLabel: Kode
NewPasswordLabel: Kata Sandi Baru
NewPasswordConfirm: Konfirmasi Kata Sandi
NextButtonText: Selanjutnya
ResendButtonText: Kirim Ulang Kode
InitMFAPrompt:
Title: Pengaturan 2 Faktor
Description: Otentikasi 2 faktor memberi Anda keamanan tambahan untuk akun pengguna Anda.

View File

@@ -86,6 +86,15 @@ InitUserDone:
NextButtonText: Avanti
CancelButtonText: annulla
InviteUser:
Title: Attiva utente
Description: Verifica la tua email con il codice seguente e imposta la tua password.
CodeLabel: Codice
NewPasswordLabel: Nuova password
NewPasswordConfirm: Conferma password
NextButtonText: Avanti
ResendButtonText: Reinvia codice
InitMFAPrompt:
Title: Impostazione a 2 fattori
Description: L'autenticazione a due fattori offre un'ulteriore sicurezza al vostro account utente. Questo garantisce che solo voi possiate accedere al vostro account.

View File

@@ -79,6 +79,15 @@ InitUserDone:
NextButtonText: 次へ
CancelButtonText: キャンセル
InviteUser:
Title: ユーザーの有効化
Description: 下のコードでメールアドレスを確認し、パスワードを設定してください。
CodeLabel: コード
NewPasswordLabel: 新しいパスワード
NewPasswordConfirm: パスワードの確認
NextButtonText: 次へ
ResendButtonText: コードを再送信
InitMFAPrompt:
Title: 二要素認証のセットアップ
Description: 二要素認証でアカウントのセキュリティを強化します。

View File

@@ -86,6 +86,15 @@ InitUserDone:
NextButtonText: следно
CancelButtonText: откажи
InviteUser:
Title: Активирање на корисникот
Description: Проверете го вашиот имејл со кодот подолу и поставете ја вашата лозинка.
CodeLabel: Код
NewPasswordLabel: Нова лозинка
NewPasswordConfirm: Потврди лозинка
NextButtonText: Следно
ResendButtonText: Повторно испрати код
InitMFAPrompt:
Title: Подесување на 2-факторска автентикација
Description: 2-факторската автентикација ви дава дополнителна безбедност за вашата корисничка сметка. Ова обезбедува само вие да имате пристап до вашата сметка.

View File

@@ -86,6 +86,15 @@ InitUserDone:
NextButtonText: Volgende
CancelButtonText: Annuleren
InviteUser:
Title: Gebruiker activeren
Description: Verifieer uw e-mail met de onderstaande code en stel uw wachtwoord in.
CodeLabel: Code
NewPasswordLabel: Nieuw wachtwoord
NewPasswordConfirm: Wachtwoord bevestigen
NextButtonText: Volgende
ResendButtonText: Code opnieuw verzenden
InitMFAPrompt:
Title: 2-Factor Setup
Description: 2-factor authenticatie geeft u extra beveiliging voor uw gebruikersaccount. Hierdoor bent u de enige die toegang heeft tot uw account.

View File

@@ -86,6 +86,15 @@ InitUserDone:
NextButtonText: dalej
CancelButtonText: anuluj
InviteUser:
Title: Aktywuj użytkownika
Description: Zweryfikuj swój adres e-mail za pomocą poniższego kodu i ustaw swoje hasło.
CodeLabel: Kod
NewPasswordLabel: Nowe hasło
NewPasswordConfirm: Potwierdź hasło
NextButtonText: Dalej
ResendButtonText: Wyślij ponownie kod
InitMFAPrompt:
Title: Konfiguracja 2-etapowego uwierzytelniania
Description: 2-etapowe uwierzytelnianie daje Ci dodatkową ochronę dla Twojego konta użytkownika. Dzięki temu masz pewność, że tylko Ty masz dostęp do swojego konta.

View File

@@ -86,6 +86,15 @@ InitUserDone:
NextButtonText: próximo
CancelButtonText: cancelar
InviteUser:
Title: Ativar usuário
Description: Verifique seu e-mail com o código abaixo e defina sua senha.
CodeLabel: Código
NewPasswordLabel: Nova senha
NewPasswordConfirm: Confirmar senha
NextButtonText: Próximo
ResendButtonText: Reenviar código
InitMFAPrompt:
Title: Configuração de 2 fatores
Description: A autenticação de 2 fatores fornece uma segurança adicional para sua conta de usuário. Isso garante que apenas você tenha acesso à sua conta.

View File

@@ -85,6 +85,15 @@ InitUserDone:
NextButtonText: далее
CancelButtonText: отмена
InviteUser:
Title: Активировать пользователя
Description: Проверьте свой адрес электронной почты с помощью кода ниже и установите свой пароль.
CodeLabel: Код
NewPasswordLabel: Новый пароль
NewPasswordConfirm: Подтвердить пароль
NextButtonText: Далее
ResendButtonText: Отправить код повторно
InitMFAPrompt:
Title: Установка двухфакторной аутентификации
Description: Двухфакторная аутентификация обеспечивает дополнительную защиту вашей учётной записи.

View File

@@ -86,6 +86,15 @@ InitUserDone:
NextButtonText: Fortsätt
CancelButtonText: Avbryt
InviteUser:
Title: Aktivera användare
Description: Verifiera din e-post med koden nedan och sätt ditt lösenord.
CodeLabel: Kod
NewPasswordLabel: Nytt lösenord
NewPasswordConfirm: Bekräfta lösenord
NextButtonText: Nästa
ResendButtonText: Skicka koden igen
InitMFAPrompt:
Title: tvåfaktorinställningar
Description: 2-factor-identifiering ökar säkerheten för ditt konto. Enbart du som har tillgång till enheten kan logga in.

View File

@@ -86,6 +86,15 @@ InitUserDone:
NextButtonText: 继续
CancelButtonText: 取消
InviteUser:
Title: 激活用户
Description: 使用以下代码验证您的电子邮件并设置您的密码。
CodeLabel: 代码
NewPasswordLabel: 新密码
NewPasswordConfirm: 确认密码
NextButtonText: 下一步
ResendButtonText: 重新发送代码
InitMFAPrompt:
Title: 两步验证设置
Description: 两步验证为您的账户提供了额外的安全保障。这确保只有你能访问你的账户。

View File

@@ -0,0 +1,63 @@
{{template "main-top" .}}
<div class="lgn-head">
<h1>{{t "InviteUser.Title"}}</h1>
{{ template "user-profile" . }}
<p>{{t "InviteUser.Description"}}</p>
</div>
<form action="{{ inviteUserUrl }}" method="POST">
{{ .CSRF }}
<input type="hidden" name="authRequestID" value="{{ .AuthReqID }}" />
<input type="hidden" name="userID" value="{{ .UserID }}" />
<input type="hidden" name="orgID" value="{{ .OrgID }}" />
<input type="text" name="loginName" value="{{if .DisplayLoginNameSuffix}}{{.LoginName}}{{else}}{{.UserName}}{{end}}" autocomplete="username" class="hidden" />
<div class="fields">
<div class="field">
<label class="lgn-label" for="code">{{t "InviteUser.CodeLabel"}}</label>
<input class="lgn-input" {{if .ErrMessage}}shake {{end}} type="text" id="code" name="code" value="{{.Code}}" autocomplete="one-time-code" autofocus
required>
</div>
<div class="field">
<label class="lgn-label" for="password">{{t "InviteUser.NewPasswordLabel"}}</label>
<input data-minlength="{{ .MinLength }}" data-has-uppercase="{{ .HasUppercase }}"
data-has-lowercase="{{ .HasLowercase }}" data-has-number="{{ .HasNumber }}"
data-has-symbol="{{ .HasSymbol }}" class="lgn-input" type="password" id="password" name="password"
autocomplete="new-password" autofocus required>
</div>
<div class="field">
<label class="lgn-label" for="passwordconfirm">{{t "InviteUser.NewPasswordConfirm"}}</label>
<input class="lgn-input" type="password" id="passwordconfirm" name="passwordconfirm"
autocomplete="new-password" autofocus required>
{{ template "password-complexity-policy-description" . }}
</div>
</div>
{{ template "error-message" .}}
<div class="lgn-actions lgn-reverse-order">
<!-- position element in header -->
<a class="lgn-icon-button lgn-left-action" href="{{ loginUrl }}">
<i class="lgn-icon-arrow-left-solid"></i>
</a>
<button type="submit" id="init-button" name="resend" value="false"
class="lgn-primary lgn-raised-button">{{t "InviteUser.NextButtonText"}}</button>
<span class="fill-space"></span>
<button type="submit" name="resend" value="true" class="lgn-stroked-button" formnovalidate>{{t "InviteUser.ResendButtonText"}}</button>
</div>
</form>
<script src="{{ resourceUrl "scripts/form_submit.js" }}"></script>
<script src="{{ resourceUrl "scripts/password_policy_check.js" }}"></script>
<script src="{{ resourceUrl "scripts/init_password_check.js" }}"></script>
{{template "main-bottom" .}}