Rescue Paperjet v1 implementation

This commit is contained in:
Elijah 2026-08-14 21:05:22 -07:00
parent 7ff5c8130d
commit db9e2ca51b
83 changed files with 6186 additions and 3894 deletions

View file

@ -1,10 +1,11 @@
"""FastAPI dependencies for authentication and security."""
from fastapi import Request, HTTPException
from fastapi import HTTPException, Request
from app.auth.session import verify_session
from app.config import settings
def get_current_user(request: Request) -> int:
"""
Dependency that extracts and validates the session cookie.
@ -14,11 +15,11 @@ def get_current_user(request: Request) -> int:
token = request.cookies.get(settings.COOKIE_NAME)
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
user_id = verify_session(token)
if not user_id:
raise HTTPException(status_code=401, detail="Session expired or invalid")
return user_id
@ -27,9 +28,11 @@ def verify_csrf(request: Request) -> None:
Dependency to mitigate CSRF for cookie-based auth.
Requires X-Requested-With header on all mutating requests.
"""
if request.method in ["POST", "PUT", "PATCH", "DELETE"]:
if request.headers.get("X-Requested-With") != "XMLHttpRequest":
raise HTTPException(
status_code=403,
detail="CSRF check failed: missing X-Requested-With header"
)
if (
request.method in ["POST", "PUT", "PATCH", "DELETE"]
and request.headers.get("X-Requested-With") != "XMLHttpRequest"
):
raise HTTPException(
status_code=403,
detail="CSRF check failed: missing X-Requested-With header",
)

View file

@ -1,20 +1,22 @@
"""Argon2id password hashing and verification."""
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from argon2.exceptions import InvalidHashError, VerifyMismatchError
# Argon2id is the default for PasswordHasher
ph = PasswordHasher()
def hash_password(password: str) -> str:
"""Hash a plaintext password."""
return ph.hash(password)
def verify_password(hashed: str, password: str) -> bool:
"""Verify a password against a hash. Returns True if matched."""
try:
ph.verify(hashed, password)
# Note: We skip check_needs_rehash() for simplicity in this single-user app.
return True
except VerifyMismatchError:
except (InvalidHashError, VerifyMismatchError):
return False

View file

@ -2,6 +2,7 @@
import time
from collections import defaultdict
from fastapi import HTTPException
from app.config import settings
@ -27,7 +28,7 @@ def check_rate_limit(ip: str) -> None:
detail=f"Too many failed attempts. Try again in {remaining} seconds.",
headers={"Retry-After": str(remaining)},
)
# If lockout has expired, reset
if record["lockout_until"] > 0 and record["lockout_until"] <= now:
record["attempts"] = 0

View file

@ -1,11 +1,13 @@
"""Session token generation and validation."""
from typing import Any
from fastapi import Response
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from app.config import settings
def get_serializer() -> URLSafeTimedSerializer:
"""Return a configured URLSafeTimedSerializer."""
return URLSafeTimedSerializer(settings.SECRET_KEY)
@ -14,7 +16,7 @@ def create_session(response: Response, user_id: int = 1) -> None:
"""Create a new session token and set it as an HTTP-only cookie."""
serializer = get_serializer()
token = serializer.dumps({"user_id": user_id})
response.set_cookie(
key=settings.COOKIE_NAME,
value=token,
@ -41,7 +43,7 @@ def verify_session(token: str) -> int | None:
serializer = get_serializer()
try:
data: dict[str, Any] = serializer.loads(
token,
token,
max_age=settings.COOKIE_MAX_AGE_SECONDS
)
return data.get("user_id")