from __future__ import annotations from dataclasses import dataclass from datetime import UTC, datetime, timedelta from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import get_settings from app.core.exceptions import AuthenticationFailedError from app.core.exceptions import AuthSessionExpiredError from app.core.exceptions import UnauthorizedError from app.core.ids import new_id from app.core.security import generate_session_token from app.core.security import hash_password from app.core.security import hash_session_token from app.core.security import verify_password from app.models.entities import AuthSession from app.models.entities import User from app.repositories.auth_session_repository import AuthSessionRepository from app.repositories.user_repository import UserRepository @dataclass class LoginResult: user: User access_token: str expires_at: datetime class AuthService: def __init__(self, session: AsyncSession) -> None: self.session = session self.user_repository = UserRepository(session) self.auth_session_repository = AuthSessionRepository(session) self.settings = get_settings() async def ensure_bootstrap_user(self) -> User | None: if not self.settings.bootstrap_username or not self.settings.bootstrap_password: return None if await self.user_repository.count_users() > 0: return await self.user_repository.get_by_username(self.settings.bootstrap_username) user = User( id=new_id("user"), username=self.settings.bootstrap_username, password_hash=hash_password(self.settings.bootstrap_password), ) return await self.user_repository.create(user) async def login(self, username: str, password: str) -> LoginResult: user = await self.user_repository.get_by_username(username) if user is None or not verify_password(password, user.password_hash): raise AuthenticationFailedError() expires_at = self._utc_now_naive() + timedelta(hours=self.settings.auth_session_ttl_hours) access_token = generate_session_token() auth_session = AuthSession( id=new_id("sess"), user_id=user.id, token_hash=hash_session_token(access_token), expires_at=expires_at, revoked_at=None, ) await self.auth_session_repository.create(auth_session) return LoginResult(user=user, access_token=access_token, expires_at=expires_at) async def get_current_user(self, bearer_token: str | None) -> User: token = self._extract_bearer_token(bearer_token) now = self._utc_now_naive() auth_session = await self.auth_session_repository.get_active_by_token_hash( hash_session_token(token), now, ) if auth_session is None: existing_session = await self.auth_session_repository.get_by_token_hash(hash_session_token(token)) if existing_session is not None and existing_session.expires_at <= now: raise AuthSessionExpiredError() raise UnauthorizedError() user = await self.user_repository.get_by_id(auth_session.user_id) if user is None: raise UnauthorizedError() return user async def logout(self, bearer_token: str | None) -> None: token = self._extract_bearer_token(bearer_token) auth_session = await self.auth_session_repository.get_by_token_hash(hash_session_token(token)) if auth_session is None or auth_session.revoked_at is not None: raise UnauthorizedError() auth_session.revoked_at = self._utc_now_naive() await self.auth_session_repository.save(auth_session) @staticmethod def _extract_bearer_token(bearer_token: str | None) -> str: if bearer_token is None: raise UnauthorizedError() scheme, _, token = bearer_token.partition(" ") if scheme.lower() != "bearer" or not token: raise UnauthorizedError() return token @staticmethod def _utc_now_naive() -> datetime: return datetime.now(UTC).replace(tzinfo=None)