From b084e5b4686894b1f7ef5280f9fb640dd397fc98 Mon Sep 17 00:00:00 2001 From: Xiaoxia AI Date: Sun, 21 Jun 2026 08:35:25 +0800 Subject: [PATCH] feat(auth): add bearer current user dependency --- apps/api/app/api/routes/auth_simple.py | 25 ++++++++-- apps/api/app/auth.py | 67 ++++++++++++++++++++++++++ tests/unit/test_auth_simple.py | 44 +++++++++++++++++ 3 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 apps/api/app/auth.py diff --git a/apps/api/app/api/routes/auth_simple.py b/apps/api/app/api/routes/auth_simple.py index 41351f4bb..93d692f2b 100644 --- a/apps/api/app/api/routes/auth_simple.py +++ b/apps/api/app/api/routes/auth_simple.py @@ -5,6 +5,7 @@ The route layer is intentionally thin: repository construction lives in app.dependencies and authentication behavior lives in application use cases. """ +from app.auth import AuthenticatedUser, get_current_user from app.config import settings from app.dependencies import get_user_repository from fastapi import APIRouter, Depends, HTTPException, status @@ -49,6 +50,14 @@ class LoginResponse(BaseModel): expires_in: int +class CurrentUserResponse(BaseModel): + user_id: str + email: str + username: str + display_name: str + email_verified: bool + + @router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED) async def register( request: RegisterRequest, @@ -103,11 +112,17 @@ async def login( ) -@router.get("/me") -async def get_current_user_info(): - raise HTTPException( - status_code=status.HTTP_501_NOT_IMPLEMENTED, - detail="/auth/me requires bearer-token dependency integration", +@router.get("/me", response_model=CurrentUserResponse) +async def get_current_user_info( + authenticated_user: AuthenticatedUser = Depends(get_current_user), +): + user = authenticated_user.user + return CurrentUserResponse( + user_id=user.id, + email=user.email, + username=user.username, + display_name=user.display_name, + email_verified=user.email_verified, ) diff --git a/apps/api/app/auth.py b/apps/api/app/auth.py new file mode 100644 index 000000000..cf6fb3747 --- /dev/null +++ b/apps/api/app/auth.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import jwt +from app.config import settings +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jwt import ExpiredSignatureError, InvalidTokenError + +from packages.domain.entities import User +from packages.ports.user_repository import UserRepository + +from .dependencies import get_user_repository + +bearer_scheme = HTTPBearer(auto_error=False) + + +@dataclass(frozen=True, slots=True) +class AuthenticatedUser: + user: User + session_id: str | None = None + token_type: str | None = None + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + user_repository: UserRepository = Depends(get_user_repository), +) -> AuthenticatedUser: + if credentials is None or credentials.scheme.lower() != "bearer": + raise _unauthorized("Missing bearer token") + + payload = _decode_user_token(credentials.credentials) + user_id = payload.get("sub") + if not isinstance(user_id, str) or not user_id: + raise _unauthorized("Invalid token subject") + + user = user_repository.find_by_id(user_id) + if user is None: + raise _unauthorized("User no longer exists") + + return AuthenticatedUser( + user=user, + session_id=payload.get("sid"), + token_type=payload.get("type"), + ) + + +def _decode_user_token(token: str) -> dict: + try: + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"]) + except ExpiredSignatureError: + raise _unauthorized("Token expired") from None + except InvalidTokenError: + raise _unauthorized("Invalid token") from None + + if payload.get("type") not in {"user_auth", "access"}: + raise _unauthorized("Invalid token type") + return payload + + +def _unauthorized(detail: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=detail, + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/tests/unit/test_auth_simple.py b/tests/unit/test_auth_simple.py index dd3d47e08..f448a050e 100644 --- a/tests/unit/test_auth_simple.py +++ b/tests/unit/test_auth_simple.py @@ -1,13 +1,18 @@ +import asyncio import sys from pathlib import Path import jwt +import pytest +from fastapi import HTTPException +from fastapi.security import HTTPAuthorizationCredentials ROOT = Path(__file__).resolve().parents[2] API_ROOT = ROOT / "apps" / "api" if str(API_ROOT) not in sys.path: sys.path.insert(0, str(API_ROOT)) +from app.auth import get_current_user from app.config import settings from packages.application.auth.login_use_case import LoginRequest, LoginUseCase @@ -140,3 +145,42 @@ def test_login_use_case_rejects_wrong_password(): assert response is None assert error == "Invalid email or password" + + +def test_current_user_dependency_resolves_bearer_user_token(): + repo = InMemoryUserRepository() + repo.save( + User( + id="user-1", + email="user@example.com", + username="user", + display_name="User", + password_hash=password_hasher.hash_password("Password1"), + ) + ) + token = jwt.encode({"sub": "user-1", "sid": "session-1", "type": "user_auth"}, settings.JWT_SECRET_KEY, "HS256") + + authenticated_user = asyncio.run( + get_current_user(HTTPAuthorizationCredentials(scheme="Bearer", credentials=token), repo) + ) + + assert authenticated_user.user.id == "user-1" + assert authenticated_user.session_id == "session-1" + + +def test_current_user_dependency_rejects_missing_token(): + with pytest.raises(HTTPException) as error: + asyncio.run(get_current_user(None, InMemoryUserRepository())) + + assert error.value.status_code == 401 + + +def test_current_user_dependency_rejects_wrong_token_type(): + token = jwt.encode({"sub": "user-1", "type": "refresh"}, settings.JWT_SECRET_KEY, "HS256") + + with pytest.raises(HTTPException) as error: + asyncio.run( + get_current_user(HTTPAuthorizationCredentials(scheme="Bearer", credentials=token), InMemoryUserRepository()) + ) + + assert error.value.status_code == 401