Repository: coreos/dex
Pull request #1116 (2017-11-07 09:33:19)

password connectors: make prompt configurable
<img width="517" alt="screen shot 2017-11-07 at 10 32 29" src="https://user-images.githubusercontent.com/870638/32486463-fe1b7c34-c3a6-11e7-94f0-adf4e52d4d6e.png">
and
<img width="523" alt="screen shot 2017-11-07 at 10 32 53" src="https://user-images.githubusercontent.com/870638/32486488-0e7eb0f0-c3a7-11e7-8a7c-44a96030aa19.png">


ericchiang: Is this going to impact all login methods that require passwords? E.g. LDAP? Maybe we can o something where we say "email" for the local connector but "user" for the LDAP connector?
srenatus: @ericchiang Oh my -- you're so right. I forgot about the other password-y connectors. 🙄 

> Maybe we can o something where we say "email" for the local connector but "user" for the LDAP connector?

I'll look into how to make that happen.
srenatus: @ericchiang I've updated the branch -- what do you think? This changes the default display for LDAP users, please advise if you think this is acceptable, or how to make it acceptable.

Finally, we can support the enterprise 😄 
<img width="519" alt="screen shot 2017-11-08 at 10 41 14" src="https://user-images.githubusercontent.com/870638/32541939-7212defa-c471-11e7-9103-8afdd2e540e3.png">

srenatus: @ericchiang: simplified, rebased and squashed ✔️ 
srenatus: @glerchundi here we go 😃 

Patch:
diff --git a/Documentation/ldap-connector.md b/Documentation/ldap-connector.md
index 761d87d5..0a0813c7 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 bc5f3b18..c442c54a 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 5d19a51d..585907cc 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/examples/config-ldap.yaml b/examples/config-ldap.yaml
index 513a0005..6b423ddf 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 345cd496..c265e0b1 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 e0b7d359..f915b5ac 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/templates.go b/server/templates.go
index 4c11e2c4..aff4568c 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 7a6c8aa6..bd2e954d 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 }}
 


Test patch:
diff --git a/connector/ldap/ldap_test.go b/connector/ldap/ldap_test.go
index 3b903856..23ad593b 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 18abd820..4a8b1257 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/server/server_test.go b/server/server_test.go
index a67cfbf4..b5f73363 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()