diff options
| -rw-r--r-- | amt-jb.conf | 4 | ||||
| -rw-r--r-- | jb-ui/src/JBApp.tsx | 4 | ||||
| -rw-r--r-- | jb-ui/src/pages/MagicLink.tsx | 44 | ||||
| -rw-r--r-- | jb/api/__init__.py | 0 | ||||
| -rw-r--r-- | jb/api/auth.py | 92 | ||||
| -rw-r--r-- | jb/api/magic_token.py | 41 | ||||
| -rw-r--r-- | jb/main.py | 2 | ||||
| -rw-r--r-- | jb/models/auth.py | 65 | ||||
| -rw-r--r-- | jb/settings.py | 7 | ||||
| -rw-r--r-- | jb/views/auth.py | 77 | ||||
| -rw-r--r-- | requirements.txt | 1 |
11 files changed, 334 insertions, 3 deletions
diff --git a/amt-jb.conf b/amt-jb.conf index c6edf5f..8bd87c6 100644 --- a/amt-jb.conf +++ b/amt-jb.conf @@ -9,4 +9,6 @@ autorestart=true numprocs=2 numprocs_start=1 process_name=uvicorn-%(process_num)d -environment=LD_LIBRARY_PATH="/usr/local/lib:"
\ No newline at end of file +environment=LD_LIBRARY_PATH="/usr/local/lib:" +stopasgroup=true +killasgroup=true
\ No newline at end of file diff --git a/jb-ui/src/JBApp.tsx b/jb-ui/src/JBApp.tsx index 76e3f29..ef23b3b 100644 --- a/jb-ui/src/JBApp.tsx +++ b/jb-ui/src/JBApp.tsx @@ -19,6 +19,7 @@ import { setAssignmentID, setProductUserID, setTurkSubmitTo } from "@/models/app import { addEvent } from "@/models/grlEventsSlice"; import { addStatsData } from "@/models/grlStatsSlice"; import Home from "@/pages/Home"; +import MagicLink from "@/pages/MagicLink"; import Preview from "@/pages/Preview"; import Result from "@/pages/Result"; import Work from "@/pages/Work"; @@ -193,6 +194,7 @@ function JBApp() { return ( <BrowserRouter basename={routeBasename}> <Routes> + <Route path="/auth/magic-link/" element={<MagicLink />} /> <Route element={<QueryParamProcessor />}> <Route element={<Layout />}> <Route path="/" element={<Home />} /> @@ -206,4 +208,4 @@ function JBApp() { ) } -export default JBApp;
\ No newline at end of file +export default JBApp; diff --git a/jb-ui/src/pages/MagicLink.tsx b/jb-ui/src/pages/MagicLink.tsx new file mode 100644 index 0000000..cf7f87b --- /dev/null +++ b/jb-ui/src/pages/MagicLink.tsx @@ -0,0 +1,44 @@ +import { useEffect, useRef, useState } from "react"; + +type ExchangeState = "working" | "failed" | "missing"; + +const MagicLink = function () { + const started = useRef(false); + const [state, setState] = useState<ExchangeState>("working"); + + useEffect(() => { + // React Strict Mode runs effects twice in development. Never redeem twice. + if (started.current) return; + started.current = true; + + const params = new URLSearchParams(window.location.search); + const token = params.get("token"); + if (!token) { + setState("missing"); + return; + } + + // Keep the credential out of browser history and subsequent Referer headers. + window.history.replaceState({}, "", window.location.pathname); + + void fetch("/auth/magic-link/exchange", { + method: "POST", + credentials: "include", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({token}), + }).then((response) => { + if (!response.ok) throw new Error("Magic-link exchange failed"); + window.location.replace("/"); + }).catch(() => setState("failed")); + }, []); + + if (state === "missing") { + return <p>This login link is missing its token.</p>; + } + if (state === "failed") { + return <p>This login link is invalid or has expired.</p>; + } + return <p>Signing you in…</p>; +}; + +export default MagicLink; diff --git a/jb/api/__init__.py b/jb/api/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jb/api/__init__.py diff --git a/jb/api/auth.py b/jb/api/auth.py new file mode 100644 index 0000000..7d6c352 --- /dev/null +++ b/jb/api/auth.py @@ -0,0 +1,92 @@ +import logging +from datetime import datetime, timedelta, timezone +from typing import Annotated +from uuid import uuid4 + +import jwt +from fastapi import Depends, HTTPException, Request, Response, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from jb.config import settings +from jb.models.auth import AuthenticatedUser + +bearer = HTTPBearer(auto_error=False) +logger = logging.getLogger(__name__) + +AUTH_CACHE_TTL_SECONDS = 60 + +SESSION_COOKIE_NAME = "jb_session" +JWT_ISSUER = "jamesbillings67" +JWT_AUDIENCE = "jamesbillings67" + + +def get_authenticated_user( + request: Request, + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)], +) -> AuthenticatedUser: + """FastAPI dependency for endpoints requiring a valid session.""" + if settings.session_jwt_secret is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Account session signing key is not configured", + ) + + token = request.cookies.get(SESSION_COOKIE_NAME) + if credentials is not None and credentials.scheme.lower() == "bearer": + token = credentials.credentials + + if token is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing session token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + claims = jwt.decode( + token, + settings.session_jwt_secret.get_secret_value(), + algorithms=["HS256"], + issuer=JWT_ISSUER, + audience=JWT_AUDIENCE, + options={"require": ["sub", "iat", "exp", "jti", "iss", "aud", "type"]}, + ) + except jwt.PyJWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired session token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if claims["type"] != "session" or not isinstance(claims["sub"], str): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired session token", + headers={"WWW-Authenticate": "Bearer"}, + ) + product_user_id = claims.get("sub") + + # todo: in here, hit THL by the user's bpuid (email hash), + # in order to 1) be sure user exists & 2) pull the display_name + user_email = ... # lookup in thl by product_user_id + + return AuthenticatedUser(email=user_email) + + +def create_session(product_user_id: str) -> str: + now = datetime.now(timezone.utc) + return jwt.encode( + { + "sub": product_user_id, + "iat": now, + "exp": now + timedelta(seconds=settings.session_token_ttl_seconds), + "jti": uuid4().hex, + "iss": JWT_ISSUER, + "aud": JWT_AUDIENCE, + "type": "session", + }, + settings.session_jwt_secret.get_secret_value(), + algorithm="HS256", + ) + + diff --git a/jb/api/magic_token.py b/jb/api/magic_token.py new file mode 100644 index 0000000..136e1b4 --- /dev/null +++ b/jb/api/magic_token.py @@ -0,0 +1,41 @@ +import hashlib +import secrets + +from fastapi import HTTPException, status + +from jb.decorators import REDIS + +MAGIC_TOKEN_PREFIX = "auth:magic:" +MAGIC_TOKEN_TTL: int = 5 * 60 # 5 minutes, in seconds + + +def redis_token_key(token: str) -> str: + # Redis never contains a usable credential, even if its keys are exposed. + digest = hashlib.sha256(token.encode("utf-8")).hexdigest() + return f"{MAGIC_TOKEN_PREFIX}{digest}" + + +def create_magic_token(user_email: str) -> str: + """Create a short-lived, single-use token for a user. + The raw token can then be sent by email. + """ + if not user_email or not user_email.strip(): + raise ValueError("user_email must not be empty") + + token = secrets.token_urlsafe(32) + REDIS.set( + redis_token_key(token), + user_email, + ex=MAGIC_TOKEN_TTL, + ) + return token + + +def consume_magic_token(token: str) -> str: + user_email = REDIS.getdel(redis_token_key(token)) + if user_email is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired magic token", + ) + return user_email @@ -7,6 +7,7 @@ from starlette.middleware.cors import CORSMiddleware from starlette.middleware.trustedhost import TrustedHostMiddleware from jb.views.common import common_router +from jb.views.auth import auth_router from jb.settings import BASE_HTML from jb.config import settings @@ -31,6 +32,7 @@ app.add_middleware( app.add_middleware(TrustedHostMiddleware, allowed_hosts=["*"]) app.include_router(router=common_router) +app.include_router(router=auth_router) @app.get("/robots.txt") diff --git a/jb/models/auth.py b/jb/models/auth.py new file mode 100644 index 0000000..5f31bd3 --- /dev/null +++ b/jb/models/auth.py @@ -0,0 +1,65 @@ +import hashlib +import hmac + +from pydantic import ( + BaseModel, + ConfigDict, + EmailStr, + Field, + TypeAdapter, + computed_field, +) + +from jb.config import settings + + +def email_to_product_user_id(email: str) -> str: + """Return a deterministic, non-reversible product user ID for an email. + + The same normalized email and secret salt always produce the same ID. Keep + the salt private and stable; changing it changes every generated ID. + """ + salt_bytes = settings.magic_token_salt.get_secret_value().encode("utf-8") + if len(salt_bytes) < 32: + raise ValueError("salt must be at least 32 bytes") + + if not email.isascii(): + raise ValueError("email must contain ASCII characters only") + + normalized_email = str(TypeAdapter(EmailStr).validate_python(email)).lower() + return hmac.new( + key=salt_bytes, + msg=normalized_email.encode("utf-8"), + digestmod=hashlib.sha256, + ).hexdigest() + + +class AuthenticatedUser(BaseModel): + """A user that has been authenticated and exists in THL""" + + email: EmailStr = Field() + + @computed_field + def product_user_id(self) -> str: + return email_to_product_user_id(self.email) + + +class AccountCreate(BaseModel): + email: EmailStr = Field() + + +class AccountLogin(BaseModel): + email: EmailStr = Field() + + + +class MagicLinkExchangeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + token: str = Field(min_length=1) + + +class SessionResponse(BaseModel): + session_token: str + token_type: str = "bearer" + expires_in: int diff --git a/jb/settings.py b/jb/settings.py index d529591..cc1e870 100644 --- a/jb/settings.py +++ b/jb/settings.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Optional from generalresearchutils.models.custom_types import InfluxDsn -from pydantic import Field, PostgresDsn, HttpUrl, RedisDsn +from pydantic import Field, PostgresDsn, HttpUrl, RedisDsn, SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict from jb.models.custom_types import UUIDStr @@ -55,6 +55,11 @@ class Settings(AmtJbBaseSettings): sns_path: str = Field() + session_token_ttl_seconds: int = Field(default=30 * 24 * 60 * 60, gt=0) + session_jwt_secret: SecretStr = Field(min_length=32) + + magic_token_salt: SecretStr = Field(min_length=32) + class TestSettings(Settings): model_config = SettingsConfigDict( diff --git a/jb/views/auth.py b/jb/views/auth.py new file mode 100644 index 0000000..0591294 --- /dev/null +++ b/jb/views/auth.py @@ -0,0 +1,77 @@ +"""Redis-backed magic-link authentication. + +The user service is deliberately not coupled to this module. Once that service +has resolved an email address to its stable user identifier, call +``create_magic_token`` and put the returned token in the emailed login URL. +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Response, status +from fastapi.responses import HTMLResponse + +from jb.api.auth import ( + SESSION_COOKIE_NAME, + create_session, + get_authenticated_user, +) +from jb.api.magic_token import consume_magic_token +from jb.config import settings +from jb.models.auth import ( + MagicLinkExchangeRequest, + AuthenticatedUser, + email_to_product_user_id, +) +from jb.settings import BASE_HTML + +auth_router = APIRouter(prefix="/auth", tags=["Auth"]) + + +@auth_router.get("/magic-link/", response_class=HTMLResponse, include_in_schema=False) +def magic_link_landing_page() -> HTMLResponse: + """Serve the SPA without redeeming the token; email prefetches are harmless.""" + return HTMLResponse( + BASE_HTML, + headers={ + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "X-Robots-Tag": "noindex, nofollow", + }, + ) + + +@auth_router.post("/magic-link/exchange", status_code=status.HTTP_204_NO_CONTENT) +def exchange_magic_link(body: MagicLinkExchangeRequest, response: Response) -> None: + """Exchange a magic link only after its landing page makes an explicit POST.""" + user_email = consume_magic_token(body.token) + product_user_id = email_to_product_user_id(user_email) + + # todo: hit thl to make sure this user exists + + session_token = create_session(product_user_id) + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=session_token, + max_age=settings.session_token_ttl_seconds, + httponly=True, + secure=not settings.debug, + samesite="lax", + path="/", + ) + + +@auth_router.get("/session", response_model=AuthenticatedUser) +def get_session( + user: Annotated[AuthenticatedUser, Depends(get_authenticated_user)], +) -> AuthenticatedUser: + return user + + +@auth_router.delete("/session", status_code=status.HTTP_204_NO_CONTENT) +def delete_session( + response: Response, +) -> None: + # Logout is idempotent so clients can always discard their local token. + # JWT sessions are stateless, so logout discards the browser cookie. A token + # copied elsewhere remains valid until its short, configured expiration. + response.delete_cookie(key=SESSION_COOKIE_NAME, path="/") diff --git a/requirements.txt b/requirements.txt index 8976073..c23b668 100644 --- a/requirements.txt +++ b/requirements.txt @@ -66,6 +66,7 @@ Pygments==2.19.2 pylibmc==1.6.3 pymemcache==4.0.0 PyMySQL==1.1.2 +PyJWT==2.13.0 pytest==8.4.2 python-dateutil==2.9.0.post0 python-dotenv==1.1.1 |
