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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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} }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[:]
|
||||
}
|
||||
Reference in New Issue
Block a user