Invalidate existing sessions on password change/reset (B-34)
Neither self-service password change nor the admin reset invalidated already-issued JWTs — a 24h-lifetime token stayed valid regardless, so a stolen token (or an attacker who already had the old password) kept working past a password change meant to lock them out. The admin reset exists precisely for the "account compromised" case and didn't evict the attacker at all. Add User.token_version (migration 943dbd74d983), embedded in every JWT as a "tv" claim and checked against the DB on every request in get_current_user/get_optional_user; a mismatch reads as session_expired. Both change-password and the admin reset bump it. change-password hands back a freshly minted token so the caller's own session keeps working instead of being logged out by its own request; the admin reset does not, since that session isn't the one making the call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -215,6 +215,11 @@ async def reset_user_password(
|
||||
|
||||
new_password = secrets.token_urlsafe(12)
|
||||
user.password_hash = hash_password(new_password)
|
||||
# B-34: this endpoint exists precisely for the "account compromised" case —
|
||||
# without bumping token_version, whoever was already logged in (the
|
||||
# attacker, if that's who prompted the reset) stayed logged in on their
|
||||
# existing token until it naturally expired, unaffected by the reset.
|
||||
user.token_version += 1
|
||||
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)
|
||||
|
||||
+15
-3
@@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import http_error
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.auth.security import MIN_PASSWORD_LENGTH, hash_password, verify_password
|
||||
from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password
|
||||
from app.db.models import Round, RoundParticipant, User
|
||||
from app.db.session import get_session
|
||||
from app.wallet.balance import compute_pending_balance
|
||||
@@ -45,12 +45,16 @@ class ChangePasswordRequest(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.post("/me/change-password", status_code=status.HTTP_204_NO_CONTENT)
|
||||
class ChangePasswordResponse(BaseModel):
|
||||
access_token: str
|
||||
|
||||
|
||||
@router.post("/me/change-password", response_model=ChangePasswordResponse)
|
||||
async def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
) -> 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)."""
|
||||
@@ -67,7 +71,15 @@ async def change_password(
|
||||
)
|
||||
|
||||
user.password_hash = hash_password(body.new_password)
|
||||
# B-34: bumping token_version invalidates every token issued before this
|
||||
# point — including this very request's own bearer token, and any an
|
||||
# attacker who knew the old password might be holding. A fresh token is
|
||||
# handed back so *this* session keeps working without forcing a re-login;
|
||||
# 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 session.commit()
|
||||
return ChangePasswordResponse(access_token=create_access_token(user.id, user.token_version))
|
||||
|
||||
|
||||
class LastRoundResultResponse(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user