Browse Source

Implemented server and auth

master
Patrick Gaskin 6 months ago
parent
commit
44d65a6f19
Signed by: geek1011 GPG Key ID: A2FD79F68A2AB707
5 changed files with 328 additions and 28 deletions
  1. +95
    -15
      config.go
  2. +7
    -1
      go.mod
  3. +14
    -0
      go.sum
  4. +143
    -4
      main.go
  5. +69
    -8
      render.go

+ 95
- 15
config.go View File

@ -7,6 +7,7 @@ import (
"strings"
"github.com/BurntSushi/toml"
"github.com/dgrijalva/jwt-go/v4"
)
type Config struct {
@ -55,28 +56,45 @@ func (c Config) Filtered(authProvider, authUser string) Config {
return filtered
}
// AuthProviders contains the supported providers. At most one may be enabled at
// a time.
type AuthProviders struct {
GitHub *AuthProviderGitHub `toml:"github"`
JWT *AuthProviderJWT `toml:"jwt"`
}
func (a AuthProviders) Validate() error {
if a.GitHub != nil {
if err := a.GitHub.Validate(); err != nil {
return fmt.Errorf("github: %w", err)
var n int
if a.JWT != nil {
n++
if err := a.JWT.Validate(); err != nil {
return fmt.Errorf("jwt: %w", err)
}
}
if n > 1 {
return fmt.Errorf("at most one provider may be set at once")
}
return nil
}
func (a AuthProviders) Contains(p string) bool {
switch p {
case "github":
return a.GitHub != nil
case "jwt":
return a.JWT != nil
default:
return false
}
}
// GetProvider returns the provider and its name, or nil and an empty string.
func (a AuthProviders) GetProvider() (interface{}, string) {
switch {
case a.JWT != nil:
return *a.JWT, "jwt"
default:
return nil, ""
}
}
func (a AuthProviders) ValidateUserList(u PropList) error {
for _, k := range u.Keys() {
if !a.Contains(k) {
@ -86,18 +104,80 @@ func (a AuthProviders) ValidateUserList(u PropList) error {
return nil
}
type AuthProviderGitHub struct {
ClientID string `toml:"client"`
Secret string `toml:"secret"`
}
func (a AuthProviderGitHub) Validate() error {
if a.ClientID == "" {
return fmt.Errorf("client ID is required")
}
// AuthProviderJWT authenticates against a JWT (the sub field is checked). This
// provider is intended for use with reverse proxies.
//
// If login/logout URLs are provided (relative or absolute), they are shown as
// buttons.
//
// If CallbackParam is set, a parameter is added to the login and logout URLs
// with a URL to redirect back to.
//
// Example 1 - caddy + loginsrv middleware:
//
// Example 1: Caddy config (loginsrv middleware):
// example.com {
// proxy / dashboard.local:8080 {
// except /login /logout
// transparent
// }
// login {
// login_path /login
// logout_url /
// jwt_secret "MYSECRET"
// simple username=password,username1=password1
// }
// }
//
// Example 1: Provider config:
// secret = "MYSECRET"
// alg = "HS512"
// cookie_name = "jwt_token"
// login_url = "/login"
// logout_url = "/login?logout=true"
// callback_param = "backTo"
//
// Example 2 - loginsrv on separate domain (dashboard on d.example.com, login on l.example.com):
//
// Example 2: Provider config:
// secret = "MYSECRET"
// alg = "HS512"
// cookie_name = "loginsrv_jwt_token"
// login_url = "https://l.example.com/login"
// logout_url = "https://l.example.com/login?logout=true"
// callback_param = "backTo"
//
// Example 2: loginsrv config:
// -cookie-domain=".example.com"
// -cookie-name="loginsrv_jwt_token"
// -cookie-secure=false (if using HTTP)
// -jwt-secret="MYSECRET"
// -redirect=true
// -redirect-query-parameter="backTo"
// -redirect-check-referer=false
// -logout-url="/"
type AuthProviderJWT struct {
Secret string `toml:"secret"`
Alg string `toml:"alg"`
CookieName string `toml:"cookie_name"`
LoginURL string `toml:"login_url"` // optional
LogoutURL string `toml:"logout_url"` // optional
CallbackParam string `toml:"callback_param"` // optional
}
func (a AuthProviderJWT) Validate() error {
if a.Secret == "" {
return fmt.Errorf("secret is required")
}
if a.CookieName == "" {
return fmt.Errorf("cookie name is required")
}
if a.Alg == "" {
return fmt.Errorf("unknown signing method %#v", a.Alg)
}
if jwt.GetSigningMethod(a.Alg) == nil {
return fmt.Errorf("unsupported signing method %#v", a.Alg)
}
return nil
}

+ 7
- 1
go.mod View File

@ -2,4 +2,10 @@ module git.geek1011.net/geek1011/dashboard
go 1.13
require github.com/BurntSushi/toml v0.3.1
require (
github.com/BurntSushi/toml v0.3.1
github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1
github.com/go-chi/chi v4.0.3+incompatible
github.com/spf13/pflag v1.0.5
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa // indirect
)

+ 14
- 0
go.sum View File

@ -1,2 +1,16 @@
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 h1:CaO/zOnF8VvUfEbhRatPcwKVWamvbYd8tQGRWacE9kU=
github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4=
github.com/go-chi/chi v4.0.3+incompatible h1:gakN3pDJnzZN5jqFV2TEdF66rTfKeITyR8qu6ekICEY=
github.com/go-chi/chi v4.0.3+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa h1:F+8P+gmewFQYRk6JoLQLwjBCTu3mcIURZfNkVweuRKA=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

+ 143
- 4
main.go View File

@ -1,18 +1,157 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"github.com/dgrijalva/jwt-go/v4"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/spf13/pflag"
)
func main() {
c, err := ParseConfig(os.Stdin)
addr := pflag.StringP("addr", "a", ":8080", "The address to listen on")
iconsDir := pflag.StringP("icons-dir", "i", "./icons", "The directory where icons are stored")
config := pflag.StringP("config", "c", "./services.conf", "The path to the config file")
help := pflag.BoolP("help", "h", false, "Show this help text")
pflag.Parse()
if *help {
fmt.Fprintf(os.Stderr, "Usage: %s [options]\n\nOptions:\n", os.Args[0])
pflag.PrintDefaults()
os.Exit(0)
return
}
if fi, err := os.Stat(*iconsDir); err != nil {
fmt.Fprintf(os.Stderr, "Error: Icons dir %#v: %v.\n", *iconsDir, err)
os.Exit(1)
return
} else if !fi.IsDir() {
fmt.Fprintf(os.Stderr, "Error: Icons dir %#v: not a dir.\n", *iconsDir)
os.Exit(1)
return
}
f, err := os.OpenFile(*config, os.O_RDONLY, 0)
if err != nil {
panic(err)
fmt.Fprintf(os.Stderr, "Error: Open config %#v: %v.\n", *config, err)
os.Exit(1)
return
}
c, err := ParseConfig(f)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: Parse config: %v.\n", err)
f.Close()
os.Exit(1)
return
}
f.Close()
if err := c.Validate(); err != nil {
panic(err)
fmt.Fprintf(os.Stderr, "Error: Validate config: %v.\n", err)
os.Exit(1)
return
}
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
r.Use(middleware.RedirectSlashes)
r.Use(middlewareAuth(c))
r.Get("/", indexHandler(c))
r.Handle("/icons/*", http.StripPrefix("/icons", http.FileServer(http.Dir(*iconsDir))))
fmt.Printf("Listening on http://%s.\n", *addr)
if err := http.ListenAndServe(*addr, r); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v.\n", err)
os.Exit(1)
return
}
}
type ctxKey int
const (
_ ctxKey = iota
authProviderKey
authUserKey
)
// middlewareAuth process the authentication and will set authProviderKey to
// an empty string or the currently configured provider and authUserKey to an
// empty string or the currently validly authenticated username.
func middlewareAuth(c *Config) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var authUser string
p, authProvider := c.Auth.GetProvider()
if p != nil {
switch p := p.(type) {
case AuthProviderJWT:
ck, err := r.Cookie(p.CookieName)
if err != nil {
if err == http.ErrNoCookie {
// no login info
break
}
panic("error reading auth cookie: " + err.Error() + "!")
}
tok, err := jwt.ParseWithClaims(ck.Value, &jwt.StandardClaims{}, jwt.KnownKeyfunc(jwt.GetSigningMethod(p.Alg), []byte(p.Secret)))
if err != nil {
log.Printf("[%s] parse JWT %#v: %v", middleware.GetReqID(r.Context()), ck.Value, err)
break
}
if !tok.Valid {
log.Printf("[%s] parse JWT %#v: invalid", middleware.GetReqID(r.Context()), ck.Value)
}
authUser = tok.Claims.(*jwt.StandardClaims).Subject
default:
panic(authProvider + " not implemented!")
}
}
ctx := r.Context()
ctx = context.WithValue(ctx, authProviderKey, authProvider)
ctx = context.WithValue(ctx, authUserKey, authUser)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// getAuth gets the valid claims parsed in middlewareAuth.
func getAuth(ctx context.Context) (authUser, authProvider string) {
authProvider = ctx.Value(authProviderKey).(string)
authUser = ctx.Value(authUserKey).(string)
return authProvider, authUser
}
func indexHandler(c *Config) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
purl := r.URL
purl.Host = r.Host
authProvider, authUser := getAuth(ctx)
log.Printf("[%s] url=%#v authProvider=%#v authUser=%#v", middleware.GetReqID(ctx), purl.String(), authProvider, authUser)
w.Header().Set("Cache-Control", "no-cache, private, max-age=0")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-Dashboard-Auth", authProvider+"::"+authUser)
w.WriteHeader(http.StatusOK)
RenderIndex(os.Stdout, c.Filtered(os.Args[1], os.Args[2]))
RenderIndex(w, c.Filtered(authProvider, authUser), c.Auth, authUser, purl.String())
})
}

+ 69
- 8
render.go View File

@ -1,10 +1,30 @@
package main
import "io"
import (
"fmt"
"html/template"
"io"
"net/url"
)
import "html/template"
var tmpl = template.Must(template.New("").Funcs(template.FuncMap{
"withparam": func(origURL, paramName, paramValue string) (string, error) {
if paramName == "" {
return "", fmt.Errorf("paramName is empty")
}
u, err := url.Parse(origURL)
if err != nil {
return "", err
}
v := u.Query()
v.Set(paramName, paramValue)
u.RawQuery = v.Encode()
var tmpl = template.Must(template.New("").Parse(`<!DOCTYPE html>
return u.String(), nil
},
}).Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
@ -23,9 +43,37 @@ var tmpl = template.Must(template.New("").Parse(`
}
body {
font-family: Roboto;
font-family: Roboto, sans-serif;
color: #111;
}
.login-info {
display: block;
text-align: right;
padding: 16px;
}
.button,
.button:link,
.button:visited {
display: inline-block;
vertical-align: middle;
border: 1px solid rgba(0, 0, 0, 0.4);
color: #fff;
background: #216bbf;
text-decoration: none;
padding: 4px 8px;
border-radius: 3px;
margin: 0 4px;
}
.button:hover {
background: #2a77cc;
}
.button:active {
background: #1a5597;
}
.apps-category {
display: block;
@ -119,7 +167,14 @@ var tmpl = template.Must(template.New("").Parse(`
</style>
</head>
<body>
<!-- TODO: login/out button, user info -->
<div class="login-info">
{{if .AuthUser}}
Logged in as {{.AuthUser}}.
{{with .Auth.JWT}}{{if .LogoutURL}}<a class="button button--red" href="{{if .CallbackParam}}{{withparam .LogoutURL .CallbackParam $.URL}}{{else}}{{.LogoutURL}}{{end}}">Logout</a>{{end}}{{end}}
{{else}}
{{with .Auth.JWT}}{{if .LoginURL}}<a class="button button--green" href="{{if .CallbackParam}}{{withparam .LoginURL .CallbackParam $.URL}}{{else}}{{.LoginURL}}{{end}}">Login</a>{{end}}{{end}}
{{end}}
</div>
<!-- TODO: tag filter sidebar/navbar -->
{{range $catEntry := .Config.Category.Sorted}}
{{$catID := $catEntry.Key}}
@ -133,7 +188,7 @@ var tmpl = template.Must(template.New("").Parse(`
<a href="{{$app.Link}}" title="{{$app.Domain}}" class="apps-category__apps__app">
{{with $app.Icon}}
<div class="apps-category__apps__app__icon">
<img src="{{.}}" alt=""/>
<img src="icons/{{.}}" alt=""/>
</div>
{{end}}
<div class="apps-category__apps__app__label">
@ -164,8 +219,14 @@ var tmpl = template.Must(template.New("").Parse(`
</html>
`))
func RenderIndex(w io.Writer, filteredConfig Config) error {
// RenderIndex renders the dashboard. The provided config must be filtered
// already, if needed. The URL must be the fully-qualified URL, including the
// hostname, of the current page (and is used for login/logouts callbacks).
func RenderIndex(w io.Writer, filteredConfig Config, authProvider AuthProviders, authUser, url string) error {
return tmpl.Execute(w, map[string]interface{}{
"Config": filteredConfig,
"Config": filteredConfig,
"Auth": authProvider,
"AuthUser": authUser,
"URL": url,
})
}

Loading…
Cancel
Save