*: add theme based frontend configuration

This PR reworks the web layout so static files can be provided and
a "themes" directory to allow a certain degree of control over logos,
styles, etc.

This PR does NOT add general support for frontend customization,
only enough to allow us to start exploring theming internally.
The dex binary also must now be run from the root directory since
templates are no longer "compiled into" the binary.

The docker image has been updated with frontend assets.
This commit is contained in:
Eric Chiang 2016-11-30 14:26:54 -08:00
parent e267dbd236
commit 391dc51c13
16 changed files with 175 additions and 556 deletions

View file

@ -13,6 +13,11 @@ RUN apk add --update ca-certificates openssl
COPY _output/bin/dex /usr/local/bin/dex COPY _output/bin/dex /usr/local/bin/dex
# Import frontend assets and set the correct CWD directory so the assets
# are in the default path.
COPY web /web
WORKDIR /
ENTRYPOINT ["dex"] ENTRYPOINT ["dex"]
CMD ["version"] CMD ["version"]

View file

@ -25,7 +25,7 @@ LD_FLAGS="-w -X $(REPO_PATH)/version.Version=$(VERSION)"
build: bin/dex bin/example-app build: bin/dex bin/example-app
bin/dex: FORCE generated bin/dex: FORCE
@go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex @go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex
bin/example-app: FORCE bin/example-app: FORCE
@ -35,9 +35,6 @@ bin/example-app: FORCE
release-binary: release-binary:
@go build -o _output/bin/dex -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex @go build -o _output/bin/dex -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex
.PHONY: generated
generated: server/templates_default.go
test: test:
@go test -v -i $(shell go list ./... | grep -v '/vendor/') @go test -v -i $(shell go list ./... | grep -v '/vendor/')
@go test -v $(shell go list ./... | grep -v '/vendor/') @go test -v $(shell go list ./... | grep -v '/vendor/')
@ -57,9 +54,6 @@ lint:
golint -set_exit_status $$package $$i || exit 1; \ golint -set_exit_status $$package $$i || exit 1; \
done done
server/templates_default.go: $(wildcard web/templates/**)
@go run server/templates_default_gen.go
_output/bin/dex: _output/bin/dex:
# Using rkt to build the dex binary. # Using rkt to build the dex binary.
@./scripts/rkt-build @./scripts/rkt-build

View file

@ -30,7 +30,7 @@ type Config struct {
GRPC GRPC `json:"grpc"` GRPC GRPC `json:"grpc"`
Expiry Expiry `json:"expiry"` Expiry Expiry `json:"expiry"`
Templates server.TemplateConfig `json:"templates"` Frontend server.WebConfig `json:"frontend"`
// StaticClients cause the server to use this list of clients rather than // StaticClients cause the server to use this list of clients rather than
// querying the storage. Write operations, like creating a client, will fail. // querying the storage. Write operations, like creating a client, will fail.

View file

@ -151,7 +151,7 @@ func serve(cmd *cobra.Command, args []string) error {
Issuer: c.Issuer, Issuer: c.Issuer,
Connectors: connectors, Connectors: connectors,
Storage: s, Storage: s,
TemplateConfig: c.Templates, Web: c.Frontend,
EnablePasswordDB: c.EnablePasswordDB, EnablePasswordDB: c.EnablePasswordDB,
} }
if c.Expiry.SigningKeys != "" { if c.Expiry.SigningKeys != "" {

View file

@ -14,7 +14,7 @@ storage:
# Configuration for the HTTP endpoints. # Configuration for the HTTP endpoints.
web: web:
http: 127.0.0.1:5556 http: 0.0.0.0:5556
# Uncomment for HTTPS options. # Uncomment for HTTPS options.
# https: 127.0.0.1:5554 # https: 127.0.0.1:5554
# tlsCert: /etc/dex/tls.crt # tlsCert: /etc/dex/tls.crt

View file

@ -56,7 +56,32 @@ type Config struct {
EnablePasswordDB bool EnablePasswordDB bool
TemplateConfig TemplateConfig Web WebConfig
}
// WebConfig holds the server's frontend templates and asset configuration.
//
// These are currently very custom to CoreOS and it's not recommended that
// outside users attempt to customize these.
type WebConfig struct {
// A filepath to web static.
//
// It is expected to contain the following directories:
//
// * static - Static static served at "( issuer URL )/static".
// * templates - HTML templates controlled by dex.
// * themes/(theme) - Static static served at "( issuer URL )/theme".
//
Dir string
// Defaults to "( issuer URL )/theme/logo.png"
LogoURL string
// Defaults to "dex"
Issuer string
// Defaults to "coreos"
Theme string
} }
func value(val, defaultValue time.Duration) time.Duration { func value(val, defaultValue time.Duration) time.Duration {
@ -130,9 +155,17 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy)
supported[respType] = true supported[respType] = true
} }
tmpls, err := loadTemplates(c.TemplateConfig) web := webConfig{
dir: c.Web.Dir,
logoURL: c.Web.LogoURL,
issuerURL: c.Issuer,
issuer: c.Web.Issuer,
theme: c.Web.Theme,
}
static, theme, tmpls, err := loadWebConfig(web)
if err != nil { if err != nil {
return nil, fmt.Errorf("server: failed to load templates: %v", err) return nil, fmt.Errorf("server: failed to load web static: %v", err)
} }
now := c.Now now := c.Now
@ -159,6 +192,10 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy)
handleFunc := func(p string, h http.HandlerFunc) { handleFunc := func(p string, h http.HandlerFunc) {
r.HandleFunc(path.Join(issuerURL.Path, p), h) r.HandleFunc(path.Join(issuerURL.Path, p), h)
} }
handlePrefix := func(p string, h http.Handler) {
prefix := path.Join(issuerURL.Path, p)
r.PathPrefix(prefix).Handler(http.StripPrefix(prefix, h))
}
r.NotFoundHandler = http.HandlerFunc(s.notFound) r.NotFoundHandler = http.HandlerFunc(s.notFound)
discoveryHandler, err := s.discoveryHandler() discoveryHandler, err := s.discoveryHandler()
@ -175,6 +212,8 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy)
handleFunc("/callback", s.handleConnectorCallback) handleFunc("/callback", s.handleConnectorCallback)
handleFunc("/approval", s.handleApproval) handleFunc("/approval", s.handleApproval)
handleFunc("/healthz", s.handleHealth) handleFunc("/healthz", s.handleHealth)
handlePrefix("/static", static)
handlePrefix("/theme", theme)
s.mux = r s.mux = r
startKeyRotation(ctx, c.Storage, rotationStrategy, now) startKeyRotation(ctx, c.Storage, rotationStrategy, now)

View file

@ -11,6 +11,8 @@ import (
"net/http/httptest" "net/http/httptest"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
"os"
"path/filepath"
"reflect" "reflect"
"sort" "sort"
"strings" "strings"
@ -85,6 +87,9 @@ func newTestServer(ctx context.Context, t *testing.T, updateConfig func(c *Confi
Connector: mock.NewCallbackConnector(), Connector: mock.NewCallbackConnector(),
}, },
}, },
Web: WebConfig{
Dir: filepath.Join(os.Getenv("GOPATH"), "src/github.com/coreos/dex/web"),
},
} }
if updateConfig != nil { if updateConfig != nil {
updateConfig(&config) updateConfig(&config)

View file

@ -6,8 +6,10 @@ import (
"io/ioutil" "io/ioutil"
"log" "log"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings"
"text/template" "text/template"
) )
@ -18,8 +20,6 @@ const (
tmplOOB = "oob.html" tmplOOB = "oob.html"
) )
const coreOSLogoURL = "https://coreos.com/assets/images/brand/coreos-wordmark-135x40px.png"
var requiredTmpls = []string{ var requiredTmpls = []string{
tmplApproval, tmplApproval,
tmplLogin, tmplLogin,
@ -27,65 +27,122 @@ var requiredTmpls = []string{
tmplOOB, tmplOOB,
} }
// TemplateConfig describes. type templates struct {
type TemplateConfig struct { loginTmpl *template.Template
// TODO(ericchiang): Asking for a directory with a set of templates doesn't indicate approvalTmpl *template.Template
// what the templates should look like and doesn't allow consumers of this package to passwordTmpl *template.Template
// provide their own templates in memory. In the future clean this up. oobTmpl *template.Template
// 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 { type webConfig struct {
LogoURL string dir string
Issuer string logoURL string
issuer string
theme string
issuerURL string
} }
func loadTemplates(config TemplateConfig) (*templates, error) { func join(base, path string) string {
var tmpls *template.Template b := strings.HasSuffix(base, "/")
if config.Dir != "" { p := strings.HasPrefix(path, "/")
files, err := ioutil.ReadDir(config.Dir) switch {
if err != nil { case b && p:
return nil, fmt.Errorf("read dir: %v", err) return base + path[1:]
case b || p:
return base + path
default:
return base + "/" + path
}
}
func dirExists(dir string) error {
stat, err := os.Stat(dir)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("directory %q does not exist", dir)
} }
filenames := []string{} return fmt.Errorf("stat directory %q: %v", dir, err)
for _, file := range files { }
if file.IsDir() { if !stat.IsDir() {
continue return fmt.Errorf("path %q is a file not a directory", dir)
} }
filenames = append(filenames, filepath.Join(config.Dir, file.Name())) return nil
} }
if len(filenames) == 0 {
return nil, fmt.Errorf("no files in template dir %s", config.Dir) // loadWebConfig returns static assets, theme assets, and templates used by the frontend by
} // reading the directory specified in the webConfig.
if tmpls, err = template.ParseFiles(filenames...); err != nil { //
return nil, fmt.Errorf("parse files: %v", err) // The directory layout is expected to be:
} //
} else { // ( web directory )
// Load templates from memory. This code is largely copied from the standard library's // |- static
// ParseFiles source code. // |- themes
// See: https://goo.gl/6Wm4mN // | |- (theme name)
for name, data := range defaultTemplates { // |- templates
var t *template.Template //
if tmpls == nil { func loadWebConfig(c webConfig) (static, theme http.Handler, templates *templates, err error) {
tmpls = template.New(name) if c.theme == "" {
} c.theme = "coreos"
if name == tmpls.Name() { }
t = tmpls if c.issuer == "" {
} else { c.issuer = "dex"
t = tmpls.New(name) }
} if c.dir == "" {
if _, err := t.Parse(data); err != nil { c.dir = "./web"
return nil, fmt.Errorf("parsing %s: %v", name, err) }
} if c.logoURL == "" {
c.logoURL = join(c.issuerURL, "theme/logo.png")
}
if err := dirExists(c.dir); err != nil {
return nil, nil, nil, fmt.Errorf("load web dir: %v", err)
}
staticDir := filepath.Join(c.dir, "static")
templatesDir := filepath.Join(c.dir, "templates")
themeDir := filepath.Join(c.dir, "themes", c.theme)
for _, dir := range []string{staticDir, templatesDir, themeDir} {
if err := dirExists(dir); err != nil {
return nil, nil, nil, fmt.Errorf("load dir: %v", err)
} }
} }
static = http.FileServer(http.Dir(staticDir))
theme = http.FileServer(http.Dir(themeDir))
templates, err = loadTemplates(c, templatesDir)
return
}
// loadTemplates parses the expected templates from the provided directory.
func loadTemplates(c webConfig, templatesDir string) (*templates, error) {
files, err := ioutil.ReadDir(templatesDir)
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(templatesDir, file.Name()))
}
if len(filenames) == 0 {
return nil, fmt.Errorf("no files in template dir %q", templatesDir)
}
funcs := map[string]interface{}{
"issuer": func() string { return c.issuer },
"logo": func() string { return c.logoURL },
"url": func(s string) string { return join(c.issuerURL, s) },
}
tmpls, err := template.New("").Funcs(funcs).ParseFiles(filenames...)
if err != nil {
return nil, fmt.Errorf("parse files: %v", err)
}
missingTmpls := []string{} missingTmpls := []string{}
for _, tmplName := range requiredTmpls { for _, tmplName := range requiredTmpls {
if tmpls.Lookup(tmplName) == nil { if tmpls.Lookup(tmplName) == nil {
@ -95,16 +152,7 @@ func loadTemplates(config TemplateConfig) (*templates, error) {
if len(missingTmpls) > 0 { if len(missingTmpls) > 0 {
return nil, fmt.Errorf("missing template(s): %s", missingTmpls) return nil, fmt.Errorf("missing template(s): %s", missingTmpls)
} }
if config.LogoURL == "" {
config.LogoURL = coreOSLogoURL
}
if config.Issuer == "" {
config.Issuer = "dex"
}
return &templates{ return &templates{
globalData: config,
loginTmpl: tmpls.Lookup(tmplLogin), loginTmpl: tmpls.Lookup(tmplLogin),
approvalTmpl: tmpls.Lookup(tmplApproval), approvalTmpl: tmpls.Lookup(tmplApproval),
passwordTmpl: tmpls.Lookup(tmplPassword), passwordTmpl: tmpls.Lookup(tmplPassword),
@ -118,14 +166,6 @@ var scopeDescriptions = map[string]string{
"email": "View your email", "email": "View your email",
} }
type templates struct {
globalData TemplateConfig
loginTmpl *template.Template
approvalTmpl *template.Template
passwordTmpl *template.Template
oobTmpl *template.Template
}
type connectorInfo struct { type connectorInfo struct {
ID string ID string
Name string Name string
@ -142,21 +182,19 @@ func (t *templates) login(w http.ResponseWriter, connectors []connectorInfo, aut
sort.Sort(byName(connectors)) sort.Sort(byName(connectors))
data := struct { data := struct {
TemplateConfig
Connectors []connectorInfo Connectors []connectorInfo
AuthReqID string AuthReqID string
}{t.globalData, connectors, authReqID} }{connectors, authReqID}
renderTemplate(w, t.loginTmpl, data) renderTemplate(w, t.loginTmpl, data)
} }
func (t *templates) password(w http.ResponseWriter, authReqID, callback, lastUsername string, lastWasInvalid bool) { func (t *templates) password(w http.ResponseWriter, authReqID, callback, lastUsername string, lastWasInvalid bool) {
data := struct { data := struct {
TemplateConfig
AuthReqID string AuthReqID string
PostURL string PostURL string
Username string Username string
Invalid bool Invalid bool
}{t.globalData, authReqID, callback, lastUsername, lastWasInvalid} }{authReqID, string(callback), lastUsername, lastWasInvalid}
renderTemplate(w, t.passwordTmpl, data) renderTemplate(w, t.passwordTmpl, data)
} }
@ -170,20 +208,18 @@ func (t *templates) approval(w http.ResponseWriter, authReqID, username, clientN
} }
sort.Strings(accesses) sort.Strings(accesses)
data := struct { data := struct {
TemplateConfig
User string User string
Client string Client string
AuthReqID string AuthReqID string
Scopes []string Scopes []string
}{t.globalData, username, clientName, authReqID, accesses} }{username, clientName, authReqID, accesses}
renderTemplate(w, t.approvalTmpl, data) renderTemplate(w, t.approvalTmpl, data)
} }
func (t *templates) oob(w http.ResponseWriter, code string) { func (t *templates) oob(w http.ResponseWriter, code string) {
data := struct { data := struct {
TemplateConfig
Code string Code string
}{t.globalData, code} }{code}
renderTemplate(w, t.oobTmpl, data) renderTemplate(w, t.oobTmpl, data)
} }

File diff suppressed because one or more lines are too long

View file

@ -1,85 +0,0 @@
// +build ignore
package main
import (
"bytes"
"fmt"
"io/ioutil"
"log"
"os/exec"
"path/filepath"
)
// ignoreFile uses "git check-ignore" to determine if we should ignore a file.
func ignoreFile(p string) (ok bool, err error) {
err = exec.Command("git", "check-ignore", p).Run()
if err == nil {
return true, nil
}
exitErr, ok := err.(*exec.ExitError)
if ok {
if sys := exitErr.Sys(); sys != nil {
e, ok := sys.(interface {
// Is the returned value something that returns an exit status?
ExitStatus() int
})
if ok && e.ExitStatus() == 1 {
return false, nil
}
}
}
return false, err
}
// Maps aren't deterministic, use a struct instead.
type fileData struct {
name string
data string
}
func main() {
// ReadDir guarentees result in sorted order.
dir, err := ioutil.ReadDir("web/templates")
if err != nil {
log.Fatal(err)
}
files := []fileData{}
for _, file := range dir {
p := filepath.Join("web/templates", file.Name())
ignore, err := ignoreFile(p)
if err != nil {
log.Fatal(err)
}
if ignore {
continue
}
data, err := ioutil.ReadFile(p)
if err != nil {
log.Fatal(err)
}
if bytes.Contains(data, []byte{'`'}) {
log.Fatalf("file %s contains escape character '`' and cannot be compiled into go source", p)
}
files = append(files, fileData{file.Name(), string(data)})
}
f := new(bytes.Buffer)
fmt.Fprintln(f, "// This file was generated by the makefile. Do not edit.")
fmt.Fprintln(f)
fmt.Fprintln(f, "package server")
fmt.Fprintln(f)
fmt.Fprintln(f, "// defaultTemplates is a key for file name to file data of the files in web/templates.")
fmt.Fprintln(f, "var defaultTemplates = map[string]string{")
for _, file := range files {
fmt.Fprintf(f, "\t%q: `%s`,\n", file.name, file.data)
}
fmt.Fprintln(f, "}")
if err := ioutil.WriteFile("server/templates_default.go", f.Bytes(), 0644); err != nil {
log.Fatal(err)
}
}

View file

@ -1,16 +1 @@
package server 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"
}

0
web/static/main.css Normal file
View file

View file

@ -3,8 +3,10 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>{{ .Issuer }}</title> <title>{{ issuer }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="{{ url "static/main.css" }}" rel="stylesheet">
<link href="{{ url "theme/style.css" }}" rel="stylesheet">
<style> <style>
* { * {
-webkit-box-sizing: border-box; -webkit-box-sizing: border-box;
@ -232,7 +234,7 @@
<body> <body>
<div id="navbar"> <div id="navbar">
<div id="navbar-logo-wrap"> <div id="navbar-logo-wrap">
<img id="navbar-logo" src="{{ .LogoURL }}"> <img id="navbar-logo" src="{{ logo }}">
</div> </div>
</div> </div>

View file

@ -1,7 +1,7 @@
{{ template "header.html" . }} {{ template "header.html" . }}
<div class="panel"> <div class="panel">
<h2 class="heading">Log in to {{ .Issuer }} </h2> <h2 class="heading">Log in to {{ issuer }} </h2>
<div> <div>
{{ range $c := .Connectors }} {{ range $c := .Connectors }}

BIN
web/themes/coreos/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View file