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 }} + +