| """ |
| Shared-password auth via signed cookie. |
| Set APP_PASSWORD in env. SESSION_SECRET signs the cookie; falls back to a derived value. |
| """ |
|
|
| import hashlib |
| import os |
| import secrets |
|
|
| from fastapi import FastAPI, Request, Response |
| from fastapi.responses import JSONResponse |
| from itsdangerous import BadSignature, URLSafeTimedSerializer |
| from starlette.middleware.base import BaseHTTPMiddleware |
|
|
| APP_PASSWORD = os.environ.get('APP_PASSWORD', '') |
| SESSION_SECRET = os.environ.get( |
| 'SESSION_SECRET', |
| hashlib.sha256(('racing-' + APP_PASSWORD).encode()).hexdigest() if APP_PASSWORD else 'dev-only-secret', |
| ) |
| COOKIE_NAME = 'auth' |
| COOKIE_MAX_AGE = 60 * 60 * 24 * 30 |
|
|
| |
| OPEN_PATHS = {'/api/login', '/api/logout', '/api/auth-status', |
| '/healthz', '/api/docs', '/openapi.json'} |
| OPEN_PREFIXES = ('/static/',) |
|
|
| _serializer = URLSafeTimedSerializer(SESSION_SECRET, salt='auth-cookie') |
|
|
|
|
| def _sign(): |
| return _serializer.dumps('ok') |
|
|
|
|
| def _verify(token): |
| try: |
| _serializer.loads(token, max_age=COOKIE_MAX_AGE) |
| return True |
| except (BadSignature, Exception): |
| return False |
|
|
|
|
| class AuthMiddleware(BaseHTTPMiddleware): |
| async def dispatch(self, request: Request, call_next): |
| path = request.url.path |
| |
| if path == '/' or path in OPEN_PATHS or any(path.startswith(p) for p in OPEN_PREFIXES): |
| return await call_next(request) |
| |
| if not APP_PASSWORD: |
| return await call_next(request) |
| token = request.cookies.get(COOKIE_NAME) |
| if token and _verify(token): |
| return await call_next(request) |
| return JSONResponse(status_code=401, content={'detail': 'unauthorized'}) |
|
|
|
|
| def register_auth_routes(app: FastAPI): |
| @app.post('/api/login') |
| async def login(request: Request): |
| body = await request.json() |
| password = body.get('password', '') |
| if not APP_PASSWORD: |
| |
| response = JSONResponse({'ok': True, 'mode': 'dev'}) |
| response.set_cookie(COOKIE_NAME, _sign(), max_age=COOKIE_MAX_AGE, |
| httponly=True, samesite='lax', secure=False) |
| return response |
| if not secrets.compare_digest(password, APP_PASSWORD): |
| return JSONResponse(status_code=401, content={'detail': 'wrong password'}) |
| response = JSONResponse({'ok': True}) |
| |
| response.set_cookie(COOKIE_NAME, _sign(), max_age=COOKIE_MAX_AGE, |
| httponly=True, samesite='none', secure=True) |
| return response |
|
|
| @app.post('/api/logout') |
| async def logout(response: Response): |
| response = JSONResponse({'ok': True}) |
| response.delete_cookie(COOKIE_NAME) |
| return response |
|
|
| @app.get('/api/auth-status') |
| async def auth_status(request: Request): |
| if not APP_PASSWORD: |
| return {'auth_required': False} |
| token = request.cookies.get(COOKIE_NAME) |
| return {'auth_required': True, 'authenticated': bool(token and _verify(token))} |
|
|