From 391dc51c13d4a11647f39a387885089c001c5feb Mon Sep 17 00:00:00 2001 From: Eric Chiang Date: Wed, 30 Nov 2016 14:26:54 -0800 Subject: [PATCH] *: 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. --- Dockerfile | 5 + Makefile | 8 +- cmd/dex/config.go | 2 +- cmd/dex/serve.go | 2 +- examples/config-dev.yaml | 2 +- server/server.go | 45 +++- server/server_test.go | 5 + server/templates.go | 192 ++++++++++------- server/templates_default.go | 362 -------------------------------- server/templates_default_gen.go | 85 -------- server/templates_test.go | 15 -- web/static/main.css | 0 web/templates/header.html | 6 +- web/templates/login.html | 2 +- web/themes/coreos/logo.png | Bin 0 -> 2218 bytes web/themes/coreos/style.css | 0 16 files changed, 175 insertions(+), 556 deletions(-) delete mode 100644 server/templates_default.go delete mode 100644 server/templates_default_gen.go create mode 100644 web/static/main.css create mode 100644 web/themes/coreos/logo.png create mode 100644 web/themes/coreos/style.css diff --git a/Dockerfile b/Dockerfile index c5361667..4f0d43a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,11 @@ RUN apk add --update ca-certificates openssl 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"] CMD ["version"] diff --git a/Makefile b/Makefile index 62ab5895..5aa8144e 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ LD_FLAGS="-w -X $(REPO_PATH)/version.Version=$(VERSION)" build: bin/dex bin/example-app -bin/dex: FORCE generated +bin/dex: FORCE @go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex bin/example-app: FORCE @@ -35,9 +35,6 @@ bin/example-app: FORCE release-binary: @go build -o _output/bin/dex -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex -.PHONY: generated -generated: server/templates_default.go - test: @go test -v -i $(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; \ done -server/templates_default.go: $(wildcard web/templates/**) - @go run server/templates_default_gen.go - _output/bin/dex: # Using rkt to build the dex binary. @./scripts/rkt-build diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 2ee9e58a..dc3715b8 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -30,7 +30,7 @@ type Config struct { GRPC GRPC `json:"grpc"` 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 // querying the storage. Write operations, like creating a client, will fail. diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index e218e473..c9baa9d3 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -151,7 +151,7 @@ func serve(cmd *cobra.Command, args []string) error { Issuer: c.Issuer, Connectors: connectors, Storage: s, - TemplateConfig: c.Templates, + Web: c.Frontend, EnablePasswordDB: c.EnablePasswordDB, } if c.Expiry.SigningKeys != "" { diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index 5f94e202..1b5a48d3 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -14,7 +14,7 @@ storage: # Configuration for the HTTP endpoints. web: - http: 127.0.0.1:5556 + http: 0.0.0.0:5556 # Uncomment for HTTPS options. # https: 127.0.0.1:5554 # tlsCert: /etc/dex/tls.crt diff --git a/server/server.go b/server/server.go index b8d2c8d3..91d119e9 100644 --- a/server/server.go +++ b/server/server.go @@ -56,7 +56,32 @@ type Config struct { 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 { @@ -130,9 +155,17 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy) 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 { - 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 @@ -159,6 +192,10 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy) handleFunc := func(p string, h http.HandlerFunc) { 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) discoveryHandler, err := s.discoveryHandler() @@ -175,6 +212,8 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy) handleFunc("/callback", s.handleConnectorCallback) handleFunc("/approval", s.handleApproval) handleFunc("/healthz", s.handleHealth) + handlePrefix("/static", static) + handlePrefix("/theme", theme) s.mux = r startKeyRotation(ctx, c.Storage, rotationStrategy, now) diff --git a/server/server_test.go b/server/server_test.go index a5865dfa..3ab41940 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -11,6 +11,8 @@ import ( "net/http/httptest" "net/http/httputil" "net/url" + "os" + "path/filepath" "reflect" "sort" "strings" @@ -85,6 +87,9 @@ func newTestServer(ctx context.Context, t *testing.T, updateConfig func(c *Confi Connector: mock.NewCallbackConnector(), }, }, + Web: WebConfig{ + Dir: filepath.Join(os.Getenv("GOPATH"), "src/github.com/coreos/dex/web"), + }, } if updateConfig != nil { updateConfig(&config) diff --git a/server/templates.go b/server/templates.go index e8285fe3..649d5b08 100644 --- a/server/templates.go +++ b/server/templates.go @@ -6,8 +6,10 @@ import ( "io/ioutil" "log" "net/http" + "os" "path/filepath" "sort" + "strings" "text/template" ) @@ -18,8 +20,6 @@ const ( tmplOOB = "oob.html" ) -const coreOSLogoURL = "https://coreos.com/assets/images/brand/coreos-wordmark-135x40px.png" - var requiredTmpls = []string{ tmplApproval, tmplLogin, @@ -27,65 +27,122 @@ var requiredTmpls = []string{ tmplOOB, } -// TemplateConfig describes. -type TemplateConfig struct { - // TODO(ericchiang): Asking for a directory with a set of templates doesn't indicate - // what the templates should look like and doesn't allow consumers of this package to - // provide their own templates in memory. In the future clean this up. - - // 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 templates struct { + loginTmpl *template.Template + approvalTmpl *template.Template + passwordTmpl *template.Template + oobTmpl *template.Template } -type globalData struct { - LogoURL string - Issuer string +type webConfig struct { + dir string + logoURL string + issuer string + theme string + issuerURL 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) +func join(base, path string) string { + b := strings.HasSuffix(base, "/") + p := strings.HasPrefix(path, "/") + switch { + case b && p: + 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{} - 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) - } + return fmt.Errorf("stat directory %q: %v", dir, err) + } + if !stat.IsDir() { + return fmt.Errorf("path %q is a file not a directory", dir) + } + return nil +} + +// loadWebConfig returns static assets, theme assets, and templates used by the frontend by +// reading the directory specified in the webConfig. +// +// The directory layout is expected to be: +// +// ( web directory ) +// |- static +// |- themes +// | |- (theme name) +// |- templates +// +func loadWebConfig(c webConfig) (static, theme http.Handler, templates *templates, err error) { + if c.theme == "" { + c.theme = "coreos" + } + if c.issuer == "" { + c.issuer = "dex" + } + if c.dir == "" { + c.dir = "./web" + } + 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{} for _, tmplName := range requiredTmpls { if tmpls.Lookup(tmplName) == nil { @@ -95,16 +152,7 @@ func loadTemplates(config TemplateConfig) (*templates, error) { 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), @@ -118,14 +166,6 @@ var scopeDescriptions = map[string]string{ "email": "View your email", } -type templates struct { - globalData TemplateConfig - loginTmpl *template.Template - approvalTmpl *template.Template - passwordTmpl *template.Template - oobTmpl *template.Template -} - type connectorInfo struct { ID string Name string @@ -142,21 +182,19 @@ func (t *templates) login(w http.ResponseWriter, connectors []connectorInfo, aut sort.Sort(byName(connectors)) data := struct { - TemplateConfig Connectors []connectorInfo AuthReqID string - }{t.globalData, connectors, authReqID} + }{connectors, authReqID} renderTemplate(w, t.loginTmpl, data) } func (t *templates) password(w http.ResponseWriter, authReqID, callback, lastUsername string, lastWasInvalid bool) { data := struct { - TemplateConfig AuthReqID string PostURL string Username string Invalid bool - }{t.globalData, authReqID, callback, lastUsername, lastWasInvalid} + }{authReqID, string(callback), lastUsername, lastWasInvalid} renderTemplate(w, t.passwordTmpl, data) } @@ -170,20 +208,18 @@ func (t *templates) approval(w http.ResponseWriter, authReqID, username, clientN } sort.Strings(accesses) data := struct { - TemplateConfig User string Client string AuthReqID string Scopes []string - }{t.globalData, username, clientName, authReqID, accesses} + }{username, clientName, authReqID, accesses} renderTemplate(w, t.approvalTmpl, data) } func (t *templates) oob(w http.ResponseWriter, code string) { data := struct { - TemplateConfig Code string - }{t.globalData, code} + }{code} renderTemplate(w, t.oobTmpl, data) } diff --git a/server/templates_default.go b/server/templates_default.go deleted file mode 100644 index 651c7411..00000000 --- a/server/templates_default.go +++ /dev/null @@ -1,362 +0,0 @@ -// This file was generated by the makefile. Do not edit. - -package server - -// defaultTemplates is a key for file name to file data of the files in web/templates. -var defaultTemplates = map[string]string{ - "approval.html": `{{ template "header.html" . }} - -
-

Grant Access

- -
-
-
{{ .Client }} would like to:
- {{ range $scope := .Scopes }} -
  • -
    - {{ $scope }} -
    -
  • - {{ end }} -
    -
    - -
    -
    -
    - - - -
    -
    -
    -
    - - - -
    -
    -
    - -
    - -{{ template "footer.html" . }} -`, - "footer.html": ` - - -`, - "header.html": ` - - - - - {{ .Issuer }} - - - - - - - -
    - -`, - "login.html": `{{ template "header.html" . }} - -
    -

    Log in to {{ .Issuer }}

    - -
    - {{ range $c := .Connectors }} - - {{ end }} -
    - -
    - - -{{ template "footer.html" . }} -`, - "oob.html": `{{ template "header.html" . }} - -
    -

    Login Successful

    - - Please copy this code, switch to your application and paste it there: -
    - -
    - -{{ template "footer.html" . }} -`, - "password.html": `{{ template "header.html" . }} - -
    -

    Log in to Your Account

    -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    - - - {{ if .Invalid }} -
    - Invalid username and password. -
    - {{ end }} - - - -
    -
    - -{{ template "footer.html" . }} -`, -} diff --git a/server/templates_default_gen.go b/server/templates_default_gen.go deleted file mode 100644 index 0e46df78..00000000 --- a/server/templates_default_gen.go +++ /dev/null @@ -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) - } -} diff --git a/server/templates_test.go b/server/templates_test.go index efbb29ed..abb4e431 100644 --- a/server/templates_test.go +++ b/server/templates_test.go @@ -1,16 +1 @@ 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" -} diff --git a/web/static/main.css b/web/static/main.css new file mode 100644 index 00000000..e69de29b diff --git a/web/templates/header.html b/web/templates/header.html index cadb078d..79438ec4 100644 --- a/web/templates/header.html +++ b/web/templates/header.html @@ -3,8 +3,10 @@ - {{ .Issuer }} + {{ issuer }} + +