147 lines
4.2 KiB
Python
147 lines
4.2 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Autenticazione utenti famiglia — pattern Loogle Casa."""
|
|
|
|
import base64
|
|
import datetime
|
|
import hashlib
|
|
import hmac
|
|
import os
|
|
import secrets
|
|
import time
|
|
from typing import Optional
|
|
|
|
from fastapi import HTTPException, Request
|
|
|
|
from .db import get_conn
|
|
|
|
SESSION_DAYS = 30
|
|
PBKDF2_ITER = 240_000
|
|
MAX_ATTEMPTS = 8
|
|
WINDOW_S = 600
|
|
_attempts: dict = {}
|
|
|
|
FAMILY_USERS = (
|
|
("daniele", True),
|
|
("lucia", False),
|
|
("davide", False),
|
|
("luca", False),
|
|
)
|
|
|
|
DEFAULT_SCOPES = (
|
|
"context:read context:write knowledge:read knowledge:write gitea:read gitea:write"
|
|
)
|
|
|
|
|
|
def hash_password(password: str) -> str:
|
|
salt = os.urandom(16)
|
|
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITER)
|
|
return "pbkdf2$%d$%s$%s" % (
|
|
PBKDF2_ITER,
|
|
base64.b64encode(salt).decode(),
|
|
base64.b64encode(dk).decode(),
|
|
)
|
|
|
|
|
|
def verify_password(password: str, stored: str) -> bool:
|
|
try:
|
|
_, iters, salt_b64, dk_b64 = stored.split("$")
|
|
salt = base64.b64decode(salt_b64)
|
|
expected = base64.b64decode(dk_b64)
|
|
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, int(iters))
|
|
return hmac.compare_digest(dk, expected)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def ensure_family_users() -> None:
|
|
conn = get_conn()
|
|
for username, is_admin in FAMILY_USERS:
|
|
row = conn.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()
|
|
if row:
|
|
continue
|
|
conn.execute(
|
|
"INSERT INTO users(username,password_hash,is_admin,must_change_password)"
|
|
" VALUES (?,?,?,1)",
|
|
(username, hash_password(username), 1 if is_admin else 0),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def throttle(ip: str) -> None:
|
|
now = time.time()
|
|
hist = [t for t in _attempts.get(ip, []) if now - t < WINDOW_S]
|
|
_attempts[ip] = hist
|
|
if len(hist) >= MAX_ATTEMPTS:
|
|
raise HTTPException(429, "Troppi tentativi: riprova tra qualche minuto")
|
|
|
|
|
|
def record_attempt(ip: str) -> None:
|
|
_attempts.setdefault(ip, []).append(time.time())
|
|
|
|
|
|
def authenticate(username: str, password: str) -> Optional[dict]:
|
|
conn = get_conn()
|
|
row = conn.execute("SELECT * FROM users WHERE username=?", (username.strip(),)).fetchone()
|
|
if not row or not verify_password(password, row["password_hash"]):
|
|
return None
|
|
return dict(row)
|
|
|
|
|
|
def get_user_by_id(user_id: int) -> Optional[dict]:
|
|
row = get_conn().execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def get_user_by_username(username: str) -> Optional[dict]:
|
|
row = get_conn().execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def change_password(user_id: int, old_password: str, new_password: str) -> bool:
|
|
row = get_conn().execute("SELECT password_hash FROM users WHERE id=?", (user_id,)).fetchone()
|
|
if not row or not verify_password(old_password, row["password_hash"]):
|
|
return False
|
|
conn = get_conn()
|
|
conn.execute(
|
|
"UPDATE users SET password_hash=?, must_change_password=0 WHERE id=?",
|
|
(hash_password(new_password), user_id),
|
|
)
|
|
conn.commit()
|
|
return True
|
|
|
|
|
|
def current_user_from_cookie(request: Request) -> dict:
|
|
token = request.cookies.get("mcp_session", "")
|
|
if not token:
|
|
raise HTTPException(401, "Non autenticato")
|
|
row = get_conn().execute(
|
|
"SELECT u.id,u.username,u.is_admin,u.must_change_password"
|
|
" FROM sessions s JOIN users u ON u.id=s.user_id"
|
|
" WHERE s.token=? AND s.expires_at > datetime('now')",
|
|
(token,),
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(401, "Sessione scaduta")
|
|
return dict(row)
|
|
|
|
|
|
def require_admin(request: Request) -> dict:
|
|
user = current_user_from_cookie(request)
|
|
if not user["is_admin"]:
|
|
raise HTTPException(403, "Riservato all'amministratore")
|
|
return user
|
|
|
|
|
|
def ensure_sessions_table() -> None:
|
|
get_conn().execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
token TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
expires_at TEXT NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
get_conn().commit()
|