312 Commits

Author SHA1 Message Date
0e0da4bd14 Fix nginx header inheritance regression and add 0.0.0.0/8 to SSRF blocklist
NEW-1: add_header in location /api block suppressed server-level security
headers (HSTS, CSP, X-Frame-Options, etc). Duplicate all security headers
into the /api block explicitly per nginx inheritance rules.

NEW-2: Add 0.0.0.0/8 to _BLOCKED_NETWORKS — on Linux 0.0.0.0 connects
to localhost, bypassing the existing loopback check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:41:16 +08:00
20d0c2ff57 Fix pentest findings: Cache-Control, SSRF save-time validation, Permissions-Policy
L-01: Add Cache-Control: no-store to all /api/ responses via nginx
L-02: Validate ntfy_server_url against blocked networks at save time
I-03: Add Permissions-Policy header to restrict unused browser APIs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:52:28 +08:00
b04854a488 Default date/time fields to today/now on create forms
Todo, reminder, project, and task forms now pre-fill date/time
fields with today's date and current time when creating new items.
Edit mode still uses stored values. DOB fields excluded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:08:55 +08:00
0c6ea1ccff Fix QA review findings: server-side DOB validation, naive date max prop
- W-01: Add date_of_birth validators to RegisterRequest and ProfileUpdate
  (reject future dates and years before 1900)
- W-05: Replace .toISOString().slice() with local date formatting for
  DatePicker max prop on registration form

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:59:54 +08:00
6cd648f3a8 Replace native date/time inputs with DatePicker across calendar and todo forms
- EventForm + EventDetailPanel: native <Input type=date|datetime-local> → DatePicker with dynamic mode via all_day toggle
- TodoForm + TodoDetailPanel: merge date + time into single datetime DatePicker, remove separate time input, move recurrence select into 2-col grid beside date picker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:43:14 +08:00
e20c04ac4f Fix DatePicker popup flashing at top-left in Chromium
Latent bug: useEffect runs after paint, so the popup rendered at
{top:0, left:0} before repositioning. Switched to useLayoutEffect
which runs synchronously before paint, ensuring correct position
on first frame. Both Chromium and Firefox unaffected by the change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:26:49 +08:00
63b3a3a073 Fix Firefox DatePicker popup positioning at top-left
When Firefox input variant falls through to button variant, the
positioning logic, close handler, and click-outside handler still
checked variant==='input' and used wrapperRef (which is unattached).
Introduced usesNativeInput flag (input variant + not Firefox) so all
three handlers correctly use triggerRef for Firefox fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:56:05 +08:00
e7979afba3 Firefox DatePicker input variant falls through to button variant
Instead of type=text with raw ISO strings, Firefox users now get
the same button-style picker used on the registration screen.
Chromium keeps native date/datetime-local for segmented editing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:49:03 +08:00
8e922a1f1c Use type=text for DatePicker in Firefox to eliminate double icon
Firefox has no CSS pseudo-element to hide its native date picker
calendar icon (Mozilla bug 1830890, open P3). Firefox's date input
doesn't provide Chrome's segmented editing anyway — it renders as
a plain text field with an appended icon.

Fix: detect Firefox via user agent at module load, render type=text
with ISO format placeholder. Chromium keeps native date/datetime-local
for segmented editing UX. min/max omitted for Firefox (only valid on
native date inputs). Custom popup handles all date selection in both.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 04:01:38 +08:00
db2ec156e4 Fix Firefox double calendar icon with opaque cover button
@-moz-document url-prefix() was dead since Firefox 61 and
-moz-appearance: textfield has no effect on date inputs.
Firefox has no CSS pseudo-element for the date picker icon.

Fix: custom Calendar button resized to a full-height w-9 panel
with bg-background + rounded-r-md that completely occludes
Firefox's native icon underneath. Chromium still uses
::-webkit-calendar-picker-indicator to remove its native icon.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:53:36 +08:00
01aed12769 Fix Firefox duplicate calendar icon with -moz-appearance: textfield
The opaque background overlay approach didn't fully cover Firefox's
native icon. Instead, use @-moz-document url-prefix() to apply
-moz-appearance: textfield which strips all native date input chrome
(including the calendar icon) in Firefox. Safe because the DatePicker
provides its own custom popup. Removed the bg-background z-[1]
workaround from the custom button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:45:48 +08:00
e9d4ba384f Fix duplicate calendar icon in Firefox DatePicker
Chromium's icon is hidden via ::-webkit-calendar-picker-indicator.
Firefox doesn't support that pseudo-element, so the custom Calendar
button now has bg-background + z-[1] to opaquely cover Firefox's
native icon. Removed invalid -moz pseudo-element rules.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:40:40 +08:00
a30483fbbc Switch DatePicker input variant to native date/datetime-local types
Replaces <input type="text"> with custom display format conversion
with native <input type="date"> / <input type="datetime-local"> for
exact visual parity with Chrome's built-in segmented editing UI.
Removes ~50 lines of isoToDisplay/displayToIso conversion code.
Hides native picker icon inside .datepicker-wrapper via CSS so only
the custom Calendar icon (opening the popup) is visible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:33:02 +08:00
247c701e12 Match native datetime-local format in DatePicker input variant
Pad 12-hour display to 2 digits to match Chrome native input format:
03/03/2026 03:12 AM (was 3:12 AM). Relax day/month parser to accept
1-2 digit input while still outputting zero-padded ISO strings.
Update placeholder to DD/MM/YYYY hh:mm AM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:21:47 +08:00
59a4f67b42 Display DD/MM/YYYY and 12-hour AM/PM in DatePicker
Input variant now shows user-friendly format (DD/MM/YYYY for date,
DD/MM/YYYY h:mm AM/PM for datetime) instead of raw ISO strings.
Internal display state syncs bidirectionally with ISO value prop
using a ref flag to avoid overwriting during active typing.

Popup time selectors changed from 24-hour to 12-hour with AM/PM
dropdown. Button variant datetime display also updated to AM/PM.

Backend contract unchanged — onChange still emits ISO strings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:07:07 +08:00
4dc3c856b0 Add input variant to DatePicker for typeable date fields
DatePicker now supports variant="button" (default, registration DOB)
and variant="input" (typeable text input + calendar icon trigger).
Input variant lets users type dates manually while the calendar icon
opens the same popup picker. Smart blur management prevents onBlur
from firing when focus moves between input, icon, and popup.

9 non-registration usages updated to variant="input".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:43:45 +08:00
013f9ec010 Add custom DatePicker component, replace all native date inputs
Custom date-picker.tsx with date/datetime modes, portal popup with
month/year dropdowns, min/max constraints, and hidden input for form
validation. Replaces all 10 native <input type="date"> and
<input type="datetime-local"> across LockScreen, SettingsPage,
PersonForm, TodoForm, TodoDetailPanel, TaskForm, TaskDetailPanel,
ProjectForm, ReminderForm, and ReminderDetailPanel. Adds Chromium
calendar icon invert CSS fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:30:52 +08:00
da61676fef Fix missing date_of_birth in admin user detail API response
UserDetailResponse was built from UserListItem (which excludes
date_of_birth), so the field always returned null. Explicitly
pass user.date_of_birth to the response constructor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:42:06 +08:00
3a456e56dd Show date of birth with calculated age in IAM user detail
Adds date_of_birth to UserDetailResponse schema, AdminUserDetail
TypeScript type, and the User Information card in UserDetailSection.
Displays formatted date with age in parentheses (e.g. "3/02/2000 (26)").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:58:21 +08:00
e8109cef6b Add required email + date of birth to registration, shared validators, partial index
- S-01: Extract _EMAIL_REGEX, _validate_email_format, _validate_name_field
  shared helpers in schemas/auth.py — used by RegisterRequest, ProfileUpdate,
  and admin.CreateUserRequest (eliminates 3x duplicated regex)
- S-04: Migration 038 replaces plain unique constraint on email with a
  partial unique index WHERE email IS NOT NULL
- Email is now required on registration (was optional)
- Date of birth is now required on registration, editable in settings
- User model gains date_of_birth (Date, nullable) column
- ProfileUpdate/ProfileResponse include date_of_birth
- Registration form adds required Email, Date of Birth fields
- Settings Profile card adds Date of Birth input (save-on-blur)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:21:11 +08:00
02efe04fc4 Fix QA critical/warning findings on profile feature
C-01: Replace setattr loop with explicit field assignment in update_profile
C-02: Fix useEffect dependency to profileQuery.dataUpdatedAt for re-sync
W-01: Add audit log entry for profile updates
W-02: Use less misleading generic error for email uniqueness on registration
W-03: Early return on empty PUT body to avoid unnecessary commit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:09:15 +08:00
45f3788fb0 Add preferred name + email to registration, profile card to settings
Registration form now collects optional preferred_name and email fields.
Settings page Profile card expanded with first name, last name, and email
(editable via new GET/PUT /api/auth/profile endpoints). Email uniqueness
enforced on both registration and profile update. No migrations needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:02:42 +08:00
3e39c709b7 Merge security/pentest-remediation-20260302: production hardening + pentest remediation
- Auto-derive COOKIE_SECURE from ENVIRONMENT and CORS_ORIGINS from UMBRA_URL
- Fix 503s behind reverse proxy with uvicorn --proxy-headers
- Harden nginx: real client IP restoration, HSTS, custom dotfile 404
- Multi-stage Dockerfile: remove build tools from runtime image
- Concurrent session limiting with oldest-first eviction
- LIKE wildcard escaping in admin audit log filter
- Cookie path=/ for consistent scope
- Restructure .env.example for better onboarding UX

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 18:14:35 +08:00
dadd19bc30 Auto-derive CORS_ORIGINS from UMBRA_URL in production
In production, CORS_ORIGINS now defaults to UMBRA_URL so deployers only
need to set the external URL once. In development it defaults to
http://localhost:5173 (Vite dev server). Explicit CORS_ORIGINS env var
is still respected as an override for multi-origin or custom setups.

This means a production .env only needs: ENVIRONMENT, SECRET_KEY,
UMBRA_URL, and DB credentials. COOKIE_SECURE and CORS_ORIGINS both
auto-derive.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:53:26 +08:00
cad1ca00c7 Fix SECRET_KEY sentinel in backend/.env.example
Align with config.py check so the fatal safety exit triggers correctly
if this file is used verbatim in production.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:46:39 +08:00
c986028f51 Multi-stage Dockerfile: remove gcc/psql from runtime image (PT-11)
Convert to two-stage build: builder stage installs gcc and compiles
Python C extensions, runtime stage copies only the installed packages.
Removes gcc and postgresql-client from the production image, reducing
attack surface. postgresql-client was unused (healthchecks use urllib).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:43:43 +08:00
ab7e4a7c7e Backend pentest remediation (PT-03/05/06/07)
PT-03: Make UMBRA_URL configurable via env var (default http://localhost).
Replaces hardcoded http://10.0.69.35 in notification dispatch job and
ntfy test endpoint. Add UMBRA_URL to .env.example.

PT-05: Add explicit path="/" to session cookie for clarity.

PT-06: Add concurrent session limit (MAX_SESSIONS_PER_USER, default 10).
When exceeded, oldest sessions are revoked. New login always succeeds.

PT-07: Escape LIKE metacharacters (%, _) in admin audit log action
filter to prevent wildcard abuse.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:43:27 +08:00
7721bf5cec Harden nginx: real client IP, HSTS, custom dotfile 404 (PT-01/02/04)
PT-01: Add set_real_ip_from/real_ip_header/real_ip_recursive to restore
real client IP from X-Forwarded-For. Rate limiting now keys on actual
client IP instead of the Pangolin proxy IP.

PT-02: Add Strict-Transport-Security header (max-age 1 year) to both
the server block and static assets block.

PT-04: Replace bare 404 on dotfile requests with JSON response to
suppress nginx server identity disclosure in error pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:43:12 +08:00
ccfc5e151a Fix SECRET_KEY sentinel mismatch in .env.example (W-01)
The .env.example value didn't match the sentinel checked in config.py,
so copying .env.example verbatim to production would bypass the fatal
safety exit. Aligned to use the same default string.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:21:32 +08:00
fee454fc33 Fix 503s behind reverse proxy: add uvicorn --proxy-headers
FastAPI trailing-slash redirects (307) were using http:// instead of
https:// because uvicorn wasn't reading X-Forwarded-Proto from the
reverse proxy. When Pangolin (TLS-terminating proxy) received the
http:// redirect it returned 503, breaking all list endpoints
(/events, /calendars, /settings, /projects, /people, /locations).

Adding --proxy-headers makes uvicorn honour X-Forwarded-Proto so
redirects use the correct scheme. --forwarded-allow-ips '*' trusts
headers from any IP since nginx sits on the Docker bridge network.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:17:39 +08:00
0c7d057654 Auto-derive COOKIE_SECURE from ENVIRONMENT setting
COOKIE_SECURE now defaults to None and auto-derives from ENVIRONMENT
(production → true, else false) via a Pydantic model_validator. Explicit
env var values are still respected as an override escape hatch. Adds a
startup log line showing the resolved value. Restructures .env.example
with clear sections and inline docs, removes redundant production
checklist block.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:38:54 +08:00
21aa670a39 Extract real client IP from proxy headers instead of Docker bridge IP
Nginx already forwards X-Forwarded-For and X-Real-IP, but the backend
read request.client.host directly — always returning 172.18.0.x. Added
get_client_ip() helper to audit service; updated all 13 call sites.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 19:20:07 +08:00
f8c2df9328 Merge multi-user RBAC with login flow fixes, QA + pentest remediations
Multi-user RBAC (8 phases):
- user_id FKs on all 12 domain models (migrations 026-037)
- Per-user query scoping on all 14 routers
- Admin portal: IAM, system config, audit logs
- Registration flow with optional MFA enforcement
- Role-based authorization (admin/standard)

Login flow fixes:
- Block inactive user login (HTTP 403) before session creation
- Optimistic setQueryData eliminates login flicker
- Unified inline error alerts (401/403/423)
- Axios 401 interceptor excludes auth endpoints

Security hardening:
- Global CSRF middleware (X-Requested-With header check)
- extra="forbid" on all Pydantic input schemas
- max_length on all string fields, ge/le on path IDs
- Timing-safe login (M-02 dummy hash)
- Password reuse prevention on change-password
- Setup endpoint 500 fix (func.count vs scalar_one_or_none)

Verified: QA review (0 critical) + pentest (51+ test cases, 0 exploitable)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 03:02:28 +08:00
a313ce8b32 Update README for multi-user RBAC release
- Add multi-user RBAC, admin portal, and registration to features
- Update tech stack (37 migrations, CSRF middleware, RBAC)
- Expand security section with IDOR protection, CSRF, input validation,
  timing safety, inactive user blocking, password reuse prevention
- Update project structure (18 models, 13 schema modules, admin components)
- Add admin endpoints to API overview
- Note pentest verification (51+ test cases, 0 exploitable findings)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 02:58:52 +08:00
d269742aa2 Fix pentest findings: setup 500 error + password reuse prevention
- L-01: Setup endpoint used scalar_one_or_none() on unbounded User
  query, throwing 500 MultipleResultsFound when >1 user exists.
  Replaced with select(func.count()) for a reliable check.
- L-02: Change-password allowed reusing the same password, defeating
  must_change_password enforcement. Added equality check before
  accepting the new password.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 02:42:00 +08:00
1aeb725410 Fix issues from QA review: hash upgrade ordering, interceptor scope, guard
- W-01: Move is_active check before hash upgrade so disabled accounts
  don't get their password hash silently mutated on rejected login
- W-02: Narrow interceptor exclusion to specific auth endpoints instead
  of blanket /auth/* prefix (future-proofs against new auth routes)
- W-03: Add null guard on optimistic setQueryData to handle undefined
  cache gracefully instead of spreading undefined
- S-01: Clear loginError when switching from register back to login mode
- S-03: Add detail dict to auth.login_blocked_inactive audit event

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 02:13:48 +08:00
c4c06be148 Fix login error vanishing: exclude auth endpoints from 401 interceptor
The global axios 401 interceptor was firing window.location.href =
'/login' on every 401 response, including POST /auth/login with wrong
credentials. This caused a full page reload to /login, which remounted
the entire React tree and reset all LockScreen state (loginError,
username, password) before the user could see the error alert.

Fix: skip the redirect for /auth/* endpoints, which legitimately
return 401 for invalid credentials. The interceptor still redirects
to /login for expired sessions on protected API calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 01:43:18 +08:00
5426657b2e Fix login error alert disappearing due to browser autofill
After a failed login, the browser's password manager fires onChange
events on the username/password inputs (clearing or resetting them).
The onChange handlers were calling setLoginError(null), which wiped
the error alert immediately after it appeared.

Fix: remove setLoginError(null) from input onChange handlers. The
error now clears at the start of the next submit attempt via the
existing setLoginError(null) in handleCredentialSubmit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 01:37:57 +08:00
b2d81f7015 Block inactive user login + fix login flicker + inline error alerts
- Backend: reject is_active=False users with HTTP 403 after password
  verification but before session creation (prevents last_login_at
  update, lockout reset, and MFA token issuance for disabled accounts)
- Frontend: optimistic setQueryData on successful login eliminates the
  form flash between mutation success and auth query refetch
- LockScreen: replace lockoutMessage + toast.error with unified
  loginError inline alert for 401/403/423 responses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 01:21:06 +08:00
c68fd69cdf Make temp password click-to-copy in reset password flow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:35:18 +08:00
8582b41b03 Add user profile fields + IAM search, email column, detail panel
Backend:
- Migration 037: add email, first_name, last_name to users table
- User model: add 3 profile columns
- Admin schemas: extend UserListItem/UserDetailResponse/CreateUserRequest
  with profile fields, email validator, name field sanitization
- _create_user_defaults: accept optional preferred_name kwarg
- POST /users: set profile fields, email uniqueness check, IntegrityError guard
- GET /users/{id}: join Settings for preferred_name, include must_change_password/locked_until

Frontend:
- AdminUser/AdminUserDetail types: add profile + detail fields
- useAdmin: add CreateUserPayload profile fields + useAdminUserDetail query
- CreateUserDialog: optional profile section (first/last name, email, preferred name)
- IAMPage: search bar filtering on username/email/name, email column in table,
  row click to select user with accent highlight
- UserDetailSection: two-column detail panel (User Info + Security & Permissions)
  with inline role editing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:40:20 +08:00
c3654dc069 Fix audit log target for deleted users + create user 500 error
1. Audit log: COALESCE target_username with detail JSON fallback so
   deleted users still show their username in the target column
   (target_user_id is SET NULL by FK cascade, but detail JSON
   preserves the username).

2. Create/get user: add exclude={"active_sessions"} to model_dump()
   calls — UserListItem defaults active_sessions=0, so model_dump()
   includes it, then the explicit active_sessions=N keyword argument
   causes a duplicate keyword TypeError. DB commit already happened,
   so the user exists but the response was a 500.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:44:10 +08:00
48e15fa677 UX polish for delete-user: username toast, hide self-delete
S-03: Delete toast now shows the deleted username from the API response
S-04: Delete button hidden for the current admin's own row (backend
still guards with 403, but no reason to show a dead button)

Adds username to auth status response so the frontend can identify
the current user.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:30:43 +08:00
e7cb6de7d5 Add admin delete-user with full cascade cleanup
Migration 036 adds ondelete rules to 5 transitive FKs that would
otherwise block user deletion (calendar_events via calendars,
project_tasks via projects, todos via projects, etc.).

DELETE /api/admin/users/{user_id} with self-action guard, last-admin
guard, session revocation, and audit logging. Frontend gets a red
two-click confirm button in the IAM actions menu.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:20:47 +08:00
c56830ddb0 Fix SyntaxError in admin.py: add default to Request params
Three functions (update_user_role, toggle_mfa_enforce, toggle_user_active)
had `request: Request` without a default after Path params with defaults,
causing Python SyntaxError on import.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 18:31:25 +08:00
1ebc41b9d7 L-03: Session 7-day sliding window with 30-day hard ceiling
Reduce session expiry from 30 days to 7 days of inactivity while
preserving a 30-day absolute token lifetime for itsdangerous:

- SESSION_MAX_AGE_DAYS=7: sliding window for DB expires_at + cookie
- SESSION_TOKEN_HARD_CEILING_DAYS=30: itsdangerous max_age (prevents
  rejecting renewed tokens whose creation timestamp exceeds 7 days)
- get_current_user: silently extends expires_at and re-issues cookie
  when >1 day has elapsed since last renewal
- Active users never notice; 7 days of inactivity forces re-login;
  30-day absolute ceiling forces re-login regardless of activity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:45:15 +08:00
8e27f2920b M-02: Timing-safe login prevents username enumeration
When a login targets a non-existent username, run Argon2id against a
pre-computed dummy hash so response time (~60ms) matches wrong-password
attempts. Also reorder the login flow to run verify_password_with_upgrade
BEFORE the lockout check, preventing timing side-channels that could
distinguish locked accounts from wrong passwords.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:44:27 +08:00
2f58282c31 M-01+M-03: Add input validation and extra=forbid to all request schemas
- Add max_length constraints to all string fields in request schemas,
  matching DB column limits (title:255, description:5000, etc.)
- Add min_length=1 to required name/title fields
- Add ConfigDict(extra="forbid") to all request schemas to reject
  unknown fields (prevents silent field injection)
- Add Path(ge=1, le=2147483647) to all integer path parameters across
  all routers to prevent integer overflow → 500 errors
- Add max_length to TOTP inline schemas (code:6, mfa_token:256, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:43:55 +08:00
581efa183a H-01: Add global CSRF middleware for all mutating endpoints
Replace the admin-only verify_xhr dependency with a pure ASGI
CSRFHeaderMiddleware that validates X-Requested-With: XMLHttpRequest
on all POST/PUT/PATCH/DELETE requests globally. Pre-auth endpoints
(login, setup, register, totp-verify, enforce-setup/confirm) are
exempt. This closes the CSRF gap where non-admin routes accepted
requests without origin validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:39:42 +08:00
9f7bbbfcbb Add per-user active session counts to IAM user list
Move active_sessions field from UserDetailResponse into UserListItem
so GET /admin/users returns session counts. Uses a correlated subquery
to count non-revoked, non-expired sessions per user.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 13:26:32 +08:00