diff --git a/Documentation/ldap-connector.md b/Documentation/ldap-connector.md index 761d87d579cd802a211b921945ea370f8d5e9f35..0a0813c75109b4cb394dab2fcde556c8274c84ae 100644 --- a/Documentation/ldap-connector.md +++ b/Documentation/ldap-connector.md @@ -90,6 +90,10 @@ connectors: bindDN: uid=seviceaccount,cn=users,dc=example,dc=com bindPW: password + # The attribute to display in the provided password prompt. If unset, will + # display "Username" + usernamePrompt: SSO Username + # User search maps a username and password entered by a user to a LDAP entry. userSearch: # BaseDN to start the search from. It will translate to the query diff --git a/connector/connector.go b/connector/connector.go index bc5f3b18a5d44d4fc56a628d521ca94e10a1327d..c442c54af2851aa62ad1d1d349e5d3604a37ec20 100644 --- a/connector/connector.go +++ b/connector/connector.go @@ -39,7 +39,10 @@ type Identity struct { // PasswordConnector is an interface implemented by connectors which take a // username and password. +// Prompt() is used to inform the handler what to display in the password +// template. If this returns an empty string, it'll default to "Username". type PasswordConnector interface { + Prompt() string Login(ctx context.Context, s Scopes, username, password string) (identity Identity, validPassword bool, err error) } diff --git a/connector/ldap/ldap.go b/connector/ldap/ldap.go index 5d19a51d29bb5cad7b7eff7d1992ae419fdb9969..585907cc708aefa34325081cfabbd38cbe7e7a55 100644 --- a/connector/ldap/ldap.go +++ b/connector/ldap/ldap.go @@ -77,6 +77,11 @@ type Config struct { BindDN string `json:"bindDN"` BindPW string `json:"bindPW"` + // UsernamePrompt allows users to override the username attribute (displayed + // in the username/password prompt). If unset, the handler will use + // "Username". + UsernamePrompt string `json:"usernamePrompt"` + // User entry search configuration. UserSearch struct { // BsaeDN to start the search from. For example "cn=users,dc=example,dc=com" @@ -545,3 +550,7 @@ func (c *ldapConnector) groups(ctx context.Context, user ldap.Entry) ([]string, } return groupNames, nil } + +func (c *ldapConnector) Prompt() string { + return c.UsernamePrompt +} diff --git a/connector/ldap/ldap_test.go b/connector/ldap/ldap_test.go index 3b903856b6df029a17bbba5dbbf3bc5610fd7d57..23ad593bbd0058fbdd56b51abb027bc0164dc321 100644 --- a/connector/ldap/ldap_test.go +++ b/connector/ldap/ldap_test.go @@ -437,6 +437,31 @@ userpassword: foo runTests(t, schema, connectLDAPS, c, tests) } +func TestUsernamePrompt(t *testing.T) { + tests := map[string]struct { + config Config + expected string + }{ + "with usernamePrompt unset it returns \"\"": { + config: Config{}, + expected: "", + }, + "with usernamePrompt set it returns that": { + config: Config{UsernamePrompt: "Email address"}, + expected: "Email address", + }, + } + + for n, d := range tests { + t.Run(n, func(t *testing.T) { + conn := &ldapConnector{Config: d.config} + if actual := conn.Prompt(); actual != d.expected { + t.Errorf("expected %v, got %v", d.expected, actual) + } + }) + } +} + // runTests runs a set of tests against an LDAP schema. It does this by // setting up an OpenLDAP server and injecting the provided scheme. // diff --git a/connector/mock/connectortest.go b/connector/mock/connectortest.go index 18abd820fbc7b97760d0770c2e3186791b9bec1a..4a8b1257a40d8c0b193e6425ccaa3b72afe1b377 100644 --- a/connector/mock/connectortest.go +++ b/connector/mock/connectortest.go @@ -110,3 +110,5 @@ func (p passwordConnector) Login(ctx context.Context, s connector.Scopes, userna } return identity, false, nil } + +func (p passwordConnector) Prompt() string { return "" } diff --git a/examples/config-ldap.yaml b/examples/config-ldap.yaml index 513a000538190c1da508a0a8150b01af00acd7ef..6b423ddf1f6d5255c4514a792b28b0e591cdce50 100644 --- a/examples/config-ldap.yaml +++ b/examples/config-ldap.yaml @@ -15,11 +15,13 @@ connectors: # No TLS for this setup. insecureNoSSL: true - + # This would normally be a read-only user. bindDN: cn=admin,dc=example,dc=org bindPW: admin - + + usernamePrompt: Email Address + userSearch: baseDN: ou=People,dc=example,dc=org filter: "(objectClass=person)" diff --git a/server/handlers.go b/server/handlers.go index 345cd496165e2fbe8052858a35591d14b52cf23b..c265e0b1d287d2e9dfb78bfffd2ac54a0e59e847 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -250,7 +250,7 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { } http.Redirect(w, r, callbackURL, http.StatusFound) case connector.PasswordConnector: - if err := s.templates.password(w, r.URL.String(), "", false); err != nil { + if err := s.templates.password(w, r.URL.String(), "", usernamePrompt(conn), false); err != nil { s.logger.Errorf("Server template error: %v", err) } case connector.SAMLConnector: @@ -298,7 +298,7 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { return } if !ok { - if err := s.templates.password(w, r.URL.String(), username, true); err != nil { + if err := s.templates.password(w, r.URL.String(), username, usernamePrompt(passwordConnector), true); err != nil { s.logger.Errorf("Server template error: %v", err) } return @@ -1005,3 +1005,11 @@ func (s *Server) tokenErrHelper(w http.ResponseWriter, typ string, description s s.logger.Errorf("token error response: %v", err) } } + +// Check for username prompt override from connector. Defaults to "Username". +func usernamePrompt(conn connector.PasswordConnector) string { + if attr := conn.Prompt(); attr != "" { + return attr + } + return "Username" +} diff --git a/server/server.go b/server/server.go index e0b7d3597e87113a344ffa873ceebbef25b9f694..f915b5acb66eaad7646e7be6dd92400149d3167f 100644 --- a/server/server.go +++ b/server/server.go @@ -344,6 +344,10 @@ func (db passwordDB) Refresh(ctx context.Context, s connector.Scopes, identity c return identity, nil } +func (db passwordDB) Prompt() string { + return "Email Address" +} + // newKeyCacher returns a storage which caches keys so long as the next func newKeyCacher(s storage.Storage, now func() time.Time) storage.Storage { if now == nil { diff --git a/server/server_test.go b/server/server_test.go index a67cfbf49c382752ae80d370a3c1e297a7123abf..b5f733630ee4526d6e963d32bce87cb825e0af73 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1017,6 +1017,16 @@ func TestPasswordDB(t *testing.T) { } +func TestPasswordDBUsernamePrompt(t *testing.T) { + s := memory.New(logger) + conn := newPasswordDB(s) + + expected := "Email Address" + if actual := conn.Prompt(); actual != expected { + t.Errorf("expected %v, got %v", expected, actual) + } +} + type storageWithKeysTrigger struct { storage.Storage f func() diff --git a/server/templates.go b/server/templates.go index 4c11e2c4a372f7a65a1dc693618923139a3efab1..aff4568c1e50f9881c4e52c5b4567c992ff51484 100644 --- a/server/templates.go +++ b/server/templates.go @@ -139,6 +139,7 @@ func loadTemplates(c webConfig, templatesDir string) (*templates, error) { "issuer": func() string { return c.issuer }, "logo": func() string { return c.logoURL }, "url": func(s string) string { return join(c.issuerURL, s) }, + "lower": strings.ToLower, } tmpls, err := template.New("").Funcs(funcs).ParseFiles(filenames...) @@ -189,12 +190,13 @@ func (t *templates) login(w http.ResponseWriter, connectors []connectorInfo) err return renderTemplate(w, t.loginTmpl, data) } -func (t *templates) password(w http.ResponseWriter, postURL, lastUsername string, lastWasInvalid bool) error { +func (t *templates) password(w http.ResponseWriter, postURL, lastUsername, usernamePrompt string, lastWasInvalid bool) error { data := struct { - PostURL string - Username string - Invalid bool - }{postURL, lastUsername, lastWasInvalid} + PostURL string + Username string + UsernamePrompt string + Invalid bool + }{postURL, lastUsername, usernamePrompt, lastWasInvalid} return renderTemplate(w, t.passwordTmpl, data) } diff --git a/web/templates/password.html b/web/templates/password.html index 7a6c8aa6cbcae0811afd3c628806d4f1f57fe1a8..bd2e954dc1c4a243662d74f15f76b53c069228e6 100644 --- a/web/templates/password.html +++ b/web/templates/password.html @@ -5,9 +5,9 @@ <form method="post" action="{{ .PostURL }}"> <div class="theme-form-row"> <div class="theme-form-label"> - <label for="userid">Username</label> + <label for="userid">{{ .UsernamePrompt }}</label> </div> - <input tabindex="1" required id="login" name="login" type="text" class="theme-form-input" placeholder="username" {{ if .Username }} value="{{ .Username }}" {{ else }} autofocus {{ end }}/> + <input tabindex="1" required id="login" name="login" type="text" class="theme-form-input" placeholder="{{ .UsernamePrompt | lower }}" {{ if .Username }} value="{{ .Username }}" {{ else }} autofocus {{ end }}/> </div> <div class="theme-form-row"> <div class="theme-form-label"> @@ -18,7 +18,7 @@ {{ if .Invalid }} <div id="login-error" class="dex-error-box"> - Invalid username and password. + Invalid {{ .UsernamePrompt }} and password. </div> {{ end }}