Bound credentials, brake password changes, cap bug reports, page the user list, structure every error (B-94, SEC-54, SEC-55, API-09, API-15, API-16, API-19, API-21, API-28, API-33, REG-12, AUD-42)

- login and password-change fields: at most 256 characters
- wrong current password: per-user brake (5 free, 2s doubling to 300s), 429 even for the
  right password while locked; password_changed audited in the same transaction
- POST /bug-reports: 10 per IP per hour, consumed only by a stored report
- GET /admin/users: limit/after_id paging, the panel walks the pages
- 401/403/404/405/422/503 all answer {code, message, params}; the 422 omits the input
- static files revalidated on every use (Cache-Control: no-cache)
- error.* keys for the new codes in all seven languages

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-18 00:59:25 +02:00
1 parent c1b192f6d1
commit 24e5fc3d8b
23 files changed
+374 -111

No files matched your search

+16 -3
View File
@@ -1,11 +1,13 @@
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import http_error
from app.audit.log import write_audit_log
from app.api.timeutil import isoformat_utc
from app.auth.dependencies import get_current_user
from app.auth.routes import _rate_limited_error, _rate_limiters
from app.auth.security import (
MIN_PASSWORD_LENGTH,
create_access_token,
@@ -52,8 +54,8 @@ async def me(
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
current_password: str = Field(max_length=256) # B-94 (REG-12)
new_password: str = Field(max_length=256)
class ChangePasswordResponse(BaseModel):
@@ -63,13 +65,22 @@ class ChangePasswordResponse(BaseModel):
@router.post("/me/change-password", response_model=ChangePasswordResponse)
async def change_password(
body: ChangePasswordRequest,
request: Request,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> ChangePasswordResponse:
"""Self-service password change — requires the current password, unlike the
admin-only /admin/users/{id}/reset-password (which is for a user who's
actually locked out and can't provide it)."""
# B-94 (SEC-54): a stolen token must not be able to guess the current password, change it
# and lock the owner out. Checked before the Argon2 hash and even for the right password.
limiter = _rate_limiters(request).change_password
key = f"user:{user.id}"
retry_after = limiter.retry_after(key)
if retry_after > 0:
raise _rate_limited_error(retry_after)
if not await verify_password_async(body.current_password, user.password_hash):
limiter.record_failure(key)
raise http_error(
status.HTTP_401_UNAUTHORIZED, "current_password_incorrect", "current password is incorrect"
)
@@ -89,7 +100,9 @@ async def change_password(
# every other open session (this user's other devices, or an attacker's)
# gets "session_expired" on its next request.
user.token_version += 1
await write_audit_log(session, "password_changed", {"user_id": user.id}, user_id=user.id)
await session.commit()
limiter.record_success(key)
return ChangePasswordResponse(access_token=create_access_token(user.id, user.token_version))