Skip to content

Commit f3f2b61

Browse files
egeozcanclaude
andcommitted
feat(auth): add CSRF tokens, styled 403, login rate-limit, mr user CLI, per-role E2E
Implements the remaining deferred hardening/completeness groups for the opt-in user-accounts/RBAC feature. All of it is auth-gated, so the auth-off default deployment is byte-for-byte unchanged (verified by the green auth-off E2E run). Group B — hardening - Synchronizer-token CSRF (defense-in-depth atop SameSite=Lax). Each session carries a random Session.CsrfToken, published via a <meta> tag, /v1/auth/me, and the template chokepoint. withCSRFProtection (authn -> CSRF -> authz) rejects state-changing cookie-authenticated requests whose token is missing or wrong. The token is read from the X-CSRF-Token header, the csrf_token query param (native multipart upload forms), or an urlencoded body field — never from a multipart/JSON body, so per-upload size limits are preserved. Bearer, auth-off, safe methods, the login/logout flow, and read-via-POST are exempt. JS wraps fetch + native form submits to attach the token automatically. - Styled 403 page (RenderForbidden, mirroring RenderNotFound); denyAccess and denyCSRF render it for browser navigations, JSON stays machine-readable. - Per-IP login rate-limit (in-memory sliding window) via -login-max-attempts / -login-attempt-window (default off / no-op). Group C — mr user admin CLI - user list|get|create|update|delete (update is a client-side partial update, since the API is a full replace). Registered, CLI docs regenerated. Group D — test matrix - Auth-enabled ephemeral E2E server + auth.fixture.ts (seeds the four roles and in/out-of-scope groups) + dedicated `auth` Playwright project with per-role access, scope-confinement, and login-page a11y specs. - Fixed a real WCAG bug surfaced by the new a11y specs: login button contrast (bg-amber-600, 3.4:1) -> bg-amber-700 (AA), matching the standard submit button. Tests: full Go unit suite (sqlite + postgres) green; new csrf/forbidden/ rate-limit Go tests; full browser+CLI E2E green (1537 passed, 2 known flakes); auth E2E (12) green on both sqlite and postgres; staticcheck clean; mr docs lint OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b09efd6 commit f3f2b61

44 files changed

Lines changed: 1838 additions & 101 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ All settings can be configured via environment variables (in `.env`) or command-
146146
| `-session-cookie-secure` | `SESSION_COOKIE_SECURE=1` | Mark the session cookie `Secure` (HTTPS-only). Enable behind TLS. |
147147
| `-create-admin-user` | `CREATE_ADMIN_USER` | Bootstrap: create (or reset to enabled admin) this username at startup. Idempotent. Requires `-create-admin-password`. |
148148
| `-create-admin-password` | `CREATE_ADMIN_PASSWORD` | Password for `-create-admin-user`. |
149+
| `-login-max-attempts` | `LOGIN_MAX_ATTEMPTS` | Max failed login attempts per client IP within `-login-attempt-window` before throttling with HTTP 429. `0` (default) disables login rate-limiting. In-memory and per-process (counters reset on restart). |
150+
| `-login-attempt-window` | `LOGIN_ATTEMPT_WINDOW` | Sliding window for `-login-max-attempts`, and the lockout duration once it is hit (default: 15m). |
149151

150152
### Authentication & roles
151153

@@ -158,7 +160,7 @@ Auth is **opt-in**. With `-auth` set, requests must authenticate via a browser s
158160

159161
Group-limited users/guests are confined to their scope group and all of its descendants across lists, single-item reads, search, MRQL, file serving, group export, and writes (fail-closed). Bootstrap the first admin with `-create-admin-user`/`-create-admin-password`. The `mr` CLI authenticates with `mr auth login` (stores an API token) or the `MR_TOKEN` env var.
160162

161-
CSRF: the session cookie is `SameSite=Lax`, which blocks cross-site state-changing (POST/PUT/DELETE) requests; API-token (Bearer) requests carry no ambient cookie and are not CSRF-exposed.
163+
CSRF: the session cookie is `SameSite=Lax`, which blocks cross-site state-changing (POST/PUT/DELETE) requests; API-token (Bearer) requests carry no ambient cookie and are not CSRF-exposed. Layered on top of that baseline is a per-session synchronizer token (defense-in-depth): each session carries a random `Session.CsrfToken`, published to the page in a `<meta name="csrf-token">` tag and on `/v1/auth/me`. State-changing, cookie-authenticated requests must echo it via the `X-CSRF-Token` header (the JS `fetch` wrapper adds it automatically), the `csrf_token` query parameter (native multipart upload forms), or a `csrf_token` urlencoded form field; the `withCSRFProtection` middleware rejects mismatches with 403. The check is a no-op when auth is disabled, and skips safe methods, the login/logout flow, read-via-POST endpoints, and Bearer requests. The CSRF middleware never reads multipart or JSON bodies, so per-upload size limits are preserved.
162164

163165
Alternative file systems via flags use format `-alt-fs=key:path` (can be repeated).
164166
Via env vars, use `FILE_ALT_COUNT=N` with `FILE_ALT_NAME_1`, `FILE_ALT_PATH_1`, etc.

application_context/auth_config.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,22 @@ func (ctx *MahresourcesContext) SessionTTL() time.Duration {
2828
}
2929
return ctx.Config.SessionTTL
3030
}
31+
32+
// LoginRateLimit returns the max failed login attempts per client IP within
33+
// LoginRateWindow before throttling. 0 (the default) disables rate-limiting.
34+
func (ctx *MahresourcesContext) LoginRateLimit() int {
35+
if ctx.Config.LoginRateLimit < 0 {
36+
return 0
37+
}
38+
return ctx.Config.LoginRateLimit
39+
}
40+
41+
// LoginRateWindow returns the sliding window over which failed logins are
42+
// counted (and the lockout duration once the limit is hit), defaulting to 15
43+
// minutes when rate-limiting is enabled but no window is configured.
44+
func (ctx *MahresourcesContext) LoginRateWindow() time.Duration {
45+
if ctx.Config.LoginRateWindow <= 0 {
46+
return 15 * time.Minute
47+
}
48+
return ctx.Config.LoginRateWindow
49+
}

application_context/context.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,13 @@ type MahresourcesConfig struct {
124124
SessionCookieName string
125125
// SessionCookieSecure marks the session cookie Secure (HTTPS-only).
126126
SessionCookieSecure bool
127+
// LoginRateLimit is the number of failed login attempts permitted from a
128+
// single client IP within LoginRateWindow before further attempts are
129+
// throttled (HTTP 429). 0 disables login rate-limiting.
130+
LoginRateLimit int
131+
// LoginRateWindow is the sliding window over which failed login attempts are
132+
// counted (and the lockout duration once the limit is hit).
133+
LoginRateWindow time.Duration
127134
}
128135

129136
// MahresourcesInputConfig holds all configuration options that can be passed
@@ -211,6 +218,11 @@ type MahresourcesInputConfig struct {
211218
SessionTTL time.Duration
212219
// SessionCookieSecure marks the session cookie Secure (HTTPS-only).
213220
SessionCookieSecure bool
221+
// LoginRateLimit caps failed login attempts per client IP within
222+
// LoginRateWindow before throttling (0 disables it).
223+
LoginRateLimit int
224+
// LoginRateWindow is the sliding window for LoginRateLimit.
225+
LoginRateWindow time.Duration
214226
// CreateAdminUser/CreateAdminPassword bootstrap an admin account at startup
215227
// when set. Idempotent: an existing account with that username is reset to an
216228
// enabled admin with the given password.
@@ -905,6 +917,8 @@ func CreateContextWithConfig(cfg *MahresourcesInputConfig) (*MahresourcesContext
905917
SessionTTL: sessionTTL,
906918
SessionCookieName: "mr_session",
907919
SessionCookieSecure: cfg.SessionCookieSecure,
920+
LoginRateLimit: cfg.LoginRateLimit,
921+
LoginRateWindow: cfg.LoginRateWindow,
908922
}), db, mainFs
909923
}
910924

application_context/session_context.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,15 @@ func (ctx *MahresourcesContext) CreateSession(userID uint, ttl time.Duration, us
2626
if err != nil {
2727
return "", nil, err
2828
}
29+
csrf, err := auth.GenerateToken()
30+
if err != nil {
31+
return "", nil, err
32+
}
2933
now := time.Now()
3034
session := &models.Session{
3135
UserId: userID,
3236
TokenHash: auth.HashToken(raw),
37+
CsrfToken: csrf,
3338
ExpiresAt: now.Add(ttl),
3439
LastSeenAt: now,
3540
UserAgent: userAgent,

auth/context.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import "context"
66
type contextKey struct{ name string }
77

88
var principalKey = contextKey{"principal"}
9+
var csrfTokenKey = contextKey{"csrfToken"}
910

1011
// WithPrincipal returns a child context carrying the authenticated principal.
1112
func WithPrincipal(ctx context.Context, p *Principal) context.Context {
@@ -21,3 +22,20 @@ func PrincipalFromContext(ctx context.Context) *Principal {
2122
p, _ := ctx.Value(principalKey).(*Principal)
2223
return p
2324
}
25+
26+
// WithCSRFToken returns a child context carrying the session's CSRF synchronizer
27+
// token. Only set for cookie-authenticated requests; Bearer requests carry no
28+
// token (and are exempt from CSRF checks).
29+
func WithCSRFToken(ctx context.Context, token string) context.Context {
30+
return context.WithValue(ctx, csrfTokenKey, token)
31+
}
32+
33+
// CSRFTokenFromContext returns the session CSRF token stored on the context, or
34+
// "" if none is present.
35+
func CSRFTokenFromContext(ctx context.Context) string {
36+
if ctx == nil {
37+
return ""
38+
}
39+
t, _ := ctx.Value(csrfTokenKey).(string)
40+
return t
41+
}

0 commit comments

Comments
 (0)