Local auth: users, sessions, register/login/logout/me (#4) #23

Merged
steve merged 2 commits from phase-1-local-auth into main 2026-07-18 21:05:01 +00:00
12 changed files with 370 additions and 150 deletions
Showing only changes of commit 02c928ac6d - Show all commits
+3
View File
@@ -44,6 +44,9 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
h := &handlers{cfg: cfg, svc: svc}
v1 := r.Group("/api/v1")
// CSRF defense for every state-changing API call (no-op unless PANSY_BASE_URL
// is set; see csrfGuard).
v1.Use(h.csrfGuard())
v1.GET("/healthz", healthz)
// Auth endpoints are exempt from requireAuth (you can't be logged in yet);
+70 -26
View File
@@ -4,6 +4,7 @@ import (
"errors"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
1
@@ -30,7 +31,7 @@ type registerRequest struct {
type loginRequest struct {
Email string `json:"email" binding:"required,email"`
Review

🟡 loginRequest missing max password boundary

error-handling · flagged by 1 model

🪰 Gadfly · advisory

🟡 **loginRequest missing max password boundary** _error-handling · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
Password string `json:"password" binding:"required"`
Password string `json:"password" binding:"required,max=1024"`
}
// register creates a local account and logs it in (sets the session cookie).
@@ -47,15 +48,11 @@ func (h *handlers) register(c *gin.Context) {
Password: req.Password,
})
if err != nil {
h.writeServiceError(c, err)
writeServiceError(c, err)
return
}
Review

🟠 startSession error in register/login is unlogged; operator-invisible, leaves half-created user on register

correctness, error-handling, maintainability · flagged by 3 models

  • internal/api/auth.go:54-57 & :75-78startSession failure is swallowed (unlogged). When CreateSession returns an error (DB write failure, disk full, unique constraint), the handler returns a generic 500 envelope without logging the underlying cause. The logout handler (auth.go:87) and writeServiceError's default branch (auth.go:177) both slog.Error the cause; these two paths bypass that. For register this is especially bad: the user row is already committed, so the client…

🪰 Gadfly · advisory

🟠 **startSession error in register/login is unlogged; operator-invisible, leaves half-created user on register** _correctness, error-handling, maintainability · flagged by 3 models_ - **`internal/api/auth.go:54-57` & `:75-78` — `startSession` failure is swallowed (unlogged).** When `CreateSession` returns an error (DB write failure, disk full, unique constraint), the handler returns a generic 500 envelope without logging the underlying cause. The `logout` handler (auth.go:87) and `writeServiceError`'s default branch (auth.go:177) both `slog.Error` the cause; these two paths bypass that. For `register` this is especially bad: the user row is already committed, so the client… <sub>🪰 Gadfly · advisory</sub>
if err := h.startSession(c, user.ID); err != nil {
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not start session")
return
}
c.JSON(http.StatusOK, user)
h.startSessionAndRespond(c, user)
}
// login verifies credentials and sets the session cookie.
1
@@ -68,15 +65,11 @@ func (h *handlers) login(c *gin.Context) {
user, err := h.svc.Login(c.Request.Context(), req.Email, req.Password)
if err != nil {
h.writeServiceError(c, err)
writeServiceError(c, err)
return
}
if err := h.startSession(c, user.ID); err != nil {
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not start session")
return
}
c.JSON(http.StatusOK, user)
h.startSessionAndRespond(c, user)
}
// logout deletes the current session (if any) and clears the cookie. It is
@@ -102,37 +95,84 @@ func (h *handlers) providers(c *gin.Context) {
}
// requireAuth is middleware that rejects requests without a valid session and,
// on success, stashes the resolved user in the context. Feature routers added by
// later issues (gardens, objects, …) attach this to their protected groups.
// on success, stashes the resolved user in the context and slides the cookie's
// lifetime forward in step with the server-side session (otherwise an active
// user would be logged out 30 days after login regardless of activity). Feature
// routers added by later issues (gardens, objects, …) attach this to their
// protected groups.
func (h *handlers) requireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
token, err := c.Cookie(sessionCookie)
if err != nil || token == "" {
writeAPIError(c, http.StatusUnauthorized, "UNAUTHENTICATED", "authentication required")
c.Abort()
abortUnauthenticated(c)
Outdated
Review

🔴 Sliding session expiry (server-side) is never propagated to the client cookie's Max-Age, so active users are logged out after 30 days from login regardless of activity

correctness · flagged by 2 models

  • internal/api/auth.go:107-126,145-148 — Sliding session expiry never reaches the browser cookie, so "sliding 30-day expiry" doesn't actually work. setSessionCookie (sets Max-Age) is only called from startSession (auth.go:129-136), which is only invoked by register/login (auth.go:54,75). requireAuth (auth.go:107-126) calls svc.ResolveSession, which slides sessions.expires_at forward in the DB via TouchSession (internal/service/auth.go:159-161) but never re-issues…

🪰 Gadfly · advisory

🔴 **Sliding session expiry (server-side) is never propagated to the client cookie's Max-Age, so active users are logged out after 30 days from login regardless of activity** _correctness · flagged by 2 models_ - **`internal/api/auth.go:107-126,145-148` — Sliding session expiry never reaches the browser cookie, so "sliding 30-day expiry" doesn't actually work.** `setSessionCookie` (sets `Max-Age`) is only called from `startSession` (`auth.go:129-136`), which is only invoked by `register`/`login` (`auth.go:54,75`). `requireAuth` (`auth.go:107-126`) calls `svc.ResolveSession`, which slides `sessions.expires_at` forward in the DB via `TouchSession` (`internal/service/auth.go:159-161`) but never re-issues… <sub>🪰 Gadfly · advisory</sub>
return
}
user, err := h.svc.ResolveSession(c.Request.Context(), token)
user, expiresAt, err := h.svc.ResolveSession(c.Request.Context(), token)
if err != nil {
Review

requireAuth repeats an identical unauthorized-response triplet in two branches

maintainability · flagged by 1 model

  • internal/api/auth.go:111 and internal/api/auth.go:119 — the two failure branches in requireAuth (missing cookie and ResolveSession error) write the exact same writeAPIError(...) / c.Abort() / return triplet. Verified by reading the middleware. Minor, but a one-line unauthorized(c) helper would remove the duplication and make it obvious both paths are intentionally treated the same (the comment already explains why, so the intent isn't lost — just the repetition).

🪰 Gadfly · advisory

⚪ **requireAuth repeats an identical unauthorized-response triplet in two branches** _maintainability · flagged by 1 model_ - `internal/api/auth.go:111` and `internal/api/auth.go:119` — the two failure branches in `requireAuth` (`missing cookie` and `ResolveSession error`) write the exact same `writeAPIError(...) / c.Abort() / return` triplet. Verified by reading the middleware. Minor, but a one-line `unauthorized(c)` helper would remove the duplication and make it obvious both paths are intentionally treated the same (the comment already explains why, so the intent isn't lost — just the repetition). <sub>🪰 Gadfly · advisory</sub>
// Any resolution failure (missing/expired/tampered) is 401, never 404:
// the client should log in, not think a resource is gone.
writeAPIError(c, http.StatusUnauthorized, "UNAUTHENTICATED", "authentication required")
c.Abort()
abortUnauthenticated(c)
return
}
c.Set(actorKey, user)
// Refresh the client cookie to the session's current expiry so browser and
// server slide together.
h.setSessionCookie(c, token, expiresAt)
c.Next()
}
}
// startSession issues a session and writes the cookie.
func (h *handlers) startSession(c *gin.Context, userID int64) error {
token, expiresAt, err := h.svc.CreateSession(c.Request.Context(), userID)
// abortUnauthenticated writes the standard 401 and stops the handler chain.
func abortUnauthenticated(c *gin.Context) {
writeAPIError(c, http.StatusUnauthorized, "UNAUTHENTICATED", "authentication required")
c.Abort()
}
// csrfGuard rejects state-changing requests whose Origin header doesn't match
// the instance's own origin — a defense against login/logout CSRF, which
// SameSite=Lax cookies alone do not prevent. It is a no-op unless PANSY_BASE_URL
// is set (so it never interferes with the local Vite dev proxy), and it allows
// requests with no Origin (non-browser clients like curl, which aren't a CSRF
// vector). Safe methods are always allowed.
func (h *handlers) csrfGuard() gin.HandlerFunc {
var wantHost string
if u, err := url.Parse(h.cfg.BaseURL); err == nil {
wantHost = u.Host
}
Outdated
Review

🟡 Session cookie Secure attribute defaults to false when PANSY_BASE_URL unset, risking token leak if deployed behind a proxy without setting the env var

security · flagged by 1 model

  • Session cookie defaults to non-Secure when PANSY_BASE_URL is unset (internal/api/auth.go:141-143, internal/config/config.go:70). cookieSecure() only returns true when BaseURL starts with https://, and the default BaseURL is "". A self-hoster who puts pansy behind an HTTPS-terminating reverse proxy but forgets to set PANSY_BASE_URL gets a session cookie without Secure; any same-host HTTP request (or downgrade/MITM) then leaks the bearer token. This is documented as inten…

🪰 Gadfly · advisory

🟡 **Session cookie Secure attribute defaults to false when PANSY_BASE_URL unset, risking token leak if deployed behind a proxy without setting the env var** _security · flagged by 1 model_ - **Session cookie defaults to non-Secure when `PANSY_BASE_URL` is unset** (`internal/api/auth.go:141-143`, `internal/config/config.go:70`). `cookieSecure()` only returns true when `BaseURL` starts with `https://`, and the default `BaseURL` is `""`. A self-hoster who puts pansy behind an HTTPS-terminating reverse proxy but forgets to set `PANSY_BASE_URL` gets a session cookie without `Secure`; any same-host HTTP request (or downgrade/MITM) then leaks the bearer token. This is documented as inten… <sub>🪰 Gadfly · advisory</sub>
return func(c *gin.Context) {
if wantHost == "" {
c.Next()
return
}
Outdated
Review

setSessionCookie MaxAge floor of 1 masks clock-skew/negative-duration bugs instead of clearing the cookie

error-handling · flagged by 1 model

  • internal/api/auth.go:146maxAge floor of 1 masks clock-skew bugs. max(int(time.Until(expiresAt).Seconds()), 1) clamps a negative duration to a 1-second cookie instead of clearing it. Not currently reachable (expiresAt = now + 30d), but the floor silently swallows a clock-skew class of bug. Trivial; leave or change to clear the cookie when time.Until(expiresAt) <= 0.

🪰 Gadfly · advisory

⚪ **setSessionCookie MaxAge floor of 1 masks clock-skew/negative-duration bugs instead of clearing the cookie** _error-handling · flagged by 1 model_ - **`internal/api/auth.go:146` — `maxAge` floor of 1 masks clock-skew bugs.** `max(int(time.Until(expiresAt).Seconds()), 1)` clamps a negative duration to a 1-second cookie instead of clearing it. Not currently reachable (`expiresAt = now + 30d`), but the floor silently swallows a clock-skew class of bug. Trivial; leave or change to clear the cookie when `time.Until(expiresAt) <= 0`. <sub>🪰 Gadfly · advisory</sub>
switch c.Request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
c.Next()
return
}
if origin := c.GetHeader("Origin"); origin != "" {
if u, err := url.Parse(origin); err != nil || !strings.EqualFold(u.Host, wantHost) {
writeAPIError(c, http.StatusForbidden, "CROSS_ORIGIN", "cross-origin request rejected")
c.Abort()
return
}
}
c.Next()
}
}
// startSessionAndRespond issues a session, sets the cookie, and returns the user
// as JSON — the shared tail of register and login.
Outdated
Review

🟡 writeServiceError method has unused receiver

maintainability · flagged by 1 model

  • internal/api/auth.go:164 writeServiceError method has unused receiver — Declared as func (h *handlers) writeServiceError(...), but h is never referenced inside the body, which is a small readability misdirection. Fix: Change it to a package-level function: func writeServiceError(c *gin.Context, err error).

🪰 Gadfly · advisory

🟡 **writeServiceError method has unused receiver** _maintainability · flagged by 1 model_ - **`internal/api/auth.go:164`** `writeServiceError` method has unused receiver — Declared as `func (h *handlers) writeServiceError(...)`, but `h` is never referenced inside the body, which is a small readability misdirection. **Fix:** Change it to a package-level function: `func writeServiceError(c *gin.Context, err error)`. <sub>🪰 Gadfly · advisory</sub>
func (h *handlers) startSessionAndRespond(c *gin.Context, user *domain.User) {
token, expiresAt, err := h.svc.CreateSession(c.Request.Context(), user.ID)
if err != nil {
return err
// The account exists and is usable via login; only the auto-login cookie
// failed. Log it so the operator can see the underlying DB problem.
slog.Error("api: could not start session", "user_id", user.ID, "error", err)
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not start session")
return
}
h.setSessionCookie(c, token, expiresAt)
return nil
c.JSON(http.StatusOK, user)
}
// cookieSecure reports whether the session cookie should carry the Secure
@@ -143,6 +183,10 @@ func (h *handlers) cookieSecure() bool {
}
func (h *handlers) setSessionCookie(c *gin.Context, token string, expiresAt time.Time) {
// Callers only ever pass a future expiry (login/register set now+TTL;
// requireAuth uses a session already checked to be unexpired), so this is
// always well above the floor; the floor merely avoids emitting a delete
// cookie (MaxAge<=0) from a pathological clock skew.
maxAge := max(int(time.Until(expiresAt).Seconds()), 1)
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(sessionCookie, token, maxAge, "/", "", h.cookieSecure(), true)
@@ -161,7 +205,7 @@ func mustActor(c *gin.Context) *domain.User {
// writeServiceError maps a service-layer sentinel error to pansy's JSON error
// envelope. Login failures never leak which of email/password was wrong.
func (h *handlers) writeServiceError(c *gin.Context, err error) {
func writeServiceError(c *gin.Context, err error) {
switch {
case errors.Is(err, domain.ErrInvalidCredentials):
writeAPIError(c, http.StatusUnauthorized, "INVALID_CREDENTIALS", "invalid email or password")
+76 -5
View File
@@ -35,16 +35,22 @@ func localCfg() *config.Config {
return &config.Config{Registration: config.RegistrationOpen, LocalAuth: true}
}
// doJSON issues a JSON request, optionally carrying a session cookie.
func doJSON(t *testing.T, r *gin.Engine, method, path string, body any, cookie *http.Cookie) *httptest.ResponseRecorder {
// encodeBody JSON-encodes v into a reader for a request body.
func encodeBody(t *testing.T, v any) *bytes.Buffer {
t.Helper()
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
if v != nil {
if err := json.NewEncoder(&buf).Encode(v); err != nil {
t.Fatalf("encode body: %v", err)
}
}
req := httptest.NewRequest(method, path, &buf)
return &buf
}
// doJSON issues a JSON request, optionally carrying a session cookie.
func doJSON(t *testing.T, r *gin.Engine, method, path string, body any, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, encodeBody(t, body))
req.Header.Set("Content-Type", "application/json")
if cookie != nil {
req.AddCookie(cookie)
@@ -153,6 +159,71 @@ func TestProvidersEndpoint(t *testing.T) {
}
}
func TestAuthenticatedRequestRefreshesCookie(t *testing.T) {
// requireAuth must re-set the session cookie so the browser's Max-Age slides
// with the server session rather than expiring 30 days after login.
r := authEngine(t, localCfg())
w := doJSON(t, r, http.MethodPost, "/api/v1/auth/register",
map[string]string{"email": "[email protected]", "displayName": "Alice", "password": "password123"}, nil)
cookie := sessionCookieFrom(t, w)
w = doJSON(t, r, http.MethodGet, "/api/v1/auth/me", nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("me status = %d", w.Code)
}
// A fresh session cookie should be present on the /me response.
refreshed := sessionCookieFrom(t, w)
if refreshed.Value != cookie.Value {
t.Errorf("refreshed cookie value changed: got %q want same token", refreshed.Value)
}
if refreshed.MaxAge <= 0 {
t.Errorf("refreshed cookie Max-Age = %d, want > 0", refreshed.MaxAge)
}
}
func TestCSRFGuardRejectsCrossOrigin(t *testing.T) {
cfg := localCfg()
cfg.BaseURL = "https://pansy.example.com"
r := authEngine(t, cfg)
body := map[string]string{"email": "[email protected]", "displayName": "Alice", "password": "password123"}
// Cross-origin POST is rejected before touching the service.
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", encodeBody(t, body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", "https://evil.example.com")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("cross-origin register status = %d, want 403", w.Code)
}
// Same-origin POST is allowed.
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", encodeBody(t, body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", "https://pansy.example.com")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("same-origin register status = %d, want 200 (body %s)", w.Code, w.Body.String())
}
}
func TestCSRFGuardNoopWithoutBaseURL(t *testing.T) {
// In local dev (no PANSY_BASE_URL) the guard must not interfere, even with a
// foreign Origin (the Vite dev proxy forwards the browser's origin).
r := authEngine(t, localCfg())
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register",
encodeBody(t, map[string]string{"email": "[email protected]", "displayName": "Alice", "password": "password123"}))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", "http://localhost:5173")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("dev cross-origin register status = %d, want 200", w.Code)
}
}
func TestSecureCookieWithHTTPSBaseURL(t *testing.T) {
cfg := localCfg()
cfg.BaseURL = "https://pansy.example.com"
+53 -29
View File
@@ -3,12 +3,17 @@ package service
import (
"context"
"errors"
"log/slog"
"strings"
"time"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// maxPasswordLen caps accepted password length. It bounds argon2 input and
// request work, and sits far above any real password.
const maxPasswordLen = 1024
// RegisterInput is the payload for local self-service signup.
type RegisterInput struct {
Email string
1
@@ -46,27 +51,22 @@ func (s *Service) Register(ctx context.Context, in RegisterInput) (*domain.User,
email := normalizeEmail(in.Email)
displayName := strings.TrimSpace(in.DisplayName)
Review

🔴 TOCTOU race in Register allows multiple bootstrap admins

correctness, error-handling, security · flagged by 3 models

  • TOCTOU race allows multiple bootstrap admins on a fresh instance internal/service/auth.go:53-82Register reads CountUsers, checks count > 0 && !RegistrationOpen(), then calls CreateUser. Two concurrent requests on a fresh DB can both observe count == 0, pass the closed-registration gate, and both create accounts with IsAdmin: true (as long as they use different emails). This breaks the intended “exactly one bootstrap admin” security model and lets an attacker race the legit…

🪰 Gadfly · advisory

🔴 **TOCTOU race in Register allows multiple bootstrap admins** _correctness, error-handling, security · flagged by 3 models_ - **TOCTOU race allows multiple bootstrap admins on a fresh instance** `internal/service/auth.go:53-82` — `Register` reads `CountUsers`, checks `count > 0 && !RegistrationOpen()`, then calls `CreateUser`. Two concurrent requests on a fresh DB can both observe `count == 0`, pass the closed-registration gate, and both create accounts with `IsAdmin: true` (as long as they use different emails). This breaks the intended “exactly one bootstrap admin” security model and lets an attacker race the legit… <sub>🪰 Gadfly · advisory</sub>
if email == "" || displayName == "" || in.Password == "" {
if email == "" || displayName == "" || in.Password == "" || len(in.Password) > maxPasswordLen {
return nil, domain.ErrInvalidInput
}
count, err := s.store.CountUsers(ctx)
// Cheap best-effort gate so a closed instance doesn't burn argon2 work on
// signups it will reject anyway. The authoritative, race-free gate — plus
// atomic first-user-is-admin assignment and duplicate-email detection — lives
// in store.CreateUser's single INSERT.
if !s.cfg.RegistrationOpen() {
n, err := s.store.CountUsers(ctx)
if err != nil {
return nil, err
}
// Closed registration still allows the very first account so a locked-down
// instance can be bootstrapped without editing config.
if count > 0 && !s.cfg.RegistrationOpen() {
if n > 0 {
return nil, domain.ErrRegistrationClosed
}
// Friendly duplicate check. The UNIQUE index is the real guard against a
// race; that path surfaces as a generic insert error (500), acceptable at
// household scale.
if _, err := s.store.GetUserByEmail(ctx, email); err == nil {
return nil, domain.ErrEmailTaken
} else if !errors.Is(err, domain.ErrNotFound) {
return nil, err
}
hash, err := hashPassword(in.Password)
1
@@ -78,8 +78,7 @@ func (s *Service) Register(ctx context.Context, in RegisterInput) (*domain.User,
Email: email,
DisplayName: displayName,
PasswordHash: &hash,
IsAdmin: count == 0,
})
}, s.cfg.RegistrationOpen())
Outdated
Review

🟠 First-user-is-admin is read-then-write TOCTOU: two concurrent first registrations with different emails both become admin

correctness · flagged by 1 model

🪰 Gadfly · advisory

🟠 **First-user-is-admin is read-then-write TOCTOU: two concurrent first registrations with different emails both become admin** _correctness · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
}
// Login verifies an email/password pair and returns the user. Unknown-email and
@@ -89,6 +88,10 @@ func (s *Service) Login(ctx context.Context, email, password string) (*domain.Us
if !s.cfg.LocalAuth {
return nil, domain.ErrLocalAuthDisabled
}
if len(password) > maxPasswordLen {
// No stored password is this long; reject without spending argon2 work.
return nil, domain.ErrInvalidCredentials
}
u, err := s.store.GetUserByEmail(ctx, normalizeEmail(email))
switch {
Review

🟠 Empty dummyHash (rand failure in New) short-circuits verifyPassword with zero argon2 work, defeating the timing equalizer it claims to provide

error-handling · flagged by 2 models

  • internal/service/auth.go:97, 103 + internal/service/service.go:47-51 — empty dummyHash defeats the timing equalizer with no runtime signal. If hashPassword fails in New (only path: crypto/rand.Read failure), s.dummyHash stays "". verifyPassword("", password) short-circuits in decodeHash (verified: strings.Split("", "$")[""], length 1 ≠ 6 → errBadHash) doing zero argon2 work. Then unknown-email returns ~instantly while wrong-password pays full argon2 cost —…

🪰 Gadfly · advisory

🟠 **Empty dummyHash (rand failure in New) short-circuits verifyPassword with zero argon2 work, defeating the timing equalizer it claims to provide** _error-handling · flagged by 2 models_ - **`internal/service/auth.go:97, 103` + `internal/service/service.go:47-51` — empty `dummyHash` defeats the timing equalizer with no runtime signal.** If `hashPassword` fails in `New` (only path: `crypto/rand.Read` failure), `s.dummyHash` stays `""`. `verifyPassword("", password)` short-circuits in `decodeHash` (verified: `strings.Split("", "$")` → `[""]`, length 1 ≠ 6 → `errBadHash`) doing **zero** argon2 work. Then unknown-email returns ~instantly while wrong-password pays full argon2 cost —… <sub>🪰 Gadfly · advisory</sub>
1
@@ -105,7 +108,13 @@ func (s *Service) Login(ctx context.Context, email, password string) (*domain.Us
}
ok, err := verifyPassword(*u.PasswordHash, password)
if err != nil || !ok {
if err != nil {
// A stored hash we can't parse is a data problem, not a wrong password;
// surface it so a corrupt row doesn't silently lock a user out unnoticed.
slog.Error("service: malformed stored password hash", "user_id", u.ID, "error", err)
return nil, domain.ErrInvalidCredentials
}
if !ok {
return nil, domain.ErrInvalidCredentials
}
return u, nil
@@ -129,38 +138,53 @@ func (s *Service) CreateSession(ctx context.Context, userID int64) (token string
return raw, exp, nil
}
// ResolveSession validates a raw bearer token and returns its user. An expired
// session is deleted and treated as absent (domain.ErrNotFound). A still-valid
// session has its expiry slid forward, but only when that moves it by more than
// an hour, so a busy client doesn't write on every request.
func (s *Service) ResolveSession(ctx context.Context, rawToken string) (*domain.User, error) {
// ResolveSession validates a raw bearer token and returns its user and the
Outdated
Review

🟡 Two DB round-trips on every authenticated request

performance · flagged by 1 model

  • internal/service/auth.go:141Two DB round-trips on every authenticated request (observation, not blocking). ResolveSession calls GetSession (by PK) and then GetUserByID (by PK). Both are indexed and fast, but every protected endpoint pays two query overheads. A single JOIN query would halve the DB work on the hot path. Verified by reading store/sessions.go and store/users.go — neither currently exposes a joined fetch. Fix: Consider adding GetSessionWithUser in a follow-…

🪰 Gadfly · advisory

🟡 **Two DB round-trips on every authenticated request** _performance · flagged by 1 model_ - `internal/service/auth.go:141` — **Two DB round-trips on every authenticated request** (observation, not blocking). `ResolveSession` calls `GetSession` (by PK) and then `GetUserByID` (by PK). Both are indexed and fast, but every protected endpoint pays two query overheads. A single JOIN query would halve the DB work on the hot path. Verified by reading `store/sessions.go` and `store/users.go` — neither currently exposes a joined fetch. **Fix:** Consider adding `GetSessionWithUser` in a follow-… <sub>🪰 Gadfly · advisory</sub>
// session's current expiry (so the caller can slide the client cookie in
// lockstep with the server). An expired session is deleted and treated as absent
// (domain.ErrNotFound). A still-valid session has its expiry slid forward, but
// only when that moves it by more than an hour, so a busy client doesn't write
// on every request.
func (s *Service) ResolveSession(ctx context.Context, rawToken string) (*domain.User, time.Time, error) {
if rawToken == "" {
return nil, domain.ErrNotFound
return nil, time.Time{}, domain.ErrNotFound
Outdated
Review

🟡 DeleteSession error silently discarded on corrupt expiry

error-handling · flagged by 3 models

  • internal/service/auth.go:149ResolveSession also silently discards errors from s.store.DeleteSession(...) on the corrupt-expiry path. If the lazy delete fails, the stale/corrupt row stays in the database until the background sweeper eventually removes it. These errors should be logged rather than thrown away.

🪰 Gadfly · advisory

🟡 **DeleteSession error silently discarded on corrupt expiry** _error-handling · flagged by 3 models_ - **`internal/service/auth.go:149`** — `ResolveSession` also silently discards errors from `s.store.DeleteSession(...)` on the corrupt-expiry path. If the lazy delete fails, the stale/corrupt row stays in the database until the background sweeper eventually removes it. These errors should be logged rather than thrown away. <sub>🪰 Gadfly · advisory</sub>
}
hash := hashToken(rawToken)
sess, err := s.store.GetSession(ctx, hash)
if err != nil {
return nil, err
return nil, time.Time{}, err
}
Review

🟡 DeleteSession error silently discarded on expired session

error-handling · flagged by 2 models

  • internal/service/auth.go:155ResolveSession also silently discards errors from s.store.DeleteSession(...) on the already-expired path. If the lazy delete fails, the stale/corrupt row stays in the database until the background sweeper eventually removes it. These errors should be logged rather than thrown away.

🪰 Gadfly · advisory

🟡 **DeleteSession error silently discarded on expired session** _error-handling · flagged by 2 models_ - **`internal/service/auth.go:155`** — `ResolveSession` also silently discards errors from `s.store.DeleteSession(...)` on the already-expired path. If the lazy delete fails, the stale/corrupt row stays in the database until the background sweeper eventually removes it. These errors should be logged rather than thrown away. <sub>🪰 Gadfly · advisory</sub>
exp, err := parseTime(sess.ExpiresAt)
if err != nil {
// A corrupt expiry means we can't trust the session; drop it.
Review

🟠 Sliding session renewal bumps DB expiry but never refreshes the cookie Max-Age — browser logs the user out ~30 days after the original login regardless of activity

correctness, error-handling · flagged by 3 models

  • internal/service/auth.go:159 / internal/api/auth.go:145-149 — sliding renewal extends the DB session but never refreshes the cookie's Max-Age. ResolveSession calls s.store.TouchSession(...) to push expires_at forward, but the cookie set at login (setSessionCookie) computes maxAge once from expiresAt and is never re-issued on /me or any subsequent request. So a user who keeps using the app will have their server-side session slid to "now + 30 days" indefinitely, but the br…

🪰 Gadfly · advisory

🟠 **Sliding session renewal bumps DB expiry but never refreshes the cookie Max-Age — browser logs the user out ~30 days after the original login regardless of activity** _correctness, error-handling · flagged by 3 models_ - **`internal/service/auth.go:159` / `internal/api/auth.go:145-149` — sliding renewal extends the DB session but never refreshes the cookie's Max-Age.** `ResolveSession` calls `s.store.TouchSession(...)` to push `expires_at` forward, but the cookie set at login (`setSessionCookie`) computes `maxAge` once from `expiresAt` and is never re-issued on `/me` or any subsequent request. So a user who keeps using the app will have their server-side session slid to "now + 30 days" indefinitely, but the br… <sub>🪰 Gadfly · advisory</sub>
_ = s.store.DeleteSession(ctx, hash)
return nil, domain.ErrNotFound
if delErr := s.store.DeleteSession(ctx, hash); delErr != nil {
slog.Warn("service: deleting session with corrupt expiry", "error", delErr)
}
return nil, time.Time{}, domain.ErrNotFound
}
now := s.now()
if !now.Before(exp) {
_ = s.store.DeleteSession(ctx, hash)
return nil, domain.ErrNotFound
if delErr := s.store.DeleteSession(ctx, hash); delErr != nil {
slog.Warn("service: deleting expired session", "error", delErr)
}
return nil, time.Time{}, domain.ErrNotFound
}
if newExp := now.Add(sessionTTL); newExp.Sub(exp) > time.Hour {
_ = s.store.TouchSession(ctx, hash, formatTime(newExp))
if err := s.store.TouchSession(ctx, hash, formatTime(newExp)); err != nil {
// Non-fatal: the session is still valid at its current expiry.
slog.Warn("service: sliding session expiry", "error", err)
} else {
exp = newExp
}
}
return s.store.GetUserByID(ctx, sess.UserID)
user, err := s.store.GetUserByID(ctx, sess.UserID)
if err != nil {
return nil, time.Time{}, err
}
return user, exp, nil
}
// Logout deletes the session behind a raw bearer token. It is idempotent.
+29 -7
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"errors"
"strings"
"testing"
"time"
@@ -98,6 +99,20 @@ func TestRegistrationClosedAllowsBootstrapThenBlocks(t *testing.T) {
}
}
func TestRejectsOverlongPassword(t *testing.T) {
s := newTestService(t, openConfig())
long := strings.Repeat("a", maxPasswordLen+1)
if _, err := s.Register(context.Background(), RegisterInput{Email: "[email protected]", DisplayName: "A", Password: long}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("register overlong err = %v, want ErrInvalidInput", err)
}
mustRegister(t, s, "[email protected]", "Bob", "password123")
if _, err := s.Login(context.Background(), "[email protected]", long); !errors.Is(err, domain.ErrInvalidCredentials) {
t.Errorf("login overlong err = %v, want ErrInvalidCredentials", err)
}
}
func TestLocalAuthDisabledRejectsRegisterAndLogin(t *testing.T) {
cfg := openConfig()
cfg.LocalAuth = false
@@ -139,7 +154,7 @@ func TestLoginRejectsOIDCOnlyUser(t *testing.T) {
iss, sub := "https://idp.example", "subject-1"
if _, err := s.store.CreateUser(context.Background(), &domain.User{
Email: "[email protected]", DisplayName: "O", OIDCIssuer: &iss, OIDCSubject: &sub,
}); err != nil {
}, true); err != nil {
t.Fatalf("seed oidc user: %v", err)
}
if _, err := s.Login(context.Background(), "[email protected]", "anything"); !errors.Is(err, domain.ErrInvalidCredentials) {
@@ -156,29 +171,32 @@ func TestSessionLifecycle(t *testing.T) {
t.Fatalf("CreateSession: %v", err)
}
got, err := s.ResolveSession(context.Background(), token)
got, exp, err := s.ResolveSession(context.Background(), token)
if err != nil {
t.Fatalf("ResolveSession: %v", err)
}
if got.ID != u.ID {
t.Errorf("resolved user %d, want %d", got.ID, u.ID)
}
if !exp.After(s.now()) {
t.Errorf("resolved expiry %v is not in the future", exp)
}
// Logout invalidates it.
if err := s.Logout(context.Background(), token); err != nil {
t.Fatalf("Logout: %v", err)
}
if _, err := s.ResolveSession(context.Background(), token); !errors.Is(err, domain.ErrNotFound) {
if _, _, err := s.ResolveSession(context.Background(), token); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("resolve after logout err = %v, want ErrNotFound", err)
}
}
func TestResolveSessionRejectsGarbageToken(t *testing.T) {
s := newTestService(t, openConfig())
if _, err := s.ResolveSession(context.Background(), "not-a-real-token"); !errors.Is(err, domain.ErrNotFound) {
if _, _, err := s.ResolveSession(context.Background(), "not-a-real-token"); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("garbage token err = %v, want ErrNotFound", err)
}
if _, err := s.ResolveSession(context.Background(), ""); !errors.Is(err, domain.ErrNotFound) {
if _, _, err := s.ResolveSession(context.Background(), ""); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("empty token err = %v, want ErrNotFound", err)
}
}
@@ -197,7 +215,7 @@ func TestSessionExpiryAndLazyDeletion(t *testing.T) {
// Jump past the 30-day TTL: the session must be treated as gone...
s.now = func() time.Time { return base.Add(sessionTTL + time.Hour) }
if _, err := s.ResolveSession(context.Background(), token); !errors.Is(err, domain.ErrNotFound) {
if _, _, err := s.ResolveSession(context.Background(), token); !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("expired resolve err = %v, want ErrNotFound", err)
}
// ...and lazily deleted from the store.
@@ -219,9 +237,13 @@ func TestSessionSlidingRenewal(t *testing.T) {
// Use it 10 days later; expiry should slide forward.
s.now = func() time.Time { return base.Add(10 * 24 * time.Hour) }
if _, err := s.ResolveSession(context.Background(), token); err != nil {
_, resolvedExp, err := s.ResolveSession(context.Background(), token)
if err != nil {
t.Fatalf("ResolveSession: %v", err)
}
if !resolvedExp.After(firstExp) {
t.Errorf("returned expiry did not slide: %v not after %v", resolvedExp, firstExp)
}
sess, err := s.store.GetSession(context.Background(), hashToken(token))
if err != nil {
t.Fatalf("GetSession: %v", err)
+37 -17
View File
@@ -11,13 +11,13 @@ import (
"golang.org/x/crypto/argon2"
)
// argon2id parameters. ~64 MiB memory / 1 pass / 4 lanes is the interactive
// profile recommended by the argon2 authors and is comfortable on a
// self-hosted box. They are encoded into every stored hash, so raising them
// later leaves old hashes verifiable.
// argon2id parameters: RFC 9106's second recommended profile (m=64 MiB, t=3,
// p=4) — the memory-constrained option, appropriate for a self-hosted box where
// the 2 GiB first profile is too heavy. They are encoded into every stored hash,
// so raising them later leaves old hashes verifiable.
const (
argonMemKiB = 64 * 1024 // 64 MiB
argonTime = 1
argonTime = 3
Outdated
Review

🟡 Argon2id t=1 with m=64MiB/p=4 doesn't match either RFC 9106 recommended parameter profile, weakening password hash resistance

security · flagged by 1 model

🪰 Gadfly · advisory

🟡 **Argon2id t=1 with m=64MiB/p=4 doesn't match either RFC 9106 recommended parameter profile, weakening password hash resistance** _security · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
argonThreads = 4
argonKeyLen = 32
argonSaltLen = 16
@@ -25,12 +25,27 @@ const (
// errBadHash marks a stored hash string that could not be parsed — a data or
// programming error, not a wrong password. Callers treat it as an auth failure
Review

🟡 errBadHash doc says callers should log it but no caller does

maintainability · flagged by 1 model

  • internal/service/password.go:27-28 — The errBadHash doc says "Callers treat it as an auth failure but should log it." No caller logs it: service.Login (auth.go:107-110) folds errBadHash into ErrInvalidCredentials via if err != nil || !ok with no log, and the only other caller in tests asserts the error. So the "should log it" guidance is unfulfilled — either drop the prescription from the comment or actually log it in Login (a stored hash that won't parse is a real data/ops s…

🪰 Gadfly · advisory

🟡 **errBadHash doc says callers should log it but no caller does** _maintainability · flagged by 1 model_ - **`internal/service/password.go:27-28`** — The `errBadHash` doc says "Callers treat it as an auth failure but should log it." No caller logs it: `service.Login` (auth.go:107-110) folds `errBadHash` into `ErrInvalidCredentials` via `if err != nil || !ok` with no log, and the only other caller in tests asserts the error. So the "should log it" guidance is unfulfilled — either drop the prescription from the comment or actually log it in `Login` (a stored hash that won't parse is a real data/ops s… <sub>🪰 Gadfly · advisory</sub>
// but should log it.
// and log it (Login does).
var errBadHash = errors.New("service: malformed password hash")
// b64 is the padding-free base64 used inside the PHC-style hash string.
var b64 = base64.RawStdEncoding
// timingHash is a valid argon2id hash used to equalize login response time when
// an email is unknown (see Service.dummyHash). It uses a fixed salt rather than
// crypto/rand so it can never fail to be produced at startup — an all-important
// property, since a missing timing hash would silently re-open account
// enumeration. It guards nothing, so a static salt is safe; deriving it from the
// live argon parameters keeps its cost matched to a real verify.
func timingHash() string {
salt := []byte("pansy-timing-eq!") // exactly argonSaltLen (16) bytes
key := argon2.IDKey([]byte("x"), salt, argonTime, argonMemKiB, argonThreads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemKiB, argonTime, argonThreads,
b64.EncodeToString(salt), b64.EncodeToString(key),
)
}
// hashPassword returns a self-describing argon2id hash in the PHC string format
// "$argon2id$v=19$m=...,t=...,p=...$salt$hash" (all base64, no padding).
func hashPassword(password string) (string, error) {
1
@@ -57,26 +72,31 @@ func verifyPassword(encoded, password string) (bool, error) {
}
// decodeHash parses a PHC-format argon2id string into its parameters, salt, and
// derived key.
// derived key. Any structural problem returns errBadHash (with zero values) so
// the caller never has to distinguish parse failures.
func decodeHash(encoded string) (mem, t uint32, threads uint8, salt, key []byte, err error) {
parts := strings.Split(encoded, "$")
// ["", "argon2id", "v=19", "m=..,t=..,p=..", "<salt>", "<hash>"]
if len(parts) != 6 || parts[1] != "argon2id" {
fail := func() (uint32, uint32, uint8, []byte, []byte, error) {
return 0, 0, 0, nil, nil, errBadHash
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version {
return 0, 0, 0, nil, nil, errBadHash
parts := strings.Split(encoded, "$")
// ["", "argon2id", "v=19", "m=..,t=..,p=..", "<salt>", "<hash>"]
if len(parts) != 6 || parts[1] != "argon2id" {
return fail()
}
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &mem, &t, &threads); err != nil {
return 0, 0, 0, nil, nil, errBadHash
var version int
if _, e := fmt.Sscanf(parts[2], "v=%d", &version); e != nil || version != argon2.Version {
return fail()
}
if _, e := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &mem, &t, &threads); e != nil {
return fail()
}
if salt, err = b64.DecodeString(parts[4]); err != nil {
return 0, 0, 0, nil, nil, errBadHash
return fail()
}
if key, err = b64.DecodeString(parts[5]); err != nil || len(key) == 0 {
return 0, 0, 0, nil, nil, errBadHash
return fail()
}
return mem, t, threads, salt, key, nil
}
+18 -19
View File
@@ -1,9 +1,11 @@
// Package service is pansy's business-logic seam: every operation is a method on
// *Service taking (ctx, actor, args), and all permission checks and invariants
// live here rather than in the HTTP handlers. REST handlers (internal/api) and,
// later, agent tools (internal/agent) are thin adapters over these methods, so
// both inherit the same rules. This file holds the shared plumbing; feature
// methods live alongside it (auth.go, and gardens/objects/… in later issues).
// Package service is pansy's business-logic seam: all permission checks and
// invariants live here rather than in the HTTP handlers. Resource operations
Outdated
Review

🟡 Package doc claims methods take (ctx, actor, args) but no method in this PR takes an actor

maintainability · flagged by 1 model

🪰 Gadfly · advisory

🟡 **Package doc claims methods take (ctx, actor, args) but no method in this PR takes an actor** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
// take (ctx, actor, args) so every rule is enforced regardless of caller; the
// auth operations here are the exception — they establish the actor, so they
// take credentials rather than one. REST handlers (internal/api) and, later,
// agent tools (internal/agent) are thin adapters over these methods, so both
// inherit the same rules. This file holds the shared plumbing; feature methods
// live alongside it (auth.go, and gardens/objects/… in later issues).
package service
import (
@@ -12,7 +14,6 @@ import (
"encoding/base64"
"encoding/hex"
"fmt"
"log/slog"
"time"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
@@ -33,23 +34,21 @@ type Service struct {
cfg *config.Config
// now is the clock, injectable so tests can advance time (session expiry).
now func() time.Time
// dummyHash is a valid argon2id hash of a throwaway password. Login verifies
// against it when an email is unknown so the response time doesn't reveal
// whether an account exists.
// dummyHash is a valid argon2id hash Login verifies against when an email is
// unknown, so response time doesn't reveal whether an account exists. It is
// produced by timingHash (fixed salt, no RNG) so it is always present — an
// empty one would silently re-open account enumeration.
dummyHash string
}
// New constructs a Service. It precomputes a dummy password hash used to
// equalize login timing; if that fails (it shouldn't), login still works but
// loses the timing defense.
// New constructs a Service.
func New(st *store.DB, cfg *config.Config) *Service {
Review

🔴 Empty dummyHash breaks login timing equalization and enables account enumeration

correctness, error-handling, security · flagged by 2 models

  • Empty dummyHash nullifies the login timing defense. In Service.New (internal/service/service.go:47-51), if hashPassword fails, s.dummyHash remains "". In Login (internal/service/auth.go:95-104), the unknown-email and OIDC-only paths call verifyPassword(s.dummyHash, password), which returns errBadHash instantly without touching argon2. The wrong-password path on a real account, however, performs the full 64 MiB argon2 computation. The resulting timing gap is large and me…

🪰 Gadfly · advisory

🔴 **Empty dummyHash breaks login timing equalization and enables account enumeration** _correctness, error-handling, security · flagged by 2 models_ - **Empty `dummyHash` nullifies the login timing defense.** In `Service.New` (`internal/service/service.go:47-51`), if `hashPassword` fails, `s.dummyHash` remains `""`. In `Login` (`internal/service/auth.go:95-104`), the unknown-email and OIDC-only paths call `verifyPassword(s.dummyHash, password)`, which returns `errBadHash` instantly without touching argon2. The wrong-password path on a real account, however, performs the full 64 MiB argon2 computation. The resulting timing gap is large and me… <sub>🪰 Gadfly · advisory</sub>
s := &Service{store: st, cfg: cfg, now: time.Now}
if h, err := hashPassword("pansy-timing-equalizer-not-a-real-password"); err != nil {
slog.Warn("service: could not precompute login timing hash", "error", err)
} else {
s.dummyHash = h
return &Service{
store: st,
cfg: cfg,
now: time.Now,
dummyHash: timingHash(),
}
return s
}
// formatTime renders a time as pansy's canonical UTC string.
@@ -0,0 +1,7 @@
-- 0002_sessions_expires_index.sql — index sessions.expires_at.
--
-- The periodic sweep (and lazy cleanup) run `DELETE FROM sessions WHERE
-- expires_at <= ?`; without this index that is a full table scan. The table is
-- small at household scale, but the index is cheap insurance and keeps the sweep
-- O(expired) rather than O(all sessions).
CREATE INDEX idx_sessions_expires ON sessions (expires_at);
+5 -5
View File
@@ -42,15 +42,15 @@ func TestMigrateCreatesSchema(t *testing.T) {
if err := db.SQL().QueryRow(`SELECT max(version) FROM schema_migrations`).Scan(&version); err != nil {
t.Fatalf("read schema_migrations: %v", err)
}
if version != 1 {
t.Errorf("schema version = %d, want 1", version)
if version != 2 {
t.Errorf("schema version = %d, want 2", version)
}
}
func TestMigrateIsIdempotent(t *testing.T) {
db := openTestDB(t)
// A second Migrate must be a no-op and leave exactly one recorded version.
// A second Migrate must be a no-op and leave exactly one row per migration.
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("second Migrate: %v", err)
}
@@ -58,8 +58,8 @@ func TestMigrateIsIdempotent(t *testing.T) {
if err := db.SQL().QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&count); err != nil {
t.Fatalf("count schema_migrations: %v", err)
}
if count != 1 {
t.Errorf("schema_migrations rows = %d, want 1", count)
if count != 2 {
t.Errorf("schema_migrations rows = %d, want 2", count)
}
}
+34 -4
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
@@ -20,7 +21,7 @@ type scanner interface {
// scanUser reads one users row. is_admin is stored as INTEGER 0/1 (the driver
Review

🟡 scanUser comment says is_admin read into an int but code declares int64

maintainability · flagged by 1 model

🪰 Gadfly · advisory

🟡 **scanUser comment says is_admin read into an int but code declares int64** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
// returns it as int64, which does not convert straight to bool), so it is read
// into an int and mapped.
// into an int64 and mapped.
func scanUser(s scanner) (*domain.User, error) {
var (
u domain.User
@@ -39,15 +40,37 @@ func scanUser(s scanner) (*domain.User, error) {
// CreateUser inserts a new user and returns the stored row (with generated id
// and timestamps). PasswordHash/OIDCIssuer/OIDCSubject may be nil.
func (d *DB) CreateUser(ctx context.Context, u *domain.User) (*domain.User, error) {
//
// Two invariants are enforced inside the single INSERT statement so concurrent
// registrations can't violate them (SQLite serializes writers, and the COUNT
// subqueries see the latest committed state):
// - is_admin is set iff this is the first user — no read-then-write window in
// which two "first" registrations both become admin.
// - the row is inserted only when the table is empty (bootstrap) or allowSignup
// is true; otherwise zero rows are affected and ErrRegistrationClosed is
// returned. This is the authoritative registration gate.
//
// A duplicate email trips the UNIQUE index and maps to ErrEmailTaken.
func (d *DB) CreateUser(ctx context.Context, u *domain.User, allowSignup bool) (*domain.User, error) {
res, err := d.sql.ExecContext(ctx,
`INSERT INTO users (email, display_name, password_hash, oidc_issuer, oidc_subject, is_admin)
VALUES (?, ?, ?, ?, ?, ?)`,
u.Email, u.DisplayName, u.PasswordHash, u.OIDCIssuer, u.OIDCSubject, boolToInt(u.IsAdmin),
SELECT ?, ?, ?, ?, ?, (SELECT count(*) FROM users) = 0
WHERE (SELECT count(*) FROM users) = 0 OR ?`,
u.Email, u.DisplayName, u.PasswordHash, u.OIDCIssuer, u.OIDCSubject, boolToInt(allowSignup),
)
if err != nil {
if isUniqueViolation(err) {
return nil, domain.ErrEmailTaken
}
return nil, fmt.Errorf("store: insert user: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("store: user insert rows: %w", err)
}
if n == 0 {
return nil, domain.ErrRegistrationClosed
}
id, err := res.LastInsertId()
if err != nil {
return nil, fmt.Errorf("store: user insert id: %w", err)
@@ -99,3 +122,10 @@ func boolToInt(b bool) int {
}
return 0
}
// isUniqueViolation reports whether err is a SQLite UNIQUE-constraint failure.
// modernc surfaces these in the error text; the message is stable across SQLite
// versions ("UNIQUE constraint failed: <table>.<column>").
func isUniqueViolation(err error) bool {
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
}