*: 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
# 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"]

View File

@ -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

View File

@ -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.

View File

@ -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 != "" {

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)
}

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
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>
<meta charset="utf-8">
<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">
<link href="{{ url "static/main.css" }}" rel="stylesheet">
<link href="{{ url "theme/style.css" }}" rel="stylesheet">
<style>
* {
-webkit-box-sizing: border-box;
@ -232,7 +234,7 @@
<body>
<div id="navbar">
<div id="navbar-logo-wrap">
<img id="navbar-logo" src="{{ .LogoURL }}">
<img id="navbar-logo" src="{{ logo }}">
</div>
</div>

View File

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File