fix(rmi-backend): define missing User helpers + import CryptContext (fixes 23 F821 in app/auth.py)
This commit is contained in:
parent
c762564d40
commit
6b29227131
1 changed files with 67 additions and 25 deletions
92
app/auth.py
92
app/auth.py
|
|
@ -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"],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue