File size: 3,337 Bytes
62f41ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9687f99
 
62f41ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9687f99
62f41ca
9687f99
62f41ca
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
"""
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  # 30 days

# paths that bypass auth
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
        # always allow open paths and the index page (frontend self-renders login modal on 401)
        if path == '/' or path in OPEN_PATHS or any(path.startswith(p) for p in OPEN_PREFIXES):
            return await call_next(request)
        # allow if no password configured (dev mode)
        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:
            # dev mode — accept any password and set cookie so the frontend doesn't loop
            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})
        # SameSite=None + Secure required so the cookie survives the HF iframe (cross-site).
        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))}