Files
pansy/internal/api/api.go
T
steveandClaude Opus 4.8 02c928ac6d
Build image / build-and-push (push) Successful in 5s
Address Gadfly review on #4: TOCTOU, sliding cookie, CSRF, argon2
Fixes from the PR #23 adversarial review (graded 35 real / 1 false positive):

Security / correctness
- Race-free registration: is_admin and the registration gate are now
  computed atomically inside a single INSERT...SELECT, so concurrent
  first registrations can't both become admin or bypass closed
  registration (fixed the whole TOCTOU cluster).
- Sliding session now reaches the browser: ResolveSession returns the
  current expiry and requireAuth re-sets the cookie, so active users
  aren't logged out 30 days after login regardless of activity.
- Login CSRF: csrfGuard rejects state-changing requests whose Origin
  doesn't match PANSY_BASE_URL (no-op when unset, so the dev proxy is
  unaffected). SameSite=Lax alone didn't cover this.
- argon2id tuned to RFC 9106's second recommended profile (t=3).
- Timing equalizer can't fail open: the dummy hash is derived
  deterministically (fixed salt, no RNG) so it's always present.
- Password length (<=1024) enforced in the service for both register
  and login, not just HTTP binding tags; login rejects over-long input
  before spending argon2 work.

Error handling / robustness
- Login logs a malformed stored hash instead of silently treating it as
  a wrong password.
- Best-effort session writes (Touch/Delete during renewal, expiry, and
  corrupt-expiry cleanup) now log on failure.
- index sessions.expires_at via new migration 0002 (0001 is immutable).

Maintainability
- Extract startSessionAndRespond and abortUnauthenticated; make
  writeServiceError a free function; consistent error handling in
  decodeHash; doc/comment fixes.

Tests: over-long password, CSRF guard (cross-origin/same-origin/dev
no-op), and cookie refresh on authenticated requests; migration-version
assertions bumped to 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 17:04:35 -04:00

69 lines
2.4 KiB
Go

// Package api wires pansy's HTTP surface: a gin engine with structured logging
// and panic recovery, the versioned JSON API under /api/v1, and (via spa.go) the
// embedded single-page-app fallback. Handlers stay thin — decode, call the
// service layer, encode — so all business logic and permission checks live in
// internal/service (added by later issues).
package api
import (
"log/slog"
"net/http"
"github.com/gin-gonic/gin"
sloggin "github.com/samber/slog-gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// handlers carries the dependencies shared by every HTTP handler. Handlers stay
// thin: decode the request, call a service method, encode the result.
type handlers struct {
cfg *config.Config
svc *service.Service
}
// New builds the gin engine with the standard middleware stack and registers the
// API routes against the given service. The embedded SPA fallback is registered
// separately by the caller via RegisterSPA (see spa.go) so the API can be built
// and tested without a web build present.
func New(cfg *config.Config, svc *service.Service) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(sloggin.New(slog.Default()), gin.Recovery())
if err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {
// Do not leave gin's trust-everyone default active on a parse failure —
// that would let any client spoof X-Forwarded-For. Fall back to trusting
// no proxies, which is also the behavior when none are configured.
slog.Error("api: invalid trusted proxies, trusting none", "error", err)
_ = r.SetTrustedProxies(nil)
}
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);
// /me is the one that needs a session. Feature routers in later issues attach
// h.requireAuth() to their own protected groups.
auth := v1.Group("/auth")
auth.POST("/register", h.register)
auth.POST("/login", h.login)
auth.POST("/logout", h.logout)
auth.GET("/providers", h.providers)
auth.GET("/me", h.requireAuth(), h.me)
return r
}
// healthz is a liveness probe: always returns {"ok": true} when the server is up.
func healthz(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
}