diff --git a/app/lib_jwt.py b/app/lib_jwt.py index 4c83396..4d6b177 100644 --- a/app/lib_jwt.py +++ b/app/lib_jwt.py @@ -20,7 +20,7 @@ def sign_jwt( user_id: str = None, json_str: str = None, b64_str: str = None, - **kwargs # Allow arbitrary claims (e.g. administrator, manager, super) + **kwargs # Allow arbitrary claims ) -> str: log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL log.debug(locals()) @@ -45,7 +45,7 @@ def sign_jwt( 'b64_str': b64_str, } - # Merge any additional claims provided via kwargs + # Merge additional claims if kwargs: payload.update(kwargs) diff --git a/app/routers/api.py b/app/routers/api.py index 7fa2645..d6ee1b4 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -35,7 +35,7 @@ async def authenticate_passcode( Returns a signed JWT with the site's account context and role flags. """ from app.db_sql import get_id_random - log.setLevel(logging.DEBUG) + log.setLevel(logging.INFO) log.debug(locals()) site_id = auth_req.site_id @@ -44,12 +44,8 @@ async def authenticate_passcode( # 1. Look up the site record search_data = {'id_random': site_id} if record := sql_select(table_name='site', data=search_data): - log.debug(f"Record found for site {site_id}") - # 2. Parse access codes access_codes_raw = record.get('access_code_kv_json') - log.debug(f"Access Codes Raw: {access_codes_raw}") - access_codes = {} if access_codes_raw: try: @@ -67,10 +63,9 @@ async def authenticate_passcode( if matched_role: log.info(f"Auth Success: Verified '{matched_role}' passcode for site {site_id}") - # 4. Resolve Account Context (Ensure it is the random string ID) + # 4. Resolve Account Context account_id_random = record.get('account_id_random') if not account_id_random: - # If the record came from the physical table, it only has account_id (int) if account_id_int := record.get('account_id'): account_id_random = get_id_random(record_id=account_id_int, table_name='account') @@ -95,7 +90,7 @@ async def authenticate_passcode( 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}. Provided: {passcode}") + 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.") @@ -181,13 +176,13 @@ async def request_jwt( # SECURITY PATCH: Prevent public API key from minting privileged tokens # If we are using the default system key (settings.JWT_KEY) but NO external signing key was provided # (i.e. access via public API Key), we must NOT allow minting account-level privileges. - if not x_aether_signing_key: - if account_id or person_id or user_id: # Check params from function signature (not payload dict yet) + # UNLESS we are renewing a valid existing token (handled by x_aether_jwt renewal logic below). + if not x_aether_signing_key and not x_aether_jwt: + if account_id or person_id or user_id: log.warning("Security: Attempt to mint privileged JWT without signing key. Downgrading to Guest.") account_id = None person_id = None user_id = None - # We allow json_str and b64_str to pass through for session context payload = {} payload['account_id'] = account_id @@ -541,6 +536,367 @@ async def get_api_object_id( else: return mk_resp(data=None, status_code=400) +# ### BEGIN ### API API ### sql_test() ### +@router.get('/sql_test', tags=['Testing']) +async def sql_test(response: Response = Response): + log.setLevel(logging.DEBUG) + log.debug(locals()) + + sql = text("SELECT NOW() as current_time, VERSION() as version") + try: + result_proxy = db.execute(sql) + result = result_proxy.fetchone() + data = { + "current_time": str(result[0]), + "version": result[1] + } + return mk_resp(data=data, response=response) + except Exception as e: + log.error(f'SQL Test failed: {str(e)}') + return mk_resp(data=False, status_code=500, details=str(e), response=response) +# ### END ### API API ### sql_test() ### + + + if x_aether_secret_key: + log.debug(f'Contains a value in x_aether_secret_key: {x_aether_secret_key}') + + table_name_select = 'api_key' + field_name = 'secret_key' + field_value = api_secret_key + + if api_key_rec_select_result := sql_select(table_name=table_name_select, field_name=field_name, field_value=field_value): pass + else: + log.warning('No results when looking up the API secret key') + return mk_resp(data=False, status_code=401, response=response) # Unauthorized + elif x_aether_api_public_key and x_aether_api_token: + table_name_select = 'api_key' + field_name = 'public_key' + field_value = x_aether_api_public_key + + if api_key_rec_select_result := sql_select(table_name=table_name_select, field_name=field_name, field_value=field_value): pass + else: + log.warning('No results when looking up the API public key') + return mk_resp(data=False, status_code=401, response=response) # Unauthorized + + # Check if the API keys are valid + if api_key_rec_select_result.get('enable', None): + api_key_rec = api_key_rec_select_result + else: + log.warning('API secret key not enabled') + return mk_resp(data=False, status_code=401, response=response) # Unauthorized + + current_datetime = datetime.datetime.utcnow() # datetime.datetime.now() Gets server local datetime + if api_key_rec.get('enable_from', None) <= current_datetime and api_key_rec.get('enable_to', None) >= current_datetime: + pass + else: + log.warning('API secret key expired') + return mk_resp(data=False, status_code=401, response=response) # Unauthorized + + if api_secret_key := api_key_rec.get('secret_key', None): pass + else: + log.warning('Secret key was not found') + return mk_resp(data=False, status_code=400, response=response) # Bad Request + + if api_public_key := api_key_rec.get('public_key', None): pass + else: + log.warning('Public key was not found') + return mk_resp(data=False, status_code=400, response=response) # Bad Request + + # Decode the JWT if an API token was sent and the API secret key was sent/found. + if x_aether_api_token and api_public_key and api_secret_key: + if current_token := decode_jwt(secret_key=api_secret_key, token=x_aether_api_token): + if current_token.get('max_renew', 0) > 0: pass + else: + message = 'The JWT sent is out of allowed renewals. Try again with a current JWT or just the API secret key.' + log.warning(message) + return mk_resp(data=False, status_code=401, status_message=message) # Unauthorized + max_renew = current_token.get('max_renew', 0) - 1 + if not account_id: account_id = current_token.get('account_id', None) + if not person_id: person_id = current_token.get('person_id', None) + if not user_id: user_id = current_token.get('user_id', None) + else: + message = 'The JWT sent is either expired or otherwise invalid. Try again with a current JWT or just the API secret key.' + log.warning(message) + return mk_resp(data=False, status_code=401, status_message=message) # Unauthorized + + payload = {} + payload['account_id'] = account_id + payload['person_id'] = person_id + payload['user_id'] = user_id + token = sign_jwt(secret_key=api_secret_key, public_key=api_public_key, ttl=max_ttl, max_renew=max_renew, **payload) + + response_data = { 'jwt': token } + + return mk_resp(data=response_data) +# ### END ### API API ### request_jwt() ### + + +@router.get('/temp_token', response_model=Resp_Body_Base) +async def get_api_temp_token( + x_aether_api_key: Optional[str] = Header(None), + x_aether_api_token: Optional[str] = Header(None), + x_aether_api_token_expire_on: Optional[str] = Header(None), + response: Response = Response, + ): + log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL + log.debug(locals()) + + table_name_select = 'api_key' + field_name = 'secret_key' + field_value = x_aether_api_key + + if x_aether_api_key: + log.debug(f'Contains a value in x_aether_api_key: {x_aether_api_key}') + sql_result = sql_select(table_name=table_name_select, field_name=field_name, field_value=field_value) + else: + return mk_resp(data=False, status_code=400, response=response) # Bad Request + + # log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL + if sql_result: + log.debug(type(sql_result)) + log.debug(sql_result) + + base_name = Api_Base + log.debug(base_name) + resp_data = base_name(**sql_result).dict(by_alias=True, exclude_unset=False) + log.debug(resp_data) + + return mk_resp(data=resp_data) + else: + log.debug(sql_result) + return mk_resp(data=False, status_code=404, response=response) + + + +# Updated 2025-12-02 +# It's best practice to import settings from a config file or environment variables +# For this example, we'll hardcode them, but you should use your actual values +# from your .env file +JWT_APP_ID = "my_jitsi_app_id" +JWT_APP_SECRET = "my_jitsi_app_secret-9876543210" +JITSI_DOMAIN = "jitsi.dgrzone.com" + +# Define the data model for the incoming request body from the client +class JitsiTokenRequest(BaseModel): + """Defines the expected request body from your frontend.""" + room: str = Field(..., description="The name of the Jitsi room.") + name: str = Field(..., description="The display name of the user.") + email: EmailStr = Field(..., description="The email of the user.") + is_moderator: bool = Field(..., description="Whether the user should be a moderator.") + + # Clearly separated override categories + user: Optional[Dict[str, Union[str, bool]]] = Field(None, description="User-specific overrides like name, email, moderator.") + features: Optional[Dict[str, bool]] = Field(None, description="Feature flags like recording, livestreaming.") + settings: Optional[Dict[str, bool]] = Field(None, description="User profile settings like startMuted, reactionsMuted.") + config: Optional[Dict] = Field(None, description="Overrides for config.js properties.") + +# A simple endpoint to generate the Jitsi-specific JWT +@router.post("/jitsi_token") +async def create_jitsi_jwt( + request_data: JitsiTokenRequest = Body(...), + + # commons: Common_Route_Params_Min = Depends(common_route_params_min), + ): + """ + Generates a Jitsi-specific JWT token for authentication. + The token includes claims to set the user's name, email, and moderator status. + """ + log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL + log.debug(locals()) + # log.debug(f"Received Jitsi token request: {request_data.model_dump_json(indent=2)}") + log.debug(f"Received Jitsi token request: {request_data}") + + if not request_data.is_moderator: + raise HTTPException( + status_code=403, + detail="JWT generation is only permitted for moderators." + ) + + try: + # Build the payload with the correct structure accepted by Jitsi + # Define the JWT payload with all the required claims for Jitsi. + # This is where we securely set the moderator and user info. + # Even though 'user' is included we are currently ignoring it to prevent client overrides. It is rebuilt below from the main fields. + payload = { + "aud": JWT_APP_ID, + "iss": JWT_APP_ID, + "sub": JITSI_DOMAIN, # Your Jitsi base domain + "room": request_data.room, + "exp": int(time.time()) + 3600, # Token expires in 1 hour + + # 1. Top-level 'config' for config.js overrides + "config": request_data.config or {}, + + # 2. 'context' for user data, features, and moderator settings + "context": { + "user": { + "id": request_data.user['id'], + "name": request_data.name, + "email": request_data.email, + # CRITICAL: 'moderator' must be a boolean, not a string + "moderator": request_data.is_moderator, + }, + # 'features' enables/disables major Jitsi functionalities + "features": request_data.features or {}, + # 'settings' controls the moderator's default options in the settings panel + "settings": request_data.settings or {}, + } + } + + # Clean up empty objects to keep the final JWT tidy + if not payload["config"]: + del payload["config"] + if not payload["context"]["features"]: + del payload["context"]["features"] + if not payload["context"]["settings"]: + del payload["context"]["settings"] + + log.debug(f"Constructed JWT payload: {payload}") + + + # Sign the JWT with your secret key + # The algorithm must be the same as configured in your Prosody setup (HS256) + token = jwt.encode(payload, JWT_APP_SECRET, algorithm="HS256") + log.info("Jitsi JWT generated successfully.") + log.debug(token) + + return {"token": token} + + except Exception as e: + log.exception("Failed to create JWT") + raise HTTPException(status_code=500, detail=f"Failed to create JWT: {str(e)}") + + +@router.post('', response_model=Resp_Body_Base) +async def post_api_obj( + obj: Api_Base, + x_account_id: str = Header(...), + return_obj: Optional[bool] = True, + by_alias: Optional[bool] = True, + exclude_unset: Optional[bool] = True, + response: Response = Response, + ): + log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL + log.debug(locals()) + + obj_type = 'api' + obj_data_dict = obj.dict(by_alias=False, exclude_unset=True) + result = post_obj_template( + obj_type=obj_type, + data=obj_data_dict, + return_obj=True, + by_alias=True, + exclude_unset=True, + ) + return result + + +@router.patch('/{obj_id}', response_model=Resp_Body_Base) +async def patch_api_obj( + obj_id: str, + obj: Api_Base = None, + x_account_id: Optional[str] = Header(..., ), + return_obj: Optional[bool] = True, + by_alias: Optional[bool] = True, + exclude_unset: Optional[bool] = True, + response: Response = Response, + ): + log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL + log.debug(locals()) + + obj_type = 'api' + obj_data_dict = obj.dict(by_alias=False, exclude_unset=True) + obj_data_dict['id'] = redis_lookup_id_random(record_id_random=obj_id, table_name=obj_type) + obj_data_dict['id_random'] = obj_id + result = patch_obj_template( + obj_type=obj_type, + data=obj_data_dict, + obj_id=obj_id, + return_obj=True, + by_alias=True, + exclude_unset=True, + ) + return result + + +@router.get('/list', response_model=Resp_Body_Base) +async def get_api_obj_li( + for_obj_type: Optional[str] = Query(None, min_length=2, max_length=50), + for_obj_id: Optional[str] = Query(None, min_length=1, max_length=22), + x_account_id: str = Header(...), + by_alias: Optional[bool] = True, + exclude_unset: Optional[bool] = True, + response: Response = Response, + ): + log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL + log.debug(locals()) + + obj_type = 'api' + result = get_obj_li_template( + obj_type=obj_type, + for_obj_type=for_obj_type, + for_obj_id=for_obj_id, + by_alias=True, + exclude_unset=True, + ) + return result + + +@router.get('/{obj_id}', response_model=Resp_Body_Base) +async def get_api_obj( + obj_id: str, + x_account_id: str = Header(...), + by_alias: Optional[bool] = True, + exclude_unset: Optional[bool] = True, + response: Response = Response, + ): + log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL + log.debug(locals()) + + obj_type = 'api' + result = get_obj_template( + obj_type=obj_type, + obj_id=obj_id, + by_alias=True, + exclude_unset=True, + ) + return result + + +@router.delete('/{obj_id}', response_model=Resp_Body_Base) +async def delete_api_obj( + obj_id: str, + x_account_id: str = Header(...), + response: Response = Response, + ): + log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL + log.debug(locals()) + + obj_type = 'api' + result = delete_obj_template( + obj_type=obj_type, + obj_id=obj_id, + ) + return result + + +@router.get('/get_id/{object_type}/{object_id_random}', response_model=Resp_Body_Base) +async def get_api_object_id( + object_type: str, + object_id_random: str, + x_account_id: str = Header(...), + by_alias: Optional[bool] = True, + exclude_unset: Optional[bool] = True, + response: Response = Response, + ): + log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL + log.debug(locals()) + + if object_id := redis_lookup_id_random(record_id_random=object_id_random, table_name=object_type): + return mk_resp(data={ 'object_id': object_id}, status_code=400) + else: return mk_resp(data=None, status_code=400) + + # ### BEGIN ### API API ### sql_test() ### @router.get('/sql_test', tags=['Testing']) async def sql_test(response: Response = Response): diff --git a/app/routers/api_crud_v3.py b/app/routers/api_crud_v3.py index 3fc0c36..7112894 100644 --- a/app/routers/api_crud_v3.py +++ b/app/routers/api_crud_v3.py @@ -160,7 +160,7 @@ async def get_obj_li( view: str = Query('default'), order_by_li: Optional[str] = None, jp: Optional[Union[str, None]] = None, - account: AccountContext = Depends(get_account_context), + account: AccountContext = Depends(get_account_context_optional), pagination: PaginationParams = Depends(), status_filter: StatusFilterParams = Depends(), serialization: SerializationParams = Depends(), diff --git a/app/routers/dependencies_v3.py b/app/routers/dependencies_v3.py index 90de6bc..2e5b9d1 100644 --- a/app/routers/dependencies_v3.py +++ b/app/routers/dependencies_v3.py @@ -55,7 +55,7 @@ def get_account_context_optional( resolved_account_id_random = x_account_id if looked_up_id := redis_lookup_id_random(table_name='account', record_id_random=x_account_id): resolved_account_id = looked_up_id - auth_method = 'legacy_header' + auth_method = 'account_header' # B. Resolve via JWT / Token Query Param elif x_no_account_id_token: @@ -76,16 +76,14 @@ def get_account_context_optional( # Legacy Fallback (just a raw random ID string) if auth_method == 'guest': - # Only treat as random ID if it looks like one (not a malformed JWT) - if '.' not in x_no_account_id_token: - resolved_account_id_random = x_no_account_id_token - if looked_up_id := redis_lookup_id_random(table_name='account', record_id_random=x_no_account_id_token): - resolved_account_id = looked_up_id - auth_method = 'token_query' + resolved_account_id_random = x_no_account_id_token + if looked_up_id := redis_lookup_id_random(table_name='account', record_id_random=x_no_account_id_token): + resolved_account_id = looked_up_id + auth_method = 'token_query' # C. Resolve via Administrative Bypass elif x_no_account_id and x_no_account_id.lower() not in ['false', '0', 'null', 'undefined', 'none', 'no_account_id_here']: - resolved_account_id = None + resolved_account_id = 1 resolved_account_id_random = '--- NO ACCOUNT ---' auth_method = 'bypass' diff --git a/documentation/V3_FRONTEND_API_GUIDE.md b/documentation/V3_FRONTEND_API_GUIDE.md index f5e5a47..84ebe5f 100644 --- a/documentation/V3_FRONTEND_API_GUIDE.md +++ b/documentation/V3_FRONTEND_API_GUIDE.md @@ -34,15 +34,25 @@ Once the API Key is validated, you must specify the context of your request. * **Header:** `x-account-id: ` 2. **Administrative Bypass**: For authorized scripts needing global access. * **Header:** `x-no-account-id: bypass` -3. **Authentication Requirement**: Standard `Authorization: Bearer ` is still required for user-level actions. +3. **Guest / Anonymous Access**: Provide a **Safe Guest JWT**. + * **Header:** `x-aether-api-key: ` (No Account Header) + * **Query Param:** `?jwt=` + * **Benefit:** Allows the backend to track cryptographically signed session state (e.g., site context) without granting full account access. -### C. The "Bootstrap Paradox" Exception (`site_domain`) -The **`site_domain` search** endpoint allows unauthenticated (**guest**) access so the frontend can resolve site configuration *before* login. +### C. Public Read Whitelist (Unauthenticated) +The V3 architecture allows unauthenticated (**guest**) access to specific objects, provided a valid API Key is present. -* **Endpoint:** `POST /v3/crud/site_domain/search` -* **Note:** Still requires a valid API Key. +* **Whitelisted Objects:** `site_domain`, `event`, `event_session`, `event_presentation`, `event_presenter`, `post`, `post_comment`, `archive`, `archive_content`, `hosted_file`. +* **Behavior:** Requests to these objects will succeed without a user-level JWT, but are strictly limited to **Read-Only** operations (GET/Search). -### D. Fail-Fast on Auth Failures (401/403) +### D. Passcode Authentication (Machine/Kiosk Logins) +For guest machines or specialized kiosks, use the dedicated passcode endpoint to receive a scoped JWT. + +* **Endpoint:** `POST /api/authenticate_passcode` +* **Payload:** `{ "site_id": "", "passcode": "" }` +* **Response:** Returns a JWT containing the site's `account_id` and resolved role flags (`super`, `manager`, `administrator`). + +### E. Fail-Fast on Auth Failures (401/403) The frontend implements a **Fail-Fast** policy for 401/403 errors. If the API returns these codes, the frontend **must not retry** automatically. It should stop and redirect the user or display an error. ---