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:
Livio Spring
2024-04-10 17:46:30 +02:00
committed by GitHub
parent b3e3239d76
commit dcfa2f7955
75 changed files with 1432 additions and 418 deletions

View File

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

View 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)
}

View File

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

View File

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

View File

@@ -316,6 +316,11 @@ LogoutDone:
Title: Излязъл
Description: Вие излязохте успешно.
LoginButtonText: Влизам
LinkingUserPrompt:
Title: Намерен съществуващ потребител
Description: „Искате ли да свържете съществуващия си акаунт:“
LinkButtonText: Връзка
OtherButtonText: Други възможности
LinkingUsersDone:
Title: Свързване с потребители
Description: Свързването с потребители е готово.

View File

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

View File

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

View File

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

View File

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

View File

@@ -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é.

View File

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

View File

@@ -317,6 +317,12 @@ LogoutDone:
Description: 正常にログアウトしました。
LoginButtonText: ログイン
LinkingUserPrompt:
Title: 既存のユーザーが見つかりました
Description: "既存のアカウントをリンクしますか:"
LinkButtonText: リンク
OtherButtonText: その他のオプション
LinkingUsersDone:
Title: ユーザーリンク
Description: ユーザーリンクが完了しました。

View File

@@ -325,6 +325,12 @@ LogoutDone:
Description: Успешно сте одјавени.
LoginButtonText: најава
LinkingUserPrompt:
Title: Пронајден е постоечки корисник
Description: "Дали сакате да ја поврзете вашата постоечка сметка:"
LinkButtonText: Bрска
OtherButtonText: Други опции
LinkingUsersDone:
Title: Поврзување на корисници
Description: Поврзувањето на корисници е завршено.

View File

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

View File

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

View File

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

View File

@@ -324,6 +324,12 @@ LogoutDone:
Description: Вы успешно вышли из системы.
LoginButtonText: вход
LinkingUserPrompt:
Title: Существующий пользователь найден
Description: "Хотите ли вы связать существующую учетную запись:"
LinkButtonText: Связь
OtherButtonText: Другие варианты
LinkingUsersDone:
Title: Привязка пользователя
Description: Привязка пользователя выполнена.

View File

@@ -325,6 +325,12 @@ LogoutDone:
Description: 您已成功退出登录。
LoginButtonText: 登录
LinkingUserPrompt:
Title: 已找到现有用户
Description: "您想关联您现有的帐户吗:"
LinkButtonText: 关联
OtherButtonText: 其他选项
LinkingUsersDone:
Title: 用户链接
Description: 用户链接完成。

View 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" .}}