This commit is contained in:
ponzischeme89
2026-04-18 07:23:55 +12:00
parent f210020772
commit 6d44e05de4
396 changed files with 75296 additions and 0 deletions
View File
+58
View File
@@ -0,0 +1,58 @@
"""
FastAPI dependency for extracting and validating the current authenticated user.
"""
import uuid
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.jwt import verify_access_token
from app.database import get_db
from app.models.user import User
bearer_scheme = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
"""
Extract Bearer token from Authorization header, verify it,
and return the corresponding User from the database.
Raises:
401 HTTPException if token is missing, invalid, or expired.
401 HTTPException if the user no longer exists or is inactive.
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = verify_access_token(credentials.credentials)
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
user_uuid = uuid.UUID(user_id)
except (JWTError, ValueError):
raise credentials_exception
result = await db.execute(select(User).where(User.id == user_uuid))
user = result.scalars().first()
if user is None:
raise credentials_exception
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive user account",
)
return user
+84
View File
@@ -0,0 +1,84 @@
"""
Explicit JWT creation and verification.
No ORM magic — all logic is auditable here.
"""
import hashlib
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from app.config import settings
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""
Create a signed JWT access token.
Args:
data: Payload data to encode (must include 'sub' key).
expires_delta: Token lifetime. Defaults to ACCESS_TOKEN_EXPIRE_MINUTES.
Returns:
Encoded JWT string.
"""
to_encode = data.copy()
if expires_delta is not None:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire, "iat": datetime.now(timezone.utc)})
encoded_jwt = jwt.encode(
to_encode,
settings.SECRET_KEY,
algorithm=settings.ALGORITHM,
)
return encoded_jwt
def verify_access_token(token: str) -> dict:
"""
Verify and decode a JWT access token.
Args:
token: Encoded JWT string.
Returns:
Decoded payload dict.
Raises:
JWTError: If the token is invalid or expired.
"""
payload = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
)
return payload
def hash_refresh_token(plaintext: str) -> str:
"""SHA-256 hash a refresh token for storage. Fast is fine — it's already a random secret."""
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
def create_refresh_token() -> tuple[str, str]:
"""
Generate a cryptographically secure refresh token.
Returns:
Tuple of (plaintext_token, hashed_token).
Store only the hash; send the plaintext to the client.
"""
plaintext = secrets.token_urlsafe(64)
return plaintext, hash_refresh_token(plaintext)
def get_token_expiry(days: Optional[int] = None) -> datetime:
"""Return a UTC datetime for token expiry."""
expire_days = days if days is not None else settings.REFRESH_TOKEN_EXPIRE_DAYS
return datetime.now(timezone.utc) + timedelta(days=expire_days)
+71
View File
@@ -0,0 +1,71 @@
"""
FastAPI dependency helpers for authenticated member access.
Member tokens carry role='member' in the JWT payload.
"""
import uuid
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.jwt import verify_access_token
from app.database import get_db
from app.models.member import Member
bearer_scheme = HTTPBearer()
async def _get_member_from_token(
credentials: HTTPAuthorizationCredentials,
db: AsyncSession,
) -> Member:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = verify_access_token(credentials.credentials)
if payload.get("role") != "member":
raise credentials_exception
member_id: str = payload.get("sub")
if member_id is None:
raise credentials_exception
member_uuid = uuid.UUID(member_id)
except (JWTError, ValueError):
raise credentials_exception
result = await db.execute(select(Member).where(Member.id == member_uuid))
member = result.scalars().first()
if member is None:
raise credentials_exception
if not member.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive member account",
)
return member
async def get_authenticated_member(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
) -> Member:
return await _get_member_from_token(credentials, db)
async def get_current_member(
member: Member = Depends(get_authenticated_member),
) -> Member:
if member.member_status != "active":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Member onboarding is not complete.",
)
return member
+21
View File
@@ -0,0 +1,21 @@
"""
Password hashing and verification using bcrypt directly.
"""
import bcrypt
def hash_password(password: str) -> str:
"""Hash a plaintext password using bcrypt."""
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plaintext password against a bcrypt hash."""
try:
return bcrypt.checkpw(
plain_password.encode("utf-8"),
hashed_password.encode("utf-8"),
)
except ValueError:
# bcrypt 4.x raises for oversized inputs; treat them as invalid credentials.
return False