feat(auth): add active sessions management
Some checks failed
CI / test (push) Failing after 33s

- store refresh session metadata in redis (ip/user-agent/created_at)

- add auth APIs: list sessions, revoke one, revoke all

- add web privacy UI for active sessions
This commit is contained in:
2026-03-08 11:41:03 +03:00
parent da73b79ee7
commit e685a38be6
7 changed files with 309 additions and 11 deletions

View File

@@ -2,11 +2,19 @@ from datetime import datetime, timedelta, timezone
from uuid import uuid4
from fastapi import Depends, HTTPException, status
from fastapi import Request
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import repository as auth_repository
from app.auth.token_store import get_refresh_token_user_id, revoke_refresh_token_jti, store_refresh_token_jti
from app.auth.token_store import (
RefreshSession,
get_refresh_token_user_id,
list_refresh_sessions_for_user,
revoke_all_refresh_sessions_for_user,
revoke_refresh_token_jti,
store_refresh_token_jti,
)
from app.auth.schemas import (
LoginRequest,
RefreshTokenRequest,
@@ -111,7 +119,13 @@ async def resend_verification_email(
await db.commit()
async def login_user(db: AsyncSession, payload: LoginRequest) -> TokenResponse:
async def login_user(
db: AsyncSession,
payload: LoginRequest,
*,
ip_address: str | None = None,
user_agent: str | None = None,
) -> TokenResponse:
user = await get_user_by_email(db, payload.email)
if not user or not verify_password(payload.password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
@@ -121,14 +135,26 @@ async def login_user(db: AsyncSession, payload: LoginRequest) -> TokenResponse:
refresh_jti = str(uuid4())
refresh_token = create_refresh_token(str(user.id), jti=refresh_jti)
await store_refresh_token_jti(user_id=user.id, jti=refresh_jti, ttl_seconds=_refresh_ttl_seconds())
await store_refresh_token_jti(
user_id=user.id,
jti=refresh_jti,
ttl_seconds=_refresh_ttl_seconds(),
ip_address=ip_address,
user_agent=user_agent,
)
return TokenResponse(
access_token=create_access_token(str(user.id)),
refresh_token=refresh_token,
)
async def refresh_tokens(db: AsyncSession, payload: RefreshTokenRequest) -> TokenResponse:
async def refresh_tokens(
db: AsyncSession,
payload: RefreshTokenRequest,
*,
ip_address: str | None = None,
user_agent: str | None = None,
) -> TokenResponse:
credentials_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
@@ -155,13 +181,40 @@ async def refresh_tokens(db: AsyncSession, payload: RefreshTokenRequest) -> Toke
await revoke_refresh_token_jti(jti=refresh_jti)
new_jti = str(uuid4())
await store_refresh_token_jti(user_id=int(user_id), jti=new_jti, ttl_seconds=_refresh_ttl_seconds())
await store_refresh_token_jti(
user_id=int(user_id),
jti=new_jti,
ttl_seconds=_refresh_ttl_seconds(),
ip_address=ip_address,
user_agent=user_agent,
)
return TokenResponse(
access_token=create_access_token(str(user_id)),
refresh_token=create_refresh_token(str(user_id), jti=new_jti),
)
async def list_user_sessions(user_id: int) -> list[RefreshSession]:
return await list_refresh_sessions_for_user(user_id=user_id)
async def revoke_user_session(*, user_id: int, jti: str) -> None:
active_user_id = await get_refresh_token_user_id(jti=jti)
if active_user_id is None or active_user_id != user_id:
return
await revoke_refresh_token_jti(jti=jti)
async def revoke_all_user_sessions(*, user_id: int) -> None:
await revoke_all_refresh_sessions_for_user(user_id=user_id)
def get_request_metadata(request: Request) -> tuple[str | None, str | None]:
ip_address = request.client.host if request.client else None
user_agent = request.headers.get("user-agent")
return ip_address, user_agent
async def request_password_reset(
db: AsyncSession,
payload: RequestPasswordResetRequest,