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.
175 lines
5.3 KiB
Go
175 lines
5.3 KiB
Go
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")
|
|
}
|
|
}
|