First product
This commit is contained in:
parent
778da9e2fc
commit
4433d7d2c4
157 changed files with 23975 additions and 0 deletions
2
app/auth/__init__.py
Normal file
2
app/auth/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
"""Authentication package for scholarr."""
|
||||
|
||||
27
app/auth/deps.py
Normal file
27
app/auth/deps.py
Normal 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,
|
||||
)
|
||||
|
||||
62
app/auth/rate_limit.py
Normal file
62
app/auth/rate_limit.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict, 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]] = defaultdict(deque)
|
||||
self._lock = Lock()
|
||||
|
||||
def check(self, key: str) -> RateLimitDecision:
|
||||
with self._lock:
|
||||
now_value = self._now()
|
||||
attempts = self._attempts[key]
|
||||
self._trim_expired(attempts, now_value)
|
||||
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[key]
|
||||
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()
|
||||
|
||||
19
app/auth/security.py
Normal file
19
app/auth/security.py
Normal 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
|
||||
|
||||
38
app/auth/service.py
Normal file
38
app/auth/service.py
Normal 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)
|
||||
50
app/auth/session.py
Normal file
50
app/auth/session.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue