fix(rmi-backend): define missing User helpers + import CryptContext (fixes 23 F821 in app/auth.py)

This commit is contained in:
opencode 2026-07-06 15:55:14 +02:00
parent c762564d40
commit 6b29227131

View file

@ -14,8 +14,11 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.core.redis import get_redis
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["auth"]) router = APIRouter(tags=["auth"])
@ -83,12 +86,12 @@ def _generate_backup_codes(count: int = 8) -> list:
def _hash_backup_code(code: str) -> str: def _hash_backup_code(code: str) -> str:
"""Hash a backup code for storage (same as password hashing).""" """Hash a backup code for storage (same as password hashing)."""
return pwd_context.hash(code) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue return pwd_context.hash(code)
def _verify_backup_code(code: str, hashed: str) -> bool: def _verify_backup_code(code: str, hashed: str) -> bool:
"""Verify a backup code against its hash.""" """Verify a backup code against its hash."""
return pwd_context.verify(code, hashed) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue return pwd_context.verify(code, hashed)
# ── Config ── # ── Config ──
@ -98,6 +101,8 @@ JWT_EXPIRY_DAYS = 7
import bcrypt # noqa: E402 import bcrypt # noqa: E402
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
"""Hash password using bcrypt directly.""" """Hash password using bcrypt directly."""
@ -112,6 +117,45 @@ def verify_password(password: str, hashed: str) -> bool:
return False return False
# ── Redis User Store ──
def _get_user(user_id: str) -> dict[str, Any] | None:
"""Fetch user record by id from Redis hash rmi:users."""
r = get_redis()
if not r:
return None
raw = r.hget("rmi:users", user_id)
if not raw:
return None
try:
return json.loads(raw)
except (json.JSONDecodeError, TypeError):
return None
def _get_user_by_email(email: str) -> dict[str, Any] | None:
"""Fetch user record by email via rmi:users:email index → rmi:users hash."""
r = get_redis()
if not r:
return None
user_id = r.hget("rmi:users:email", email.lower())
if not user_id:
return None
if isinstance(user_id, bytes):
user_id = user_id.decode("utf-8")
return _get_user(user_id)
def _save_user(user: dict[str, Any]) -> None:
"""Persist user record to Redis hash rmi:users, refresh email index if present."""
r = get_redis()
if not r:
return
user_id = user["id"]
r.hset("rmi:users", user_id, json.dumps(user))
if user.get("email"):
r.hset("rmi:users:email", user["email"].lower(), user_id)
# ── Helpers ── # ── Helpers ──
def _is_valid_email(email: str) -> bool: def _is_valid_email(email: str) -> bool:
"""Basic email validation.""" """Basic email validation."""
@ -265,7 +309,7 @@ def register_email(req: EmailRegisterRequest):
raise HTTPException(status_code=400, detail="Password must be at least 8 characters") raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
# Check if user exists # Check if user exists
existing = _get_user_by_email(req.email) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue existing = _get_user_by_email(req.email)
if existing: if existing:
raise HTTPException(status_code=400, detail="Email already registered") raise HTTPException(status_code=400, detail="Email already registered")
@ -290,7 +334,7 @@ def register_email(req: EmailRegisterRequest):
} }
# Save user + email index # Save user + email index
r = get_redis() # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue r = get_redis()
r.hset("rmi:users", user_id, json.dumps(user)) r.hset("rmi:users", user_id, json.dumps(user))
r.hset("rmi:users:email", req.email.lower(), user_id) r.hset("rmi:users:email", req.email.lower(), user_id)
@ -315,7 +359,7 @@ def register_email(req: EmailRegisterRequest):
@router.post("/login", response_model=WalletAuthResponse) @router.post("/login", response_model=WalletAuthResponse)
def login_email(req: EmailLoginRequest): def login_email(req: EmailLoginRequest):
"""Login with email/password. If 2FA enabled, returns partial token requiring /2fa/login.""" """Login with email/password. If 2FA enabled, returns partial token requiring /2fa/login."""
user = _get_user_by_email(req.email) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue user = _get_user_by_email(req.email)
if not user or not user.get("password_hash"): if not user or not user.get("password_hash"):
raise HTTPException(status_code=401, detail="Invalid credentials") raise HTTPException(status_code=401, detail="Invalid credentials")
@ -434,7 +478,7 @@ async def get_current_user(request: Request):
if not payload: if not payload:
raise HTTPException(status_code=401, detail="Invalid token") raise HTTPException(status_code=401, detail="Invalid token")
user = _get_user(payload["id"]) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue user = _get_user(payload["id"])
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
@ -530,7 +574,7 @@ async def google_callback(request: Request):
google_user = resp.json() google_user = resp.json()
email = google_user.get("email") email = google_user.get("email")
user = _get_user_by_email(email) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue user = _get_user_by_email(email)
if not user: if not user:
user_id = _derive_user_id(email) user_id = _derive_user_id(email)
user = { user = {
@ -546,7 +590,7 @@ async def google_callback(request: Request):
"scans_remaining": 5, "scans_remaining": 5,
"scans_used": 0, "scans_used": 0,
} }
r = get_redis() # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue r = get_redis()
r.hset("rmi:users", user_id, json.dumps(user)) r.hset("rmi:users", user_id, json.dumps(user))
r.hset("rmi:users:email", email.lower(), user_id) r.hset("rmi:users:email", email.lower(), user_id)
@ -601,7 +645,7 @@ async def google_callback_post(request: Request):
google_user = resp.json() google_user = resp.json()
email = google_user.get("email") email = google_user.get("email")
user = _get_user_by_email(email) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue user = _get_user_by_email(email)
if not user: if not user:
user_id = _derive_user_id(email) user_id = _derive_user_id(email)
user = { user = {
@ -617,7 +661,7 @@ async def google_callback_post(request: Request):
"scans_remaining": 5, "scans_remaining": 5,
"scans_used": 0, "scans_used": 0,
} }
r = get_redis() # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue r = get_redis()
r.hset("rmi:users", user_id, json.dumps(user)) r.hset("rmi:users", user_id, json.dumps(user))
r.hset("rmi:users:email", email.lower(), user_id) r.hset("rmi:users:email", email.lower(), user_id)
@ -671,7 +715,7 @@ async def telegram_auth(req: TelegramAuthRequest):
raise HTTPException(status_code=401, detail="Invalid Telegram auth data") raise HTTPException(status_code=401, detail="Invalid Telegram auth data")
telegram_user_id = f"tg:{req.id}" telegram_user_id = f"tg:{req.id}"
user = _get_user(telegram_user_id) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue user = _get_user(telegram_user_id)
if not user: if not user:
display_name = f"{req.first_name or ''} {req.last_name or ''}".strip() or req.username or f"User{req.id}" display_name = f"{req.first_name or ''} {req.last_name or ''}".strip() or req.username or f"User{req.id}"
@ -692,7 +736,7 @@ async def telegram_auth(req: TelegramAuthRequest):
"scans_remaining": 5, "scans_remaining": 5,
"scans_used": 0, "scans_used": 0,
} }
r = get_redis() # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue r = get_redis()
r.hset("rmi:users", telegram_user_id, json.dumps(user)) r.hset("rmi:users", telegram_user_id, json.dumps(user))
r.hset("rmi:users:telegram", str(req.id), telegram_user_id) r.hset("rmi:users:telegram", str(req.id), telegram_user_id)
@ -801,7 +845,7 @@ async def github_callback(request: Request):
if not email: if not email:
email = f"{github_user.get('login', 'github_user')}@github.rmi" email = f"{github_user.get('login', 'github_user')}@github.rmi"
user = _get_user_by_email(email) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue user = _get_user_by_email(email)
if not user: if not user:
user_id = _derive_user_id(email) user_id = _derive_user_id(email)
user = { user = {
@ -817,7 +861,7 @@ async def github_callback(request: Request):
"scans_remaining": 5, "scans_remaining": 5,
"scans_used": 0, "scans_used": 0,
} }
r = get_redis() # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue r = get_redis()
r.hset("rmi:users", user_id, json.dumps(user)) r.hset("rmi:users", user_id, json.dumps(user))
r.hset("rmi:users:email", email.lower(), user_id) r.hset("rmi:users:email", email.lower(), user_id)
@ -863,8 +907,6 @@ async def x_callback(request: Request):
import httpx import httpx
from app.core.redis import get_redis
client_id = os.getenv("X_CLIENT_ID") client_id = os.getenv("X_CLIENT_ID")
client_secret = os.getenv("X_CLIENT_SECRET") client_secret = os.getenv("X_CLIENT_SECRET")
redirect_uri = os.getenv("X_REDIRECT_URI", "https://rugmunch.io/api/v1/auth/x/callback") redirect_uri = os.getenv("X_REDIRECT_URI", "https://rugmunch.io/api/v1/auth/x/callback")
@ -904,7 +946,7 @@ async def x_callback(request: Request):
username = x_user.get("username", f"x_user_{x_user.get('id', 'unknown')}") username = x_user.get("username", f"x_user_{x_user.get('id', 'unknown')}")
email = f"{username}@x.rmi" # X API v2 doesn't always return email email = f"{username}@x.rmi" # X API v2 doesn't always return email
user = _get_user_by_email(email) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue user = _get_user_by_email(email)
if not user: if not user:
user_id = _derive_user_id(email) user_id = _derive_user_id(email)
user = { user = {
@ -938,7 +980,7 @@ async def get_current_user(request: Request) -> dict[str, Any] | None: # noqa:
payload = _verify_jwt(token) payload = _verify_jwt(token)
if not payload: if not payload:
return None return None
user = _get_user(payload["id"]) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue user = _get_user(payload["id"])
if not user: if not user:
return None return None
return user return user
@ -975,7 +1017,7 @@ async def twofa_setup(request: Request):
backup_codes = _generate_backup_codes(8) backup_codes = _generate_backup_codes(8)
# Store pending secret (not enabled until verified) # Store pending secret (not enabled until verified)
r = get_redis() # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue r = get_redis()
pending = { pending = {
"secret": secret, "secret": secret,
"backup_codes": [_hash_backup_code(c) for c in backup_codes], "backup_codes": [_hash_backup_code(c) for c in backup_codes],
@ -1001,7 +1043,7 @@ async def twofa_enable(req: TwoFAEnableRequest, request: Request):
if not user: if not user:
raise HTTPException(status_code=401, detail="Authentication required") raise HTTPException(status_code=401, detail="Authentication required")
r = get_redis() # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue r = get_redis()
pending_raw = r.get(f"rmi:2fa_pending:{user['id']}") pending_raw = r.get(f"rmi:2fa_pending:{user['id']}")
if not pending_raw: if not pending_raw:
raise HTTPException(status_code=400, detail="No pending 2FA setup. Call /2fa/setup first.") raise HTTPException(status_code=400, detail="No pending 2FA setup. Call /2fa/setup first.")
@ -1019,7 +1061,7 @@ async def twofa_enable(req: TwoFAEnableRequest, request: Request):
user["totp_created_at"] = pending["created_at"] user["totp_created_at"] = pending["created_at"]
user["totp_backup_codes"] = pending["backup_codes"] # already hashed user["totp_backup_codes"] = pending["backup_codes"] # already hashed
user["totp_last_used"] = None user["totp_last_used"] = None
_save_user(user) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue _save_user(user)
# Clean up pending # Clean up pending
r.delete(f"rmi:2fa_pending:{user['id']}") r.delete(f"rmi:2fa_pending:{user['id']}")
@ -1050,7 +1092,7 @@ async def twofa_disable(request: Request):
]: ]:
user.pop(key, None) user.pop(key, None)
_save_user(user) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue _save_user(user)
logger.info(f"[2FA DISABLED] user={user['id']} email={user['email']}") logger.info(f"[2FA DISABLED] user={user['id']} email={user['email']}")
return {"status": "ok", "message": "2FA disabled successfully"} return {"status": "ok", "message": "2FA disabled successfully"}
@ -1114,7 +1156,7 @@ async def twofa_verify(req: TwoFAVerifyRequest, request: Request):
# Update last used # Update last used
user["totp_last_used"] = datetime.utcnow().isoformat() user["totp_last_used"] = datetime.utcnow().isoformat()
_save_user(user) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue _save_user(user)
return {"status": "ok", "verified": True} return {"status": "ok", "verified": True}
@ -1125,7 +1167,7 @@ async def twofa_login(req: TwoFALoginRequest):
if not PYOTP_AVAILABLE: if not PYOTP_AVAILABLE:
raise HTTPException(status_code=503, detail="2FA service unavailable") raise HTTPException(status_code=503, detail="2FA service unavailable")
user = _get_user_by_email(req.email) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue user = _get_user_by_email(req.email)
if not user or not user.get("password_hash"): if not user or not user.get("password_hash"):
raise HTTPException(status_code=401, detail="Invalid credentials") raise HTTPException(status_code=401, detail="Invalid credentials")
@ -1157,7 +1199,7 @@ async def twofa_login(req: TwoFALoginRequest):
# Update last used # Update last used
user["totp_last_used"] = datetime.utcnow().isoformat() user["totp_last_used"] = datetime.utcnow().isoformat()
_save_user(user) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue _save_user(user)
token = _create_jwt( token = _create_jwt(
user["id"], user["id"],