diff --git a/src/contract_check/api/routes/auth/password.py b/src/contract_check/api/routes/auth/password.py index ffe26eb..1ac4c27 100644 --- a/src/contract_check/api/routes/auth/password.py +++ b/src/contract_check/api/routes/auth/password.py @@ -8,10 +8,12 @@ from fastapi import APIRouter, HTTPException, status from src.contract_check.api.deps import ( AsyncSessionDep, + CurrentUserDep, NotificationPublisherDep, RefreshStoreDep, create_email_user, fetch_user_by_email, + fetch_user_by_id_full, ) from src.contract_check.api.routes.auth.support import ( _build_reset_link, @@ -20,6 +22,7 @@ from src.contract_check.api.routes.auth.support import ( _require_web_auth_enabled, ) from src.contract_check.api.schemas import ( + ChangePasswordRequest, ForgotPasswordRequest, LoginRequest, LogoutRequest, @@ -225,3 +228,41 @@ async def reset_password( log.info("user_password_reset", user_id=str(user_id)) return OkResponse(ok=True, detail="password updated") + + +@router.post( + "/change-password", + response_model=OkResponse, + status_code=status.HTTP_200_OK, +) +async def change_password( + session: AsyncSessionDep, + refresh_store: RefreshStoreDep, + user: CurrentUserDep, + body: ChangePasswordRequest, +) -> OkResponse: + """Rotate the password of the authenticated user. + + Requires the current password. On success all active refresh tokens are + revoked, forcing re-login on other devices. + """ + _require_web_auth_enabled() + + db_user = await fetch_user_by_id_full(session, user.user_id) + if db_user is None or not db_user.password_hash: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="no password set") + if not verify_password(body.current_password, db_user.password_hash): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials") + + await UserRepository(session).set_password(user.user_id, hash_password(body.new_password)) + await session.commit() + + # Best-effort revoke of existing sessions; ignore Redis hiccups so the + # password itself is still rotated. + try: + await refresh_store.revoke_all(user.user_id) + except Exception as exc: # noqa: BLE001 + log.warning("change_password_revoke_failed", user_id=str(user.user_id), error=str(exc)) + + log.info("user_password_changed", user_id=str(user.user_id)) + return OkResponse(ok=True, detail="password updated") diff --git a/src/contract_check/api/schemas/__init__.py b/src/contract_check/api/schemas/__init__.py index 7527bd1..17130ac 100644 --- a/src/contract_check/api/schemas/__init__.py +++ b/src/contract_check/api/schemas/__init__.py @@ -15,6 +15,7 @@ from src.contract_check.api.schemas.analysis_result import ( ) from src.contract_check.api.schemas.auth import ( AuthResponse, + ChangePasswordRequest, ForgotPasswordRequest, LoginRequest, LogoutRequest, @@ -106,6 +107,7 @@ __all__ = [ "LogoutRequest", "ForgotPasswordRequest", "ResetPasswordRequest", + "ChangePasswordRequest", "OkResponse", "PasskeyRegistrationOptions", "PasskeyAuthenticationOptions", diff --git a/src/contract_check/api/schemas/auth.py b/src/contract_check/api/schemas/auth.py index 662a5cd..6581f2c 100644 --- a/src/contract_check/api/schemas/auth.py +++ b/src/contract_check/api/schemas/auth.py @@ -105,6 +105,11 @@ class ResetPasswordRequest(BaseModel): password: str = Field(..., min_length=8, max_length=128) +class ChangePasswordRequest(BaseModel): + current_password: str = Field(..., min_length=1, max_length=128) + new_password: str = Field(..., min_length=8, max_length=128) + + class OkResponse(BaseModel): """Generic `{ok: true, ...}` payload for state-mutating auth endpoints."""