262 lines
9.2 KiB
Python
262 lines
9.2 KiB
Python
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
|
|
}
|
|
}
|