Files
Messenger/app/auth/service.py
benya 85631b566a
All checks were successful
CI / test (push) Successful in 9m2s
Implement security hardening, notification pipeline, and CI test suite
Security hardening:

- Added IP/user rate limiting with Redis-backed counters and fail-open behavior.

- Added message anti-spam controls (per-chat rate + duplicate cooldown).

- Implemented refresh token rotation with JTI tracking and revoke support.

Notification pipeline:

- Added Celery app and async notification tasks for mention/offline delivery.

- Added Redis-based presence tracking and integrated it into realtime connect/disconnect.

- Added notification dispatch from message flow and notifications listing endpoint.

Quality gates and CI:

- Added pytest async integration tests for auth and chat/message lifecycle.

- Added pytest config, test fixtures, and GitHub Actions CI workflow.

- Fixed bcrypt/passlib compatibility by pinning bcrypt version.

- Documented worker and quality-gate commands in README.
2026-03-07 21:46:30 +03:00

240 lines
8.8 KiB
Python

from datetime import datetime, timedelta, timezone
from uuid import uuid4
from fastapi import Depends, HTTPException, status
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.schemas import (
LoginRequest,
RefreshTokenRequest,
RegisterRequest,
RequestPasswordResetRequest,
ResendVerificationRequest,
ResetPasswordRequest,
TokenResponse,
VerifyEmailRequest,
)
from app.config.settings import settings
from app.database.session import get_db
from app.email.service import EmailService, get_email_service
from app.users.models import User
from app.users.repository import create_user, get_user_by_email, get_user_by_id, get_user_by_username
from app.utils.security import (
create_access_token,
create_refresh_token,
decode_token,
generate_random_token,
hash_password,
verify_password,
)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.api_v1_prefix}/auth/login")
def _refresh_ttl_seconds() -> int:
return settings.refresh_token_expire_days * 24 * 60 * 60
async def register_user(
db: AsyncSession,
payload: RegisterRequest,
email_service: EmailService,
) -> None:
existing_email = await get_user_by_email(db, payload.email)
if existing_email:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email is already registered")
existing_username = await get_user_by_username(db, payload.username)
if existing_username:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username is already taken")
user = await create_user(
db,
email=payload.email,
username=payload.username,
password_hash=hash_password(payload.password),
)
verification_token = generate_random_token()
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.email_verification_token_expire_hours)
await auth_repository.delete_email_verification_tokens_for_user(db, user.id)
await auth_repository.create_email_verification_token(db, user.id, verification_token, expires_at)
await db.commit()
await email_service.send_verification_email(payload.email, verification_token)
async def verify_email(db: AsyncSession, payload: VerifyEmailRequest) -> None:
record = await auth_repository.get_email_verification_token(db, payload.token)
if not record:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification token")
now = datetime.now(timezone.utc)
expires_at = record.expires_at if record.expires_at.tzinfo else record.expires_at.replace(tzinfo=timezone.utc)
if expires_at < now:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification token expired")
user = await get_user_by_id(db, record.user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user.email_verified = True
await auth_repository.delete_email_verification_tokens_for_user(db, user.id)
await db.commit()
async def resend_verification_email(
db: AsyncSession,
payload: ResendVerificationRequest,
email_service: EmailService,
) -> None:
user = await get_user_by_email(db, payload.email)
if not user or user.email_verified:
return
verification_token = generate_random_token()
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.email_verification_token_expire_hours)
await auth_repository.delete_email_verification_tokens_for_user(db, user.id)
await auth_repository.create_email_verification_token(db, user.id, verification_token, expires_at)
await db.commit()
await email_service.send_verification_email(user.email, verification_token)
async def login_user(db: AsyncSession, payload: LoginRequest) -> 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")
if not user.email_verified:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Email not verified")
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())
return TokenResponse(
access_token=create_access_token(str(user.id)),
refresh_token=refresh_token,
)
async def refresh_tokens(db: AsyncSession, payload: RefreshTokenRequest) -> TokenResponse:
credentials_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
try:
token_payload = decode_token(payload.refresh_token)
except ValueError as exc:
raise credentials_error from exc
if token_payload.get("type") != "refresh":
raise credentials_error
user_id = token_payload.get("sub")
refresh_jti = token_payload.get("jti")
if not user_id or not str(user_id).isdigit() or not refresh_jti:
raise credentials_error
active_user_id = await get_refresh_token_user_id(jti=refresh_jti)
if active_user_id is None or active_user_id != int(user_id):
raise credentials_error
user = await get_user_by_id(db, int(user_id))
if not user:
raise credentials_error
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())
return TokenResponse(
access_token=create_access_token(str(user_id)),
refresh_token=create_refresh_token(str(user_id), jti=new_jti),
)
async def request_password_reset(
db: AsyncSession,
payload: RequestPasswordResetRequest,
email_service: EmailService,
) -> None:
user = await get_user_by_email(db, payload.email)
if not user:
return
reset_token = generate_random_token()
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.password_reset_token_expire_hours)
await auth_repository.delete_password_reset_tokens_for_user(db, user.id)
await auth_repository.create_password_reset_token(db, user.id, reset_token, expires_at)
await db.commit()
await email_service.send_password_reset_email(user.email, reset_token)
async def reset_password(db: AsyncSession, payload: ResetPasswordRequest) -> None:
record = await auth_repository.get_password_reset_token(db, payload.token)
if not record:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid reset token")
now = datetime.now(timezone.utc)
expires_at = record.expires_at if record.expires_at.tzinfo else record.expires_at.replace(tzinfo=timezone.utc)
if expires_at < now:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token expired")
user = await get_user_by_id(db, record.user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user.password_hash = hash_password(payload.new_password)
await auth_repository.delete_password_reset_tokens_for_user(db, user.id)
await db.commit()
async def get_current_user(token: str = Depends(oauth2_scheme), db: AsyncSession = Depends(get_db)) -> User:
credentials_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(token)
except ValueError as exc:
raise credentials_error from exc
if payload.get("type") != "access":
raise credentials_error
user_id = payload.get("sub")
if not user_id or not str(user_id).isdigit():
raise credentials_error
user = await get_user_by_id(db, int(user_id))
if not user:
raise credentials_error
return user
async def get_current_user_for_ws(token: str, db: AsyncSession) -> User:
try:
payload = decode_token(token)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
if payload.get("type") != "access":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
user_id = payload.get("sub")
if not user_id or not str(user_id).isdigit():
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
user = await get_user_by_id(db, int(user_id))
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
def get_email_sender() -> EmailService:
return get_email_service()