From 50b1e009039ed5a86ae54ce39ad62fd5b5e0108d Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Thu, 18 Jun 2026 15:48:48 -0400 Subject: [PATCH] feat(auth): add /v3/action/auth/authenticate_passcode endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves passcode→JWT auth into the V3 action namespace. The new router (api_v3_actions_auth.py) is a clean re-registration of the existing working logic. The legacy /api/authenticate_passcode is kept but marked deprecated via DeprecationParams while the frontend transitions. Co-Authored-By: Claude Sonnet 4.6 --- app/routers/api.py | 2 +- app/routers/api_v3_actions_auth.py | 97 ++++++++++++++++++++++++++++++ app/routers/registry.py | 2 + 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 app/routers/api_v3_actions_auth.py diff --git a/app/routers/api.py b/app/routers/api.py index 18974c4..5cf1d3a 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -37,7 +37,7 @@ class PasscodeAuthRequest(BaseModel): site_id: str = Field(..., description="The random string ID of the site") passcode: str = Field(..., min_length=5, description="The passcode to verify") -@router.post('/authenticate_passcode', response_model=Resp_Body_Base) +@router.post('/authenticate_passcode', response_model=Resp_Body_Base, dependencies=[Depends(DeprecationParams)]) async def authenticate_passcode( auth_req: PasscodeAuthRequest, response: Response = Response, diff --git a/app/routers/api_v3_actions_auth.py b/app/routers/api_v3_actions_auth.py new file mode 100644 index 0000000..57bd39b --- /dev/null +++ b/app/routers/api_v3_actions_auth.py @@ -0,0 +1,97 @@ +from fastapi import APIRouter, Response +from pydantic import BaseModel, Field +import json + +from app.lib_general import sign_jwt, log +from app.config import settings +from app.db_sql import sql_select, get_id_random +from app.models.response_models import Resp_Body_Base, mk_resp + +router = APIRouter() + +ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated'] + +ROLE_TTL = { + 'super': 8 * 3600, + 'manager': 24 * 3600, + 'administrator': 48 * 3600, + 'trusted': 48 * 3600, + 'public': 24 * 3600, + 'authenticated': 12 * 3600, +} + +class PasscodeAuthRequest(BaseModel): + """Request model for site-based passcode authentication.""" + site_id: str = Field(..., description="Random string ID of the site") + passcode: str = Field(..., min_length=5, description="The passcode to verify") + +@router.post('/authenticate_passcode', response_model=Resp_Body_Base) +async def authenticate_passcode( + auth_req: PasscodeAuthRequest, + response: Response = Response, + ): + """ + Passcode-to-JWT. Verifies a passcode against site.access_code_kv_json. + Returns a signed JWT with the site's account context, full role flags, + and a per-role TTL. jwt.json_str.auth_type='passcode' distinguishes this + token from a user login JWT. + """ + site_id = auth_req.site_id + passcode = auth_req.passcode + + search_data = {'id_random': site_id} + if record := sql_select(table_name='site', data=search_data): + access_codes_raw = record.get('access_code_kv_json') + access_codes = {} + if access_codes_raw: + try: + access_codes = json.loads(access_codes_raw) if isinstance(access_codes_raw, str) else access_codes_raw + except Exception as e: + log.error(f"Failed to parse access_code_kv_json for site {site_id}: {e}") + + matched_role = None + for role in ROLE_PRIORITY: + code = access_codes.get(role) + if code and str(code) == str(passcode): + matched_role = role + break + + if matched_role: + log.info(f"Auth Success: Verified '{matched_role}' passcode for site {site_id}") + + account_id_random = record.get('account_id_random') + if not account_id_random: + if account_id_int := record.get('account_id'): + account_id_random = get_id_random(record_id=account_id_int, table_name='account') + + payload = { + 'account_id': account_id_random, + 'super': (matched_role == 'super'), + 'manager': (matched_role == 'manager'), + 'administrator': (matched_role == 'administrator'), + 'trusted': (matched_role == 'trusted'), + 'public': (matched_role == 'public'), + 'authenticated': (matched_role == 'authenticated'), + 'json_str': json.dumps({ + 'auth_type': 'passcode', + 'site_id': site_id, + 'role': matched_role + }) + } + + token = sign_jwt( + secret_key=settings.JWT_KEY, + ttl=ROLE_TTL[matched_role], + **payload + ) + + return mk_resp( + data={'jwt': token, 'account_id': account_id_random, 'role': matched_role}, + response=response + ) + else: + log.warning(f"Auth Failed: Invalid passcode for site {site_id}") + return mk_resp(data=False, status_code=401, response=response, status_message="Invalid passcode.") + else: + log.warning(f"Auth Failed: Site {site_id} not found.") + return mk_resp(data=False, status_code=404, response=response, status_message="Site not found.") diff --git a/app/routers/registry.py b/app/routers/registry.py index ede8703..79d8cb1 100644 --- a/app/routers/registry.py +++ b/app/routers/registry.py @@ -5,6 +5,7 @@ from app.routers import ( data_store, event_badge_importing, event_importing, + api_v3_actions_auth, api_v3_actions_email, api_v3_actions_hosted_file, api_v3_actions_event_file, api_v3_actions_event_exhibit, api_v3_actions_e_zoom, api_v3_actions_e_novi_mailman, api_v3_actions_idaa, api_v3_actions_user, lookup_v3, user, @@ -54,6 +55,7 @@ def setup_routers(app: FastAPI): app.include_router(api_v3_actions_idaa.router, prefix='/v3/action/idaa', tags=['IDAA Actions (V3)']) app.include_router(api_v3_actions_user.router, prefix='/v3/action/user', tags=['User (V3 Actions)']) app.include_router(api_v3_actions_email.router, prefix='/v3/action/email', tags=['Email (V3 Actions)']) + app.include_router(api_v3_actions_auth.router, prefix='/v3/action/auth', tags=['Auth (V3 Actions)']) # app.include_router(lookup.router, prefix='/lu', tags=['Lookup']) # LEGACY (disabled) - superseded by /v3/lookup app.include_router(lookup_v3.router, prefix='/v3/lookup', tags=['Lookup V3'])