import uuid import time import threading from fastapi import APIRouter, HTTPException, Header, Depends, Request, Response from fastapi.responses import JSONResponse from pydantic import BaseModel, EmailStr from typing import Optional from app.models.user import get_db_connection from app.core.auth import ( hash_password, verify_password, create_token, decode_token, seed_admin, COOKIE_NAME, X_AUTH_HEADER, ) router = APIRouter() class LoginRequest(BaseModel): username: Optional[str] = "admin" password: str class RegisterRequest(BaseModel): username: str email: str password: str class ChangePasswordRequest(BaseModel): old_password: str new_password: str # ── Brute-force guard: in-memory per-IP failed-login limiter ── _LOGIN_FAILURES = {} # ip -> [timestamps] _LOGIN_LOCK = threading.Lock() MAX_LOGIN_ATTEMPTS = 10 LOGIN_WINDOW_SEC = 900 # 15 min LOGIN_BLOCK_SEC = 900 def _check_login_ratelimit(ip: str): now = time.time() with _LOGIN_LOCK: stamps = [t for t in _LOGIN_FAILURES.get(ip, []) if now - t < LOGIN_WINDOW_SEC] if len(stamps) >= MAX_LOGIN_ATTEMPTS: raise HTTPException(status_code=429, detail="Quá nhiều lần đăng nhập thất bại. Vui lòng thử lại sau 15 phút.") _LOGIN_FAILURES[ip] = stamps def _record_login_failure(ip: str): now = time.time() with _LOGIN_LOCK: stamps = _LOGIN_FAILURES.setdefault(ip, []) stamps.append(now) _LOGIN_FAILURES[ip] = [t for t in stamps if now - t < LOGIN_WINDOW_SEC] def _record_login_success(ip: str): with _LOGIN_LOCK: _LOGIN_FAILURES.pop(ip, None) def _set_auth_cookie(response: Response, token: str): response.set_cookie( COOKIE_NAME, token, max_age=7 * 24 * 3600, httponly=True, samesite="lax", # path="/" (default); secure flag set by proxy when behind TLS ) def get_current_user(request: Request, authorization: Optional[str] = Header(None), x_auth_token: Optional[str] = Header(None)): token = None if authorization and authorization.startswith("Bearer "): token = authorization.split(" ")[1] elif x_auth_token: token = x_auth_token elif request.cookies.get(COOKIE_NAME): token = request.cookies.get(COOKIE_NAME) if not token: raise HTTPException(status_code=401, detail="Thiếu Token xác thực hoặc Token không hợp lệ") payload = decode_token(token) if not payload: raise HTTPException(status_code=401, detail="Token đã hết hạn hoặc không hợp lệ") return payload def enforce_password_changed(user: dict): """Bắt buộc người dùng phải đổi mật khẩu ở lần đăng nhập đầu tiên (22_CLIENT_DESK.md §4.1).""" if user.get("must_change_password"): raise HTTPException( status_code=403, detail="Tài khoản bắt buộc phải đổi mật khẩu ở lần đăng nhập đầu tiên trước khi thực hiện xử lý nhạc (HTTP 403 Forbidden)." ) @router.post("/login") async def login(req: LoginRequest, request: Request): client_ip = request.client.host if request.client else "unknown" _check_login_ratelimit(client_ip) conn = get_db_connection() cursor = conn.cursor() username = (req.username or "").strip() if not username: username = "admin" password = (req.password or "").strip() # Case-insensitive search by username or email cursor.execute("SELECT * FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)", (username, username)) user = cursor.fetchone() # Auto-heal seed_admin if admin record missing if not user and username.lower() == "admin": conn.close() seed_admin() conn = get_db_connection() cursor = conn.cursor() cursor.execute("SELECT * FROM users WHERE username = 'admin'") user = cursor.fetchone() conn.close() if not user or not user["is_active"]: _record_login_failure(client_ip) raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác") if not verify_password(password, user["hashed_password"]): _record_login_failure(client_ip) raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác") token = create_token(user["id"], user["username"], user["role"], user["must_change_password"]) _record_login_success(client_ip) resp = JSONResponse({ "access_token": token, "user": { "id": user["id"], "username": user["username"], "email": user["email"], "role": user["role"], "must_change_password": bool(user["must_change_password"]) } }) _set_auth_cookie(resp, token) return resp def _validate_password_strength(password: str): """Minimal strength policy: >= 8 chars and not trivially common.""" if len(password) < 8: raise HTTPException(status_code=400, detail="Mật khẩu phải có ít nhất 8 ký tự") lowered = password.lower() if lowered in ("admin123", "password", "12345678", "123456789", "qwerty123"): raise HTTPException(status_code=400, detail="Mật khẩu quá dễ đoán, vui lòng chọn mật khẩu khác") @router.post("/register") async def register(req: RegisterRequest, request: Request): username = req.username.strip() email = req.email.strip() password = req.password.strip() _validate_password_strength(password) conn = get_db_connection() cursor = conn.cursor() cursor.execute("SELECT id FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)", (username, email)) if cursor.fetchone(): conn.close() raise HTTPException(status_code=400, detail="Tên người dùng hoặc Email đã tồn tại") user_id = str(uuid.uuid4()) hashed_pwd = hash_password(password) now = time.time() cursor.execute(""" INSERT INTO users (id, username, email, hashed_password, role, must_change_password, created_at, is_active) VALUES (?, ?, ?, ?, 'standard', 0, ?, 1) """, (user_id, username, email, hashed_pwd, now)) cursor.execute(""" INSERT INTO user_quotas (user_id, storage_limit_mb, max_tracks) VALUES (?, 500, 16) """, (user_id,)) conn.commit() conn.close() token = create_token(user_id, username, "standard", False) resp = JSONResponse({ "access_token": token, "user": { "id": user_id, "username": username, "email": email, "role": "standard", "must_change_password": False } }) _set_auth_cookie(resp, token) return resp @router.post("/change-password") async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)): user_id = current_user["user_id"] old_pwd = req.old_password.strip() new_pwd = req.new_password.strip() conn = get_db_connection() cursor = conn.cursor() cursor.execute("SELECT hashed_password FROM users WHERE id = ?", (user_id,)) user = cursor.fetchone() if not user or not verify_password(old_pwd, user["hashed_password"]): conn.close() raise HTTPException(status_code=400, detail="Mật khẩu hiện tại không chính xác") new_hashed = hash_password(new_pwd) cursor.execute(""" UPDATE users SET hashed_password = ?, must_change_password = 0 WHERE id = ? """, (new_hashed, user_id)) conn.commit() cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,)) updated_user = cursor.fetchone() conn.close() new_token = create_token(updated_user["id"], updated_user["username"], updated_user["role"], False) resp = JSONResponse({ "message": "Đổi mật khẩu thành công!", "access_token": new_token }) _set_auth_cookie(resp, new_token) return resp @router.get("/profile") async def get_profile(current_user: dict = Depends(get_current_user)): user_id = current_user["user_id"] conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" SELECT u.id, u.username, u.email, u.role, u.must_change_password, q.storage_limit_mb, q.max_tracks FROM users u LEFT JOIN user_quotas q ON u.id = q.user_id WHERE u.id = ? """, (user_id,)) row = cursor.fetchone() cursor.execute("SELECT SUM(size_bytes) as total_used FROM projects WHERE user_id = ?", (user_id,)) used_row = cursor.fetchone() used_bytes = used_row["total_used"] if used_row and used_row["total_used"] else 0 used_mb = round(used_bytes / (1024 * 1024), 2) conn.close() if not row: raise HTTPException(status_code=404, detail="Không tìm thấy thông tin tài khoản") return { "id": row["id"], "username": row["username"], "email": row["email"], "role": row["role"], "must_change_password": bool(row["must_change_password"]), "quota": { "storage_limit_mb": row["storage_limit_mb"] or 500, "used_mb": used_mb, "max_tracks": row["max_tracks"] or 16 } } @router.get("/first-time") async def auth_first_time(): # "Lần đăng nhập đầu" = tài khoản admin vẫn dùng mật khẩu MẶC ĐỊNH # (chưa từng đổi). Sau khi đổi lần đầu → first_time = false → UI xóa # gợi ý username/mật khẩu (Tùy chọn - Admin có thể bỏ trống, lần đầu: # admin123, nút Điền nhanh). try: conn = get_db_connection() try: cur = conn.cursor() cur.execute( "SELECT id, hashed_password, must_change_password FROM users " "WHERE LOWER(role) = 'admin' ORDER BY created_at ASC LIMIT 1" ) row = cur.fetchone() finally: conn.close() if not row: return {"first_time": True} still_default = False try: still_default = verify_password("admin123", row["hashed_password"]) except Exception: still_default = False return {"first_time": bool(row["must_change_password"]) and still_default} except Exception: return {"first_time": True}