temp commit

This commit is contained in:
Justin Visser 2026-02-26 12:54:19 +01:00
parent 8760f27b51
commit 0e9e49df16
193 changed files with 23228 additions and 935 deletions

View file

@ -0,0 +1,2 @@
"""Authentication package for scholarr."""

View file

@ -0,0 +1,27 @@
from __future__ import annotations
from functools import lru_cache
from app.auth.rate_limit import SlidingWindowRateLimiter
from app.auth.security import PasswordService
from app.auth.service import AuthService
from app.settings import settings
@lru_cache
def get_password_service() -> PasswordService:
return PasswordService()
@lru_cache
def get_auth_service() -> AuthService:
return AuthService(password_service=get_password_service())
@lru_cache
def get_login_rate_limiter() -> SlidingWindowRateLimiter:
return SlidingWindowRateLimiter(
max_attempts=settings.login_rate_limit_attempts,
window_seconds=settings.login_rate_limit_window_seconds,
)

View file

@ -0,0 +1,69 @@
from __future__ import annotations
from collections import deque
from collections.abc import Callable
from dataclasses import dataclass
from math import ceil
from threading import Lock
from time import monotonic
@dataclass(frozen=True)
class RateLimitDecision:
allowed: bool
retry_after_seconds: int = 0
class SlidingWindowRateLimiter:
def __init__(
self,
*,
max_attempts: int,
window_seconds: int,
now: Callable[[], float] = monotonic,
) -> None:
self._max_attempts = max_attempts
self._window_seconds = window_seconds
self._now = now
self._attempts: dict[str, deque[float]] = {}
self._lock = Lock()
def check(self, key: str) -> RateLimitDecision:
with self._lock:
now_value = self._now()
attempts = self._attempts.get(key)
if attempts is None:
return RateLimitDecision(allowed=True)
self._trim_expired(attempts, now_value)
if not attempts:
self._attempts.pop(key, None)
return RateLimitDecision(allowed=True)
if len(attempts) >= self._max_attempts:
retry_after = self._window_seconds - (now_value - attempts[0])
return RateLimitDecision(
allowed=False,
retry_after_seconds=max(1, ceil(retry_after)),
)
return RateLimitDecision(allowed=True)
def record_failure(self, key: str) -> None:
with self._lock:
now_value = self._now()
attempts = self._attempts.get(key)
if attempts is None:
attempts = deque()
self._attempts[key] = attempts
self._trim_expired(attempts, now_value)
attempts.append(now_value)
def reset(self, key: str) -> None:
with self._lock:
self._attempts.pop(key, None)
def clear_all(self) -> None:
with self._lock:
self._attempts.clear()
def _trim_expired(self, attempts: deque[float], now_value: float) -> None:
while attempts and now_value - attempts[0] >= self._window_seconds:
attempts.popleft()

View file

@ -0,0 +1,55 @@
from __future__ import annotations
import logging
from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.session import clear_session_user, get_session_user, set_session_user
from app.db.models import User
from app.security.csrf import CSRF_SESSION_KEY
from app.services.domains.users import application as user_service
logger = logging.getLogger(__name__)
def invalidate_session(request: Request) -> None:
clear_session_user(request)
request.session.pop(CSRF_SESSION_KEY, None)
async def get_authenticated_user(
request: Request,
db_session: AsyncSession,
) -> User | None:
session_user = get_session_user(request)
if session_user is None:
return None
user = await user_service.get_user_by_id(db_session, session_user.id)
if user is None or not user.is_active:
logger.info(
"auth.session_invalidated",
extra={
"event": "auth.session_invalidated",
"session_user_id": session_user.id,
},
)
invalidate_session(request)
return None
if user.email != session_user.email or user.is_admin != session_user.is_admin:
set_session_user(
request,
user_id=user.id,
email=user.email,
is_admin=user.is_admin,
)
return user
def login_rate_limit_key(request: Request, email: str) -> str:
client_host = request.client.host if request.client is not None else "unknown"
normalized_email = email.strip().lower()
return f"{client_host}:{normalized_email or '<empty>'}"

View file

@ -0,0 +1,19 @@
from __future__ import annotations
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
class PasswordService:
def __init__(self, hasher: PasswordHasher | None = None) -> None:
self._hasher = hasher or PasswordHasher()
def hash_password(self, password: str) -> str:
return self._hasher.hash(password)
def verify_password(self, password_hash: str, password: str) -> bool:
try:
return bool(self._hasher.verify(password_hash, password))
except (InvalidHashError, VerificationError):
return False

View file

@ -0,0 +1,38 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.security import PasswordService
from app.db.models import User
class AuthService:
def __init__(self, password_service: PasswordService) -> None:
self._password_service = password_service
async def authenticate_user(
self,
db_session: AsyncSession,
*,
email: str,
password: str,
) -> User | None:
normalized_email = email.strip().lower()
if not normalized_email or not password:
return None
result = await db_session.execute(
select(User).where(User.email == normalized_email)
)
user = result.scalar_one_or_none()
if user is None or not user.is_active:
return None
if not self._password_service.verify_password(user.password_hash, password):
return None
return user
def hash_password(self, password: str) -> str:
return self._password_service.hash_password(password)
def verify_password(self, *, password_hash: str, password: str) -> bool:
return self._password_service.verify_password(password_hash, password)

View file

@ -0,0 +1,50 @@
from __future__ import annotations
from dataclasses import dataclass
from starlette.requests import Request
SESSION_USER_ID_KEY = "auth_user_id"
SESSION_USER_EMAIL_KEY = "auth_user_email"
SESSION_USER_IS_ADMIN_KEY = "auth_user_is_admin"
@dataclass(frozen=True)
class SessionUser:
id: int
email: str
is_admin: bool
def get_session_user(request: Request) -> SessionUser | None:
user_id = request.session.get(SESSION_USER_ID_KEY)
email = request.session.get(SESSION_USER_EMAIL_KEY)
is_admin = request.session.get(SESSION_USER_IS_ADMIN_KEY)
if user_id is None or email is None or is_admin is None:
return None
try:
parsed_user_id = int(user_id)
except (TypeError, ValueError):
return None
if not isinstance(email, str):
return None
return SessionUser(id=parsed_user_id, email=email, is_admin=bool(is_admin))
def set_session_user(
request: Request,
*,
user_id: int,
email: str,
is_admin: bool,
) -> None:
request.session[SESSION_USER_ID_KEY] = int(user_id)
request.session[SESSION_USER_EMAIL_KEY] = email
request.session[SESSION_USER_IS_ADMIN_KEY] = bool(is_admin)
def clear_session_user(request: Request) -> None:
request.session.pop(SESSION_USER_ID_KEY, None)
request.session.pop(SESSION_USER_EMAIL_KEY, None)
request.session.pop(SESSION_USER_IS_ADMIN_KEY, None)