You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

106 lines
4.1 KiB

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)