Spaces:
Running
Running
stouchinglhh
collector: εζ₯ιιε·₯η¨ v1.2.1οΌε
¨ι¨ LFS 樑εΌζδ»ΆζιεοΌδΈ Space η°ζζδ»ΆεεΉΆοΌ
d646dd7 Download processor/app/auth.py from Stouching-Robotics/DataFlowing: direct link, hf CLI and curl.
- Browser
- Download file 9.17 kB
-
https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/app/auth.py
- Command line
-
hf download hf://spaces/Stouching-Robotics/DataFlowing/processor/app/auth.py
-
curl -L -o auth.py https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/app/auth.py
9.17 kB
| """Authentication utilities β bcrypt password hashing + JWT tokens.""" | |
| import hashlib | |
| import hmac | |
| import json | |
| import os | |
| from datetime import datetime, timedelta, timezone | |
| from typing import Optional | |
| from fastapi import Depends, HTTPException, Request | |
| # ββ JWT secret ββββββββββββββββββββββββββββββββββββββββββββ | |
| _JWT_SECRET = os.environ.get( | |
| "JWT_SECRET", | |
| "egodata-v1-jwt-production-secret-key-2026" | |
| ) | |
| _JWT_ALGORITHM = "HS256" | |
| # Keep the access-token lifetime configurable while preserving the current | |
| # login API. Thirty days prevents the browser from losing its authenticated | |
| # state during normal workstation restarts; refresh-token rotation can be | |
| # introduced later for a shorter-lived access token model. | |
| try: | |
| _JWT_EXPIRE_DAYS = max(1, int(os.environ.get("JWT_EXPIRE_DAYS", "30"))) | |
| except ValueError: | |
| _JWT_EXPIRE_DAYS = 30 | |
| _TOKEN_EXPIRE_HOURS = _JWT_EXPIRE_DAYS * 24 | |
| ROLE_PERMISSIONS = { | |
| "admin": { | |
| "users.manage", "projects.manage", "workflows.manage", | |
| "processing.run", "review.read", "review.write", "system.manage", | |
| }, | |
| "engineer": { | |
| "projects.manage", "workflows.manage", "processing.run", | |
| "review.read", "review.write", | |
| }, | |
| "reviewer": {"review.read", "review.write"}, | |
| } | |
| # ββ Password hashing (HMAC-SHA256 with per-user salt) βββββ | |
| def _derive_key(password: str, salt: str) -> bytes: | |
| """Derive a key from password and salt using PBKDF2.""" | |
| import hashlib | |
| return hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 100_000) | |
| def hash_password(plain: str) -> str: | |
| """Hash a password with random salt. Returns 'salt$hex_digest'.""" | |
| salt = os.urandom(16).hex() | |
| key = _derive_key(plain, salt) | |
| return f"{salt}${key.hex()}" | |
| def verify_password(plain: str, hashed: str) -> bool: | |
| """Verify a password against its hash.""" | |
| try: | |
| salt, digest = hashed.split("$", 1) | |
| except ValueError: | |
| return False | |
| return hmac.compare_digest(_derive_key(plain, salt).hex(), digest) | |
| # ββ JWT βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _b64url_encode(data: bytes) -> str: | |
| import base64 | |
| return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") | |
| def _b64url_decode(s: str) -> bytes: | |
| import base64 | |
| # Add padding | |
| rem = len(s) % 4 | |
| if rem: | |
| s += "=" * (4 - rem) | |
| return base64.urlsafe_b64decode(s) | |
| def create_access_token(user_id: str, username: str, role: str) -> str: | |
| """Create a signed JWT access token (no external lib needed).""" | |
| header = _b64url_encode(json.dumps({"alg": _JWT_ALGORITHM, "typ": "JWT"}).encode()) | |
| now = datetime.now(timezone.utc) | |
| payload = _b64url_encode(json.dumps({ | |
| "sub": user_id, | |
| "username": username, | |
| "role": role, | |
| "iat": int(now.timestamp()), | |
| "exp": int((now + timedelta(hours=_TOKEN_EXPIRE_HOURS)).timestamp()), | |
| }).encode()) | |
| msg = f"{header}.{payload}".encode() | |
| sig = _b64url_encode(hmac.digest(_JWT_SECRET.encode(), msg, "sha256")) | |
| return f"{header}.{payload}.{sig}" | |
| def decode_access_token(token: str) -> Optional[dict]: | |
| """Decode and verify a JWT. Returns payload dict or None.""" | |
| try: | |
| parts = token.split(".") | |
| if len(parts) != 3: | |
| return None | |
| header_b64, payload_b64, sig_b64 = parts | |
| msg = f"{header_b64}.{payload_b64}".encode() | |
| expected_sig = _b64url_encode(hmac.digest(_JWT_SECRET.encode(), msg, "sha256")) | |
| if not hmac.compare_digest(sig_b64, expected_sig): | |
| return None | |
| payload = json.loads(_b64url_decode(payload_b64)) | |
| # Check expiration | |
| exp = payload.get("exp", 0) | |
| if datetime.now(timezone.utc).timestamp() > exp: | |
| return None | |
| return payload | |
| except Exception: | |
| return None | |
| # ββ FastAPI dependency βββββββββββββββββββββββββββββββββββββ | |
| async def get_current_user(request: Request) -> dict: | |
| """Extract user from cookie or Authorization header. Returns payload dict.""" | |
| token = None | |
| # Check cookie first | |
| token = request.cookies.get("auth_token") | |
| # Fallback to Authorization header | |
| if not token: | |
| auth_header = request.headers.get("Authorization", "") | |
| if auth_header.startswith("Bearer "): | |
| token = auth_header[7:] | |
| if not token: | |
| raise HTTPException(status_code=401, detail="Not authenticated") | |
| payload = decode_access_token(token) | |
| if not payload: | |
| raise HTTPException(status_code=401, detail="Invalid or expired token") | |
| # EGOData PostgreSQL is authoritative for managed accounts. The legacy | |
| # users.json path remains a fallback so an old installation can still | |
| # start and accept its existing login until the DB is initialized. | |
| db_user, db_available = await _find_db_user(user_id=str(payload["sub"])) | |
| if db_available: | |
| if db_user is None or db_user.status != "active": | |
| raise HTTPException(status_code=401, detail="User disabled or deleted") | |
| if _is_expired(db_user.expires_at): | |
| raise HTTPException(status_code=401, detail="Account expired") | |
| payload.update(_user_public_dict(db_user)) | |
| return payload | |
| from app.localstore import STATE_ROOT | |
| users_file = STATE_ROOT / "users.json" | |
| try: | |
| users = json.loads(users_file.read_text(encoding="utf-8")) | |
| except Exception: | |
| users = [] | |
| user = next( | |
| (u for u in users if str(u.get("id")) == str(payload["sub"]) | |
| and u.get("status") == "active"), | |
| None, | |
| ) | |
| if not user: | |
| raise HTTPException(status_code=401, detail="User disabled or deleted") | |
| if _is_expired(user.get("expires_at")): | |
| raise HTTPException(status_code=401, detail="Account expired") | |
| payload.update({ | |
| "username": user.get("username", payload.get("username")), | |
| "role": user.get("role", payload.get("role")), | |
| "email": user.get("email"), | |
| "id": str(user.get("id", payload["sub"])), | |
| }) | |
| return payload | |
| def _is_expired(value) -> bool: | |
| if not value: | |
| return False | |
| if isinstance(value, datetime): | |
| dt = value | |
| else: | |
| try: | |
| dt = datetime.fromisoformat(str(value).replace("Z", "+00:00")) | |
| except (TypeError, ValueError): | |
| return False | |
| if dt.tzinfo is None: | |
| dt = dt.replace(tzinfo=timezone.utc) | |
| return dt <= datetime.now(timezone.utc) | |
| def _user_public_dict(user) -> dict: | |
| return { | |
| "id": str(user.id), | |
| "username": user.username, | |
| "role": user.role, | |
| "email": user.email, | |
| "status": user.status, | |
| "expires_at": user.expires_at.isoformat() if user.expires_at else None, | |
| "last_login_at": user.last_login_at.isoformat() if user.last_login_at else None, | |
| } | |
| async def _find_db_user(*, user_id: str | None = None, username: str | None = None): | |
| """Return (user, db_available); distinguish empty DB from DB failure.""" | |
| try: | |
| from sqlalchemy import select | |
| from app.database import async_session | |
| from app.models import User | |
| async with async_session() as db: | |
| query = select(User) | |
| if user_id is not None: | |
| query = query.where(User.id == user_id) | |
| else: | |
| query = query.where(User.username == username) | |
| return (await db.execute(query)).scalar_one_or_none(), True | |
| except Exception as exc: | |
| # Keep legacy auth available during a temporary DB/tunnel outage. | |
| print(f"[Auth] Database lookup unavailable: {exc}") | |
| return None, False | |
| async def authenticate_db(username: str, password: str): | |
| """Return (user, db_available) for login, updating last_login_at.""" | |
| user, available = await _find_db_user(username=username) | |
| if not available or user is None: | |
| return (None, available) | |
| if user.status != "active" or _is_expired(user.expires_at): | |
| return (None, True) | |
| if not verify_password(password, user.password_hash): | |
| return (None, True) | |
| try: | |
| from app.database import async_session | |
| async with async_session() as db: | |
| managed = await db.get(type(user), user.id) | |
| if managed is not None: | |
| managed.last_login_at = datetime.now(timezone.utc) | |
| await db.commit() | |
| user = managed | |
| except Exception as exc: | |
| print(f"[Auth] Could not update last_login_at: {exc}") | |
| return (user, True) | |
| def require_roles(*roles: str): | |
| """Build a FastAPI dependency for role-protected routes.""" | |
| allowed = set(roles) | |
| async def dependency(current_user: dict = Depends(get_current_user)): | |
| if current_user.get("role") not in allowed: | |
| raise HTTPException(status_code=403, detail="Insufficient permissions") | |
| return current_user | |
| return dependency | |