11"""
22Authentication API routes.
33"""
4- from typing import Optional
4+ import secrets
5+ from typing import Optional , List
56from fastapi import APIRouter , Depends , HTTPException , status , Request
67from fastapi .security import HTTPBearer , HTTPAuthorizationCredentials
78from sqlalchemy .ext .asyncio import AsyncSession
89from sqlalchemy import select
9- from pydantic import BaseModel
10- from datetime import datetime , timezone
10+ from pydantic import BaseModel , Field
11+ from datetime import datetime , timezone , timedelta
1112from loguru import logger
1213
1314from app .database import get_db
14- from app .models import User
15+ from app .models import User , APIToken
1516from app .utils .auth import verify_password , create_access_token , decode_access_token , get_password_hash
1617from app .utils .rate_limit import login_rate_limiter
1718
@@ -182,10 +183,12 @@ async def login(
182183
183184
184185async def get_current_user (
186+ request : Request ,
185187 credentials : Optional [HTTPAuthorizationCredentials ] = Depends (security ),
186188 db : AsyncSession = Depends (get_db )
187189) -> User :
188- """Get current authenticated user from JWT token."""
190+ """Get current authenticated user from JWT token or API key."""
191+ # Try JWT auth first
189192 if credentials :
190193 token = credentials .credentials
191194 payload = decode_access_token (token )
@@ -198,6 +201,42 @@ async def get_current_user(
198201 if user and user .is_active :
199202 return user
200203
204+ # Fall back to API key auth via X-API-Key header
205+ api_key = request .headers .get ("X-API-Key" )
206+ if api_key :
207+ result = await db .execute (
208+ select (APIToken ).where (
209+ APIToken .token == api_key ,
210+ APIToken .is_active == True ,
211+ )
212+ )
213+ api_token = result .scalar_one_or_none ()
214+
215+ if api_token :
216+ # Check expiration
217+ if api_token .expires_at and api_token .expires_at .replace (tzinfo = timezone .utc ) < datetime .now (timezone .utc ):
218+ raise HTTPException (
219+ status_code = status .HTTP_401_UNAUTHORIZED ,
220+ detail = "API key has expired"
221+ )
222+
223+ # Load associated user
224+ result = await db .execute (select (User ).where (User .id == api_token .user_id ))
225+ user = result .scalar_one_or_none ()
226+
227+ if user and user .is_active :
228+ # Update last_used timestamp
229+ api_token .last_used = datetime .now (timezone .utc )
230+ await db .flush ()
231+ # Store API key name for downstream use (e.g., set_by in temp limits)
232+ request .state .api_key_name = api_token .name
233+ return user
234+
235+ raise HTTPException (
236+ status_code = status .HTTP_401_UNAUTHORIZED ,
237+ detail = "Invalid API key"
238+ )
239+
201240 raise HTTPException (
202241 status_code = status .HTTP_401_UNAUTHORIZED ,
203242 detail = "Not authenticated"
@@ -243,3 +282,121 @@ def require_admin(current_user: User = Depends(get_current_user)) -> User:
243282 detail = "Admin privileges required"
244283 )
245284 return current_user
285+
286+
287+ # --- API Key Management ---
288+
289+ class CreateAPIKeyRequest (BaseModel ):
290+ name : str = Field (..., min_length = 1 , max_length = 100 , description = "Descriptive name for the API key" )
291+ expires_in_days : Optional [int ] = Field (None , ge = 1 , le = 365 , description = "Days until expiration (null = never)" )
292+
293+
294+ class APIKeyResponse (BaseModel ):
295+ id : int
296+ name : str
297+ token : str
298+ token_preview : str
299+ created_at : str
300+ expires_at : Optional [str ]
301+ last_used : Optional [str ]
302+ is_active : bool
303+
304+
305+ class CreateAPIKeyResponse (BaseModel ):
306+ id : int
307+ name : str
308+ token : str
309+ created_at : str
310+ expires_at : Optional [str ]
311+
312+
313+ @router .get ("/api-keys" , response_model = List [APIKeyResponse ])
314+ async def list_api_keys (
315+ current_user : User = Depends (require_admin ),
316+ db : AsyncSession = Depends (get_db )
317+ ):
318+ """List all API keys for the current user. Tokens are masked."""
319+ result = await db .execute (
320+ select (APIToken )
321+ .where (APIToken .user_id == current_user .id )
322+ .order_by (APIToken .created_at .desc ())
323+ )
324+ tokens = result .scalars ().all ()
325+
326+ return [
327+ APIKeyResponse (
328+ id = t .id ,
329+ name = t .name or "Unnamed" ,
330+ token = t .token or "" ,
331+ token_preview = t .token [:8 ] + "..." if t .token else "" ,
332+ created_at = t .created_at .isoformat () + "Z" if t .created_at else "" ,
333+ expires_at = (t .expires_at .isoformat () + "Z" ) if t .expires_at else None ,
334+ last_used = (t .last_used .isoformat () + "Z" ) if t .last_used else None ,
335+ is_active = t .is_active ,
336+ )
337+ for t in tokens
338+ ]
339+
340+
341+ @router .post ("/api-keys" , response_model = CreateAPIKeyResponse , status_code = status .HTTP_201_CREATED )
342+ async def create_api_key (
343+ request : CreateAPIKeyRequest ,
344+ current_user : User = Depends (require_admin ),
345+ db : AsyncSession = Depends (get_db )
346+ ):
347+ """Create a new API key. The full token is returned only once."""
348+ token = secrets .token_hex (32 ) # 64-char hex string
349+
350+ expires_at = None
351+ if request .expires_in_days :
352+ expires_at = datetime .now (timezone .utc ) + timedelta (days = request .expires_in_days )
353+
354+ api_token = APIToken (
355+ user_id = current_user .id ,
356+ token = token ,
357+ name = request .name ,
358+ expires_at = expires_at ,
359+ is_active = True ,
360+ )
361+ db .add (api_token )
362+ await db .flush ()
363+ await db .refresh (api_token )
364+
365+ logger .info (f"API key '{ request .name } ' created by { current_user .username } " )
366+
367+ return CreateAPIKeyResponse (
368+ id = api_token .id ,
369+ name = api_token .name or "Unnamed" ,
370+ token = token ,
371+ created_at = api_token .created_at .isoformat () + "Z" if api_token .created_at else "" ,
372+ expires_at = (api_token .expires_at .isoformat () + "Z" ) if api_token .expires_at else None ,
373+ )
374+
375+
376+ @router .delete ("/api-keys/{key_id}" )
377+ async def revoke_api_key (
378+ key_id : int ,
379+ current_user : User = Depends (require_admin ),
380+ db : AsyncSession = Depends (get_db )
381+ ):
382+ """Revoke an API key (soft-delete by setting is_active=False)."""
383+ result = await db .execute (
384+ select (APIToken ).where (
385+ APIToken .id == key_id ,
386+ APIToken .user_id == current_user .id ,
387+ )
388+ )
389+ api_token = result .scalar_one_or_none ()
390+
391+ if not api_token :
392+ raise HTTPException (
393+ status_code = status .HTTP_404_NOT_FOUND ,
394+ detail = "API key not found"
395+ )
396+
397+ api_token .is_active = False
398+ await db .flush ()
399+
400+ logger .info (f"API key '{ api_token .name } ' (id={ key_id } ) revoked by { current_user .username } " )
401+
402+ return {"message" : "API key revoked" }
0 commit comments