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 <[email protected]>
This commit is contained in:
Livio Amstutz
2021-08-02 15:24:58 +02:00
committed by GitHub
co-authored by Fabi
parent 9b5cb38d62
commit 00220e9532
60 changed files with 2916 additions and 350 deletions
@@ -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)
@@ -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)
}
@@ -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)
}
+41 -30
View File
@@ -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:
+5
View File
@@ -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)
+21
View File
@@ -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.
+22 -1
View File
@@ -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.
@@ -0,0 +1,25 @@
{{template "main-top" .}}
<div class="lgn-head">
<h1>{{t "PasswordlessPrompt.Title"}}</h1>
{{ template "user-profile" . }}
<p>{{t "PasswordlessPrompt.DescriptionInit"}}</p>
</div>
<form action="{{ passwordlessPromptUrl }}" method="POST">
{{ .CSRF }}
<input type="hidden" name="authRequestID" value="{{ .AuthReqID }}" />
<div class="lgn-actions">
<!-- position element in header -->
<a class="lgn-icon-button lgn-left-action" href="{{ loginUrl }}">
<i class="lgn-icon-arrow-left-solid"></i>
</a>
</div>
</form>
{{template "main-bottom" .}}
@@ -0,0 +1,52 @@
{{template "main-top" .}}
<div class="head">
<h1>{{t "PasswordlessRegistration.Title"}}</h1>
{{ template "user-profile" . }}
<p>{{t "PasswordlessRegistration.Description"}}</p>
</div>
<form action="{{ passwordLessRegistrationUrl }}" 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="hidden" name="codeID" value="{{ .CodeID }}" />
<input type="hidden" name="code" value="{{ .Code }}" />
<input type="hidden" name="credentialCreationData" value="{{ .CredentialCreationData }}" />
<input type="hidden" name="credentialData" />
<div class="fields">
<p class="wa-no-support lgn-error hidden">{{t "PasswordlessRegistration.NotSupported"}}</p>
{{if not .Disabled}}
<div class="field">
<label class="lgn-label" for="name" disabled="false">{{t "PasswordlessRegistration.TokenNameLabel"}}</label>
<input class="lgn-input" type="text" id="name" name="name" autocomplete="off" autofocus>
</div>
{{end}}
<div id="wa-error" class="lgn-error hidden">
<span class="cause"></span>
<span>{{t "PasswordlessRegistration.ErrorRetry"}}</span>
</div>
</div>
{{ template "error-message" .}}
<div class="lgn-actions">
<span class="fill-space"></span>
{{if not .Disabled}}
<a id="btn-register" class="lgn-raised-button lgn-primary wa-support">{{t "PasswordlessRegistration.RegisterTokenButtonText"}}</a>
{{end}}
</div>
</form>
<script src="{{ resourceUrl "scripts/base64.js" }}"></script>
<script src="{{ resourceUrl "scripts/webauthn.js" }}"></script>
<script src="{{ resourceUrl "scripts/webauthn_register.js" }}"></script>
{{template "main-bottom" .}}
@@ -0,0 +1,27 @@
{{template "main-top" .}}
<div class="lgn-head">
<h1>{{t "PasswordlessRegistrationDone.Title"}}</h1>
{{ template "user-profile" . }}
<p>{{t "PasswordlessRegistrationDone.Description"}}</p>
</div>
<form action="{{ loginUrl }}" method="POST">
{{ .CSRF }}
<input type="hidden" name="authRequestID" value="{{ .AuthReqID }}" />
<div class="lgn-actions">
<a class="lgn-stroked-button lgn-primary" href="{{ loginUrl }}">
{{t "PasswordlessRegistrationDone.CancelButtonText"}}
</a>
<span class="fill-space"></span>
<button class="lgn-raised-button lgn-primary" type="submit">{{t "PasswordlessRegistrationDone.NextButtonText"}}</button>
</div>
</form>
{{template "main-bottom" .}}