"""
Authentication and Authorization for Mitai Jinkendo
Provides password hashing, session management, and auth dependencies
for FastAPI endpoints.
"""
import hashlib
import secrets
from typing import Optional
from datetime import datetime, timedelta
from fastapi import Header, Query, HTTPException
import bcrypt
from db import get_db, get_cursor
from media_grant import profile_id_from_media_grant
# Fester bcrypt-Hash, damit ein unbekannter Login dieselbe Rechenzeit hat wie ein Treffer.
_LOGIN_TIMING_HASH = "$2b$12$GYdnAmeiqaZg/.5J9E4f4.xhz70piViqXIpqKCY/FY609qj65sc9i"
MAX_SESSION_DAYS = 30
def hash_pin(pin: str) -> str:
"""Hash password with bcrypt. Falls back gracefully from legacy SHA256."""
return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
def verify_pin(pin: str, stored_hash: str) -> bool:
"""Verify password - supports both bcrypt and legacy SHA256."""
if not stored_hash:
return False
# Detect bcrypt hash (starts with $2b$ or $2a$)
if stored_hash.startswith('$2'):
try:
return bcrypt.checkpw(pin.encode(), stored_hash.encode())
except Exception:
return False
# Legacy SHA256 support (auto-upgrade to bcrypt on next login)
return stored_hash == hashlib.sha256(pin.encode()).hexdigest()
def verify_pin_for_login(pin: str, stored_hash: Optional[str]) -> bool:
"""Immer ein bcrypt-Vergleich, auch wenn das Konto fehlt (kein Zeit-Orakel)."""
if stored_hash and str(stored_hash).startswith("$2"):
return verify_pin(pin, stored_hash)
verify_pin(pin or "x", _LOGIN_TIMING_HASH)
if not stored_hash:
return False
return verify_pin(pin, stored_hash)
def session_lifetime_days(profile: Optional[dict]) -> int:
raw = profile.get("session_days") if profile else None
try:
days = int(raw) if raw is not None else MAX_SESSION_DAYS
except (TypeError, ValueError):
days = MAX_SESSION_DAYS
if days < 1:
days = 1
if days > MAX_SESSION_DAYS:
days = MAX_SESSION_DAYS
return days
def revoke_profile_sessions(cur, profile_id: int) -> None:
"""Alle Sitzungen eines Profils entfernen, inklusive Reset- und 2FA-Zwischentokens."""
cur.execute("DELETE FROM sessions WHERE profile_id = %s", (profile_id,))
def make_token() -> str:
"""Generate a secure random token for sessions."""
return secrets.token_urlsafe(32)
def get_session(token: str):
"""
Get session data for a given token.
Returns session dict with profile info, or None if invalid/expired.
"""
if not token:
return None
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT s.*, p.role, p.name "
"FROM sessions s JOIN profiles p ON s.profile_id=p.id "
"WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP "
"AND COALESCE(s.purpose, 'full') = 'full'",
(token,)
)
return cur.fetchone()
def get_limited_session(token: str, purpose: str):
"""Sitzung mit festem Zweck (totp_enroll, totp_challenge). Kein Fach-API-Zugriff."""
if not token or purpose not in ("totp_enroll", "totp_challenge"):
return None
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT s.*, p.role, p.name, p.email "
"FROM sessions s JOIN profiles p ON s.profile_id=p.id "
"WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP "
"AND s.purpose = %s",
(token, purpose),
)
return cur.fetchone()
def _profile_session_from_id(profile_id: int) -> Optional[dict]:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id AS profile_id, role, name FROM profiles WHERE id = %s",
(profile_id,),
)
return cur.fetchone()
def require_auth(x_auth_token: Optional[str] = Header(default=None)):
"""
FastAPI dependency - requires valid authentication.
Usage:
@app.get("/api/endpoint")
def endpoint(session: dict = Depends(require_auth)):
profile_id = session['profile_id']
...
Raises:
HTTPException 401 if not authenticated
"""
session = get_session(x_auth_token)
if not session:
raise HTTPException(401, "Nicht eingeloggt")
return session
def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), ssetoken: Optional[str] = Query(default=None)):
"""
FastAPI dependency - auth via header OR query parameter.
Dateiabruf für
/