Initial commit
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"""Authentication service.
|
||||
|
||||
Handles registration, login, token generation, and current-user lookup.
|
||||
Passwords hashed with argon2 (via argon2-cffi). Tokens are short-lived JWTs
|
||||
(access) + httpOnly refresh tokens. Refresh tokens are NOT persisted in MVP
|
||||
(stateless — revoke by letting them expire). Add a refresh_token table when
|
||||
true revocation is needed.
|
||||
|
||||
TODO (post-MVP): password reset + email verification flows are not implemented.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import jwt
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
from app.config import settings
|
||||
from app.entities.personal_data import PersonalData
|
||||
from app.entities.user import User
|
||||
from app.entities.workspace import Workspace
|
||||
from app.repositories.personal_data_repository import PersonalDataRepository
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.repositories.workspace_repository import WorkspaceRepository
|
||||
from app.utils.errors import AuthError, ConflictError, UnauthorizedError
|
||||
|
||||
_ph = PasswordHasher()
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(
|
||||
self,
|
||||
user_repo: UserRepository,
|
||||
personal_data_repo: PersonalDataRepository,
|
||||
workspace_repo: WorkspaceRepository,
|
||||
) -> None:
|
||||
self._users = user_repo
|
||||
self._personal_data = personal_data_repo
|
||||
self._workspaces = workspace_repo
|
||||
|
||||
async def register(
|
||||
self, email: str, password: str, display_name: str
|
||||
) -> tuple[User, PersonalData]:
|
||||
"""Create User + personal workspace + PersonalData. Raises ConflictError if email taken."""
|
||||
existing = await self._users.get_by_email(email)
|
||||
if existing is not None:
|
||||
raise ConflictError(f"Email already registered: {email}")
|
||||
|
||||
# Create a personal workspace for the registering user.
|
||||
workspace = Workspace(
|
||||
id=uuid4(),
|
||||
name=f"{display_name}s planering",
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
workspace = await self._workspaces.create(workspace)
|
||||
|
||||
pd = PersonalData(
|
||||
uuid=uuid4(),
|
||||
workspace_id=workspace.id,
|
||||
name=display_name,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
pd = await self._personal_data.create(pd)
|
||||
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
personal_data_uuid=pd.uuid,
|
||||
email=email,
|
||||
hashed_password=_ph.hash(password),
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
user = await self._users.create(user)
|
||||
return user, pd
|
||||
|
||||
async def login(self, email: str, password: str) -> User:
|
||||
"""Verify credentials. Raises AuthError on wrong email or password."""
|
||||
user = await self._users.get_by_email(email)
|
||||
if user is None:
|
||||
raise AuthError("Invalid email or password")
|
||||
try:
|
||||
_ph.verify(user.hashed_password, password)
|
||||
except VerifyMismatchError as exc:
|
||||
raise AuthError("Invalid email or password") from exc
|
||||
if _ph.check_needs_rehash(user.hashed_password):
|
||||
user.hashed_password = _ph.hash(password)
|
||||
await self._users.update(user.id, user)
|
||||
return user
|
||||
|
||||
def create_access_token(self, user_id: UUID) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.jwt_access_token_expire_minutes
|
||||
)
|
||||
payload = {"sub": str(user_id), "exp": expire, "type": "access"}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
def create_refresh_token(self, user_id: UUID) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=settings.jwt_refresh_token_expire_days)
|
||||
payload = {"sub": str(user_id), "exp": expire, "type": "refresh"}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
async def get_user_from_token(self, token: str) -> User:
|
||||
"""Decode a JWT access token and return the user. Raises UnauthorizedError on failure."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise UnauthorizedError("Token expired") from exc
|
||||
except jwt.InvalidTokenError as exc:
|
||||
raise UnauthorizedError("Invalid token") from exc
|
||||
|
||||
if payload.get("type") != "access":
|
||||
raise UnauthorizedError("Expected access token")
|
||||
|
||||
user_id = UUID(payload["sub"])
|
||||
user = await self._users.get_by_id(user_id)
|
||||
if user is None:
|
||||
raise UnauthorizedError("User not found")
|
||||
return user
|
||||
|
||||
async def refresh_access_token(self, refresh_token: str) -> str:
|
||||
"""Issue a new access token from a valid refresh token."""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
refresh_token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]
|
||||
)
|
||||
except jwt.InvalidTokenError as exc:
|
||||
raise UnauthorizedError("Invalid refresh token") from exc
|
||||
|
||||
if payload.get("type") != "refresh":
|
||||
raise UnauthorizedError("Expected refresh token")
|
||||
|
||||
user_id = UUID(payload["sub"])
|
||||
user = await self._users.get_by_id(user_id)
|
||||
if user is None:
|
||||
raise UnauthorizedError("User not found")
|
||||
return self.create_access_token(user.id)
|
||||
Reference in New Issue
Block a user