2022-07-11 22:49:26 +01:00
|
|
|
package webui
|
|
|
|
|
|
|
|
import (
|
|
|
|
"crypto/rand"
|
2023-03-08 13:28:25 +00:00
|
|
|
"crypto/sha256"
|
2022-07-11 22:49:26 +01:00
|
|
|
"embed"
|
|
|
|
"encoding/json"
|
|
|
|
"errors"
|
|
|
|
"html/template"
|
2023-03-08 13:28:25 +00:00
|
|
|
"io"
|
2022-07-11 22:49:26 +01:00
|
|
|
"io/fs"
|
|
|
|
"net/http"
|
|
|
|
"os"
|
|
|
|
"path"
|
|
|
|
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/go-chi/chi/v5/middleware"
|
2023-03-08 13:28:25 +00:00
|
|
|
"github.com/go-webauthn/webauthn/webauthn"
|
2022-07-11 22:49:26 +01:00
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/gorilla/csrf"
|
|
|
|
"github.com/lestrrat-go/jwx/v2/jwa"
|
|
|
|
"github.com/lestrrat-go/jwx/v2/jwe"
|
|
|
|
"github.com/lestrrat-go/jwx/v2/jwk"
|
|
|
|
"go.e43.eu/authentricity/internal/store"
|
|
|
|
"go.uber.org/zap"
|
2023-03-08 13:28:25 +00:00
|
|
|
"golang.org/x/crypto/hkdf"
|
2022-07-11 22:49:26 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
//go:embed content
|
|
|
|
var content embed.FS
|
|
|
|
|
|
|
|
type Service struct {
|
|
|
|
router *chi.Mux
|
|
|
|
templates *template.Template
|
|
|
|
store store.WritableStore
|
2023-03-08 13:28:25 +00:00
|
|
|
masterKey []byte
|
|
|
|
webAuthnKey []byte
|
2022-07-11 22:49:26 +01:00
|
|
|
cookieKey jwk.Key
|
|
|
|
tokenCookie string
|
|
|
|
cookieDomain string
|
|
|
|
cookieSecure bool
|
|
|
|
adminGroup uuid.UUID
|
2023-03-08 13:28:25 +00:00
|
|
|
wa *webauthn.WebAuthn
|
2022-07-11 22:49:26 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
func buildService(cfg Config, st store.WritableStore) *Service {
|
|
|
|
tmpl, err := template.ParseFS(content, "content/*.tmpl")
|
|
|
|
if err != nil {
|
|
|
|
zap.S().Fatalf("Error parsing templates: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
staticFs, err := fs.Sub(content, "content")
|
|
|
|
if err != nil {
|
|
|
|
zap.S().Fatalf("Error creating static FS: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
r := chi.NewRouter()
|
|
|
|
s := &Service{
|
|
|
|
router: r,
|
|
|
|
templates: tmpl,
|
|
|
|
store: st,
|
|
|
|
tokenCookie: cfg.TokenCookie,
|
|
|
|
cookieDomain: cfg.CookieDomain,
|
|
|
|
cookieSecure: !cfg.NoHTTPS,
|
|
|
|
adminGroup: cfg.AdminGroupID,
|
|
|
|
}
|
|
|
|
|
2023-03-08 13:28:25 +00:00
|
|
|
s.setupMasterKey(cfg)
|
2022-07-11 22:49:26 +01:00
|
|
|
csrf := s.setupCSRFMiddleware(cfg)
|
|
|
|
s.setupCookieSecret(cfg)
|
2023-03-08 13:28:25 +00:00
|
|
|
s.setupWebAuthn(cfg)
|
2022-07-11 22:49:26 +01:00
|
|
|
|
|
|
|
r.Use(logMiddleware(zap.L().Named("http")))
|
|
|
|
r.Use(middleware.Recoverer)
|
|
|
|
r.Use(csrf)
|
|
|
|
r.Use(s.tokenValidationMiddleware)
|
|
|
|
|
|
|
|
r.Mount("/static", http.FileServer(http.FS(staticFs)))
|
|
|
|
r.Get("/", s.indexGet)
|
|
|
|
r.Get("/login", s.loginGet)
|
|
|
|
r.Post("/login", s.loginPost)
|
2023-03-08 13:28:25 +00:00
|
|
|
r.Post("/login/webauthn-discovered", s.loginWebauthnDiscoveredPost)
|
2022-07-11 22:49:26 +01:00
|
|
|
r.Post("/logout", s.logoutPost)
|
|
|
|
r.Get("/entity", s.entitySearch)
|
|
|
|
r.Get("/entity/{id}", s.entityGet)
|
|
|
|
r.Post("/entity/{id}", s.entityPost)
|
|
|
|
r.Get("/auth", s.authGet)
|
|
|
|
r.Get("/debug/token", s.debugTokenGet)
|
|
|
|
|
|
|
|
return s
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Service) setupCookieSecret(cfg Config) {
|
|
|
|
keyPath := path.Join(cfg.SecretsDir, "cookie-key.jwk")
|
|
|
|
data, err := os.ReadFile(keyPath)
|
|
|
|
if err != nil {
|
|
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
|
|
zap.S().Fatalf("Error loading cookie key: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
zap.S().Info("No cookie key found, generating new key")
|
|
|
|
|
|
|
|
var raw [16]byte
|
|
|
|
_, err := rand.Read(raw[:])
|
|
|
|
if err != nil {
|
|
|
|
zap.S().Fatalf("Generating reading randomness: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
key, err := jwk.FromRaw(raw[:])
|
|
|
|
if err != nil {
|
|
|
|
zap.S().Fatalf("Generating constructing cookie key: %v", err)
|
|
|
|
}
|
|
|
|
if err := jwk.AssignKeyID(key); err != nil {
|
|
|
|
zap.S().Fatalf("Generating key ID: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
key.Set(jwk.AlgorithmKey, jwa.DIRECT)
|
|
|
|
key.Set(jwe.ContentEncryptionKey, jwa.A128GCM)
|
|
|
|
|
|
|
|
buf, err := json.MarshalIndent(key, "", " ")
|
|
|
|
if err != nil {
|
|
|
|
zap.S().Fatalf("Marshaling key for storage: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := os.WriteFile(keyPath, buf, 0600); err != nil {
|
|
|
|
zap.S().Fatalf("Saving generated key: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
s.cookieKey = key
|
|
|
|
} else {
|
|
|
|
key, err := jwk.ParseKey(data)
|
|
|
|
if err != nil {
|
|
|
|
zap.S().Fatalf("Loading cookie key: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
s.cookieKey = key
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-03-08 13:28:25 +00:00
|
|
|
func (s *Service) setupMasterKey(cfg Config) {
|
|
|
|
keyPath := path.Join(cfg.SecretsDir, "web.key")
|
2022-07-11 22:49:26 +01:00
|
|
|
data, err := os.ReadFile(keyPath)
|
|
|
|
if err != nil {
|
|
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
2023-03-08 13:28:25 +00:00
|
|
|
zap.S().Fatalf("Error loading web master key: %v", err)
|
2022-07-11 22:49:26 +01:00
|
|
|
}
|
|
|
|
|
2023-03-08 13:28:25 +00:00
|
|
|
zap.S().Info("No web master key found, generating new key")
|
2022-07-11 22:49:26 +01:00
|
|
|
|
|
|
|
var raw [32]byte
|
|
|
|
_, err := rand.Read(raw[:])
|
|
|
|
if err != nil {
|
|
|
|
zap.S().Fatalf("Generating reading randomness: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := os.WriteFile(keyPath, raw[:], 0600); err != nil {
|
|
|
|
zap.S().Fatalf("Saving generated key: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
data = raw[:]
|
|
|
|
}
|
|
|
|
|
2023-03-08 13:28:25 +00:00
|
|
|
if len(data) != 32 {
|
|
|
|
zap.S().Fatalf("Web master key %s too short (must be at least 32B)", keyPath)
|
|
|
|
}
|
|
|
|
|
|
|
|
s.masterKey = data
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Service) deriveKey(label string) io.Reader {
|
|
|
|
return hkdf.Expand(sha256.New, s.masterKey, []byte(label))
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Service) setupWebAuthn(cfg Config) {
|
|
|
|
s.webAuthnKey = make([]byte, 16)
|
|
|
|
_, err := s.deriveKey("WebAuthn").Read(s.webAuthnKey)
|
|
|
|
if err != nil {
|
|
|
|
zap.S().Fatalf("Error deriving WebAuthn key: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
wc := &webauthn.Config{
|
|
|
|
RPID: cfg.CookieDomain,
|
|
|
|
// TODO: Make configurable
|
|
|
|
RPDisplayName: cfg.CookieDomain,
|
|
|
|
RPOrigins: []string{cfg.WebAuthnOrigin},
|
|
|
|
Debug: true,
|
|
|
|
}
|
|
|
|
wa, err := webauthn.New(wc)
|
|
|
|
if err != nil {
|
|
|
|
zap.S().Fatalf("Error setting up webauthn: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
s.wa = wa
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Service) setupCSRFMiddleware(cfg Config) func(http.Handler) http.Handler {
|
|
|
|
var key [32]byte
|
|
|
|
_, err := s.deriveKey("CSRF").Read(key[:])
|
|
|
|
if err != nil {
|
|
|
|
zap.S().Fatalf("Error deriving CSRF key: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
return csrf.Protect(key[:],
|
2022-07-11 22:49:26 +01:00
|
|
|
csrf.Path("/"),
|
|
|
|
csrf.Secure(!cfg.NoHTTPS))
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Service) renderNotFound(w http.ResponseWriter, req *http.Request) {
|
|
|
|
http.Error(w, "Not found", http.StatusNotFound)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Service) renderBadRequest(w http.ResponseWriter, req *http.Request) {
|
|
|
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Service) renderForbidden(w http.ResponseWriter, req *http.Request) {
|
|
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Service) renderError(w http.ResponseWriter) {
|
|
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Service) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|
|
|
s.router.ServeHTTP(rw, req)
|
|
|
|
}
|