From 608d8ba98419181ae88b0dd2ae5d48b99a7d2141 Mon Sep 17 00:00:00 2001 From: Eric Chiang Date: Thu, 25 Aug 2016 13:10:19 -0700 Subject: [PATCH] *: switch dex to the ported templates --- cmd/dex/config.go | 3 + cmd/dex/serve.go | 8 +- server/handlers.go | 17 +-- server/server.go | 10 ++ server/server_test.go | 14 +-- server/templates.go | 241 +++++++++++++++++++++++++++------------ server/templates_test.go | 15 +++ 7 files changed, 216 insertions(+), 92 deletions(-) diff --git a/cmd/dex/config.go b/cmd/dex/config.go index c97ebc86..2b08885d 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -8,6 +8,7 @@ import ( "github.com/coreos/dex/connector/ldap" "github.com/coreos/dex/connector/mock" "github.com/coreos/dex/connector/oidc" + "github.com/coreos/dex/server" "github.com/coreos/dex/storage" "github.com/coreos/dex/storage/kubernetes" "github.com/coreos/dex/storage/memory" @@ -21,6 +22,8 @@ type Config struct { Web Web `yaml:"web"` OAuth2 OAuth2 `yaml:"oauth2"` + Templates server.TemplateConfig `yaml:"templates"` + StaticClients []storage.Client `yaml:"staticClients"` } diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index be27f12b..24beb4b6 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -89,11 +89,11 @@ func serve(cmd *cobra.Command, args []string) error { } serverConfig := server.Config{ - Issuer: c.Issuer, - Connectors: connectors, - Storage: s, - SupportedResponseTypes: c.OAuth2.ResponseTypes, + Issuer: c.Issuer, + Connectors: connectors, + Storage: s, + TemplateConfig: c.Templates, } serv, err := server.New(serverConfig) diff --git a/server/handlers.go b/server/handlers.go index 3aa0b368..ebbc8740 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -129,15 +129,16 @@ func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { connectorInfos := make([]connectorInfo, len(s.connectors)) i := 0 - for id := range s.connectors { + for id, conn := range s.connectors { connectorInfos[i] = connectorInfo{ - DisplayName: id, - URL: s.absPath("/auth", id), + ID: id, + Name: conn.DisplayName, + URL: s.absPath("/auth", id), } i++ } - renderLoginOptions(w, connectorInfos, state) + s.templates.login(w, connectorInfos, state) } func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { @@ -163,7 +164,7 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { } http.Redirect(w, r, callbackURL, http.StatusFound) case connector.PasswordConnector: - renderPasswordTmpl(w, state, r.URL.String(), "") + s.templates.password(w, state, r.URL.String(), "", false) default: s.notFound(w, r) } @@ -174,7 +175,7 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { return } - username := r.FormValue("username") + username := r.FormValue("login") password := r.FormValue("password") identity, ok, err := passwordConnector.Login(username, password) @@ -184,7 +185,7 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { return } if !ok { - renderPasswordTmpl(w, state, r.URL.String(), "Invalid credentials") + s.templates.password(w, state, r.URL.String(), username, true) return } redirectURL, err := s.finalizeLogin(identity, state, connID, conn.Connector) @@ -299,7 +300,7 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { s.renderError(w, http.StatusInternalServerError, errServerError, "") return } - renderApprovalTmpl(w, authReq.ID, *authReq.Claims, client, authReq.Scopes) + s.templates.approval(w, authReq.ID, authReq.Claims.Username, client.Name, authReq.Scopes) case "POST": if r.FormValue("approval") != "approve" { s.renderError(w, http.StatusInternalServerError, "approval rejected", "") diff --git a/server/server.go b/server/server.go index ef2697db..2e3c00af 100644 --- a/server/server.go +++ b/server/server.go @@ -43,6 +43,8 @@ type Config struct { // If specified, the server will use this function for determining time. Now func() time.Time + + TemplateConfig TemplateConfig } func value(val, defaultValue time.Duration) time.Duration { @@ -63,6 +65,8 @@ type Server struct { mux http.Handler + templates *templates + // If enabled, don't prompt user for approval after logging in through connector. // No package level API to set this, only used in tests. skipApproval bool @@ -107,6 +111,11 @@ func newServer(c Config, rotationStrategy rotationStrategy) (*Server, error) { supported[respType] = true } + tmpls, err := loadTemplates(c.TemplateConfig) + if err != nil { + return nil, fmt.Errorf("server: failed to load templates: %v", err) + } + now := c.Now if now == nil { now = time.Now @@ -124,6 +133,7 @@ func newServer(c Config, rotationStrategy rotationStrategy) (*Server, error) { supportedResponseTypes: supported, idTokensValidFor: value(c.IDTokensValidFor, 24*time.Hour), now: now, + templates: tmpls, } for _, conn := range c.Connectors { diff --git a/server/server_test.go b/server/server_test.go index 4865ab1f..296f22cc 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -64,7 +64,7 @@ FDWV28nTP9sqbtsmU8Tem2jzMvZ7C/Q0AuDoKELFUpux8shm8wfIhyaPnXUGZoAZ Np4vUwMSYV5mopESLWOg3loBxKyLGFtgGKVCjGiQvy6zISQ4fQo= -----END RSA PRIVATE KEY-----`) -func newTestServer(updateConfig func(c *Config)) (*httptest.Server, *Server) { +func newTestServer(t *testing.T, updateConfig func(c *Config)) (*httptest.Server, *Server) { var server *Server s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server.ServeHTTP(w, r) @@ -76,7 +76,7 @@ func newTestServer(updateConfig func(c *Config)) (*httptest.Server, *Server) { { ID: "mock", DisplayName: "Mock", - Connector: mock.New(), + Connector: mock.NewCallbackConnector(), }, }, } @@ -87,21 +87,21 @@ func newTestServer(updateConfig func(c *Config)) (*httptest.Server, *Server) { var err error if server, err = newServer(config, staticRotationStrategy(testKey)); err != nil { - panic(err) + t.Fatal(err) } server.skipApproval = true // Don't prompt for approval, just immediately redirect with code. return s, server } func TestNewTestServer(t *testing.T) { - newTestServer(nil) + newTestServer(t, nil) } func TestDiscovery(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - httpServer, _ := newTestServer(func(c *Config) { + httpServer, _ := newTestServer(t, func(c *Config) { c.Issuer = c.Issuer + "/non-root-path" }) defer httpServer.Close() @@ -129,7 +129,7 @@ func TestOAuth2CodeFlow(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - httpServer, s := newTestServer(func(c *Config) { + httpServer, s := newTestServer(t, func(c *Config) { c.Issuer = c.Issuer + "/non-root-path" }) defer httpServer.Close() @@ -255,7 +255,7 @@ func TestOAuth2ImplicitFlow(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - httpServer, s := newTestServer(func(c *Config) { + httpServer, s := newTestServer(t, func(c *Config) { // Enable support for the implicit flow. c.SupportedResponseTypes = []string{"code", "token"} }) diff --git a/server/templates.go b/server/templates.go index 7bc64144..a0d1ce17 100644 --- a/server/templates.go +++ b/server/templates.go @@ -1,101 +1,196 @@ package server import ( + "fmt" + "io" + "io/ioutil" "log" "net/http" + "path/filepath" + "sort" "text/template" - - "github.com/coreos/dex/storage" ) -type connectorInfo struct { - DisplayName string - URL string +const ( + tmplApproval = "approval.html" + tmplLogin = "login.html" + tmplPassword = "password.html" +) + +const coreOSLogoURL = "https://coreos.com/assets/images/brand/coreos-wordmark-135x40px.png" + +var requiredTmpls = []string{ + tmplApproval, + tmplLogin, + tmplPassword, } -var loginTmpl = template.Must(template.New("login-template").Parse(` - - -

Login options

-{{ range $i, $connector := .Connectors }} -{{ $connector.DisplayName }} -{{ end }} - -`)) +// TemplateConfig describes. +type TemplateConfig struct { + // Directory of the templates. If empty, these will be loaded from memory. + Dir string `yaml:"dir"` + + // Defaults to the CoreOS logo and "dex". + LogoURL string `yaml:"logoURL"` + Issuer string `yaml:"issuerName"` +} + +type globalData struct { + LogoURL string + Issuer string +} + +func loadTemplates(config TemplateConfig) (*templates, error) { + var tmpls *template.Template + if config.Dir != "" { + files, err := ioutil.ReadDir(config.Dir) + if err != nil { + return nil, fmt.Errorf("read dir: %v", err) + } + filenames := []string{} + for _, file := range files { + if file.IsDir() { + continue + } + filenames = append(filenames, filepath.Join(config.Dir, file.Name())) + } + if len(filenames) == 0 { + return nil, fmt.Errorf("no files in template dir %s", config.Dir) + } + if tmpls, err = template.ParseFiles(filenames...); err != nil { + return nil, fmt.Errorf("parse files: %v", err) + } + } else { + // Load templates from memory. This code is largely copied from the standard library's + // ParseFiles source code. + // See: https://goo.gl/6Wm4mN + for name, data := range defaultTemplates { + var t *template.Template + if tmpls == nil { + tmpls = template.New(name) + } + if name == tmpls.Name() { + t = tmpls + } else { + t = tmpls.New(name) + } + if _, err := t.Parse(data); err != nil { + return nil, fmt.Errorf("parsing %s: %v", name, err) + } + } + } + + missingTmpls := []string{} + for _, tmplName := range requiredTmpls { + if tmpls.Lookup(tmplName) == nil { + missingTmpls = append(missingTmpls, tmplName) + } + } + if len(missingTmpls) > 0 { + return nil, fmt.Errorf("missing template(s): %s", missingTmpls) + } + + if config.LogoURL == "" { + config.LogoURL = coreOSLogoURL + } + if config.Issuer == "" { + config.Issuer = "dex" + } + + return &templates{ + globalData: config, + loginTmpl: tmpls.Lookup(tmplLogin), + approvalTmpl: tmpls.Lookup(tmplApproval), + passwordTmpl: tmpls.Lookup(tmplPassword), + }, nil +} + +var scopeDescriptions = map[string]string{ + "offline_access": "Have offline access", + "profile": "View basic profile information", + "email": "View your email", +} + +type templates struct { + globalData TemplateConfig + loginTmpl *template.Template + approvalTmpl *template.Template + passwordTmpl *template.Template +} + +type connectorInfo struct { + ID string + Name string + URL string +} + +type byName []connectorInfo + +func (n byName) Len() int { return len(n) } +func (n byName) Less(i, j int) bool { return n[i].Name < n[j].Name } +func (n byName) Swap(i, j int) { n[i], n[j] = n[j], n[i] } + +func (t *templates) login(w http.ResponseWriter, connectors []connectorInfo, state string) { + sort.Sort(byName(connectors)) -func renderLoginOptions(w http.ResponseWriter, connectors []connectorInfo, state string) { data := struct { + TemplateConfig Connectors []connectorInfo State string - }{connectors, state} - renderTemplate(w, loginTmpl, data) + }{t.globalData, connectors, state} + renderTemplate(w, t.loginTmpl, data) } -var passwordTmpl = template.Must(template.New("password-template").Parse(` - -

Login

-
-Login:
-Password:
- - -{{ if .Message }} -

Error: {{ .Message }}

-{{ end }} -
- -`)) - -func renderPasswordTmpl(w http.ResponseWriter, state, callback, message string) { +func (t *templates) password(w http.ResponseWriter, state, callback, lastUsername string, lastWasInvalid bool) { data := struct { + TemplateConfig State string - Callback string - Message string - }{state, callback, message} - renderTemplate(w, passwordTmpl, data) + PostURL string + Username string + Invalid bool + }{t.globalData, state, callback, lastUsername, lastWasInvalid} + renderTemplate(w, t.passwordTmpl, data) } -var approvalTmpl = template.Must(template.New("approval-template").Parse(` - -

User: {{ .User }}

-

Client: {{ .ClientName }}

-
- - - -
-
- - - -
- -`)) - -func renderApprovalTmpl(w http.ResponseWriter, state string, identity storage.Claims, client storage.Client, scopes []string) { +func (t *templates) approval(w http.ResponseWriter, state, username, clientName string, scopes []string) { + accesses := []string{} + for _, scope := range scopes { + access, ok := scopeDescriptions[scope] + if ok { + accesses = append(accesses, access) + } + } + sort.Strings(accesses) data := struct { - User string - ClientName string - State string - }{identity.Email, client.Name, state} - renderTemplate(w, approvalTmpl, data) + TemplateConfig + User string + Client string + State string + Scopes []string + }{t.globalData, username, clientName, state, accesses} + renderTemplate(w, t.approvalTmpl, data) +} + +// small io.Writer utilitiy to determine if executing the template wrote to the underlying response writer. +type writeRecorder struct { + wrote bool + w io.Writer +} + +func (w *writeRecorder) Write(p []byte) (n int, err error) { + w.wrote = true + return w.w.Write(p) } func renderTemplate(w http.ResponseWriter, tmpl *template.Template, data interface{}) { - err := tmpl.Execute(w, data) - if err == nil { - return - } - - switch err := err.(type) { - case template.ExecError: - // An ExecError guarentees that Execute has not written to the underlying reader. + wr := &writeRecorder{w: w} + if err := tmpl.Execute(wr, data); err != nil { log.Printf("Error rendering template %s: %s", tmpl.Name(), err) - // TODO(ericchiang): replace with better internal server error. - http.Error(w, "Internal server error", http.StatusInternalServerError) - default: - // An error with the underlying write, such as the connection being - // dropped. Ignore for now. + if !wr.wrote { + // TODO(ericchiang): replace with better internal server error. + http.Error(w, "Internal server error", http.StatusInternalServerError) + } } + return } diff --git a/server/templates_test.go b/server/templates_test.go index abb4e431..efbb29ed 100644 --- a/server/templates_test.go +++ b/server/templates_test.go @@ -1 +1,16 @@ package server + +import "testing" + +func TestNewTemplates(t *testing.T) { + var config TemplateConfig + if _, err := loadTemplates(config); err != nil { + t.Fatal(err) + } +} + +func TestLoadTemplates(t *testing.T) { + var config TemplateConfig + + config.Dir = "../web/templates" +}