feat(auth): implement authentication endpoints and JWT handling
This commit is contained in:
+14
-2
@@ -1,11 +1,23 @@
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore")
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
MONGODB_URI: str
|
||||
MONGODB_DB_NAME: str
|
||||
REDIS_URL: str
|
||||
JWT_SECRET_KEY: str
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_EXPIRE_MINUTES: int = Field(default=30, gt=0)
|
||||
AUTH_USERNAME: str
|
||||
AUTH_PASSWORD: str
|
||||
|
||||
|
||||
settings = Settings()
|
||||
+2
-2
@@ -1,6 +1,5 @@
|
||||
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
||||
|
||||
from core.config import settings
|
||||
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
||||
|
||||
|
||||
class _MongoDB:
|
||||
@@ -25,6 +24,7 @@ async def close_mongodb() -> None:
|
||||
if client is not None:
|
||||
client.close()
|
||||
|
||||
|
||||
async def get_database() -> AsyncIOMotorDatabase:
|
||||
return mongodb.get_database()
|
||||
|
||||
|
||||
+1
-1
@@ -1,9 +1,9 @@
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from core.config import settings
|
||||
|
||||
_redis_pool: aioredis.Redis | None = None
|
||||
|
||||
|
||||
async def init_redis() -> None:
|
||||
global _redis_pool
|
||||
_redis_pool = aioredis.from_url(
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from authlib.jose import jwt # type: ignore
|
||||
from authlib.jose.errors import JoseError # type: ignore
|
||||
from core.config import settings
|
||||
from db.auth import get_user_by_username, is_token_revoked
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from models.auth import AccessTokenPayload, AuthUserDocument
|
||||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
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(timezone.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 get_auth_context(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
) -> AuthContext:
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
raise token_auth_error()
|
||||
|
||||
token = credentials.credentials
|
||||
try:
|
||||
payload = decode_access_token(token)
|
||||
except InvalidTokenError:
|
||||
raise token_auth_error() from None
|
||||
|
||||
if await is_token_revoked(payload.jti):
|
||||
raise token_auth_error()
|
||||
|
||||
user = await get_user_by_username(payload.sub)
|
||||
if user is None:
|
||||
raise token_auth_error()
|
||||
|
||||
return AuthContext(token=token, user=user, payload=payload)
|
||||
|
||||
|
||||
async def require_authenticated_user(
|
||||
auth_context: AuthContext = Depends(get_auth_context),
|
||||
) -> AuthUserDocument:
|
||||
return auth_context.user
|
||||
Reference in New Issue
Block a user