forked from mystiq/dex
*: 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:
parent
e267dbd236
commit
391dc51c13
16 changed files with 175 additions and 556 deletions
|
@ -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"]
|
||||
|
|
8
Makefile
8
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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 != "" {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
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)
|
||||
}
|
||||
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(config.Dir, file.Name()))
|
||||
filenames = append(filenames, filepath.Join(templatesDir, 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 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
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
0
web/static/main.css
Normal 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>
|
||||
|
||||
|
|
|
@ -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
BIN
web/themes/coreos/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.2 KiB |
0
web/themes/coreos/style.css
Normal file
0
web/themes/coreos/style.css
Normal file
Loading…
Reference in a new issue