mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-12 11:27:33 +00:00
feat(idp): provide option to auto link user (#7734)
* init auto linking * prompt handling * working * translations * console * fixes * unify * custom texts * fix tests * linting * fix check of existing user * fix bg translation * set unspecified as default in the form
This commit is contained in:
@@ -449,6 +449,59 @@ func (l *Login) handleExternalUserAuthenticated(
|
||||
callback(w, r, authReq)
|
||||
}
|
||||
|
||||
// checkAutoLinking checks if a user with the provided information (username or email) already exists within ZITADEL.
|
||||
// The decision, which information will be checked is based on the IdP template option.
|
||||
// The function returns a boolean whether a user was found or not.
|
||||
func (l *Login) checkAutoLinking(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, provider *query.IDPTemplate, externalUser *domain.ExternalUser) bool {
|
||||
queries := make([]query.SearchQuery, 0, 2)
|
||||
var user *query.NotifyUser
|
||||
switch provider.AutoLinking {
|
||||
case domain.AutoLinkingOptionUnspecified:
|
||||
// is auto linking is disable, we shouldn't even get here, but in case we do we can directly return
|
||||
return false
|
||||
case domain.AutoLinkingOptionUsername:
|
||||
// if we're checking for usernames there are to options:
|
||||
//
|
||||
// If no specific org has been requested (by id or domain scope), we'll check the provided username against
|
||||
// all existing loginnames and directly use that result to either prompt or continue with other idp options.
|
||||
if authReq.RequestedOrgID == "" {
|
||||
user, err := l.query.GetNotifyUserByLoginName(r.Context(), false, externalUser.PreferredUsername)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
l.renderLinkingUserPrompt(w, r, authReq, user, nil)
|
||||
return true
|
||||
}
|
||||
// If a specific org has been requested, we'll check the provided username against usernames (of that org).
|
||||
usernameQuery, err := query.NewUserUsernameSearchQuery(externalUser.PreferredUsername, query.TextEqualsIgnoreCase)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
queries = append(queries, usernameQuery)
|
||||
case domain.AutoLinkingOptionEmail:
|
||||
// Email will always be checked against verified email addresses.
|
||||
emailQuery, err := query.NewUserVerifiedEmailSearchQuery(string(externalUser.Email))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
queries = append(queries, emailQuery)
|
||||
}
|
||||
// restrict the possible organization if needed (for email and usernames)
|
||||
if authReq.RequestedOrgID != "" {
|
||||
resourceOwnerQuery, err := query.NewUserResourceOwnerSearchQuery(authReq.RequestedOrgID, query.TextEquals)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
queries = append(queries, resourceOwnerQuery)
|
||||
}
|
||||
user, err := l.query.GetNotifyUser(r.Context(), false, queries...)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
l.renderLinkingUserPrompt(w, r, authReq, user, nil)
|
||||
return true
|
||||
}
|
||||
|
||||
// externalUserNotExisting is called if an externalAuthentication couldn't find a corresponding externalID
|
||||
// possible solutions are:
|
||||
//
|
||||
@@ -470,6 +523,13 @@ func (l *Login) externalUserNotExisting(w http.ResponseWriter, r *http.Request,
|
||||
}
|
||||
|
||||
human, idpLink, _ := mapExternalUserToLoginUser(externalUser, orgIAMPolicy.UserLoginMustBeDomain)
|
||||
// let's check if auto-linking is enabled and if the user would be found by the corresponding option
|
||||
if provider.AutoLinking != domain.AutoLinkingOptionUnspecified {
|
||||
if l.checkAutoLinking(w, r, authReq, provider, externalUser) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// if auto creation or creation itself is disabled, send the user to the notFoundOption
|
||||
if !provider.IsCreationAllowed || !provider.IsAutoCreation {
|
||||
l.renderExternalNotFoundOption(w, r, authReq, orgIAMPolicy, human, idpLink, err)
|
||||
|
62
internal/api/ui/login/link_prompt_handler.go
Normal file
62
internal/api/ui/login/link_prompt_handler.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package login
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
)
|
||||
|
||||
const (
|
||||
tmplLinkingUserPrompt = "link_user_prompt"
|
||||
)
|
||||
|
||||
type linkingUserPromptData struct {
|
||||
userData
|
||||
Username string
|
||||
Linking domain.AutoLinkingOption
|
||||
UserID string
|
||||
}
|
||||
|
||||
type linkingUserPromptFormData struct {
|
||||
OtherUser bool `schema:"other"`
|
||||
UserID string `schema:"userID"`
|
||||
}
|
||||
|
||||
func (l *Login) renderLinkingUserPrompt(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, user *query.NotifyUser, err error) {
|
||||
var errID, errMessage string
|
||||
if err != nil {
|
||||
errID, errMessage = l.getErrorMessage(r, err)
|
||||
}
|
||||
translator := l.getTranslator(r.Context(), authReq)
|
||||
identification := user.PreferredLoginName
|
||||
// hide the suffix in case the option is set and the auth request has been started with the primary domain scope
|
||||
if authReq.RequestedOrgDomain && authReq.LabelPolicy != nil && authReq.LabelPolicy.HideLoginNameSuffix {
|
||||
identification = user.Username
|
||||
}
|
||||
data := &linkingUserPromptData{
|
||||
Username: identification,
|
||||
UserID: user.ID,
|
||||
userData: l.getUserData(r, authReq, translator, "LinkingUserPrompt.Title", "LinkingUserPrompt.Description", errID, errMessage),
|
||||
}
|
||||
l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplLinkingUserPrompt], data, nil)
|
||||
}
|
||||
|
||||
func (l *Login) handleLinkingUserPrompt(w http.ResponseWriter, r *http.Request) {
|
||||
data := new(linkingUserPromptFormData)
|
||||
authReq, err := l.getAuthRequestAndParseData(r, data)
|
||||
if err != nil {
|
||||
l.renderLogin(w, r, authReq, err)
|
||||
return
|
||||
}
|
||||
if data.OtherUser {
|
||||
l.renderExternalNotFoundOption(w, r, authReq, nil, nil, nil, nil)
|
||||
return
|
||||
}
|
||||
err = l.authRepo.SelectUser(r.Context(), authReq.ID, data.UserID, authReq.AgentID)
|
||||
if err != nil {
|
||||
l.renderLogin(w, r, authReq, err)
|
||||
return
|
||||
}
|
||||
l.renderNextStep(w, r, authReq)
|
||||
}
|
@@ -83,6 +83,7 @@ func CreateRenderer(pathPrefix string, staticStorage static.Storage, cookieName
|
||||
tmplLDAPLogin: "ldap_login.html",
|
||||
tmplDeviceAuthUserCode: "device_usercode.html",
|
||||
tmplDeviceAuthAction: "device_action.html",
|
||||
tmplLinkingUserPrompt: "link_user_prompt.html",
|
||||
}
|
||||
funcs := map[string]interface{}{
|
||||
"resourceUrl": func(file string) string {
|
||||
@@ -235,6 +236,9 @@ func CreateRenderer(pathPrefix string, staticStorage static.Storage, cookieName
|
||||
"ldapUrl": func() string {
|
||||
return path.Join(r.pathPrefix, EndpointLDAPCallback)
|
||||
},
|
||||
"linkingUserPromptUrl": func() string {
|
||||
return path.Join(r.pathPrefix, EndpointLinkingUserPrompt)
|
||||
},
|
||||
}
|
||||
var err error
|
||||
r.Renderer, err = renderer.NewRenderer(
|
||||
|
@@ -53,6 +53,8 @@ const (
|
||||
|
||||
EndpointDeviceAuth = "/device"
|
||||
EndpointDeviceAuthAction = "/device/{action}"
|
||||
|
||||
EndpointLinkingUserPrompt = "/link/user"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -122,5 +124,6 @@ func CreateRouter(login *Login, interceptors ...mux.MiddlewareFunc) *mux.Router
|
||||
router.SkipClean(true).Handle("", http.RedirectHandler(HandlerPrefix+"/", http.StatusMovedPermanently))
|
||||
router.HandleFunc(EndpointDeviceAuth, login.handleDeviceAuthUserCode).Methods(http.MethodGet, http.MethodPost)
|
||||
router.HandleFunc(EndpointDeviceAuthAction, login.handleDeviceAuthAction).Methods(http.MethodGet, http.MethodPost)
|
||||
router.HandleFunc(EndpointLinkingUserPrompt, login.handleLinkingUserPrompt).Methods(http.MethodPost)
|
||||
return router
|
||||
}
|
||||
|
@@ -316,6 +316,11 @@ LogoutDone:
|
||||
Title: Излязъл
|
||||
Description: Вие излязохте успешно.
|
||||
LoginButtonText: Влизам
|
||||
LinkingUserPrompt:
|
||||
Title: Намерен съществуващ потребител
|
||||
Description: „Искате ли да свържете съществуващия си акаунт:“
|
||||
LinkButtonText: Връзка
|
||||
OtherButtonText: Други възможности
|
||||
LinkingUsersDone:
|
||||
Title: Свързване с потребители
|
||||
Description: Свързването с потребители е готово.
|
||||
|
@@ -325,6 +325,12 @@ LogoutDone:
|
||||
Description: Byli jste úspěšně odhlášeni.
|
||||
LoginButtonText: Přihlásit se
|
||||
|
||||
LinkingUserPrompt:
|
||||
Title: Nalezen stávající uživatel
|
||||
Description: "Chcete propojit svůj stávající účet:"
|
||||
LinkButtonText: Odkaz
|
||||
OtherButtonText: Jiné možnosti
|
||||
|
||||
LinkingUsersDone:
|
||||
Title: Propojení uživatele
|
||||
Description: Uživatel propojen.
|
||||
|
@@ -324,6 +324,12 @@ LogoutDone:
|
||||
Description: Du wurdest erfolgreich abgemeldet.
|
||||
LoginButtonText: Anmelden
|
||||
|
||||
LinkingUserPrompt:
|
||||
Title: Vorhandener Benutzer gefunden
|
||||
Description: "Möchten Sie Ihr bestehendes Konto verknüpfen:"
|
||||
LinkButtonText: Verknüpfen
|
||||
OtherButtonText: Andere Optionen
|
||||
|
||||
LinkingUsersDone:
|
||||
Title: Benutzerkonto verknpüfen
|
||||
Description: Benuzterkonto verknpüft.
|
||||
|
@@ -325,6 +325,12 @@ LogoutDone:
|
||||
Description: You have logged out successfully.
|
||||
LoginButtonText: Login
|
||||
|
||||
LinkingUserPrompt:
|
||||
Title: Existing User Found
|
||||
Description: "Do you want to link your existing account:"
|
||||
LinkButtonText: Link
|
||||
OtherButtonText: Other options
|
||||
|
||||
LinkingUsersDone:
|
||||
Title: Linking User
|
||||
Description: User linked.
|
||||
|
@@ -325,6 +325,12 @@ LogoutDone:
|
||||
Description: Cerraste la sesión con éxito.
|
||||
LoginButtonText: iniciar sesión
|
||||
|
||||
LinkingUserPrompt:
|
||||
Title: Usuario existente encontrado
|
||||
Description: "¿Quieres vincular tu cuenta existente?"
|
||||
LinkButtonText: Vincular
|
||||
OtherButtonText: Otras opciones
|
||||
|
||||
LinkingUsersDone:
|
||||
Title: Vinculación de usuario
|
||||
Description: usuario vinculado con éxito.
|
||||
|
@@ -325,6 +325,12 @@ LogoutDone:
|
||||
Description: Vous vous êtes déconnecté avec succès.
|
||||
LoginButtonText: connexion
|
||||
|
||||
LinkingUserPrompt:
|
||||
Title: Utilisateur existant trouvé
|
||||
Description: "Souhaitez-vous associer votre compte existant:"
|
||||
LinkButtonText: Lier
|
||||
OtherButtonText: Autres options
|
||||
|
||||
LinkingUsersDone:
|
||||
Title: Userlinking
|
||||
Description: Le lien avec l'utilisateur est terminé.
|
||||
|
@@ -325,6 +325,12 @@ LogoutDone:
|
||||
Description: Ti sei disconnesso con successo.
|
||||
LoginButtonText: Accedi
|
||||
|
||||
LinkingUserPrompt:
|
||||
Title: Utente esistente trovato
|
||||
Description: "Desideri collegare il tuo account esistente:"
|
||||
LinkButtonText: Collegare
|
||||
OtherButtonText: Altre opzioni
|
||||
|
||||
LinkingUsersDone:
|
||||
Title: Collegamento utente
|
||||
Description: Collegamento fatto.
|
||||
|
@@ -317,6 +317,12 @@ LogoutDone:
|
||||
Description: 正常にログアウトしました。
|
||||
LoginButtonText: ログイン
|
||||
|
||||
LinkingUserPrompt:
|
||||
Title: 既存のユーザーが見つかりました
|
||||
Description: "既存のアカウントをリンクしますか:"
|
||||
LinkButtonText: リンク
|
||||
OtherButtonText: その他のオプション
|
||||
|
||||
LinkingUsersDone:
|
||||
Title: ユーザーリンク
|
||||
Description: ユーザーリンクが完了しました。
|
||||
|
@@ -325,6 +325,12 @@ LogoutDone:
|
||||
Description: Успешно сте одјавени.
|
||||
LoginButtonText: најава
|
||||
|
||||
LinkingUserPrompt:
|
||||
Title: Пронајден е постоечки корисник
|
||||
Description: "Дали сакате да ја поврзете вашата постоечка сметка:"
|
||||
LinkButtonText: Bрска
|
||||
OtherButtonText: Други опции
|
||||
|
||||
LinkingUsersDone:
|
||||
Title: Поврзување на корисници
|
||||
Description: Поврзувањето на корисници е завршено.
|
||||
|
@@ -325,6 +325,12 @@ LogoutDone:
|
||||
Description: U heeft succesvol uitgelogd.
|
||||
LoginButtonText: Inloggen
|
||||
|
||||
LinkingUserPrompt:
|
||||
Title: Bestaande gebruiker gevonden
|
||||
Description: "Wilt u uw bestaande account koppelen:"
|
||||
LinkButtonText: Koppeling
|
||||
OtherButtonText: Andere opties
|
||||
|
||||
LinkingUsersDone:
|
||||
Title: Koppeling Gebruiker
|
||||
Description: Gebruiker gekoppeld.
|
||||
|
@@ -325,6 +325,12 @@ LogoutDone:
|
||||
Description: Wylogowano pomyślnie.
|
||||
LoginButtonText: Zaloguj się
|
||||
|
||||
LinkingUserPrompt:
|
||||
Title: Znaleziono istniejącego użytkownika
|
||||
Description: "Czy chcesz połączyć swoje istniejące konto:"
|
||||
LinkButtonText: Połączyć
|
||||
OtherButtonText: Inne opcje
|
||||
|
||||
LinkingUsersDone:
|
||||
Title: Łączenie użytkowników
|
||||
Description: Łączenie użytkowników zakończone pomyślnie.
|
||||
|
@@ -321,6 +321,12 @@ LogoutDone:
|
||||
Description: Você fez logout com sucesso.
|
||||
LoginButtonText: login
|
||||
|
||||
LinkingUserPrompt:
|
||||
Title: Usuário existente encontrado
|
||||
Description: "Deseja vincular sua conta existente:"
|
||||
LinkButtonText: Link
|
||||
OtherButtonText: Outras opções
|
||||
|
||||
LinkingUsersDone:
|
||||
Title: Vinculação de usuários
|
||||
Description: Vinculação de usuários concluída.
|
||||
|
@@ -324,6 +324,12 @@ LogoutDone:
|
||||
Description: Вы успешно вышли из системы.
|
||||
LoginButtonText: вход
|
||||
|
||||
LinkingUserPrompt:
|
||||
Title: Существующий пользователь найден
|
||||
Description: "Хотите ли вы связать существующую учетную запись:"
|
||||
LinkButtonText: Связь
|
||||
OtherButtonText: Другие варианты
|
||||
|
||||
LinkingUsersDone:
|
||||
Title: Привязка пользователя
|
||||
Description: Привязка пользователя выполнена.
|
||||
|
@@ -325,6 +325,12 @@ LogoutDone:
|
||||
Description: 您已成功退出登录。
|
||||
LoginButtonText: 登录
|
||||
|
||||
LinkingUserPrompt:
|
||||
Title: 已找到现有用户
|
||||
Description: "您想关联您现有的帐户吗:"
|
||||
LinkButtonText: 关联
|
||||
OtherButtonText: 其他选项
|
||||
|
||||
LinkingUsersDone:
|
||||
Title: 用户链接
|
||||
Description: 用户链接完成。
|
||||
|
37
internal/api/ui/login/static/templates/link_user_prompt.html
Normal file
37
internal/api/ui/login/static/templates/link_user_prompt.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{{template "main-top" .}}
|
||||
|
||||
<div class="lgn-head">
|
||||
<h1>{{t "LinkingUserPrompt.Title"}}</h1>
|
||||
<p>
|
||||
{{t "LinkingUserPrompt.Description"}}<br>
|
||||
{{.Username}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<form action="{{ linkingUserPromptUrl }}" method="POST">
|
||||
|
||||
{{ .CSRF }}
|
||||
|
||||
<input type="hidden" name="authRequestID" value="{{ .AuthReqID }}" />
|
||||
<input type="hidden" name="userID" value="{{ .UserID }}" />
|
||||
|
||||
{{template "error-message" .}}
|
||||
|
||||
<div class="lgn-actions lgn-reverse-order">
|
||||
<a class="lgn-icon-button lgn-left-action" id="back-button" href="#">
|
||||
<i class="lgn-icon-arrow-left-solid"></i>
|
||||
</a>
|
||||
<button class="lgn-raised-button lgn-primary lgn-initial-focus" id="submit-button" type="submit">{{t "LinkingUserPrompt.LinkButtonText"}}</button>
|
||||
<span class="fill-space"></span>
|
||||
<button class="lgn-stroked-button" name="other" value="true">{{t "LinkingUserPrompt.OtherButtonText"}}</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<script src="{{ resourceUrl "scripts/form_submit.js" }}"></script>
|
||||
<script src="{{ resourceUrl "scripts/default_form_validation.js" }}"></script>
|
||||
<script src="{{ resourceUrl "scripts/input_suffix_offset.js" }}"></script>
|
||||
<script src="{{ resourceUrl "scripts/go_back.js" }}"></script>
|
||||
|
||||
{{template "main-bottom" .}}
|
Reference in New Issue
Block a user