authentricity/internal/webui/service.go

227 lines
5.4 KiB
Go

package webui
import (
"crypto/rand"
"crypto/sha256"
"embed"
"encoding/json"
"errors"
"html/template"
"io"
"io/fs"
"net/http"
"os"
"path"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-webauthn/webauthn/webauthn"
"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"
"golang.org/x/crypto/hkdf"
)
//go:embed content
var content embed.FS
type Service struct {
router *chi.Mux
templates *template.Template
store store.WritableStore
masterKey []byte
webAuthnKey []byte
cookieKey jwk.Key
tokenCookie string
cookieDomain string
cookieSecure bool
adminGroup uuid.UUID
wa *webauthn.WebAuthn
}
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,
}
s.setupMasterKey(cfg)
csrf := s.setupCSRFMiddleware(cfg)
s.setupCookieSecret(cfg)
s.setupWebAuthn(cfg)
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)
r.Post("/login/webauthn-discovered", s.loginWebauthnDiscoveredPost)
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
}
}
func (s *Service) setupMasterKey(cfg Config) {
keyPath := path.Join(cfg.SecretsDir, "web.key")
data, err := os.ReadFile(keyPath)
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
zap.S().Fatalf("Error loading web master key: %v", err)
}
zap.S().Info("No web master key found, generating new key")
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[:]
}
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[:],
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)
}