feat(auth): add bearer current user dependency
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user