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} h := &handlers{cfg: cfg, svc: svc}
v1 := r.Group("/api/v1") 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) v1.GET("/healthz", healthz)
// Auth endpoints are exempt from requireAuth (you can't be logged in yet); // Auth endpoints are exempt from requireAuth (you can't be logged in yet);
+70 -26
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
1
@@ -30,7 +31,7 @@ type registerRequest struct {
type loginRequest struct { type loginRequest struct {
Email string `json:"email" binding:"required,email"` 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). // 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, Password: req.Password,
}) })
if err != nil { if err != nil {
h.writeServiceError(c, err) writeServiceError(c, err)
return 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 { h.startSessionAndRespond(c, user)
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not start session")
return
}
c.JSON(http.StatusOK, user)
} }
// login verifies credentials and sets the session cookie. // 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) user, err := h.svc.Login(c.Request.Context(), req.Email, req.Password)
if err != nil { if err != nil {
h.writeServiceError(c, err) writeServiceError(c, err)
return return
} }
if err := h.startSession(c, user.ID); err != nil { h.startSessionAndRespond(c, user)
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not start session")
return
}
c.JSON(http.StatusOK, user)
} }
// logout deletes the current session (if any) and clears the cookie. It is // 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, // requireAuth is middleware that rejects requests without a valid session and,
// on success, stashes the resolved user in the context. Feature routers added by // on success, stashes the resolved user in the context and slides the cookie's
// later issues (gardens, objects, …) attach this to their protected groups. // 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 { func (h *handlers) requireAuth() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
token, err := c.Cookie(sessionCookie) token, err := c.Cookie(sessionCookie)
if err != nil || token == "" { if err != nil || token == "" {
writeAPIError(c, http.StatusUnauthorized, "UNAUTHENTICATED", "authentication required") 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>
c.Abort()
return return
} }
user, err := h.svc.ResolveSession(c.Request.Context(), token) user, expiresAt, err := h.svc.ResolveSession(c.Request.Context(), token)
if err != nil { 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: // Any resolution failure (missing/expired/tampered) is 401, never 404:
// the client should log in, not think a resource is gone. // the client should log in, not think a resource is gone.
writeAPIError(c, http.StatusUnauthorized, "UNAUTHENTICATED", "authentication required") abortUnauthenticated(c)
c.Abort()
return return
} }
c.Set(actorKey, user) 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() c.Next()
} }
} }
// startSession issues a session and writes the cookie. // abortUnauthenticated writes the standard 401 and stops the handler chain.
func (h *handlers) startSession(c *gin.Context, userID int64) error { func abortUnauthenticated(c *gin.Context) {
token, expiresAt, err := h.svc.CreateSession(c.Request.Context(), userID) 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 { 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) h.setSessionCookie(c, token, expiresAt)
return nil c.JSON(http.StatusOK, user)
} }
// cookieSecure reports whether the session cookie should carry the Secure // 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) { 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) maxAge := max(int(time.Until(expiresAt).Seconds()), 1)
c.SetSameSite(http.SameSiteLaxMode) c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(sessionCookie, token, maxAge, "/", "", h.cookieSecure(), true) 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 // writeServiceError maps a service-layer sentinel error to pansy's JSON error
// envelope. Login failures never leak which of email/password was wrong. // 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 { switch {
case errors.Is(err, domain.ErrInvalidCredentials): case errors.Is(err, domain.ErrInvalidCredentials):
writeAPIError(c, http.StatusUnauthorized, "INVALID_CREDENTIALS", "invalid email or password") 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} return &config.Config{Registration: config.RegistrationOpen, LocalAuth: true}
} }
// doJSON issues a JSON request, optionally carrying a session cookie. // encodeBody JSON-encodes v into a reader for a request body.
func doJSON(t *testing.T, r *gin.Engine, method, path string, body any, cookie *http.Cookie) *httptest.ResponseRecorder { func encodeBody(t *testing.T, v any) *bytes.Buffer {
t.Helper() t.Helper()
var buf bytes.Buffer var buf bytes.Buffer
if body != nil { if v != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil { if err := json.NewEncoder(&buf).Encode(v); err != nil {
t.Fatalf("encode body: %v", err) 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") req.Header.Set("Content-Type", "application/json")
if cookie != nil { if cookie != nil {
req.AddCookie(cookie) 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) { func TestSecureCookieWithHTTPSBaseURL(t *testing.T) {
cfg := localCfg() cfg := localCfg()
cfg.BaseURL = "https://pansy.example.com" cfg.BaseURL = "https://pansy.example.com"
+32 -32
View File
@@ -117,25 +117,25 @@ type GardenShare struct {
// GardenObject is any placeable object in a garden (bed, container, tree, path…). // GardenObject is any placeable object in a garden (bed, container, tree, path…).
// Positioned by center point + rotation about center, in garden space. // Positioned by center point + rotation about center, in garden space.
type GardenObject struct { type GardenObject struct {
ID int64 `json:"id"` ID int64 `json:"id"`
GardenID int64 `json:"gardenId"` GardenID int64 `json:"gardenId"`
Kind string `json:"kind"` Kind string `json:"kind"`
Name string `json:"name"` Name string `json:"name"`
Shape string `json:"shape"` Shape string `json:"shape"`
Points *string `json:"points,omitempty"` // reserved: JSON for polygons Points *string `json:"points,omitempty"` // reserved: JSON for polygons
XCM float64 `json:"xCm"` XCM float64 `json:"xCm"`
YCM float64 `json:"yCm"` YCM float64 `json:"yCm"`
WidthCM float64 `json:"widthCm"` WidthCM float64 `json:"widthCm"`
HeightCM float64 `json:"heightCm"` HeightCM float64 `json:"heightCm"`
RotationDeg float64 `json:"rotationDeg"` RotationDeg float64 `json:"rotationDeg"`
ZIndex int `json:"zIndex"` ZIndex int `json:"zIndex"`
Plantable bool `json:"plantable"` Plantable bool `json:"plantable"`
Color *string `json:"color,omitempty"` Color *string `json:"color,omitempty"`
Props *string `json:"props,omitempty"` // kind-specific JSON Props *string `json:"props,omitempty"` // kind-specific JSON
Notes string `json:"notes"` Notes string `json:"notes"`
Version int64 `json:"version"` Version int64 `json:"version"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
} }
// Plant is a catalog entry. OwnerID nil means a read-only seeded built-in. // Plant is a catalog entry. OwnerID nil means a read-only seeded built-in.
@@ -157,17 +157,17 @@ type Plant struct {
// Planting ("plop") is a circular patch of one plant, positioned in its parent // Planting ("plop") is a circular patch of one plant, positioned in its parent
// object's local frame. Count nil means it is derived from area / spacing². // object's local frame. Count nil means it is derived from area / spacing².
type Planting struct { type Planting struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ObjectID int64 `json:"objectId"` ObjectID int64 `json:"objectId"`
PlantID int64 `json:"plantId"` PlantID int64 `json:"plantId"`
XCM float64 `json:"xCm"` XCM float64 `json:"xCm"`
YCM float64 `json:"yCm"` YCM float64 `json:"yCm"`
RadiusCM float64 `json:"radiusCm"` RadiusCM float64 `json:"radiusCm"`
Count *int `json:"count,omitempty"` // nil = derived Count *int `json:"count,omitempty"` // nil = derived
Label *string `json:"label,omitempty"` Label *string `json:"label,omitempty"`
PlantedAt *string `json:"plantedAt,omitempty"` PlantedAt *string `json:"plantedAt,omitempty"`
RemovedAt *string `json:"removedAt,omitempty"` RemovedAt *string `json:"removedAt,omitempty"`
Version int64 `json:"version"` Version int64 `json:"version"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
} }
+58 -34
View File
@@ -3,12 +3,17 @@ package service
import ( import (
"context" "context"
"errors" "errors"
"log/slog"
"strings" "strings"
"time" "time"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" "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. // RegisterInput is the payload for local self-service signup.
type RegisterInput struct { type RegisterInput struct {
Email string Email string
1
@@ -46,27 +51,22 @@ func (s *Service) Register(ctx context.Context, in RegisterInput) (*domain.User,
email := normalizeEmail(in.Email) email := normalizeEmail(in.Email)
displayName := strings.TrimSpace(in.DisplayName) 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 return nil, domain.ErrInvalidInput
} }
count, err := s.store.CountUsers(ctx) // Cheap best-effort gate so a closed instance doesn't burn argon2 work on
if err != nil { // signups it will reject anyway. The authoritative, race-free gate — plus
return nil, err // atomic first-user-is-admin assignment and duplicate-email detection — lives
} // in store.CreateUser's single INSERT.
// Closed registration still allows the very first account so a locked-down if !s.cfg.RegistrationOpen() {
// instance can be bootstrapped without editing config. n, err := s.store.CountUsers(ctx)
if count > 0 && !s.cfg.RegistrationOpen() { if err != nil {
return nil, domain.ErrRegistrationClosed return nil, err
} }
if n > 0 {
// Friendly duplicate check. The UNIQUE index is the real guard against a return nil, domain.ErrRegistrationClosed
// 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) hash, err := hashPassword(in.Password)
1
@@ -78,8 +78,7 @@ func (s *Service) Register(ctx context.Context, in RegisterInput) (*domain.User,
Email: email, Email: email,
DisplayName: displayName, DisplayName: displayName,
PasswordHash: &hash, 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 // 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 { if !s.cfg.LocalAuth {
return nil, domain.ErrLocalAuthDisabled 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)) u, err := s.store.GetUserByEmail(ctx, normalizeEmail(email))
switch { 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) 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 nil, domain.ErrInvalidCredentials
} }
return u, nil return u, nil
@@ -129,38 +138,53 @@ func (s *Service) CreateSession(ctx context.Context, userID int64) (token string
return raw, exp, nil return raw, exp, nil
} }
// ResolveSession validates a raw bearer token and returns its user. An expired // 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 is deleted and treated as absent (domain.ErrNotFound). A still-valid // session's current expiry (so the caller can slide the client cookie in
// session has its expiry slid forward, but only when that moves it by more than // lockstep with the server). An expired session is deleted and treated as absent
// an hour, so a busy client doesn't write on every request. // (domain.ErrNotFound). A still-valid session has its expiry slid forward, but
func (s *Service) ResolveSession(ctx context.Context, rawToken string) (*domain.User, error) { // 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 == "" { 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) hash := hashToken(rawToken)
sess, err := s.store.GetSession(ctx, hash) sess, err := s.store.GetSession(ctx, hash)
if err != nil { 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) exp, err := parseTime(sess.ExpiresAt)
if err != nil { if err != nil {
// A corrupt expiry means we can't trust the session; drop it. // 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) if delErr := s.store.DeleteSession(ctx, hash); delErr != nil {
return nil, domain.ErrNotFound slog.Warn("service: deleting session with corrupt expiry", "error", delErr)
}
return nil, time.Time{}, domain.ErrNotFound
} }
now := s.now() now := s.now()
if !now.Before(exp) { if !now.Before(exp) {
_ = s.store.DeleteSession(ctx, hash) if delErr := s.store.DeleteSession(ctx, hash); delErr != nil {
return nil, domain.ErrNotFound slog.Warn("service: deleting expired session", "error", delErr)
}
return nil, time.Time{}, domain.ErrNotFound
} }
if newExp := now.Add(sessionTTL); newExp.Sub(exp) > time.Hour { 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. // Logout deletes the session behind a raw bearer token. It is idempotent.
+29 -7
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"errors" "errors"
"strings"
"testing" "testing"
"time" "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) { func TestLocalAuthDisabledRejectsRegisterAndLogin(t *testing.T) {
cfg := openConfig() cfg := openConfig()
cfg.LocalAuth = false cfg.LocalAuth = false
@@ -139,7 +154,7 @@ func TestLoginRejectsOIDCOnlyUser(t *testing.T) {
iss, sub := "https://idp.example", "subject-1" iss, sub := "https://idp.example", "subject-1"
if _, err := s.store.CreateUser(context.Background(), &domain.User{ if _, err := s.store.CreateUser(context.Background(), &domain.User{
Email: "[email protected]", DisplayName: "O", OIDCIssuer: &iss, OIDCSubject: &sub, Email: "[email protected]", DisplayName: "O", OIDCIssuer: &iss, OIDCSubject: &sub,
}); err != nil { }, true); err != nil {
t.Fatalf("seed oidc user: %v", err) t.Fatalf("seed oidc user: %v", err)
} }
if _, err := s.Login(context.Background(), "[email protected]", "anything"); !errors.Is(err, domain.ErrInvalidCredentials) { 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) t.Fatalf("CreateSession: %v", err)
} }
got, err := s.ResolveSession(context.Background(), token) got, exp, err := s.ResolveSession(context.Background(), token)
if err != nil { if err != nil {
t.Fatalf("ResolveSession: %v", err) t.Fatalf("ResolveSession: %v", err)
} }
if got.ID != u.ID { if got.ID != u.ID {
t.Errorf("resolved user %d, want %d", 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. // Logout invalidates it.
if err := s.Logout(context.Background(), token); err != nil { if err := s.Logout(context.Background(), token); err != nil {
t.Fatalf("Logout: %v", err) 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) t.Errorf("resolve after logout err = %v, want ErrNotFound", err)
} }
} }
func TestResolveSessionRejectsGarbageToken(t *testing.T) { func TestResolveSessionRejectsGarbageToken(t *testing.T) {
s := newTestService(t, openConfig()) 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) 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) 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... // Jump past the 30-day TTL: the session must be treated as gone...
s.now = func() time.Time { return base.Add(sessionTTL + time.Hour) } 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) t.Fatalf("expired resolve err = %v, want ErrNotFound", err)
} }
// ...and lazily deleted from the store. // ...and lazily deleted from the store.
@@ -219,9 +237,13 @@ func TestSessionSlidingRenewal(t *testing.T) {
// Use it 10 days later; expiry should slide forward. // Use it 10 days later; expiry should slide forward.
s.now = func() time.Time { return base.Add(10 * 24 * time.Hour) } 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) 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)) sess, err := s.store.GetSession(context.Background(), hashToken(token))
if err != nil { if err != nil {
t.Fatalf("GetSession: %v", err) t.Fatalf("GetSession: %v", err)
+37 -17
View File
@@ -11,13 +11,13 @@ import (
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
) )
// argon2id parameters. ~64 MiB memory / 1 pass / 4 lanes is the interactive // argon2id parameters: RFC 9106's second recommended profile (m=64 MiB, t=3,
// profile recommended by the argon2 authors and is comfortable on a // p=4) — the memory-constrained option, appropriate for a self-hosted box where
// self-hosted box. They are encoded into every stored hash, so raising them // the 2 GiB first profile is too heavy. They are encoded into every stored hash,
// later leaves old hashes verifiable. // so raising them later leaves old hashes verifiable.
const ( const (
argonMemKiB = 64 * 1024 // 64 MiB 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 argonThreads = 4
argonKeyLen = 32 argonKeyLen = 32
argonSaltLen = 16 argonSaltLen = 16
@@ -25,12 +25,27 @@ const (
// errBadHash marks a stored hash string that could not be parsed — a data or // 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 // 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") var errBadHash = errors.New("service: malformed password hash")
// b64 is the padding-free base64 used inside the PHC-style hash string. // b64 is the padding-free base64 used inside the PHC-style hash string.
var b64 = base64.RawStdEncoding 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 // hashPassword returns a self-describing argon2id hash in the PHC string format
// "$argon2id$v=19$m=...,t=...,p=...$salt$hash" (all base64, no padding). // "$argon2id$v=19$m=...,t=...,p=...$salt$hash" (all base64, no padding).
func hashPassword(password string) (string, error) { 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 // 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) { func decodeHash(encoded string) (mem, t uint32, threads uint8, salt, key []byte, err error) {
parts := strings.Split(encoded, "$") fail := func() (uint32, uint32, uint8, []byte, []byte, error) {
// ["", "argon2id", "v=19", "m=..,t=..,p=..", "<salt>", "<hash>"]
if len(parts) != 6 || parts[1] != "argon2id" {
return 0, 0, 0, nil, nil, errBadHash return 0, 0, 0, nil, nil, errBadHash
} }
var version int parts := strings.Split(encoded, "$")
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version { // ["", "argon2id", "v=19", "m=..,t=..,p=..", "<salt>", "<hash>"]
return 0, 0, 0, nil, nil, errBadHash 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 { 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 { 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 return mem, t, threads, salt, key, nil
} }
+1 -1
View File
@@ -45,7 +45,7 @@ func TestVerifyPasswordRejectsMalformedHash(t *testing.T) {
"not-a-hash", "not-a-hash",
"$argon2id$v=19$m=65536,t=1,p=4$onlyfourparts", "$argon2id$v=19$m=65536,t=1,p=4$onlyfourparts",
"$argon2i$v=19$m=65536,t=1,p=4$c2FsdA$aGFzaA", // wrong variant "$argon2i$v=19$m=65536,t=1,p=4$c2FsdA$aGFzaA", // wrong variant
"$argon2id$v=1$m=65536,t=1,p=4$c2FsdA$aGFzaA", // wrong version "$argon2id$v=1$m=65536,t=1,p=4$c2FsdA$aGFzaA", // wrong version
} { } {
if _, err := verifyPassword(bad, "whatever"); err == nil { if _, err := verifyPassword(bad, "whatever"); err == nil {
t.Errorf("verifyPassword(%q) err = nil, want errBadHash", bad) t.Errorf("verifyPassword(%q) err = nil, want errBadHash", bad)
+18 -19
View File
@@ -1,9 +1,11 @@
// Package service is pansy's business-logic seam: every operation is a method on // Package service is pansy's business-logic seam: all permission checks and
// *Service taking (ctx, actor, args), and all permission checks and invariants // 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>
// live here rather than in the HTTP handlers. REST handlers (internal/api) and, // take (ctx, actor, args) so every rule is enforced regardless of caller; the
// later, agent tools (internal/agent) are thin adapters over these methods, so // auth operations here are the exception — they establish the actor, so they
// both inherit the same rules. This file holds the shared plumbing; feature // take credentials rather than one. REST handlers (internal/api) and, later,
// methods live alongside it (auth.go, and gardens/objects/… in later issues). // 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 package service
import ( import (
@@ -12,7 +14,6 @@ import (
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"log/slog"
"time" "time"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config" "gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
@@ -33,23 +34,21 @@ type Service struct {
cfg *config.Config cfg *config.Config
// now is the clock, injectable so tests can advance time (session expiry). // now is the clock, injectable so tests can advance time (session expiry).
now func() time.Time now func() time.Time
// dummyHash is a valid argon2id hash of a throwaway password. Login verifies // dummyHash is a valid argon2id hash Login verifies against when an email is
// against it when an email is unknown so the response time doesn't reveal // unknown, so response time doesn't reveal whether an account exists. It is
// whether an account exists. // produced by timingHash (fixed salt, no RNG) so it is always present — an
// empty one would silently re-open account enumeration.
dummyHash string dummyHash string
} }
// New constructs a Service. It precomputes a dummy password hash used to // New constructs a Service.
// equalize login timing; if that fails (it shouldn't), login still works but
// loses the timing defense.
func New(st *store.DB, cfg *config.Config) *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} return &Service{
if h, err := hashPassword("pansy-timing-equalizer-not-a-real-password"); err != nil { store: st,
slog.Warn("service: could not precompute login timing hash", "error", err) cfg: cfg,
} else { now: time.Now,
s.dummyHash = h dummyHash: timingHash(),
} }
return s
} }
// formatTime renders a time as pansy's canonical UTC string. // 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 { if err := db.SQL().QueryRow(`SELECT max(version) FROM schema_migrations`).Scan(&version); err != nil {
t.Fatalf("read schema_migrations: %v", err) t.Fatalf("read schema_migrations: %v", err)
} }
if version != 1 { if version != 2 {
t.Errorf("schema version = %d, want 1", version) t.Errorf("schema version = %d, want 2", version)
} }
} }
func TestMigrateIsIdempotent(t *testing.T) { func TestMigrateIsIdempotent(t *testing.T) {
db := openTestDB(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 { if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("second Migrate: %v", err) 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 { if err := db.SQL().QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&count); err != nil {
t.Fatalf("count schema_migrations: %v", err) t.Fatalf("count schema_migrations: %v", err)
} }
if count != 1 { if count != 2 {
t.Errorf("schema_migrations rows = %d, want 1", count) t.Errorf("schema_migrations rows = %d, want 2", count)
} }
} }
+34 -4
View File
@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"strings"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" "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 // 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 // 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) { func scanUser(s scanner) (*domain.User, error) {
var ( var (
u domain.User 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 // CreateUser inserts a new user and returns the stored row (with generated id
// and timestamps). PasswordHash/OIDCIssuer/OIDCSubject may be nil. // 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, res, err := d.sql.ExecContext(ctx,
`INSERT INTO users (email, display_name, password_hash, oidc_issuer, oidc_subject, is_admin) `INSERT INTO users (email, display_name, password_hash, oidc_issuer, oidc_subject, is_admin)
VALUES (?, ?, ?, ?, ?, ?)`, SELECT ?, ?, ?, ?, ?, (SELECT count(*) FROM users) = 0
u.Email, u.DisplayName, u.PasswordHash, u.OIDCIssuer, u.OIDCSubject, boolToInt(u.IsAdmin), WHERE (SELECT count(*) FROM users) = 0 OR ?`,
u.Email, u.DisplayName, u.PasswordHash, u.OIDCIssuer, u.OIDCSubject, boolToInt(allowSignup),
) )
if err != nil { if err != nil {
if isUniqueViolation(err) {
return nil, domain.ErrEmailTaken
}
return nil, fmt.Errorf("store: insert user: %w", err) 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() id, err := res.LastInsertId()
if err != nil { if err != nil {
return nil, fmt.Errorf("store: user insert id: %w", err) return nil, fmt.Errorf("store: user insert id: %w", err)
@@ -99,3 +122,10 @@ func boolToInt(b bool) int {
} }
return 0 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")
}