stouchinglhh
collector: 同ζ­₯采集ε·₯程 v1.2.1οΌˆε…¨ιƒ¨ LFS ζ¨‘εΌζ–‡δ»ΆζŒ‡ι’ˆεŒ–οΌŒδΈŽ Space ηŽ°ζœ‰ζ–‡δ»ΆεˆεΉΆοΌ‰
d646dd7
Raw History Blame Contribute Delete
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