diff --git a/server/bindata.go b/server/bindata.go index 716f561c52ed59dd775c7207b67feeef8002ef80..ede564c5002e70b57b5c8cc78ca495b3cc3fbdcf 100644 --- a/server/bindata.go +++ b/server/bindata.go @@ -1118,7 +1118,7 @@ var _templatesLogin_otpHtml = []byte(`{{template "header" .}} {{end}} <label for="inputOTP" class="sr-only">OTP</label> - <input type="text" id="inputOTP" name="otp" size="6" class="form-control" required autofocus> + <input type="text" id="inputOTP" name="otp" size="6" class="form-control" required autofocus autocomplete="off"> <button type="submit" class="btn btn-primary">Login</button> @@ -1137,7 +1137,7 @@ func templatesLogin_otpHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "templates/login_otp.html", size: 543, mode: os.FileMode(420), modTime: time.Unix(1541234791, 0)} + info := bindataFileInfo{name: "templates/login_otp.html", size: 562, mode: os.FileMode(420), modTime: time.Unix(1550307595, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1164,13 +1164,13 @@ var _templatesLogin_passwordHtml = []byte(`{{template "header" .}} value="{{.Username}}" {{else}} placeholder="Username" - {{end}} required autofocus> + {{end}} autocomplete="off" required autofocus> <label for="inputPassword" class="sr-only"> Password </label> <input type="password" name="password" id="inputPassword" - class="form-control" placeholder="Password" required> + class="form-control" placeholder="Password" autocomplete="off" required> {{if .AccountRecoveryURL}} <p> @@ -1199,7 +1199,7 @@ func templatesLogin_passwordHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "templates/login_password.html", size: 1149, mode: os.FileMode(420), modTime: time.Unix(1542882702, 0)} + info := bindataFileInfo{name: "templates/login_password.html", size: 1187, mode: os.FileMode(420), modTime: time.Unix(1550307045, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1312,7 +1312,8 @@ var _templatesPageHtml = []byte(`{{define "header"}}<!DOCTYPE html> {{if .U2FSignRequest}}<meta name="u2f_request" value="{{json .U2FSignRequest}}">{{end}} <link rel="stylesheet" href="{{.URLPrefix}}/static/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO"> <link rel="stylesheet" href="{{.URLPrefix}}/static/css/signin.css" integrity="sha384-9Y3UkAyM3svAuamEoaXIxe+1MqBKJdZtL8S1FZjvE1XqkICDH7DTXNavnFV8Uk2o"> - <title>Sign In</title> + {{if .SiteFavicon}}<link rel="icon" type="image/x-icon" href="{{.URLPrefix}}/favicon.ico">{{end}} + <title>{{if .SiteName}}{{.SiteName}} - {{end}}Sign In</title> </head> <body> @@ -1347,7 +1348,7 @@ func templatesPageHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "templates/page.html", size: 1729, mode: os.FileMode(420), modTime: time.Unix(1550305837, 0)} + info := bindataFileInfo{name: "templates/page.html", size: 1870, mode: os.FileMode(420), modTime: time.Unix(1550311459, 0)} a := &asset{bytes: bytes, info: info} return a, nil } diff --git a/server/config.go b/server/config.go index 06f251e05b1a20ac87bc63b93858b5a50b6c86e9..b67de8837c6e63446829c1f13bb4307d1c04a885 100644 --- a/server/config.go +++ b/server/config.go @@ -42,6 +42,10 @@ type Config struct { KeyStoreEnableGroups []string `yaml:"keystore_enable_groups"` AccountRecoveryURL string `yaml:"account_recovery_url"` + SiteName string `yaml:"site_name"` + SiteLogo string `yaml:"site_logo"` + SiteFavicon string `yaml:"site_favicon"` + allowedServicesRx []*regexp.Regexp } diff --git a/server/http.go b/server/http.go index 12ecae6ef0e0d959bd98dc3955a63a1ef8dcd4cd..0fef31dc51ad95e2c46c3acb68fa729579130fa1 100644 --- a/server/http.go +++ b/server/http.go @@ -4,15 +4,18 @@ package server //go:generate go-bindata --nocompress --pkg server static/... templates/... import ( + "bytes" "context" "encoding/gob" "encoding/json" "fmt" "html/template" "io" + "io/ioutil" "log" "net/http" "net/url" + "os" "strings" "time" @@ -91,9 +94,13 @@ type Server struct { keystore ksclient.Client keystoreGroups []string csrfSecret []byte - tpl *template.Template + renderer *renderer urlPrefix string homepageRedirectURL string + + // User-configurable static data that we serve from memory. + siteLogo *staticContent + siteFavicon *staticContent } func sl2bl(sl []string) [][]byte { @@ -116,13 +123,14 @@ func New(loginService *LoginService, authClient authclient.Client, config *Confi Path: urlPrefix + "/", } + renderer := newRenderer(config) s := &Server{ authSessionLifetime: defaultAuthSessionLifetime, authSessionStore: store, loginService: loginService, urlPrefix: urlPrefix, homepageRedirectURL: config.HomepageRedirectURL, - tpl: parseEmbeddedTemplates(), + renderer: renderer, } if config.CSRFSecret != "" { s.csrfSecret = []byte(config.CSRFSecret) @@ -131,6 +139,21 @@ func New(loginService *LoginService, authClient authclient.Client, config *Confi s.authSessionLifetime = time.Duration(config.AuthSessionLifetimeSeconds) * time.Second } + if config.SiteLogo != "" { + siteLogo, err := loadStaticContent(config.SiteLogo) + if err != nil { + return nil, err + } + s.siteLogo = siteLogo + } + if config.SiteFavicon != "" { + siteFavicon, err := loadStaticContent(config.SiteFavicon) + if err != nil { + return nil, err + } + s.siteFavicon = siteFavicon + } + if config.KeyStore != nil { ks, err := ksclient.New(config.KeyStore) if err != nil { @@ -146,8 +169,8 @@ func New(loginService *LoginService, authClient authclient.Client, config *Confi return nil, err } s.loginHandler = newLoginHandler(s.loginCallback, devMgr, authClient, - config.AuthService, config.U2FAppID, config.URLPrefix, config.AccountRecoveryURL, - s.tpl, sessionSecrets...) + config.AuthService, config.U2FAppID, config.URLPrefix, + renderer, sessionSecrets...) return s, nil } @@ -309,11 +332,9 @@ func (h *Server) handleLogout(w http.ResponseWriter, req *http.Request, session svcJSON, _ := json.Marshal(svcs) // nolint data := map[string]interface{}{ - "CSRFField": csrf.TemplateField(req), - "URLPrefix": h.urlPrefix, "Services": svcs, - "IncludeLogoutScripts": true, "ServicesJSON": string(svcJSON), + "IncludeLogoutScripts": true, } // Clear the local session. Ignore errors. @@ -335,7 +356,13 @@ func (h *Server) handleLogout(w http.ResponseWriter, req *http.Request, session w.Header().Set("Content-Security-Policy", logoutContentSecurityPolicy) - h.tpl.ExecuteTemplate(w, "logout.html", data) // nolint + body, err := h.renderer.Render(req, "logout.html", data) + if err != nil { + log.Printf("template error in logout(): %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Write(body) // nolint } func (h *Server) handleExchange(w http.ResponseWriter, req *http.Request) { @@ -376,6 +403,14 @@ func (h *Server) Handler() http.Handler { // everywhere. root := mux.NewRouter() + // If we have customized content, serve it from well-known URLs. + if h.siteLogo != nil { + root.Handle(h.urlFor("/img/site_logo"), h.siteLogo) + } + if h.siteFavicon != nil { + root.Handle(h.urlFor("/favicon.ico"), h.siteFavicon) + } + // Serve static content to anyone. staticPath := h.urlFor("/static/") root.PathPrefix(staticPath).Handler(http.StripPrefix(staticPath, http.FileServer(&assetfs.AssetFS{ @@ -458,3 +493,65 @@ func parseEmbeddedTemplates() *template.Template { } return root } + +type renderer struct { + tpl *template.Template + + urlPrefix string + siteName string + siteLogo string + siteFavicon string + accountRecoveryURL string +} + +func newRenderer(config *Config) *renderer { + return &renderer{ + tpl: parseEmbeddedTemplates(), + urlPrefix: config.URLPrefix, + accountRecoveryURL: config.AccountRecoveryURL, + siteName: config.SiteName, + siteLogo: config.SiteLogo, + siteFavicon: config.SiteFavicon, + } +} + +func (r *renderer) Render(req *http.Request, templateName string, data map[string]interface{}) ([]byte, error) { + data["CSRFField"] = csrf.TemplateField(req) + data["URLPrefix"] = r.urlPrefix + data["AccountRecoveryURL"] = r.accountRecoveryURL + data["SiteName"] = r.siteName + data["SiteLogo"] = r.siteLogo + data["SiteFavicon"] = r.siteFavicon + + var buf bytes.Buffer + if err := r.tpl.ExecuteTemplate(&buf, templateName, data); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +type staticContent struct { + modtime time.Time + name string + data []byte +} + +func loadStaticContent(path string) (*staticContent, error) { + stat, err := os.Stat(path) + if err != nil { + return nil, err + } + data, err := ioutil.ReadFile(path) // #nosec + if err != nil { + return nil, err + } + return &staticContent{ + name: path, + modtime: stat.ModTime(), + data: data, + }, nil +} + +func (c *staticContent) ServeHTTP(w http.ResponseWriter, req *http.Request) { + http.ServeContent(w, req, c.name, c.modtime, bytes.NewReader(c.data)) +} diff --git a/server/login.go b/server/login.go index 3118f933bbc1647b749a2fa62986ff273fddb3c8..cc7f5f021cbab3d4d0e4818d0ce3cbd59dea259c 100644 --- a/server/login.go +++ b/server/login.go @@ -1,19 +1,16 @@ package server import ( - "bytes" "encoding/gob" "encoding/json" "errors" "fmt" - "html/template" "log" "net/http" "net/url" "strings" "time" - "github.com/gorilla/csrf" "github.com/gorilla/sessions" "github.com/tstranex/u2f" "go.opencensus.io/trace" @@ -72,20 +69,19 @@ func init() { type loginCallbackFunc func(http.ResponseWriter, *http.Request, string, string, *auth.UserInfo) error type loginHandler struct { - authClient authclient.Client - authService string - u2fAppID string - urlPrefix string - devMgr *device.Manager - loginCallback loginCallbackFunc - loginSessionStore sessions.Store - tpl *template.Template - accountRecoveryURL string + authClient authclient.Client + authService string + u2fAppID string + urlPrefix string + devMgr *device.Manager + loginCallback loginCallbackFunc + loginSessionStore sessions.Store + renderer *renderer } // NewLoginHandler will wrap an http.Handler with the login workflow, // invoking it only on successful login. -func newLoginHandler(okHandler loginCallbackFunc, devMgr *device.Manager, authClient authclient.Client, authService, u2fAppID, urlPrefix, accountRecoveryURL string, tpl *template.Template, keyPairs ...[]byte) *loginHandler { +func newLoginHandler(okHandler loginCallbackFunc, devMgr *device.Manager, authClient authclient.Client, authService, u2fAppID, urlPrefix string, rndr *renderer, keyPairs ...[]byte) *loginHandler { store := sessions.NewCookieStore(keyPairs...) store.Options = &sessions.Options{ HttpOnly: true, @@ -93,15 +89,14 @@ func newLoginHandler(okHandler loginCallbackFunc, devMgr *device.Manager, authCl MaxAge: 0, } return &loginHandler{ - authClient: authClient, - authService: authService, - u2fAppID: u2fAppID, - urlPrefix: strings.TrimRight(urlPrefix, "/"), - devMgr: devMgr, - loginCallback: okHandler, - loginSessionStore: store, - accountRecoveryURL: accountRecoveryURL, - tpl: parseEmbeddedTemplates(), + authClient: authClient, + authService: authService, + u2fAppID: u2fAppID, + urlPrefix: strings.TrimRight(urlPrefix, "/"), + devMgr: devMgr, + loginCallback: okHandler, + loginSessionStore: store, + renderer: rndr, } } @@ -245,7 +240,8 @@ func (l *loginHandler) handlePassword(w http.ResponseWriter, req *http.Request, env["Error"] = true } - return l.executeTemplateToBuffer(req, "login_password.html", env) + body, err := l.renderer.Render(req, "login_password.html", env) + return loginStateNone, body, err } // Handle login with password and TOTP. @@ -265,7 +261,8 @@ func (l *loginHandler) handleOTP(w http.ResponseWriter, req *http.Request, sessi env["Error"] = true } - return l.executeTemplateToBuffer(req, "login_otp.html", env) + body, err := l.renderer.Render(req, "login_otp.html", env) + return loginStateNone, body, err } // Handle login with password and hardware token. @@ -293,7 +290,8 @@ func (l *loginHandler) handleU2F(w http.ResponseWriter, req *http.Request, sessi env["Error"] = true } - return l.executeTemplateToBuffer(req, "login_u2f.html", env) + body, err := l.renderer.Render(req, "login_u2f.html", env) + return loginStateNone, body, err } // Make the auth request to the authentication server. @@ -346,19 +344,6 @@ func (l *loginHandler) makeLoginURL(req *http.Request) string { return fmt.Sprintf("%s/login?%s", l.urlPrefix, v.Encode()) } -// Renders a template to a buffer, with a return value that makes it -// convenient to use in login handlers. -func (l *loginHandler) executeTemplateToBuffer(req *http.Request, templateName string, data map[string]interface{}) (loginState, []byte, error) { - data["CSRFField"] = csrf.TemplateField(req) - data["URLPrefix"] = l.urlPrefix - data["AccountRecoveryURL"] = l.accountRecoveryURL - var buf bytes.Buffer - if err := l.tpl.ExecuteTemplate(&buf, templateName, data); err != nil { - return loginStateNone, nil, err - } - return loginStateNone, buf.Bytes(), nil -} - // Template helper function that encodes its input as JSON. func toJSON(obj interface{}) string { data, err := json.Marshal(obj) diff --git a/server/templates/page.html b/server/templates/page.html index a216a3f22a10646e0067265cfc23ee624229e967..8615364f735f721a1272091328aef692910d5a0d 100644 --- a/server/templates/page.html +++ b/server/templates/page.html @@ -6,7 +6,8 @@ {{if .U2FSignRequest}}<meta name="u2f_request" value="{{json .U2FSignRequest}}">{{end}} <link rel="stylesheet" href="{{.URLPrefix}}/static/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO"> <link rel="stylesheet" href="{{.URLPrefix}}/static/css/signin.css" integrity="sha384-9Y3UkAyM3svAuamEoaXIxe+1MqBKJdZtL8S1FZjvE1XqkICDH7DTXNavnFV8Uk2o"> - <title>Sign In</title> + {{if .SiteFavicon}}<link rel="icon" type="image/x-icon" href="{{.URLPrefix}}/favicon.ico">{{end}} + <title>{{if .SiteName}}{{.SiteName}} - {{end}}Sign In</title> </head> <body>