116 lines
3.1 KiB
Python
116 lines
3.1 KiB
Python
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Annotated
|
|
from uuid import uuid4
|
|
|
|
from authlib.jose import jwt # type: ignore
|
|
from authlib.jose.errors import JoseError # type: ignore
|
|
from fastapi import Depends, HTTPException, status
|
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
from pwdlib import PasswordHash
|
|
from pwdlib.hashers.argon2 import Argon2Hasher
|
|
|
|
from app.core.config import settings
|
|
from app.models.auth import AccessTokenPayload, AuthUserDocument
|
|
from app.repositories.auth import get_user_by_username, is_token_revoked
|
|
|
|
pwd_context = PasswordHash((Argon2Hasher(),))
|
|
bearer_scheme = HTTPBearer(auto_error=False)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class AuthContext:
|
|
token: str
|
|
user: AuthUserDocument
|
|
payload: AccessTokenPayload
|
|
|
|
|
|
class InvalidTokenError(Exception):
|
|
pass
|
|
|
|
|
|
def token_auth_error() -> HTTPException:
|
|
return HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Could not validate credentials",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
|
|
def hash_password(password: str) -> str:
|
|
return pwd_context.hash(password)
|
|
|
|
|
|
def verify_password(plain_password: str, password_hash: str) -> bool:
|
|
return pwd_context.verify(plain_password, password_hash)
|
|
|
|
|
|
def create_access_token(*, subject: str) -> str:
|
|
now = datetime.now(UTC)
|
|
expires_delta = timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
|
|
expires_at = int((now + expires_delta).timestamp())
|
|
|
|
header = {
|
|
"alg": settings.JWT_ALGORITHM,
|
|
"typ": "JWT",
|
|
}
|
|
|
|
payload = {
|
|
"sub": subject,
|
|
"jti": str(uuid4()),
|
|
"exp": expires_at,
|
|
}
|
|
|
|
token = jwt.encode(header, payload, settings.JWT_SECRET_KEY)
|
|
|
|
if isinstance(token, bytes):
|
|
return token.decode("utf-8")
|
|
|
|
return token
|
|
|
|
|
|
def decode_access_token(token: str) -> AccessTokenPayload:
|
|
try:
|
|
claims = jwt.decode(token, settings.JWT_SECRET_KEY)
|
|
claims.validate()
|
|
return AccessTokenPayload.model_validate(dict(claims))
|
|
except JoseError, ValueError, TypeError:
|
|
raise InvalidTokenError from None
|
|
|
|
|
|
async def authenticate_token(token: str) -> AuthContext:
|
|
try:
|
|
payload = decode_access_token(token)
|
|
except InvalidTokenError:
|
|
raise InvalidTokenError from None
|
|
|
|
if await is_token_revoked(payload.jti):
|
|
raise InvalidTokenError
|
|
|
|
user = await get_user_by_username(payload.sub)
|
|
if user is None:
|
|
raise InvalidTokenError
|
|
|
|
return AuthContext(token=token, user=user, payload=payload)
|
|
|
|
|
|
async def get_auth_context(
|
|
credentials: Annotated[
|
|
HTTPAuthorizationCredentials | None,
|
|
Depends(bearer_scheme),
|
|
],
|
|
) -> AuthContext:
|
|
if credentials is None or credentials.scheme.lower() != "bearer":
|
|
raise token_auth_error()
|
|
|
|
try:
|
|
return await authenticate_token(credentials.credentials)
|
|
except InvalidTokenError:
|
|
raise token_auth_error() from None
|
|
|
|
|
|
async def require_authenticated_user(
|
|
auth_context: Annotated[AuthContext, Depends(get_auth_context)],
|
|
) -> AuthUserDocument:
|
|
return auth_context.user
|