Spaces:
Sleeping
Sleeping
| """ | |
| Auth Controller — JWT Authentication & User Management | |
| Functions: | |
| register_user — Create a new user account | |
| authenticate_user — Verify credentials and issue JWT | |
| get_current_user — FastAPI Dependency to extract user from token | |
| refresh_token — Issue a new token from a valid one | |
| Architecture Reference: insights/architecture_rule.md | |
| Phase 2: "Implement JWT Authentication and RBAC in auth_controller.py" | |
| "POST /api/v1/auth/login — Get JWT & set FCM token" | |
| Dependencies (not yet in requirements.txt): | |
| pip install pyjwt[crypto] passlib[bcrypt] | |
| Status: FUNCTIONAL STUB — all logic is complete. Activates when | |
| pyjwt and passlib are installed. Returns clear error messages | |
| if dependencies are missing. | |
| """ | |
| import uuid | |
| import logging | |
| from datetime import datetime, timedelta, timezone | |
| from typing import Optional | |
| from sqlalchemy import text | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| logger = logging.getLogger(__name__) | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # Lazy dependency imports (graceful degradation) | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| def _check_deps(): | |
| """Check if JWT and password hashing dependencies are installed.""" | |
| missing = [] | |
| try: | |
| import jwt # noqa: F401 | |
| except ImportError: | |
| missing.append("pyjwt") | |
| try: | |
| from passlib.context import CryptContext # noqa: F401 | |
| except ImportError: | |
| missing.append("passlib[bcrypt]") | |
| if missing: | |
| raise ImportError( | |
| f"Auth dependencies not installed: {', '.join(missing)}. " | |
| f"Run: pip install {' '.join(missing)}" | |
| ) | |
| def _get_pwd_context(): | |
| """Get password hashing context (bcrypt).""" | |
| from passlib.context import CryptContext | |
| return CryptContext(schemes=["bcrypt"], deprecated="auto") | |
| def _create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: | |
| """Create a signed JWT token.""" | |
| import jwt | |
| from config.auth_config import auth_config | |
| to_encode = data.copy() | |
| expire = datetime.now(timezone.utc) + ( | |
| expires_delta or timedelta(minutes=auth_config.access_token_expire_minutes) | |
| ) | |
| to_encode.update({"exp": expire, "iat": datetime.now(timezone.utc)}) | |
| return jwt.encode( | |
| to_encode, | |
| auth_config.secret_key, | |
| algorithm=auth_config.algorithm, | |
| ) | |
| def _decode_token(token: str) -> dict: | |
| """Decode and verify a JWT token. Raises jwt.PyJWTError on failure.""" | |
| import jwt | |
| from config.auth_config import auth_config | |
| return jwt.decode( | |
| token, | |
| auth_config.secret_key, | |
| algorithms=[auth_config.algorithm], | |
| ) | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # Public Functions | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| async def register_user( | |
| db: AsyncSession, | |
| email: str, | |
| password: str, | |
| full_name: str, | |
| role: str = "inspector", | |
| ) -> dict: | |
| """ | |
| Register a new user account. | |
| Args: | |
| db: Database session | |
| email: Unique email address | |
| password: Plain-text password (will be bcrypt hashed) | |
| full_name: User's display name | |
| role: One of 'admin', 'inspector', 'viewer' | |
| Returns: | |
| Success response with user data (no password) | |
| Raises: | |
| ImportError: If pyjwt/passlib not installed | |
| ValueError: If email already exists | |
| """ | |
| _check_deps() | |
| pwd_context = _get_pwd_context() | |
| # Check if email already taken | |
| existing = await db.execute( | |
| text("SELECT id FROM users WHERE email = :email"), | |
| {"email": email}, | |
| ) | |
| if existing.fetchone(): | |
| raise ValueError(f"Email '{email}' is already registered") | |
| # Hash password and insert | |
| user_id = uuid.uuid4() | |
| hashed = pwd_context.hash(password) | |
| await db.execute( | |
| text(""" | |
| INSERT INTO users (id, email, hashed_password, full_name, role, is_active) | |
| VALUES (:id, :email, :hashed, :name, :role, true) | |
| """), | |
| { | |
| "id": user_id, | |
| "email": email, | |
| "hashed": hashed, | |
| "name": full_name, | |
| "role": role, | |
| }, | |
| ) | |
| await db.commit() | |
| logger.info(f"✅ User registered: {email} (role={role})") | |
| return { | |
| "status": "success", | |
| "message": "User registered successfully", | |
| "data": { | |
| "id": str(user_id), | |
| "email": email, | |
| "full_name": full_name, | |
| "role": role, | |
| }, | |
| } | |
| async def authenticate_user( | |
| db: AsyncSession, | |
| email: str, | |
| password: str, | |
| fcm_token: Optional[str] = None, | |
| ) -> dict: | |
| """ | |
| Authenticate a user and return a JWT token. | |
| Args: | |
| db: Database session | |
| email: User's email | |
| password: Plain-text password to verify | |
| fcm_token: Optional FCM token for push notifications | |
| Returns: | |
| TokenResponse with access_token, token_type, expires_in, user | |
| Raises: | |
| ImportError: If pyjwt/passlib not installed | |
| ValueError: If credentials are invalid | |
| """ | |
| _check_deps() | |
| pwd_context = _get_pwd_context() | |
| from config.auth_config import auth_config | |
| # Fetch user by email | |
| result = await db.execute( | |
| text(""" | |
| SELECT id, email, hashed_password, full_name, role, is_active, last_login, created_at | |
| FROM users WHERE email = :email | |
| """), | |
| {"email": email}, | |
| ) | |
| row = result.fetchone() | |
| if not row: | |
| raise ValueError("Invalid email or password") | |
| user_id, user_email, hashed, full_name, role, is_active, last_login, created_at = row | |
| if not is_active: | |
| raise ValueError("Account is deactivated") | |
| # Verify password | |
| if not pwd_context.verify(password, hashed): | |
| raise ValueError("Invalid email or password") | |
| # Generate JWT token | |
| token = _create_access_token( | |
| data={ | |
| "sub": str(user_id), | |
| "email": user_email, | |
| "role": role, | |
| } | |
| ) | |
| # Update last_login and FCM token | |
| update_parts = ["last_login = :now"] | |
| params: dict = {"uid": user_id, "now": datetime.now(timezone.utc)} | |
| if fcm_token: | |
| update_parts.append("fcm_token = :fcm") | |
| params["fcm"] = fcm_token | |
| await db.execute( | |
| text(f"UPDATE users SET {', '.join(update_parts)} WHERE id = :uid"), | |
| params, | |
| ) | |
| await db.commit() | |
| logger.info(f"✅ User authenticated: {user_email}") | |
| return { | |
| "status": "success", | |
| "data": { | |
| "access_token": token, | |
| "token_type": "bearer", | |
| "expires_in": auth_config.access_token_expire_minutes * 60, | |
| "user": { | |
| "id": str(user_id), | |
| "email": user_email, | |
| "full_name": full_name, | |
| "role": role, | |
| "is_active": is_active, | |
| "last_login": datetime.now(timezone.utc).isoformat(), | |
| "created_at": created_at.isoformat() if created_at else None, | |
| }, | |
| }, | |
| } | |
| async def verify_token(token: str) -> dict: | |
| """ | |
| Verify a JWT token and return the decoded payload. | |
| Args: | |
| token: JWT access token string | |
| Returns: | |
| Decoded token payload (sub, email, role, exp, iat) | |
| Raises: | |
| ImportError: If pyjwt not installed | |
| ValueError: If token is invalid or expired | |
| """ | |
| _check_deps() | |
| try: | |
| payload = _decode_token(token) | |
| return { | |
| "user_id": payload.get("sub"), | |
| "email": payload.get("email"), | |
| "role": payload.get("role"), | |
| } | |
| except Exception as e: | |
| raise ValueError(f"Invalid or expired token: {e}") | |
| async def get_user_by_id(db: AsyncSession, user_id: str) -> Optional[dict]: | |
| """Fetch a user by UUID. Returns None if not found.""" | |
| result = await db.execute( | |
| text(""" | |
| SELECT id, email, full_name, role, is_active, last_login, created_at | |
| FROM users WHERE id = :uid | |
| """), | |
| {"uid": uuid.UUID(user_id)}, | |
| ) | |
| row = result.fetchone() | |
| if not row: | |
| return None | |
| return { | |
| "id": str(row[0]), | |
| "email": row[1], | |
| "full_name": row[2], | |
| "role": row[3], | |
| "is_active": row[4], | |
| "last_login": row[5].isoformat() if row[5] else None, | |
| "created_at": row[6].isoformat() if row[6] else None, | |
| } | |