Merge pull request #711 from ericchiang/themes

*: add theme based frontend configuration
This commit is contained in:
rithu leena john 2016-11-30 22:56:09 -08:00 committed by GitHub
commit 9d9ad4a5b3
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 {
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)
}
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 { if err != nil {
return nil, fmt.Errorf("read dir: %v", err) return nil, fmt.Errorf("read dir: %v", err)
} }
filenames := []string{} filenames := []string{}
for _, file := range files { for _, file := range files {
if file.IsDir() { if file.IsDir() {
continue continue
} }
filenames = append(filenames, filepath.Join(config.Dir, file.Name())) filenames = append(filenames, filepath.Join(templatesDir, file.Name()))
} }
if len(filenames) == 0 { if len(filenames) == 0 {
return nil, fmt.Errorf("no files in template dir %s", config.Dir) return nil, fmt.Errorf("no files in template dir %q", templatesDir)
}
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)
}
}
} }
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