feat: add authentication, audit log, HTTP API and base web UI

Phase 1 foundation. Local auth per ADR-0006: Argon2id passwords, opaque
hashed session tokens with idle/absolute expiry, CSRF token plus Origin
check on cookie sessions, per-IP token bucket and per-account exponential
lockout, three roles enforced by middleware, personal API tokens for
noemactl. Audit log writer used by every admin mutation.

API: request ids, panic recovery, strict CSP and security headers, body
limits, single JSON error envelope, health/ready/status, users, sessions,
tokens, audit, token-protected /metrics.

UI: embedded html/template pages (login, first-run setup, dashboard,
system, users, account, audit, interpretation boundary), light/dark
theme, dependency-free app.js with CSRF-aware fetch and SSE helper.

Binaries: noemad with graceful shutdown, migrations at start, bootstrap
admin from environment, supervised background tasks; noemactl with
status, migrate, user create and meaningful exit codes.
This commit is contained in:
Andy Dixon
2026-06-15 20:22:19 +01:00
parent 6374e174c6
commit a002a2c100
40 changed files with 3081 additions and 20 deletions
+81
View File
@@ -0,0 +1,81 @@
package api
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/andydixon/noema/internal/app"
"github.com/andydixon/noema/internal/audit"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/config"
"github.com/andydixon/noema/internal/observability"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/migrations"
)
func testServer(t *testing.T) (*httptest.Server, string) {
t.Helper()
pool := storage.TestPool(t, migrations.FS)
cfg := config.Defaults()
cfg.DatabaseURL = "postgres://test"
log := slog.New(slog.DiscardHandler)
deps := app.Deps{Config: cfg, Log: log, DB: pool, Auth: auth.NewService(pool, time.Hour, 2*time.Hour),
Audit: audit.New(pool, log), Metrics: observability.NewMetrics(), Migrations: migrations.FS, Started: time.Now()}
ctx := context.Background()
u, err := deps.Auth.CreateUser(ctx, "admin", "administrator-pass", auth.RoleAdministrator)
if err != nil {
t.Fatal(err)
}
tok, _ := deps.Auth.CreateAPIToken(ctx, u.ID, "t", nil)
srv := httptest.NewServer(New(deps, nil).Handler())
t.Cleanup(srv.Close)
return srv, tok
}
func TestStatusAuthAndErrorEnvelope(t *testing.T) {
srv, tok := testServer(t)
res, _ := http.Get(srv.URL + "/api/v1/system/status")
if res.StatusCode != 401 {
t.Fatalf("expected 401, got %d", res.StatusCode)
}
var e ErrorBody
json.NewDecoder(res.Body).Decode(&e) //nolint:errcheck
if e.Error.Code != "unauthenticated" || e.Error.RequestID == "" {
t.Fatalf("bad error envelope: %+v", e)
}
req, _ := http.NewRequest("GET", srv.URL+"/api/v1/system/status", nil)
req.Header.Set("Authorization", "Bearer "+tok)
res, _ = http.DefaultClient.Do(req)
if res.StatusCode != 200 {
t.Fatalf("expected 200, got %d", res.StatusCode)
}
var st SystemStatus
json.NewDecoder(res.Body).Decode(&st) //nolint:errcheck
if !st.Database.OK || len(st.Migrations) == 0 {
t.Fatalf("status incomplete: %+v", st)
}
if res.Header.Get("Content-Security-Policy") == "" {
t.Fatal("missing CSP header")
}
// Unknown JSON fields and body limits are rejected as 400.
req, _ = http.NewRequest("POST", srv.URL+"/api/v1/users", strings.NewReader(`{"username":"x","password":"long-enough-pass","role":"observer","bogus":1}`))
req.Header.Set("Authorization", "Bearer "+tok)
res, _ = http.DefaultClient.Do(req)
if res.StatusCode != 400 {
t.Fatalf("expected 400 for unknown field, got %d", res.StatusCode)
}
// Bearer requests are exempt from CSRF but still role-checked.
req, _ = http.NewRequest("POST", srv.URL+"/api/v1/users", strings.NewReader(`{"username":"obs","password":"long-enough-pass","role":"observer"}`))
req.Header.Set("Authorization", "Bearer "+tok)
res, _ = http.DefaultClient.Do(req)
if res.StatusCode != 201 {
t.Fatalf("expected 201, got %d", res.StatusCode)
}
}
+11
View File
@@ -0,0 +1,11 @@
package api
import (
"errors"
"github.com/andydixon/noema/internal/storage"
)
func isStorageErr(err error) bool {
return errors.Is(err, storage.ErrNotFound) || errors.Is(err, storage.ErrConflict)
}
+140
View File
@@ -0,0 +1,140 @@
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"log/slog"
"net/http"
"runtime/debug"
"strconv"
"time"
"github.com/andydixon/noema/internal/audit"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/observability"
)
type requestIDKey struct{}
// RequestID returns the id assigned to the request.
func RequestID(r *http.Request) string {
id, _ := r.Context().Value(requestIDKey{}).(string)
return id
}
func logger(r *http.Request) *slog.Logger { return observability.Logger(r.Context()) }
// chain applies middleware in the order given (first is outermost).
func chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
func (s *Server) requestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var b [8]byte
rand.Read(b[:]) //nolint:errcheck
id := hex.EncodeToString(b[:])
ip := auth.ClientIP(r, false)
ctx := context.WithValue(r.Context(), requestIDKey{}, id)
ctx = observability.WithLogger(ctx, s.deps.Log.With("request_id", id))
ctx = audit.WithIP(ctx, ip)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) { w.status = code; w.ResponseWriter.WriteHeader(code) }
func (w *statusWriter) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = 200
}
return w.ResponseWriter.Write(b)
}
// Flush lets SSE handlers flush through the wrapper.
func (w *statusWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (s *Server) logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w}
next.ServeHTTP(sw, r)
route := r.Pattern
if route == "" {
route = "unmatched"
}
d := time.Since(start)
s.deps.Metrics.HTTPRequests.WithLabelValues(route, strconv.Itoa(sw.status)).Inc()
s.deps.Metrics.HTTPDuration.WithLabelValues(route).Observe(d.Seconds())
logger(r).Debug("http", "method", r.Method, "path", r.URL.Path, "status", sw.status, "ms", d.Milliseconds())
})
}
func (s *Server) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
if rec == http.ErrAbortHandler {
panic(rec)
}
logger(r).Error("handler panicked", "panic", rec, "stack", string(debug.Stack()))
writeError(w, r, http.StatusInternalServerError, "internal", "internal error")
}
}()
next.ServeHTTP(w, r)
})
}
// securityHeaders sets a strict CSP (no inline scripts, self only) and the
// usual hardening headers (T10).
func (s *Server) securityHeaders(next http.Handler) http.Handler {
csp := "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", csp)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
if s.deps.Config.Production() {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
func (s *Server) bodyLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, s.deps.Config.MaxRequestBody)
}
next.ServeHTTP(w, r)
})
}
// apiAuthFail is the RequireRole failure handler for JSON routes.
func apiAuthFail(w http.ResponseWriter, r *http.Request, authenticated bool) {
if authenticated {
writeError(w, r, http.StatusForbidden, "forbidden", "insufficient role")
return
}
writeError(w, r, http.StatusUnauthorized, "unauthenticated", "authentication required")
}
// require wraps a handler with a role check for API routes.
func require(min auth.Role, h http.HandlerFunc) http.Handler {
return auth.RequireRole(min, apiAuthFail)(h)
}
+75
View File
@@ -0,0 +1,75 @@
// Package api serves the versioned JSON API under /api/v1 and composes the
// middleware chain shared with the web UI.
package api
import (
"encoding/json"
"errors"
"net/http"
"github.com/andydixon/noema/internal/storage"
)
// ErrorBody is the single error envelope used by every endpoint.
type ErrorBody struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id,omitempty"`
} `json:"error"`
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
enc := json.NewEncoder(w)
enc.SetEscapeHTML(true)
enc.Encode(v) //nolint:errcheck // client gone; nothing to do
}
func writeError(w http.ResponseWriter, r *http.Request, status int, code, msg string) {
var b ErrorBody
b.Error.Code, b.Error.Message, b.Error.RequestID = code, msg, RequestID(r)
writeJSON(w, status, b)
}
// fail maps common errors to HTTP statuses; unknown errors become 500 without
// leaking internals.
func fail(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, storage.ErrNotFound):
writeError(w, r, http.StatusNotFound, "not_found", "resource not found")
case errors.Is(err, storage.ErrConflict):
writeError(w, r, http.StatusConflict, "conflict", "resource already exists")
default:
var ve ValidationError
if errors.As(err, &ve) {
writeError(w, r, http.StatusBadRequest, "invalid", ve.Error())
return
}
logger(r).Error("request failed", "err", err)
writeError(w, r, http.StatusInternalServerError, "internal", "internal error")
}
}
// ValidationError marks user-correctable input problems.
type ValidationError string
func (v ValidationError) Error() string { return string(v) }
// decode reads a JSON body with unknown fields rejected and size already
// limited by middleware.
func decode(r *http.Request, v any) error {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return ValidationError("invalid JSON body: " + err.Error())
}
return nil
}
// page is the standard list envelope.
type page[T any] struct {
Items []T `json:"items"`
NextCursor string `json:"next_cursor,omitempty"`
}
+57
View File
@@ -0,0 +1,57 @@
package api
import (
"net/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/andydixon/noema/internal/app"
"github.com/andydixon/noema/internal/auth"
)
// Server composes the API routes, the web UI and the middleware chain.
type Server struct {
deps app.Deps
mux *http.ServeMux
}
// New builds the server. ui registers the HTML routes on the same mux so that
// a single middleware chain (auth, CSRF, headers, limits) covers both.
func New(deps app.Deps, ui interface{ Register(*http.ServeMux) }) *Server {
s := &Server{deps: deps, mux: http.NewServeMux()}
s.routes()
if ui != nil {
ui.Register(s.mux)
}
return s
}
// Handler returns the fully wrapped root handler.
func (s *Server) Handler() http.Handler {
return chain(s.mux, s.requestID, s.recoverer, s.securityHeaders, s.bodyLimit, s.logging, s.deps.Auth.Middleware)
}
func (s *Server) routes() {
m := s.mux
m.HandleFunc("GET /healthz", s.healthz)
m.HandleFunc("GET /readyz", s.readyz)
// Metrics need a token with at least observer role; Prometheus supports bearer_token.
m.Handle("GET /metrics", require(auth.RoleObserver, promhttp.HandlerFor(s.deps.Metrics.Registry, promhttp.HandlerOpts{}).ServeHTTP))
m.Handle("GET /api/v1/system/status", require(auth.RoleObserver, s.systemStatus))
m.Handle("GET /api/v1/audit", require(auth.RoleOperator, s.auditList))
m.Handle("GET /api/v1/users", require(auth.RoleAdministrator, s.usersList))
m.Handle("POST /api/v1/users", require(auth.RoleAdministrator, s.usersCreate))
m.Handle("PATCH /api/v1/users/{id}", require(auth.RoleAdministrator, s.usersPatch))
m.Handle("GET /api/v1/me", require(auth.RoleObserver, s.me))
m.Handle("GET /api/v1/me/sessions", require(auth.RoleObserver, s.mySessions))
m.Handle("DELETE /api/v1/me/sessions/{id}", require(auth.RoleObserver, s.mySessionRevoke))
m.Handle("POST /api/v1/me/tokens", require(auth.RoleObserver, s.myTokenCreate))
m.Handle("GET /api/v1/me/tokens", require(auth.RoleObserver, s.myTokens))
m.Handle("DELETE /api/v1/me/tokens/{id}", require(auth.RoleObserver, s.myTokenRevoke))
m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint")
})
}
+99
View File
@@ -0,0 +1,99 @@
package api
import (
"context"
"net/http"
"runtime"
"strconv"
"time"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/internal/version"
)
// SystemStatus is the payload of GET /api/v1/system/status.
type SystemStatus struct {
Version string `json:"version"`
UptimeSeconds int64 `json:"uptime_seconds"`
Database DatabaseStatus `json:"database"`
Migrations []MigrationInfo `json:"migrations"`
Goroutines int `json:"goroutines"`
HeapBytes uint64 `json:"heap_bytes"`
SysBytes uint64 `json:"sys_bytes"`
NumCPU int `json:"num_cpu"`
GoVersion string `json:"go_version"`
}
// DatabaseStatus reports connectivity and pool usage.
type DatabaseStatus struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Version string `json:"version,omitempty"`
PoolTotal int32 `json:"pool_total"`
PoolIdle int32 `json:"pool_idle"`
PoolMax int32 `json:"pool_max"`
PGVector bool `json:"pgvector"`
}
// MigrationInfo is one migration's state.
type MigrationInfo struct {
Version int `json:"version"`
Name string `json:"name"`
Applied bool `json:"applied"`
AppliedAt string `json:"applied_at,omitempty"`
Drift bool `json:"drift"`
}
// Status collects the system status; shared with the UI system page.
func (s *Server) Status(ctx context.Context) SystemStatus {
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
st := SystemStatus{Version: version.Version, UptimeSeconds: int64(time.Since(s.deps.Started).Seconds()),
Goroutines: runtime.NumGoroutine(), HeapBytes: ms.HeapAlloc, SysBytes: ms.Sys, NumCPU: runtime.NumCPU(), GoVersion: runtime.Version()}
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if err := s.deps.DB.QueryRow(ctx, `SELECT version()`).Scan(&st.Database.Version); err != nil {
st.Database.Error = err.Error()
} else {
st.Database.OK = true
s.deps.DB.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname='vector')`).Scan(&st.Database.PGVector) //nolint:errcheck
}
stat := s.deps.DB.Stat()
st.Database.PoolTotal, st.Database.PoolIdle, st.Database.PoolMax = stat.TotalConns(), stat.IdleConns(), stat.MaxConns()
if rows, err := storage.Status(ctx, s.deps.DB, s.deps.Migrations); err == nil {
for _, m := range rows {
st.Migrations = append(st.Migrations, MigrationInfo{Version: m.Version, Name: m.Name, Applied: m.Applied, AppliedAt: m.AppliedAt, Drift: m.Drift})
}
}
return st
}
func (s *Server) systemStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.Status(r.Context()))
}
func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("ok")) //nolint:errcheck
}
func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := s.deps.DB.Ping(ctx); err != nil {
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
return
}
w.Write([]byte("ready")) //nolint:errcheck
}
func (s *Server) auditList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
before, _ := strconv.ParseInt(q.Get("before"), 10, 64)
limit, _ := strconv.Atoi(q.Get("limit"))
entries, err := s.deps.Audit.List(r.Context(), before, limit, q.Get("target_kind"), q.Get("target_id"))
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": entries})
}
+199
View File
@@ -0,0 +1,199 @@
package api
import (
"net/http"
"time"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/pkg/ids"
)
type userOut struct {
ID ids.ID `json:"id"`
Username string `json:"username"`
Role auth.Role `json:"role"`
Disabled bool `json:"disabled"`
TOTPEnabled bool `json:"totp_enabled"`
CreatedAt time.Time `json:"created_at"`
}
func toUserOut(u auth.User) userOut {
return userOut{ID: u.ID, Username: u.Username, Role: u.Role, Disabled: u.Disabled, TOTPEnabled: u.TOTPEnabled, CreatedAt: u.CreatedAt}
}
func (s *Server) usersList(w http.ResponseWriter, r *http.Request) {
users, err := s.deps.Auth.ListUsers(r.Context())
if err != nil {
fail(w, r, err)
return
}
out := make([]userOut, 0, len(users))
for _, u := range users {
out = append(out, toUserOut(u))
}
writeJSON(w, http.StatusOK, page[userOut]{Items: out})
}
func (s *Server) usersCreate(w http.ResponseWriter, r *http.Request) {
var in struct {
Username string `json:"username"`
Password string `json:"password"`
Role auth.Role `json:"role"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
u, err := s.deps.Auth.CreateUser(r.Context(), in.Username, in.Password, in.Role)
if err != nil {
fail(w, r, wrapInput(err))
return
}
s.deps.Audit.Log(r.Context(), "user.create", "user", u.ID.String(), map[string]any{"username": u.Username, "role": u.Role})
writeJSON(w, http.StatusCreated, toUserOut(u))
}
func (s *Server) usersPatch(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("invalid id"))
return
}
var in struct {
Role *auth.Role `json:"role"`
Disabled *bool `json:"disabled"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
p, _ := auth.PrincipalFrom(r.Context())
if p.User.ID == id && ((in.Disabled != nil && *in.Disabled) || (in.Role != nil && *in.Role != auth.RoleAdministrator)) {
fail(w, r, ValidationError("you cannot disable or demote your own account"))
return
}
if in.Role != nil {
if err := s.deps.Auth.SetUserRole(r.Context(), id, *in.Role); err != nil {
fail(w, r, wrapInput(err))
return
}
s.deps.Audit.Log(r.Context(), "user.role", "user", id.String(), map[string]any{"role": *in.Role})
}
if in.Disabled != nil {
if err := s.deps.Auth.SetUserDisabled(r.Context(), id, *in.Disabled); err != nil {
fail(w, r, err)
return
}
s.deps.Audit.Log(r.Context(), "user.disabled", "user", id.String(), map[string]any{"disabled": *in.Disabled})
}
u, err := s.deps.Auth.GetUser(r.Context(), id)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, toUserOut(u))
}
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
p, _ := auth.PrincipalFrom(r.Context())
writeJSON(w, http.StatusOK, toUserOut(p.User))
}
func (s *Server) mySessions(w http.ResponseWriter, r *http.Request) {
p, _ := auth.PrincipalFrom(r.Context())
list, err := s.deps.Auth.ListSessions(r.Context(), p.User.ID)
if err != nil {
fail(w, r, err)
return
}
type out struct {
ID ids.ID `json:"id"`
Current bool `json:"current"`
CreatedAt time.Time `json:"created_at"`
LastSeenAt time.Time `json:"last_seen_at"`
ExpiresAt time.Time `json:"expires_at"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
}
items := make([]out, 0, len(list))
for _, x := range list {
items = append(items, out{x.ID, x.ID == p.SessionID, x.CreatedAt, x.LastSeenAt, x.ExpiresAt, x.IP, x.UserAgent})
}
writeJSON(w, http.StatusOK, page[out]{Items: items})
}
func (s *Server) mySessionRevoke(w http.ResponseWriter, r *http.Request) {
p, _ := auth.PrincipalFrom(r.Context())
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("invalid id"))
return
}
if err := s.deps.Auth.RevokeSession(r.Context(), p.User.ID, id); err != nil {
fail(w, r, err)
return
}
s.deps.Audit.Log(r.Context(), "session.revoke", "session", id.String(), nil)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) myTokenCreate(w http.ResponseWriter, r *http.Request) {
p, _ := auth.PrincipalFrom(r.Context())
var in struct {
Name string `json:"name"`
ExpiresAt *time.Time `json:"expires_at"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
if in.Name == "" {
fail(w, r, ValidationError("name is required"))
return
}
tok, err := s.deps.Auth.CreateAPIToken(r.Context(), p.User.ID, in.Name, in.ExpiresAt)
if err != nil {
fail(w, r, err)
return
}
s.deps.Audit.Log(r.Context(), "token.create", "user", p.User.ID.String(), map[string]any{"name": in.Name})
writeJSON(w, http.StatusCreated, map[string]string{"token": tok, "note": "store this now; it is not shown again"})
}
func (s *Server) myTokens(w http.ResponseWriter, r *http.Request) {
p, _ := auth.PrincipalFrom(r.Context())
list, err := s.deps.Auth.ListAPITokens(r.Context(), p.User.ID)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": list})
}
func (s *Server) myTokenRevoke(w http.ResponseWriter, r *http.Request) {
p, _ := auth.PrincipalFrom(r.Context())
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("invalid id"))
return
}
if err := s.deps.Auth.RevokeAPIToken(r.Context(), p.User.ID, id); err != nil {
fail(w, r, err)
return
}
s.deps.Audit.Log(r.Context(), "token.revoke", "token", id.String(), nil)
w.WriteHeader(http.StatusNoContent)
}
// wrapInput turns validation-style errors from services into 400s. Services
// return plain errors for bad input; anything wrapping storage sentinels is
// left alone.
func wrapInput(err error) error {
if err == nil {
return nil
}
if isStorageErr(err) {
return err
}
return ValidationError(err.Error())
}
+29
View File
@@ -0,0 +1,29 @@
// Package app holds the dependency bundle shared by the HTTP API, the web UI
// and the CLI-facing services. It exists so that constructors take one typed
// value instead of a growing parameter list, without resorting to globals.
package app
import (
"io/fs"
"log/slog"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/andydixon/noema/internal/audit"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/config"
"github.com/andydixon/noema/internal/observability"
)
// Deps is everything a handler may need. Fields are added per phase.
type Deps struct {
Config config.Config
Log *slog.Logger
DB *pgxpool.Pool
Auth *auth.Service
Audit *audit.Logger
Metrics *observability.Metrics
Migrations fs.FS
Started time.Time
}
+103
View File
@@ -0,0 +1,103 @@
// Package audit records administrative actions in the append-only audit_log.
// Every mutation performed by an operator through the UI, API or CLI goes
// through Log so that the Audit page is complete.
package audit
import (
"context"
"encoding/json"
"log/slog"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/ids"
)
// Entry is one audit record.
type Entry struct {
ID ids.ID `json:"id"`
Seq int64 `json:"seq"`
At string `json:"at"`
ActorKind string `json:"actor_kind"`
ActorID string `json:"actor_id"`
ActorName string `json:"actor_name"`
Action string `json:"action"`
TargetKind string `json:"target_kind"`
TargetID string `json:"target_id"`
Details json.RawMessage `json:"details"`
IP string `json:"ip"`
}
// Logger writes audit entries.
type Logger struct {
db storage.Querier
log *slog.Logger
}
// New creates an audit logger.
func New(db storage.Querier, log *slog.Logger) *Logger { return &Logger{db: db, log: log} }
// Log records an action. The actor is taken from the request principal when
// present, otherwise the action is attributed to the system. Failures are
// logged but never block the underlying operation. Details must not contain
// secrets; callers pass structured, already-redacted values.
func (l *Logger) Log(ctx context.Context, action, targetKind, targetID string, details any) {
l.LogAs(ctx, "", "", action, targetKind, targetID, details)
}
// LogAs records an action on behalf of a named actor kind ("mind" for
// cognitive actions surfaced to the audit trail) when no principal applies.
func (l *Logger) LogAs(ctx context.Context, actorKind, actorID, action, targetKind, targetID string, details any) {
kind, id, name, ip := "system", "", "", ""
if p, ok := auth.PrincipalFrom(ctx); ok {
kind, id, name = "user", p.User.ID.String(), p.User.Username
}
if actorKind != "" {
kind, id = actorKind, actorID
}
if v, ok := ctx.Value(ipKey{}).(string); ok {
ip = v
}
if details == nil {
details = map[string]any{}
}
raw, err := json.Marshal(details)
if err != nil {
raw = []byte(`{"marshal_error":true}`)
}
_, err = l.db.Exec(ctx, `INSERT INTO audit_log (id, actor_kind, actor_id, actor_name, action, target_kind, target_id, details, ip)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, ids.New(), kind, id, name, action, targetKind, targetID, raw, ip)
if err != nil {
l.log.Error("audit write failed", "action", action, "err", err)
}
}
type ipKey struct{}
// WithIP attaches the client address so entries record where actions came from.
func WithIP(ctx context.Context, ip string) context.Context {
return context.WithValue(ctx, ipKey{}, ip)
}
// List returns entries newest first, paginated by seq cursor (0 = latest).
func (l *Logger) List(ctx context.Context, beforeSeq int64, limit int, targetKind, targetID string) ([]Entry, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := l.db.Query(ctx, `SELECT id, seq, at::text, actor_kind, actor_id, actor_name, action, target_kind, target_id, details, ip
FROM audit_log WHERE ($1 = 0 OR seq < $1) AND ($3 = '' OR target_kind = $3) AND ($4 = '' OR target_id = $4)
ORDER BY seq DESC LIMIT $2`, beforeSeq, limit, targetKind, targetID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Entry
for rows.Next() {
var e Entry
if err := rows.Scan(&e.ID, &e.Seq, &e.At, &e.ActorKind, &e.ActorID, &e.ActorName, &e.Action, &e.TargetKind, &e.TargetID, &e.Details, &e.IP); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
+174
View File
@@ -0,0 +1,174 @@
package auth
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/migrations"
)
func TestPasswordHashing(t *testing.T) {
h, err := HashPassword("correct horse battery")
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(h, "$argon2id$") {
t.Fatal("not argon2id")
}
if ok, _ := VerifyPassword(h, "correct horse battery"); !ok {
t.Fatal("should verify")
}
if ok, _ := VerifyPassword(h, "wrong"); ok {
t.Fatal("should not verify")
}
if _, err := HashPassword("short"); err == nil {
t.Fatal("short password accepted")
}
}
func TestLockoutSchedule(t *testing.T) {
if lockoutDuration(4) != 0 || lockoutDuration(5) != 30*time.Second || lockoutDuration(7) != 2*time.Minute || lockoutDuration(40) != time.Hour {
t.Fatal("unexpected lockout schedule")
}
}
func TestIPLimiter(t *testing.T) {
l := newIPLimiter(60, 3)
now := time.Now()
l.now = func() time.Time { return now }
for i := 0; i < 3; i++ {
if !l.Allow("a") {
t.Fatal("burst should allow")
}
}
if l.Allow("a") {
t.Fatal("should be limited")
}
if !l.Allow("b") {
t.Fatal("other key unaffected")
}
now = now.Add(2 * time.Second)
if !l.Allow("a") {
t.Fatal("should refill")
}
}
func TestLoginSessionLifecycle(t *testing.T) {
pool := storage.TestPool(t, migrations.FS)
ctx := context.Background()
svc := NewService(pool, time.Hour, 2*time.Hour)
if _, err := svc.CreateUser(ctx, "andy", "a very long password", RoleAdministrator); err != nil {
t.Fatal(err)
}
if _, err := svc.CreateUser(ctx, "ANDY", "a very long password", RoleObserver); !errors.Is(err, storage.ErrConflict) {
t.Fatalf("expected conflict on case-insensitive duplicate, got %v", err)
}
if _, _, err := svc.Login(ctx, "andy", "nope nope nope", "10.0.0.1", "t"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("expected invalid credentials, got %v", err)
}
sess, token, err := svc.Login(ctx, "Andy", "a very long password", "10.0.0.1", "t")
if err != nil {
t.Fatal(err)
}
p, err := svc.Authenticate(ctx, token)
if err != nil || p.User.Username != "andy" || p.SessionID != sess.ID || !p.ViaCookie {
t.Fatalf("authenticate: %v %+v", err, p)
}
if _, err := svc.Authenticate(ctx, "bogus"); !errors.Is(err, ErrUnauthenticated) {
t.Fatal("bogus token accepted")
}
// Lockout after repeated failures.
for i := 0; i < 5; i++ {
svc.Login(ctx, "andy", "wrong wrong wrong", "10.0.0.2", "t") //nolint:errcheck
}
if _, _, err := svc.Login(ctx, "andy", "a very long password", "10.0.0.2", "t"); !errors.Is(err, ErrLocked) {
t.Fatalf("expected lock, got %v", err)
}
// Idle expiry.
svc.now = func() time.Time { return time.Now().Add(61 * time.Minute) }
if _, err := svc.Authenticate(ctx, token); !errors.Is(err, ErrUnauthenticated) {
t.Fatal("expired session accepted")
}
svc.now = time.Now
// API token.
raw, err := svc.CreateAPIToken(ctx, p.User.ID, "cli", nil)
if err != nil {
t.Fatal(err)
}
ap, err := svc.AuthenticateToken(ctx, strings.TrimPrefix(raw, "noema_"))
if err != nil || ap.ViaCookie || ap.User.Role != RoleAdministrator {
t.Fatalf("api token: %v %+v", err, ap)
}
// Disabling revokes sessions.
sess2, token2, _ := svc.Login(ctx, "andy", "a very long password", "10.0.0.3", "t")
_ = sess2
if err := svc.SetUserDisabled(ctx, p.User.ID, true); err != nil {
t.Fatal(err)
}
if _, err := svc.Authenticate(ctx, token2); err == nil {
t.Fatal("disabled user session accepted")
}
}
func TestMiddlewareCSRFAndRoles(t *testing.T) {
pool := storage.TestPool(t, migrations.FS)
ctx := context.Background()
svc := NewService(pool, time.Hour, 2*time.Hour)
svc.CreateUser(ctx, "op", "a very long password", RoleOperator) //nolint:errcheck
sess, token, err := svc.Login(ctx, "op", "a very long password", "127.0.0.1", "t")
if err != nil {
t.Fatal(err)
}
var failed bool
onFail := func(w http.ResponseWriter, r *http.Request, authed bool) { failed = true; w.WriteHeader(401) }
ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
h := svc.Middleware(RequireRole(RoleOperator, onFail)(ok))
adminOnly := svc.Middleware(RequireRole(RoleAdministrator, onFail)(ok))
do := func(handler http.Handler, method, csrf, origin string, cookie bool) int {
req := httptest.NewRequest(method, "http://noema.local/x", nil)
if cookie {
req.AddCookie(&http.Cookie{Name: CookieName, Value: token})
}
if csrf != "" {
req.Header.Set(CSRFHeader, csrf)
}
if origin != "" {
req.Header.Set("Origin", origin)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec.Code
}
if do(h, "GET", "", "", false) != 401 || !failed {
t.Fatal("unauthenticated GET should fail")
}
if do(h, "GET", "", "", true) != 200 {
t.Fatal("authenticated GET should pass")
}
if do(h, "POST", "", "", true) != 403 {
t.Fatal("POST without CSRF should be forbidden")
}
if do(h, "POST", sess.CSRFToken, "", true) != 200 {
t.Fatal("POST with CSRF should pass")
}
if do(h, "POST", sess.CSRFToken, "http://evil.example", true) != 403 {
t.Fatal("cross-origin POST should be forbidden")
}
if do(h, "POST", "wrong", "", true) != 403 {
t.Fatal("wrong CSRF should be forbidden")
}
if do(adminOnly, "GET", "", "", true) != 401 {
t.Fatal("operator should not reach admin route")
}
}
+76
View File
@@ -0,0 +1,76 @@
package auth
import (
"sync"
"time"
)
// ipLimiter is a token bucket per client address for login attempts. It lives
// in memory because it only needs to slow down a single daemon's attackers;
// per-account lockout is persisted in the database.
type ipLimiter struct {
mu sync.Mutex
buckets map[string]*bucket
rate float64 // tokens per second
burst float64
now func() time.Time
}
type bucket struct {
tokens float64
last time.Time
}
func newIPLimiter(perMinute, burst int) *ipLimiter {
return &ipLimiter{buckets: map[string]*bucket{}, rate: float64(perMinute) / 60, burst: float64(burst), now: time.Now}
}
// Allow consumes a token for key if one is available.
func (l *ipLimiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
b := l.buckets[key]
if b == nil {
b = &bucket{tokens: l.burst, last: now}
l.buckets[key] = b
}
b.tokens += now.Sub(b.last).Seconds() * l.rate
if b.tokens > l.burst {
b.tokens = l.burst
}
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// Sweep drops buckets that have refilled, bounding memory (T18).
func (l *ipLimiter) Sweep() {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
for k, b := range l.buckets {
if b.tokens+now.Sub(b.last).Seconds()*l.rate >= l.burst {
delete(l.buckets, k)
}
}
}
// lockoutDuration grows exponentially with consecutive failures, capped at
// one hour, and starts after five failures so typos are not punished.
func lockoutDuration(failed int) time.Duration {
if failed < 5 {
return 0
}
if failed > 12 { // 2^7 * 30s already exceeds the cap; avoid shift overflow
return time.Hour
}
d := time.Duration(1<<uint(failed-5)) * 30 * time.Second
if d > time.Hour {
d = time.Hour
}
return d
}
+136
View File
@@ -0,0 +1,136 @@
package auth
import (
"context"
"crypto/subtle"
"net"
"net/http"
"net/url"
"strings"
)
// CookieName is the session cookie.
const CookieName = "noema_session"
// CSRFHeader carries the CSRF token for fetch/XHR requests; forms use the
// hidden field named CSRFField.
const (
CSRFHeader = "X-CSRF-Token"
CSRFField = "_csrf"
)
type principalKey struct{}
// PrincipalFrom returns the authenticated principal, if any.
func PrincipalFrom(ctx context.Context) (Principal, bool) {
p, ok := ctx.Value(principalKey{}).(Principal)
return p, ok
}
// WithPrincipal is exported for tests and internal callers that construct contexts.
func WithPrincipal(ctx context.Context, p Principal) context.Context {
return context.WithValue(ctx, principalKey{}, p)
}
// Middleware resolves the session cookie or bearer token into a Principal and
// enforces CSRF for cookie sessions on unsafe methods. Unauthenticated requests
// pass through without a principal; route guards decide what to do.
func (s *Service) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var p Principal
var authed bool
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer noema_") {
if pp, err := s.AuthenticateToken(r.Context(), strings.TrimPrefix(h, "Bearer noema_")); err == nil {
p, authed = pp, true
}
} else if c, err := r.Cookie(CookieName); err == nil && c.Value != "" {
if pp, err := s.Authenticate(r.Context(), c.Value); err == nil {
p, authed = pp, true
}
}
if authed && p.ViaCookie && !safeMethod(r.Method) {
if !s.csrfOK(r, p) {
http.Error(w, "CSRF token missing or invalid", http.StatusForbidden)
return
}
}
if authed {
r = r.WithContext(WithPrincipal(r.Context(), p))
}
next.ServeHTTP(w, r)
})
}
func safeMethod(m string) bool {
return m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions
}
func (s *Service) csrfOK(r *http.Request, p Principal) bool {
// Origin/Referer must match the host when present (defence in depth).
if o := r.Header.Get("Origin"); o != "" {
if u, err := url.Parse(o); err != nil || !strings.EqualFold(u.Host, r.Host) {
return false
}
} else if ref := r.Header.Get("Referer"); ref != "" {
if u, err := url.Parse(ref); err != nil || !strings.EqualFold(u.Host, r.Host) {
return false
}
}
want, err := s.CSRFToken(r.Context(), p.SessionID)
if err != nil {
return false
}
got := r.Header.Get(CSRFHeader)
if got == "" {
if ct := r.Header.Get("Content-Type"); strings.HasPrefix(ct, "application/x-www-form-urlencoded") || strings.HasPrefix(ct, "multipart/form-data") {
got = r.PostFormValue(CSRFField)
}
}
return got != "" && subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
}
// RequireRole returns middleware that rejects requests below min. onFail is
// invoked for unauthenticated or under-privileged requests so the UI can
// redirect to login while the API returns JSON.
func RequireRole(min Role, onFail func(w http.ResponseWriter, r *http.Request, authenticated bool)) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := PrincipalFrom(r.Context())
if !ok {
onFail(w, r, false)
return
}
if !p.User.Role.AtLeast(min) {
onFail(w, r, true)
return
}
next.ServeHTTP(w, r)
})
}
}
// ClientIP extracts the peer address. Proxy headers are deliberately ignored
// unless the daemon is told it sits behind a trusted proxy; secure default.
func ClientIP(r *http.Request, trustProxy bool) string {
if trustProxy {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0])
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// SetSessionCookie writes the session cookie with secure attributes.
func SetSessionCookie(w http.ResponseWriter, token string, secure bool, maxAge int) {
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: token, Path: "/", HttpOnly: true, Secure: secure,
SameSite: http.SameSiteLaxMode, MaxAge: maxAge})
}
// ClearSessionCookie expires the session cookie.
func ClearSessionCookie(w http.ResponseWriter, secure bool) {
SetSessionCookie(w, "", secure, -1)
}
+72
View File
@@ -0,0 +1,72 @@
// Package auth implements local authentication: Argon2id passwords, opaque
// sessions, CSRF tokens, login throttling, roles and HTTP middleware
// (ADR-0006).
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// Argon2id parameters: 64 MiB, 3 passes, 2 lanes. Roughly 100 ms on the
// development machine, which throttles offline guessing without hurting login.
const (
argonTime = 3
argonMemory = 64 * 1024
argonThreads = 2
argonKeyLen = 32
saltLen = 16
)
// MinPasswordLength is the shortest password accepted anywhere.
const MinPasswordLength = 12
// HashPassword returns a PHC-formatted Argon2id hash.
func HashPassword(password string) (string, error) {
if len(password) < MinPasswordLength {
return "", fmt.Errorf("password must be at least %d characters", MinPasswordLength)
}
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
}
// VerifyPassword reports whether password matches the PHC hash. It parses the
// parameters from the hash so they can be raised later without invalidating
// existing users.
func VerifyPassword(hash, password string) (bool, error) {
parts := strings.Split(hash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, errors.New("unsupported hash format")
}
var m uint32
var t uint32
var p uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
return false, fmt.Errorf("bad hash params: %w", err)
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, err
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, err
}
got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
// dummyHash is verified against when a username does not exist so that the
// response time does not reveal whether the account exists.
var dummyHash, _ = HashPassword("noema-timing-equalisation-password")
+22
View File
@@ -0,0 +1,22 @@
package auth
// Role is a coarse permission level. Higher roles include lower ones.
type Role string
// Roles in ascending order of privilege.
const (
RoleObserver Role = "observer"
RoleOperator Role = "operator"
RoleAdministrator Role = "administrator"
)
var roleRank = map[Role]int{RoleObserver: 1, RoleOperator: 2, RoleAdministrator: 3}
// Valid reports whether r is a known role.
func (r Role) Valid() bool { return roleRank[r] > 0 }
// AtLeast reports whether r grants everything min grants.
func (r Role) AtLeast(min Role) bool { return roleRank[r] >= roleRank[min] }
// AllRoles lists valid roles for forms and validation.
func AllRoles() []Role { return []Role{RoleObserver, RoleOperator, RoleAdministrator} }
+393
View File
@@ -0,0 +1,393 @@
package auth
import (
"context"
"errors"
"fmt"
"net/netip"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/ids"
)
// User is an operator account.
type User struct {
ID ids.ID
Username string
Role Role
Disabled bool
TOTPEnabled bool
LockedUntil *time.Time
CreatedAt time.Time
}
// Session is an authenticated browser or CLI session.
type Session struct {
ID ids.ID
UserID ids.ID
CSRFToken string
CreatedAt time.Time
LastSeenAt time.Time
ExpiresAt time.Time
IP string
UserAgent string
}
// Principal is the authenticated identity attached to a request context.
type Principal struct {
User User
SessionID ids.ID // empty for API-token authentication
ViaCookie bool // cookie sessions need CSRF checks; bearer tokens do not
}
// Errors returned by the service.
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrLocked = errors.New("account temporarily locked")
ErrDisabled = errors.New("account disabled")
ErrRateLimited = errors.New("too many attempts")
ErrUnauthenticated = errors.New("unauthenticated")
)
// Service performs authentication operations against the database.
type Service struct {
db *pgxpool.Pool
idleTTL time.Duration
absoluteTTL time.Duration
limiter *ipLimiter
now func() time.Time
}
// NewService creates the authentication service.
func NewService(db *pgxpool.Pool, idleTTL, absoluteTTL time.Duration) *Service {
return &Service{db: db, idleTTL: idleTTL, absoluteTTL: absoluteTTL, limiter: newIPLimiter(20, 10), now: time.Now}
}
// SweepLimiter releases idle rate-limit buckets; call periodically.
func (s *Service) SweepLimiter() { s.limiter.Sweep() }
// CreateUser adds an account. Usernames are unique case-insensitively.
func (s *Service) CreateUser(ctx context.Context, username, password string, role Role) (User, error) {
username = strings.TrimSpace(username)
if len(username) < 2 || len(username) > 64 {
return User{}, errors.New("username must be 2-64 characters")
}
if !role.Valid() {
return User{}, fmt.Errorf("invalid role %q", role)
}
hash, err := HashPassword(password)
if err != nil {
return User{}, err
}
u := User{ID: ids.New(), Username: username, Role: role, CreatedAt: s.now()}
_, err = s.db.Exec(ctx, `INSERT INTO users (id, username, password_hash, role, created_at) VALUES ($1,$2,$3,$4,$5)`,
u.ID, u.Username, hash, string(u.Role), u.CreatedAt)
if err != nil {
return User{}, storage.Translate(err)
}
return u, nil
}
// CountUsers is used to decide whether bootstrap is needed.
func (s *Service) CountUsers(ctx context.Context) (int, error) {
var n int
err := s.db.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&n)
return n, err
}
// ListUsers returns all accounts ordered by name.
func (s *Service) ListUsers(ctx context.Context) ([]User, error) {
rows, err := s.db.Query(ctx, `SELECT id, username, role, disabled, totp_enabled, locked_until, created_at FROM users ORDER BY lower(username)`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
var role string
if err := rows.Scan(&u.ID, &u.Username, &role, &u.Disabled, &u.TOTPEnabled, &u.LockedUntil, &u.CreatedAt); err != nil {
return nil, err
}
u.Role = Role(role)
out = append(out, u)
}
return out, rows.Err()
}
// GetUser fetches one account.
func (s *Service) GetUser(ctx context.Context, id ids.ID) (User, error) {
var u User
var role string
err := s.db.QueryRow(ctx, `SELECT id, username, role, disabled, totp_enabled, locked_until, created_at FROM users WHERE id=$1`, id).
Scan(&u.ID, &u.Username, &role, &u.Disabled, &u.TOTPEnabled, &u.LockedUntil, &u.CreatedAt)
u.Role = Role(role)
return u, storage.Translate(err)
}
// SetUserRole changes an account's role.
func (s *Service) SetUserRole(ctx context.Context, id ids.ID, role Role) error {
if !role.Valid() {
return fmt.Errorf("invalid role %q", role)
}
tag, err := s.db.Exec(ctx, `UPDATE users SET role=$2, updated_at=now() WHERE id=$1`, id, string(role))
if err == nil && tag.RowsAffected() == 0 {
return storage.ErrNotFound
}
return err
}
// SetUserDisabled enables or disables an account and revokes its sessions when disabling.
func (s *Service) SetUserDisabled(ctx context.Context, id ids.ID, disabled bool) error {
tag, err := s.db.Exec(ctx, `UPDATE users SET disabled=$2, updated_at=now() WHERE id=$1`, id, disabled)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return storage.ErrNotFound
}
if disabled {
_, err = s.db.Exec(ctx, `UPDATE sessions SET revoked_at=now() WHERE user_id=$1 AND revoked_at IS NULL`, id)
}
return err
}
// ChangePassword sets a new password and revokes every other session (T12).
func (s *Service) ChangePassword(ctx context.Context, id ids.ID, newPassword string, keepSession ids.ID) error {
hash, err := HashPassword(newPassword)
if err != nil {
return err
}
if _, err := s.db.Exec(ctx, `UPDATE users SET password_hash=$2, password_changed_at=now(), updated_at=now() WHERE id=$1`, id, hash); err != nil {
return err
}
_, err = s.db.Exec(ctx, `UPDATE sessions SET revoked_at=now() WHERE user_id=$1 AND id<>$2 AND revoked_at IS NULL`, id, keepSession)
return err
}
// Login verifies credentials, applying per-IP and per-account throttling, and
// returns a new session plus the raw token to set as a cookie.
func (s *Service) Login(ctx context.Context, username, password, ip, userAgent string) (Session, string, error) {
if !s.limiter.Allow(clientKey(ip)) {
return Session{}, "", ErrRateLimited
}
var (
u User
role string
hash string
failed int
disabled bool
)
err := s.db.QueryRow(ctx, `SELECT id, username, role, password_hash, disabled, failed_attempts, locked_until, totp_enabled FROM users WHERE lower(username)=lower($1)`, strings.TrimSpace(username)).
Scan(&u.ID, &u.Username, &role, &hash, &disabled, &failed, &u.LockedUntil, &u.TOTPEnabled)
if err != nil {
VerifyPassword(dummyHash, password) //nolint:errcheck // timing equalisation only
return Session{}, "", ErrInvalidCredentials
}
u.Role = Role(role)
if u.LockedUntil != nil && u.LockedUntil.After(s.now()) {
return Session{}, "", ErrLocked
}
ok, err := VerifyPassword(hash, password)
if err != nil {
return Session{}, "", err
}
if !ok {
failed++
var until *time.Time
if d := lockoutDuration(failed); d > 0 {
t := s.now().Add(d)
until = &t
}
s.db.Exec(ctx, `UPDATE users SET failed_attempts=$2, locked_until=$3 WHERE id=$1`, u.ID, failed, until) //nolint:errcheck
if until != nil {
return Session{}, "", ErrLocked
}
return Session{}, "", ErrInvalidCredentials
}
if disabled {
return Session{}, "", ErrDisabled
}
if _, err := s.db.Exec(ctx, `UPDATE users SET failed_attempts=0, locked_until=NULL WHERE id=$1`, u.ID); err != nil {
return Session{}, "", err
}
return s.createSession(ctx, u.ID, ip, userAgent)
}
func (s *Service) createSession(ctx context.Context, userID ids.ID, ip, userAgent string) (Session, string, error) {
token, hash := newToken()
csrf, _ := newToken()
now := s.now()
sess := Session{ID: ids.New(), UserID: userID, CSRFToken: csrf, CreatedAt: now, LastSeenAt: now,
ExpiresAt: now.Add(s.idleTTL), IP: clientKey(ip), UserAgent: truncate(userAgent, 256)}
_, err := s.db.Exec(ctx, `INSERT INTO sessions (id, user_id, token_hash, csrf_token, created_at, last_seen_at, expires_at, ip, user_agent)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, sess.ID, sess.UserID, hash, sess.CSRFToken, sess.CreatedAt, sess.LastSeenAt, sess.ExpiresAt, sess.IP, sess.UserAgent)
if err != nil {
return Session{}, "", err
}
return sess, token, nil
}
// Authenticate resolves a session token to a principal, sliding the idle
// expiry forward. Absolute expiry is measured from session creation.
func (s *Service) Authenticate(ctx context.Context, token string) (Principal, error) {
var (
p Principal
role string
created time.Time
expires time.Time
lastSeen time.Time
)
err := s.db.QueryRow(ctx, `SELECT s.id, s.csrf_token, s.created_at, s.last_seen_at, s.expires_at, u.id, u.username, u.role, u.disabled, u.totp_enabled, u.created_at
FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=$1 AND s.revoked_at IS NULL`, hashToken(token)).
Scan(&p.SessionID, new(string), &created, &lastSeen, &expires, &p.User.ID, &p.User.Username, &role, &p.User.Disabled, &p.User.TOTPEnabled, &p.User.CreatedAt)
if err != nil {
return Principal{}, ErrUnauthenticated
}
now := s.now()
if now.After(expires) || now.After(created.Add(s.absoluteTTL)) || p.User.Disabled {
return Principal{}, ErrUnauthenticated
}
p.User.Role = Role(role)
p.ViaCookie = true
// Touch at most once a minute to avoid a write per request.
if now.Sub(lastSeen) > time.Minute {
s.db.Exec(ctx, `UPDATE sessions SET last_seen_at=$2, expires_at=$3 WHERE id=$1`, p.SessionID, now, now.Add(s.idleTTL)) //nolint:errcheck
}
return p, nil
}
// AuthenticateToken resolves a personal API token.
func (s *Service) AuthenticateToken(ctx context.Context, token string) (Principal, error) {
var (
p Principal
role string
tokenID ids.ID
expires *time.Time
)
err := s.db.QueryRow(ctx, `SELECT t.id, t.expires_at, u.id, u.username, u.role, u.disabled, u.totp_enabled, u.created_at
FROM api_tokens t JOIN users u ON u.id=t.user_id WHERE t.token_hash=$1 AND t.revoked_at IS NULL`, hashToken(token)).
Scan(&tokenID, &expires, &p.User.ID, &p.User.Username, &role, &p.User.Disabled, &p.User.TOTPEnabled, &p.User.CreatedAt)
if err != nil || p.User.Disabled || (expires != nil && expires.Before(s.now())) {
return Principal{}, ErrUnauthenticated
}
p.User.Role = Role(role)
s.db.Exec(ctx, `UPDATE api_tokens SET last_used_at=now() WHERE id=$1`, tokenID) //nolint:errcheck
return p, nil
}
// CSRFToken returns the CSRF token bound to a session.
func (s *Service) CSRFToken(ctx context.Context, sessionID ids.ID) (string, error) {
var t string
err := s.db.QueryRow(ctx, `SELECT csrf_token FROM sessions WHERE id=$1 AND revoked_at IS NULL`, sessionID).Scan(&t)
return t, storage.Translate(err)
}
// Logout revokes one session.
func (s *Service) Logout(ctx context.Context, sessionID ids.ID) error {
_, err := s.db.Exec(ctx, `UPDATE sessions SET revoked_at=now() WHERE id=$1 AND revoked_at IS NULL`, sessionID)
return err
}
// RevokeSession revokes a session belonging to userID; returns ErrNotFound otherwise.
func (s *Service) RevokeSession(ctx context.Context, userID, sessionID ids.ID) error {
tag, err := s.db.Exec(ctx, `UPDATE sessions SET revoked_at=now() WHERE id=$1 AND user_id=$2 AND revoked_at IS NULL`, sessionID, userID)
if err == nil && tag.RowsAffected() == 0 {
return storage.ErrNotFound
}
return err
}
// ListSessions returns a user's live sessions, newest first.
func (s *Service) ListSessions(ctx context.Context, userID ids.ID) ([]Session, error) {
rows, err := s.db.Query(ctx, `SELECT id, user_id, created_at, last_seen_at, expires_at, ip, user_agent FROM sessions
WHERE user_id=$1 AND revoked_at IS NULL AND expires_at > now() ORDER BY last_seen_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Session
for rows.Next() {
var x Session
if err := rows.Scan(&x.ID, &x.UserID, &x.CreatedAt, &x.LastSeenAt, &x.ExpiresAt, &x.IP, &x.UserAgent); err != nil {
return nil, err
}
out = append(out, x)
}
return out, rows.Err()
}
// CreateAPIToken issues a personal token; the raw value is returned once.
func (s *Service) CreateAPIToken(ctx context.Context, userID ids.ID, name string, expires *time.Time) (string, error) {
token, hash := newToken()
_, err := s.db.Exec(ctx, `INSERT INTO api_tokens (id, user_id, name, token_hash, expires_at) VALUES ($1,$2,$3,$4,$5)`,
ids.New(), userID, truncate(name, 64), hash, expires)
if err != nil {
return "", err
}
return "noema_" + token, nil
}
// APIToken is a personal token's metadata (never the token itself).
type APIToken struct {
ID ids.ID
Name string
CreatedAt time.Time
LastUsedAt *time.Time
ExpiresAt *time.Time
}
// ListAPITokens lists a user's live tokens.
func (s *Service) ListAPITokens(ctx context.Context, userID ids.ID) ([]APIToken, error) {
rows, err := s.db.Query(ctx, `SELECT id, name, created_at, last_used_at, expires_at FROM api_tokens WHERE user_id=$1 AND revoked_at IS NULL ORDER BY created_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []APIToken
for rows.Next() {
var t APIToken
if err := rows.Scan(&t.ID, &t.Name, &t.CreatedAt, &t.LastUsedAt, &t.ExpiresAt); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// RevokeAPIToken revokes a token owned by userID.
func (s *Service) RevokeAPIToken(ctx context.Context, userID, tokenID ids.ID) error {
tag, err := s.db.Exec(ctx, `UPDATE api_tokens SET revoked_at=now() WHERE id=$1 AND user_id=$2 AND revoked_at IS NULL`, tokenID, userID)
if err == nil && tag.RowsAffected() == 0 {
return storage.ErrNotFound
}
return err
}
// PurgeExpiredSessions deletes sessions long past expiry. Sessions are not
// cognitive history, so removing them is fine; audit_log keeps the record.
func (s *Service) PurgeExpiredSessions(ctx context.Context) error {
_, err := s.db.Exec(ctx, `DELETE FROM sessions WHERE expires_at < now() - interval '30 days' OR revoked_at < now() - interval '30 days'`)
return err
}
func clientKey(ip string) string {
if a, err := netip.ParseAddr(ip); err == nil {
if a.Is6() {
return netip.PrefixFrom(a, 64).Masked().String() // one bucket per /64
}
return a.String()
}
return ip
}
func truncate(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
+23
View File
@@ -0,0 +1,23 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
)
// newToken returns a 256-bit random token in URL-safe base64 and its SHA-256,
// which is what gets stored. A database leak therefore does not leak sessions.
func newToken() (token string, hash []byte) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic("auth: crypto/rand unavailable: " + err.Error())
}
token = base64.RawURLEncoding.EncodeToString(b)
return token, hashToken(token)
}
func hashToken(token string) []byte {
h := sha256.Sum256([]byte(token))
return h[:]
}
+261
View File
@@ -0,0 +1,261 @@
package ui
import (
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/ids"
)
func (u *UI) loginForm(w http.ResponseWriter, r *http.Request) {
if _, ok := auth.PrincipalFrom(r.Context()); ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if n, err := u.deps.Auth.CountUsers(r.Context()); err == nil && n == 0 {
http.Redirect(w, r, "/setup", http.StatusSeeOther)
return
}
u.render(w, r, "login", "Sign in", map[string]any{"Next": safeNext(r.URL.Query().Get("next"))})
}
func (u *UI) loginSubmit(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
u.errorPage(w, r, http.StatusBadRequest, "Bad form.")
return
}
ip := auth.ClientIP(r, false)
sess, token, err := u.deps.Auth.Login(r.Context(), r.PostFormValue("username"), r.PostFormValue("password"), ip, r.UserAgent())
if err != nil {
u.deps.Metrics.AuthFailures.Inc()
msg := "Invalid username or password."
switch {
case errors.Is(err, auth.ErrLocked):
msg = "Account temporarily locked after repeated failures. Try again later."
case errors.Is(err, auth.ErrRateLimited):
msg = "Too many attempts from this address. Slow down."
case errors.Is(err, auth.ErrDisabled):
msg = "This account is disabled."
}
u.deps.Audit.Log(r.Context(), "login.failed", "user", "", map[string]any{"username": r.PostFormValue("username"), "reason": err.Error()})
w.WriteHeader(http.StatusUnauthorized)
u.render(w, r, "login", "Sign in", map[string]any{"Error": msg, "Next": safeNext(r.PostFormValue("next"))})
return
}
auth.SetSessionCookie(w, token, u.deps.Config.Production(), int(u.deps.Config.SessionAbsoluteTTL.Seconds()))
ctx := auth.WithPrincipal(r.Context(), auth.Principal{User: auth.User{ID: sess.UserID, Username: r.PostFormValue("username")}, SessionID: sess.ID})
u.deps.Audit.Log(ctx, "login.success", "session", sess.ID.String(), nil)
to := safeNext(r.PostFormValue("next"))
if to == "" {
to = "/"
}
http.Redirect(w, r, to, http.StatusSeeOther)
}
func (u *UI) logout(w http.ResponseWriter, r *http.Request) {
if p, ok := auth.PrincipalFrom(r.Context()); ok && !p.SessionID.IsZero() {
u.deps.Auth.Logout(r.Context(), p.SessionID) //nolint:errcheck
u.deps.Audit.Log(r.Context(), "logout", "session", p.SessionID.String(), nil)
}
auth.ClearSessionCookie(w, u.deps.Config.Production())
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// setup is only reachable while no users exist; it is the browser equivalent
// of the bootstrap environment variables.
func (u *UI) setupForm(w http.ResponseWriter, r *http.Request) {
if n, err := u.deps.Auth.CountUsers(r.Context()); err != nil || n > 0 {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
u.render(w, r, "setup", "Setup", nil)
}
func (u *UI) setupSubmit(w http.ResponseWriter, r *http.Request) {
if n, err := u.deps.Auth.CountUsers(r.Context()); err != nil || n > 0 {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
r.ParseForm() //nolint:errcheck
if r.PostFormValue("password") != r.PostFormValue("confirm") {
u.render(w, r, "setup", "Setup", map[string]any{"Error": "Passwords do not match."})
return
}
user, err := u.deps.Auth.CreateUser(r.Context(), r.PostFormValue("username"), r.PostFormValue("password"), auth.RoleAdministrator)
if err != nil {
u.render(w, r, "setup", "Setup", map[string]any{"Error": err.Error()})
return
}
u.deps.Audit.Log(r.Context(), "user.bootstrap", "user", user.ID.String(), map[string]any{"username": user.Username, "via": "setup page"})
u.flash(w, r, "ok", "Administrator created. Sign in.", "/login")
}
func (u *UI) dashboard(w http.ResponseWriter, r *http.Request) {
entries, _ := u.deps.Audit.List(r.Context(), 0, 8, "", "")
u.render(w, r, "dashboard", "Dashboard", map[string]any{"Minds": 0, "Status": u.statusF(r), "Audit": entries})
}
func (u *UI) system(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "system", "System", u.statusF(r))
}
func (u *UI) audit(w http.ResponseWriter, r *http.Request) {
before, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64)
entries, err := u.deps.Audit.List(r.Context(), before, 100, r.URL.Query().Get("target_kind"), r.URL.Query().Get("target_id"))
if err != nil {
u.errorPage(w, r, 500, "Could not load audit log.")
return
}
var next int64
if len(entries) == 100 {
next = entries[len(entries)-1].Seq
}
u.render(w, r, "audit", "Audit", map[string]any{"Entries": entries, "Next": next})
}
func (u *UI) account(w http.ResponseWriter, r *http.Request) {
p, _ := auth.PrincipalFrom(r.Context())
sessions, _ := u.deps.Auth.ListSessions(r.Context(), p.User.ID)
tokens, _ := u.deps.Auth.ListAPITokens(r.Context(), p.User.ID)
var newToken string
if c, err := r.Cookie("noema_newtoken"); err == nil {
newToken = c.Value
http.SetCookie(w, &http.Cookie{Name: "noema_newtoken", Path: "/account", MaxAge: -1, HttpOnly: true})
}
u.render(w, r, "account", "Account", map[string]any{"Sessions": sessions, "Tokens": tokens, "NewToken": newToken})
}
func (u *UI) accountPassword(w http.ResponseWriter, r *http.Request) {
p, _ := auth.PrincipalFrom(r.Context())
r.ParseForm() //nolint:errcheck
if r.PostFormValue("password") != r.PostFormValue("confirm") {
u.flash(w, r, "error", "New passwords do not match.", "/account")
return
}
// Re-verify the current password before changing it.
if _, _, err := u.deps.Auth.Login(r.Context(), p.User.Username, r.PostFormValue("current"), auth.ClientIP(r, false), "password-change"); err != nil {
u.flash(w, r, "error", "Current password incorrect.", "/account")
return
}
if err := u.deps.Auth.ChangePassword(r.Context(), p.User.ID, r.PostFormValue("password"), p.SessionID); err != nil {
u.flash(w, r, "error", err.Error(), "/account")
return
}
u.deps.Audit.Log(r.Context(), "user.password_change", "user", p.User.ID.String(), nil)
u.flash(w, r, "ok", "Password changed. Other sessions were signed out.", "/account")
}
func (u *UI) accountSessionRevoke(w http.ResponseWriter, r *http.Request) {
p, _ := auth.PrincipalFrom(r.Context())
id, err := ids.Parse(r.PathValue("id"))
if err != nil || u.deps.Auth.RevokeSession(r.Context(), p.User.ID, id) != nil {
u.flash(w, r, "error", "Session not found.", "/account")
return
}
u.deps.Audit.Log(r.Context(), "session.revoke", "session", id.String(), nil)
u.flash(w, r, "ok", "Session revoked.", "/account")
}
func (u *UI) accountTokenCreate(w http.ResponseWriter, r *http.Request) {
p, _ := auth.PrincipalFrom(r.Context())
r.ParseForm() //nolint:errcheck
name := strings.TrimSpace(r.PostFormValue("name"))
if name == "" {
u.flash(w, r, "error", "Token name required.", "/account")
return
}
tok, err := u.deps.Auth.CreateAPIToken(r.Context(), p.User.ID, name, nil)
if err != nil {
u.flash(w, r, "error", "Could not create token.", "/account")
return
}
u.deps.Audit.Log(r.Context(), "token.create", "user", p.User.ID.String(), map[string]any{"name": name})
// Shown exactly once on the next page load, then the cookie is cleared.
http.SetCookie(w, &http.Cookie{Name: "noema_newtoken", Value: tok, Path: "/account", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: u.deps.Config.Production()})
http.Redirect(w, r, "/account", http.StatusSeeOther)
}
func (u *UI) accountTokenRevoke(w http.ResponseWriter, r *http.Request) {
p, _ := auth.PrincipalFrom(r.Context())
id, err := ids.Parse(r.PathValue("id"))
if err != nil || u.deps.Auth.RevokeAPIToken(r.Context(), p.User.ID, id) != nil {
u.flash(w, r, "error", "Token not found.", "/account")
return
}
u.deps.Audit.Log(r.Context(), "token.revoke", "token", id.String(), nil)
u.flash(w, r, "ok", "Token revoked.", "/account")
}
func (u *UI) users(w http.ResponseWriter, r *http.Request) {
users, err := u.deps.Auth.ListUsers(r.Context())
if err != nil {
u.errorPage(w, r, 500, "Could not load users.")
return
}
u.render(w, r, "users", "Users", map[string]any{"Users": users})
}
func (u *UI) usersCreate(w http.ResponseWriter, r *http.Request) {
r.ParseForm() //nolint:errcheck
user, err := u.deps.Auth.CreateUser(r.Context(), r.PostFormValue("username"), r.PostFormValue("password"), auth.Role(r.PostFormValue("role")))
if err != nil {
msg := err.Error()
if errors.Is(err, storage.ErrConflict) {
msg = "That username already exists."
}
u.flash(w, r, "error", msg, "/admin/users")
return
}
u.deps.Audit.Log(r.Context(), "user.create", "user", user.ID.String(), map[string]any{"username": user.Username, "role": user.Role})
u.flash(w, r, "ok", "User created.", "/admin/users")
}
func (u *UI) usersUpdate(w http.ResponseWriter, r *http.Request) {
p, _ := auth.PrincipalFrom(r.Context())
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
u.flash(w, r, "error", "Invalid user.", "/admin/users")
return
}
if id == p.User.ID {
u.flash(w, r, "error", "You cannot change your own account here.", "/admin/users")
return
}
r.ParseForm() //nolint:errcheck
switch r.PostFormValue("action") {
case "role":
role := auth.Role(r.PostFormValue("role"))
if err := u.deps.Auth.SetUserRole(r.Context(), id, role); err != nil {
u.flash(w, r, "error", err.Error(), "/admin/users")
return
}
u.deps.Audit.Log(r.Context(), "user.role", "user", id.String(), map[string]any{"role": role})
case "disable", "enable":
disabled := r.PostFormValue("action") == "disable"
if err := u.deps.Auth.SetUserDisabled(r.Context(), id, disabled); err != nil {
u.flash(w, r, "error", err.Error(), "/admin/users")
return
}
u.deps.Audit.Log(r.Context(), "user.disabled", "user", id.String(), map[string]any{"disabled": disabled})
default:
u.flash(w, r, "error", "Unknown action.", "/admin/users")
return
}
u.flash(w, r, "ok", "User updated.", "/admin/users")
}
// safeNext only allows same-site relative paths for post-login redirects.
func safeNext(s string) string {
if s == "" || !strings.HasPrefix(s, "/") || strings.HasPrefix(s, "//") {
return ""
}
if _, err := url.Parse(s); err != nil {
return ""
}
return s
}
+239
View File
@@ -0,0 +1,239 @@
// Package ui serves the server-rendered administration interface. Every page
// is a Go handler plus an html/template; live updates reuse the same templates
// through SSE fragments (ADR-0007).
package ui
import (
"bytes"
"encoding/base64"
"fmt"
"html/template"
"io/fs"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/andydixon/noema/internal/app"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/version"
"github.com/andydixon/noema/web"
)
// UI holds handlers and the parsed template set.
type UI struct {
deps app.Deps
pages map[string]*template.Template
static fs.FS
statusF func(r *http.Request) any // system status provider injected by api
}
// View is the data passed to every template.
type View struct {
Title string
Path string
Principal *auth.Principal
CSRF string
Flash *Flash
Version string
Data any
}
// Flash is a one-shot message shown on the next page load.
type Flash struct{ Kind, Message string }
const flashCookie = "noema_flash"
// New parses templates. In development, when ./web exists on disk, templates
// and static files are read from disk so edits show without a rebuild.
func New(deps app.Deps, statusF func(r *http.Request) any) (*UI, error) {
tmplFS, staticFS := web.Templates(), web.Static()
if !deps.Config.Production() {
if st, err := os.Stat("web/templates"); err == nil && st.IsDir() {
tmplFS, staticFS = os.DirFS("web/templates"), os.DirFS("web/static")
deps.Log.Info("ui: serving templates and static files from ./web (development)")
}
}
u := &UI{deps: deps, static: staticFS, statusF: statusF}
if err := u.parse(tmplFS); err != nil {
return nil, err
}
return u, nil
}
func (u *UI) parse(fsys fs.FS) error {
funcs := template.FuncMap{
"hasPrefix": strings.HasPrefix,
"roles": auth.AllRoles,
"date": func(t time.Time) string { return t.Local().Format("2006-01-02 15:04") },
"ago": ago,
"duration": func(s int64) string { return (time.Duration(s) * time.Second).Truncate(time.Second).String() },
"bytes": humanBytes,
"pct": func(f float64) string { return fmt.Sprintf("%.0f%%", f*100) },
"conf": func(f float64) string { return fmt.Sprintf("%.2f", f) },
}
base, err := template.New("base").Funcs(funcs).ParseFS(fsys, "layout.html")
if err != nil {
return fmt.Errorf("ui: parse layout: %w", err)
}
pages, err := fs.Glob(fsys, "pages/*.html")
if err != nil {
return err
}
u.pages = map[string]*template.Template{}
for _, p := range pages {
t, err := base.Clone()
if err != nil {
return err
}
if _, err := t.ParseFS(fsys, p); err != nil {
return fmt.Errorf("ui: parse %s: %w", p, err)
}
u.pages[strings.TrimPrefix(strings.TrimSuffix(p, ".html"), "pages/")] = t
}
return nil
}
// render executes a page template. Rendering into a buffer first means a
// template error yields a proper 500 instead of a half-written page.
func (u *UI) render(w http.ResponseWriter, r *http.Request, page, title string, data any) {
t, ok := u.pages[page]
if !ok {
http.Error(w, "template not found: "+page, http.StatusInternalServerError)
return
}
v := View{Title: title, Path: r.URL.Path, Version: version.Version, Data: data}
if p, ok := auth.PrincipalFrom(r.Context()); ok {
v.Principal = &p
if tok, err := u.deps.Auth.CSRFToken(r.Context(), p.SessionID); err == nil {
v.CSRF = tok
}
}
if c, err := r.Cookie(flashCookie); err == nil && c.Value != "" {
if raw, err := base64.RawURLEncoding.DecodeString(c.Value); err == nil {
if kind, msg, ok := strings.Cut(string(raw), "|"); ok {
v.Flash = &Flash{Kind: kind, Message: msg}
}
}
http.SetCookie(w, &http.Cookie{Name: flashCookie, Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
}
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, "layout", v); err != nil {
u.deps.Log.Error("template execution failed", "page", page, "err", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
buf.WriteTo(w) //nolint:errcheck
}
// flash stores a one-shot message and redirects.
func (u *UI) flash(w http.ResponseWriter, r *http.Request, kind, msg, to string) {
val := base64.RawURLEncoding.EncodeToString([]byte(kind + "|" + msg))
http.SetCookie(w, &http.Cookie{Name: flashCookie, Value: val, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: u.deps.Config.Production()})
http.Redirect(w, r, to, http.StatusSeeOther)
}
func (u *UI) errorPage(w http.ResponseWriter, r *http.Request, status int, msg string) {
w.WriteHeader(status)
u.render(w, r, "error", http.StatusText(status), map[string]any{"Status": status, "Message": msg})
}
// uiAuthFail redirects anonymous users to login and shows 403 to others.
func (u *UI) uiAuthFail(w http.ResponseWriter, r *http.Request, authenticated bool) {
if authenticated {
u.errorPage(w, r, http.StatusForbidden, "Your role does not permit this page.")
return
}
next := ""
if r.Method == http.MethodGet && r.URL.Path != "/" {
next = "?next=" + url.QueryEscape(r.URL.RequestURI())
}
http.Redirect(w, r, "/login"+next, http.StatusSeeOther)
}
func (u *UI) require(min auth.Role, h http.HandlerFunc) http.Handler {
return auth.RequireRole(min, u.uiAuthFail)(h)
}
// Register mounts all UI routes.
func (u *UI) Register(m *http.ServeMux) {
m.Handle("GET /static/", http.StripPrefix("/static/", cacheStatic(http.FileServerFS(u.static))))
m.HandleFunc("GET /login", u.loginForm)
m.HandleFunc("POST /login", u.loginSubmit)
m.HandleFunc("POST /logout", u.logout)
m.HandleFunc("GET /setup", u.setupForm)
m.HandleFunc("POST /setup", u.setupSubmit)
m.HandleFunc("GET /about", func(w http.ResponseWriter, r *http.Request) { u.render(w, r, "about", "Interpretation boundary", nil) })
m.Handle("GET /{$}", u.require(auth.RoleObserver, u.dashboard))
m.Handle("GET /system", u.require(auth.RoleObserver, u.system))
m.Handle("GET /audit", u.require(auth.RoleOperator, u.audit))
m.Handle("GET /account", u.require(auth.RoleObserver, u.account))
m.Handle("POST /account/password", u.require(auth.RoleObserver, u.accountPassword))
m.Handle("POST /account/sessions/{id}/revoke", u.require(auth.RoleObserver, u.accountSessionRevoke))
m.Handle("POST /account/tokens", u.require(auth.RoleObserver, u.accountTokenCreate))
m.Handle("POST /account/tokens/{id}/revoke", u.require(auth.RoleObserver, u.accountTokenRevoke))
m.Handle("GET /admin/users", u.require(auth.RoleAdministrator, u.users))
m.Handle("POST /admin/users", u.require(auth.RoleAdministrator, u.usersCreate))
m.Handle("POST /admin/users/{id}", u.require(auth.RoleAdministrator, u.usersUpdate))
for _, p := range []struct{ path, title, note string }{
{"/minds", "Minds", "Mind management arrives in phase 2."},
{"/approvals", "Approvals", "The approval centre arrives with the capability system."},
{"/llm", "LLM", "Provider configuration arrives in phase 7."},
{"/prompts", "Prompts", "The prompt manager arrives in phase 7."},
{"/integrations", "Integrations", "Integrations arrive in phase 13."},
{"/policies", "Policies", "The policy matrix arrives in phase 12."},
} {
m.Handle("GET "+p.path, u.require(auth.RoleObserver, func(w http.ResponseWriter, r *http.Request) { u.render(w, r, "placeholder", p.title, p.note) }))
}
}
func cacheStatic(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=300")
h.ServeHTTP(w, r)
})
}
func ago(t any) string {
var tm time.Time
switch v := t.(type) {
case time.Time:
tm = v
case *time.Time:
if v == nil {
return "never"
}
tm = *v
default:
return ""
}
d := time.Since(tm)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
default:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
}
func humanBytes(n uint64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := uint64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}