Files
SonicForgeStudio/app/api/v1/auth.py
T

291 lines
10 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
}
}
@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}