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
+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[:]
}