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
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)
|
|
|