forked from mystiq/dex
Merge pull request #711 from ericchiang/themes
*: add theme based frontend configuration
This commit is contained in:
commit
9d9ad4a5b3
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