feat(auth): implement authentication endpoints and JWT handling

This commit is contained in:
gabdevele
2026-04-12 20:18:33 +02:00 Unverified
parent f3979c56ef
commit cc8ae34838
18 changed files with 640 additions and 33 deletions
+3
View File
@@ -0,0 +1,3 @@
from api.auth.router import router
__all__ = ["router"]
+40
View File
@@ -0,0 +1,40 @@
from datetime import datetime, timezone
from core.security import (
AuthContext,
create_access_token,
get_auth_context,
verify_password,
)
from db.auth import get_user_by_username, revoke_token
from fastapi import APIRouter, Depends, HTTPException, Response, status
from models.auth import LoginInput, TokenResponse
router = APIRouter(
prefix="/auth",
tags=["Auth"],
)
@router.post("/login", response_model=TokenResponse)
async def login_handler(payload: LoginInput):
user = await get_user_by_username(payload.username)
if user is None or not verify_password(payload.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
headers={"WWW-Authenticate": "Bearer"},
)
token = create_access_token(subject=user.username)
return TokenResponse(access_token=token)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout_handler(
auth_context: AuthContext = Depends(get_auth_context),
) -> Response:
expires_at = datetime.fromtimestamp(auth_context.payload.exp, tz=timezone.utc)
if expires_at > datetime.now(timezone.utc):
await revoke_token(jti=auth_context.payload.jti, expires_at=expires_at)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+19 -7
View File
@@ -1,17 +1,20 @@
from fastapi import APIRouter, HTTPException, status
from core.security import require_authenticated_user
from db.proxy import (
create_proxy,
delete_proxy,
get_proxy_by_port,
list_proxies as list_proxy_documents,
update_proxy,
)
from db.proxy import (
list_proxies as list_proxy_documents,
)
from fastapi import APIRouter, Depends, HTTPException, status
from models.proxy import ProxyCreateInput, ProxyUpdateInput
router = APIRouter(
prefix="/proxy",
tags=["Proxy"]
tags=["Proxy"],
dependencies=[Depends(require_authenticated_user)],
)
@@ -24,7 +27,10 @@ async def list_proxies_handler():
async def get_proxy(proxy_port: int):
proxy = await get_proxy_by_port(proxy_port)
if proxy is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Proxy not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Proxy not found",
)
return proxy
@@ -49,7 +55,10 @@ async def create_proxy_handler(payload: ProxyCreateInput):
async def edit_proxy(proxy_port: int, payload: ProxyUpdateInput):
updated_proxy = await update_proxy(proxy_port, payload)
if updated_proxy is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Proxy not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Proxy not found",
)
return updated_proxy
@@ -57,4 +66,7 @@ async def edit_proxy(proxy_port: int, payload: ProxyUpdateInput):
async def remove_proxy(proxy_port: int):
deleted = await delete_proxy(proxy_port)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Proxy not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Proxy not found",
)
+14 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+99
View File
@@ -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
+101
View File
@@ -0,0 +1,101 @@
from datetime import datetime, timezone
from typing import Any, Mapping
from core.mongodb import get_database
from models.auth import AuthUserDocument
from motor.motor_asyncio import AsyncIOMotorCollection
USERS_COLLECTION_NAME = "auth_users"
REVOKED_COLLECTION_NAME = "revoked_tokens"
def utcnow() -> datetime:
return datetime.now(timezone.utc)
async def users_collection() -> AsyncIOMotorCollection[dict[str, Any]]:
database = await get_database()
return database[USERS_COLLECTION_NAME]
async def revoked_tokens_collection() -> AsyncIOMotorCollection[dict[str, Any]]:
database = await get_database()
return database[REVOKED_COLLECTION_NAME]
def deserialize_user(raw: Mapping[str, Any] | None) -> AuthUserDocument | None:
if raw is None:
return None
document = dict(raw)
document.pop("_id", None)
return AuthUserDocument.model_validate(document)
async def ensure_auth_indexes() -> None:
users = await users_collection()
revoked = await revoked_tokens_collection()
await users.create_index("username", unique=True, name="uq_auth_username")
await revoked.create_index("jti", unique=True, name="uq_revoked_jti")
await revoked.create_index(
"expires_at",
expireAfterSeconds=0,
name="ttl_revoked_tokens",
)
async def ensure_single_account(*, username: str, password_hash: str) -> None:
users = await users_collection()
now = utcnow()
current = await users.find_one({}, sort=[("created_at", 1)])
if current is None:
await users.insert_one(
{
"username": username,
"password_hash": password_hash,
"created_at": now,
"updated_at": now,
}
)
return
current_id = current["_id"]
await users.update_one(
{"_id": current_id},
{
"$set": {
"username": username,
"password_hash": password_hash,
"updated_at": now,
}
},
)
await users.delete_many({"_id": {"$ne": current_id}})
async def get_user_by_username(username: str) -> AuthUserDocument | None:
users = await users_collection()
raw = await users.find_one({"username": username})
return deserialize_user(raw)
async def revoke_token(*, jti: str, expires_at: datetime) -> None:
revoked = await revoked_tokens_collection()
await revoked.update_one(
{"jti": jti},
{
"$set": {
"jti": jti,
"expires_at": expires_at,
"revoked_at": utcnow(),
}
},
upsert=True,
)
async def is_token_revoked(jti: str) -> bool:
revoked = await revoked_tokens_collection()
raw = await revoked.find_one({"jti": jti}, projection={"_id": 1})
return raw is not None
+10 -5
View File
@@ -1,12 +1,11 @@
from datetime import datetime, timezone
from typing import Any, Mapping
from motor.motor_asyncio import AsyncIOMotorCollection
from pymongo import ReturnDocument
from core.mongodb import get_database
from models.proxy import ProxyDocument, ProxyUpdateInput
from models.service import ServiceEndpointDocument
from motor.motor_asyncio import AsyncIOMotorCollection
from pymongo import ReturnDocument
from utils.proxy.types import ServiceConfig
COLLECTION_NAME = "proxies"
@@ -45,7 +44,10 @@ async def create_proxy(
proxy_port=proxy_port,
name=name,
code=code,
service=ServiceEndpointDocument(address=service["address"], port=service["port"]),
service=ServiceEndpointDocument(
address=service["address"],
port=service["port"],
),
created_at=now,
updated_at=now,
)
@@ -73,7 +75,10 @@ async def list_proxies() -> list[ProxyDocument]:
return proxies
async def update_proxy(proxy_port: int, updates: ProxyUpdateInput) -> ProxyDocument | None:
async def update_proxy(
proxy_port: int,
updates: ProxyUpdateInput,
) -> ProxyDocument | None:
payload = updates.model_dump(exclude_none=True)
if not payload:
return await get_proxy_by_port(proxy_port)
+12 -3
View File
@@ -1,17 +1,25 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from api.auth.router import router as auth_router
from api.proxy.router import router as proxy_router
from db.proxy import ensure_proxy_indexes
from core.config import settings
from core.mongodb import close_mongodb, init_mongodb
from core.redis import close_redis, init_redis
from core.security import hash_password
from db.auth import ensure_auth_indexes, ensure_single_account
from db.proxy import ensure_proxy_indexes
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(_: FastAPI):
await init_mongodb()
await init_redis()
await ensure_auth_indexes()
await ensure_single_account(
username=settings.AUTH_USERNAME,
password_hash=hash_password(settings.AUTH_PASSWORD),
)
await ensure_proxy_indexes()
try:
yield
@@ -24,6 +32,7 @@ app = FastAPI(
lifespan=lifespan,
)
app.include_router(auth_router)
app.include_router(proxy_router)
+43
View File
@@ -0,0 +1,43 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class AuthUserDocument(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str = Field(
...,
description="Unique username for the single account",
)
password_hash: str = Field(..., description="Hashed password")
created_at: datetime | None = Field(
default=None,
description="Creation timestamp in UTC",
)
updated_at: datetime | None = Field(
default=None,
description="Last update timestamp in UTC",
)
class LoginInput(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str = Field(..., description="Username")
password: str = Field(..., description="Plain text password")
class TokenResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
access_token: str = Field(..., description="JWT access token")
token_type: str = Field(default="bearer", description="Token type")
class AccessTokenPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
sub: str = Field(..., description="Subject username")
jti: str = Field(..., description="Unique token identifier")
exp: int = Field(..., description="Token expiration as unix timestamp")
+17 -6
View File
@@ -1,8 +1,7 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from models.service import ServiceEndpointDocument
from pydantic import BaseModel, ConfigDict, Field
class ProxyDocument(BaseModel):
@@ -11,9 +10,18 @@ class ProxyDocument(BaseModel):
proxy_port: int = Field(..., description="Port exposed by the proxy")
name: str = Field(..., description="Human readable proxy name")
code: str = Field(..., description="Proxy source or identifier")
service: ServiceEndpointDocument = Field(..., description="Associated upstream service")
created_at: datetime | None = Field(default=None, description="Creation timestamp in UTC")
updated_at: datetime | None = Field(default=None, description="Last update timestamp in UTC")
service: ServiceEndpointDocument = Field(
...,
description="Associated upstream service",
)
created_at: datetime | None = Field(
default=None,
description="Creation timestamp in UTC",
)
updated_at: datetime | None = Field(
default=None,
description="Last update timestamp in UTC",
)
class ProxyCreateInput(BaseModel):
@@ -22,7 +30,10 @@ class ProxyCreateInput(BaseModel):
proxy_port: int = Field(..., description="Port exposed by the proxy")
name: str = Field(..., description="Human readable proxy name")
code: str = Field(..., description="Proxy source or identifier")
service: ServiceEndpointDocument = Field(..., description="Associated upstream service")
service: ServiceEndpointDocument = Field(
...,
description="Associated upstream service",
)
class ProxyUpdateInput(BaseModel):