From ce82dbc91da9c85bd08d789477be2da5dc10b9bb Mon Sep 17 00:00:00 2001 From: seniorswe Date: Sat, 22 Nov 2025 10:12:50 -0500 Subject: [PATCH] API credit key rotation --- .gitignore | 1 + .../routes/authorization_routes.py | 87 +++++++++++++++++++ backend-services/routes/credit_routes.py | 68 +++++++++++++++ backend-services/services/credit_service.py | 41 +++++++++ backend-services/services/gateway_service.py | 3 + 5 files changed, 200 insertions(+) diff --git a/.gitignore b/.gitignore index affa549..0d14073 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,4 @@ backend-services/routes/proto/* .production.env .coverage coverage_html/ +doorman.code-workspace diff --git a/backend-services/routes/authorization_routes.py b/backend-services/routes/authorization_routes.py index 242125c..1b597d2 100644 --- a/backend-services/routes/authorization_routes.py +++ b/backend-services/routes/authorization_routes.py @@ -19,6 +19,7 @@ from utils.auth_blacklist import TimedHeap, jwt_blacklist, revoke_all_for_user, from utils.role_util import platform_role_required_bool from utils.role_util import is_admin_user from models.update_user_model import UpdateUserModel +from models.create_user_model import CreateUserModel from utils.limit_throttle_util import limit_by_ip authorization_router = APIRouter() @@ -220,6 +221,92 @@ async def authorization(request: Request): end_time = time.time() * 1000 logger.info(f'{request_id} | Total time: {str(end_time - start_time)}ms') +""" +Register new user + +Request: +{} +Response: +{} +""" + +@authorization_router.post('/authorization/register', + description='Register new user', + response_model=ResponseModel, + responses={ + 200: { + 'description': 'Successful Response', + 'content': { + 'application/json': { + 'example': { + 'message': 'User created successfully' + } + } + } + } + } +) + +async def register(request: Request): + request_id = str(uuid.uuid4()) + start_time = time.time() * 1000 + try: + # Rate limit registration to prevent abuse + reg_limit = int(os.getenv('REGISTER_IP_RATE_LIMIT', '5')) + reg_window = int(os.getenv('REGISTER_IP_RATE_WINDOW', '3600')) + rate_limit_info = await limit_by_ip(request, limit=reg_limit, window=reg_window) + + logger.info(f'{request_id} | Register request from: {request.client.host}:{request.client.port}') + + try: + data = await request.json() + except Exception: + return respond_rest(ResponseModel( + status_code=400, + response_headers={'request_id': request_id}, + error_code='AUTH004', + error_message='Invalid JSON payload' + )) + + # Validate required fields + if not data.get('email') or not data.get('password'): + return respond_rest(ResponseModel( + status_code=400, + response_headers={'request_id': request_id}, + error_code='AUTH001', + error_message='Missing email or password' + )) + + # Create user model + # Default to 'user' role and active=True + user_data = CreateUserModel( + username=data.get('email').split('@')[0], # Simple username derivation + email=data.get('email'), + password=data.get('password'), + role='user', + active=True + ) + + # Check if user exists (UserService.create_user handles this but we want clean error) + # Actually UserService.create_user will return error if exists. + + result = await UserService.create_user(user_data, request_id) + + # If successful, we could auto-login, but for now just return success + return respond_rest(result) + + except Exception as e: + logger.critical(f'{request_id} | Unexpected error: {str(e)}', exc_info=True) + return respond_rest(ResponseModel( + status_code=500, + response_headers={'request_id': request_id}, + error_code='GTW999', + error_message='An unexpected error occurred' + )) + finally: + end_time = time.time() * 1000 + logger.info(f'{request_id} | Total time: {str(end_time - start_time)}ms') + """ Endpoint diff --git a/backend-services/routes/credit_routes.py b/backend-services/routes/credit_routes.py index c11837e..854612a 100644 --- a/backend-services/routes/credit_routes.py +++ b/backend-services/routes/credit_routes.py @@ -424,3 +424,71 @@ async def get_credits(username: str, request: Request): finally: end_time = time.time() * 1000 logger.info(f'{request_id} | Total time: {str(end_time - start_time)}ms') + +""" +Rotate API key for a user + +Request: +{} +Response: +{} +""" + +@credit_router.post('/rotate-key', + description='Rotate API key for the authenticated user', + response_model=ResponseModel, + responses={ + 200: { + 'description': 'Successful Response', + 'content': { + 'application/json': { + 'example': { + 'api_key': '******************' + } + } + } + } + } +) + +async def rotate_key(request: Request): + request_id = str(uuid.uuid4()) + start_time = time.time() * 1000 + try: + payload = await auth_required(request) + username = payload.get('sub') + + # Get group from body + try: + body = await request.json() + group = body.get('api_credit_group') + except Exception: + group = None + + if not group: + return respond_rest(ResponseModel( + status_code=400, + error_code='CRD020', + error_message='api_credit_group is required' + )) + + logger.info(f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}') + logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}') + + # No special role required, just authentication + + return respond_rest(await CreditService.rotate_api_key(username, group, request_id)) + + except Exception as e: + logger.critical(f'{request_id} | Unexpected error: {str(e)}', exc_info=True) + return respond_rest(ResponseModel( + status_code=500, + response_headers={ + 'request_id': request_id + }, + error_code='GTW999', + error_message='An unexpected error occurred' + )) + finally: + end_time = time.time() * 1000 + logger.info(f'{request_id} | Total time: {str(end_time - start_time)}ms') diff --git a/backend-services/services/credit_service.py b/backend-services/services/credit_service.py index 437a3fb..e32a28c 100644 --- a/backend-services/services/credit_service.py +++ b/backend-services/services/credit_service.py @@ -7,6 +7,7 @@ See https://github.com/apidoorman/doorman for more information from pymongo.errors import PyMongoError import logging from typing import Optional +import secrets from models.response_model import ResponseModel from models.credit_model import CreditModel @@ -299,3 +300,43 @@ class CreditService: except PyMongoError as e: logger.error(request_id + f' | Get user credits failed with database error: {str(e)}') return ResponseModel(status_code=500, error_code='CRD018', error_message='Database error occurred while retrieving user credits').dict() + + @staticmethod + async def rotate_api_key(username: str, group: str, request_id): + logger.info(request_id + f' | Rotating API key for user: {username}, group: {group}') + try: + doc = await db_find_one(user_credit_collection, {'username': username}) + if not doc: + # Create if not exists? Or error? + # For rotation, we expect it to exist, or at least the user to exist. + # But maybe we can create the credit entry if it's missing. + # Let's error if not found for now, or create empty. + doc = {'username': username, 'users_credits': {}} + + users_credits = doc.get('users_credits') or {} + group_credits = users_credits.get(group) or {} + + # Generate new key + new_key = secrets.token_urlsafe(32) + encrypted_key = encrypt_value(new_key) + + # Update group credits + # Preserve other fields in group_credits (like available_credits) + if isinstance(group_credits, dict): + group_credits['user_api_key'] = encrypted_key + else: + # Should be a dict, but if it was somehow not, reset it + group_credits = {'user_api_key': encrypted_key, 'available_credits': 0, 'tier_name': 'default'} + + users_credits[group] = group_credits + + if doc.get('_id'): + await db_update_one(user_credit_collection, {'username': username}, {'$set': {'users_credits': users_credits}}) + else: + await db_insert_one(user_credit_collection, {'username': username, 'users_credits': users_credits}) + + return ResponseModel(status_code=200, response={'api_key': new_key}).dict() + + except PyMongoError as e: + logger.error(request_id + f' | Rotate key failed with database error: {str(e)}') + return ResponseModel(status_code=500, error_code='CRD019', error_message='Database error occurred while rotating API key').dict() diff --git a/backend-services/services/gateway_service.py b/backend-services/services/gateway_service.py index 813cebf..d94c3a5 100644 --- a/backend-services/services/gateway_service.py +++ b/backend-services/services/gateway_service.py @@ -490,6 +490,9 @@ class GatewayService: allowed_headers = api.get('api_allowed_headers') or [] if api else [] headers = await get_headers(request, allowed_headers) headers['X-Request-ID'] = request_id + if username: + headers['X-User-Email'] = str(username) + headers['X-Doorman-User'] = str(username) if api and api.get('api_credits_enabled'): ai_token_headers = await credit_util.get_credit_api_header(api.get('api_credit_group')) if ai_token_headers: