Harden session handling, add password reset/change, and firm up round polling
Session hardening: / and /admin now respond with Cache-Control: no-store, and
both pages re-derive their auth state on pageshow (event.persisted) as a
safety net against bfcache showing a stale logged-in/out view across
back/forward navigation. The user page also syncs logout across tabs via the
storage event, since localStorage is shared but in-memory JS state isn't.
Password recovery: admin gets a "Reset" button per user (POST
/admin/users/{id}/reset-password) that generates and sets a new password,
shown once — passwords are Argon2-hashed and can never be recovered, only
replaced. Users get self-service password change (POST
/users/me/change-password, requires the current password) under a new
Profilo tab, alongside read-only account info (username, address, balance,
join date).
Round display robustness: the user dashboard now refreshes immediately on
tab visibility change (background tabs get their timers throttled hard),
shows an explicit "connessione persa" state after repeated failed polls
instead of silently freezing on stale data, and polls faster both right when
the countdown hits zero and through the gap where the round is past its
deadline but still waiting for in-flight bets to confirm before the server
actually closes it.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
@@ -6,6 +7,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.audit.log import write_audit_log
|
||||
from app.auth.security import hash_password
|
||||
from app.config import settings
|
||||
from app.db.models import AuditLog, PendingTransaction, Round, User
|
||||
from app.db.session import get_session
|
||||
@@ -147,6 +149,35 @@ async def user_privkey(user_id: int, session: AsyncSession = Depends(get_session
|
||||
return AdminPrivkeyResponse(address=user.address, wif=wif)
|
||||
|
||||
|
||||
class AdminPasswordResetResponse(BaseModel):
|
||||
username: str
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.post(
|
||||
"/users/{user_id}/reset-password",
|
||||
response_model=AdminPasswordResetResponse,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def reset_user_password(
|
||||
user_id: int, session: AsyncSession = Depends(get_session)
|
||||
) -> AdminPasswordResetResponse:
|
||||
"""Admin-only password reset for a user who's locked out: passwords are
|
||||
Argon2-hashed (one-way), so an existing password can never be recovered or
|
||||
displayed — this generates and sets a brand new one instead, shown once so
|
||||
the admin can relay it to the user. There is no user-facing self-service
|
||||
reset; only an admin (via /admin, token-gated) can trigger this."""
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
|
||||
|
||||
new_password = secrets.token_urlsafe(12)
|
||||
user.password_hash = hash_password(new_password)
|
||||
await write_audit_log(session, "admin_password_reset", {"user_id": user_id}, user_id=user_id)
|
||||
await session.commit()
|
||||
return AdminPasswordResetResponse(username=user.username, new_password=new_password)
|
||||
|
||||
|
||||
class AdminRoundResponse(BaseModel):
|
||||
id: int
|
||||
status: str
|
||||
|
||||
+35
-2
@@ -1,21 +1,54 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.auth.security import hash_password, verify_password
|
||||
from app.db.models import User
|
||||
from app.db.session import get_session
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
_MIN_PASSWORD_LENGTH = 8
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
address: str
|
||||
balance_sats: int
|
||||
created_at: str
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
async def me(user: User = Depends(get_current_user)) -> MeResponse:
|
||||
return MeResponse(
|
||||
id=user.id, username=user.username, address=user.address, balance_sats=user.cached_balance_sats
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
address=user.address,
|
||||
balance_sats=user.cached_balance_sats,
|
||||
created_at=user.created_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.post("/me/change-password", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""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)."""
|
||||
if not verify_password(body.current_password, user.password_hash):
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "current password is incorrect")
|
||||
if len(body.new_password) < _MIN_PASSWORD_LENGTH:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"new password must be at least {_MIN_PASSWORD_LENGTH} characters")
|
||||
|
||||
user.password_hash = hash_password(body.new_password)
|
||||
await session.commit()
|
||||
|
||||
Reference in New Issue
Block a user