Hash passwords with PBKDF2-SHA256 instead of storing plaintext
All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 4s
Test / build-check (push) Successful in 3s
PR Preview / test (pull_request) Successful in 4s
PR Preview / deploy-preview (pull_request) Successful in 11s
Test / e2e-test (push) Successful in 53s

Existing plaintext passwords are auto-upgraded to hashed on next login.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 21:11:44 +00:00
parent f89cfff20b
commit 43f57c55f5

View File

@@ -6,9 +6,35 @@ import json
import os import os
from backend.database import get_db, init_db, seed_defaults, log_audit from backend.database import get_db, init_db, seed_defaults, log_audit
import hashlib
import secrets as _secrets
app = FastAPI(title="Essential Oil Formula Calculator API") app = FastAPI(title="Essential Oil Formula Calculator API")
# ── Password hashing (PBKDF2-SHA256, stdlib) ─────────
def hash_password(password: str) -> str:
salt = _secrets.token_hex(16)
h = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000)
return f"{salt}${h.hex()}"
def verify_password(password: str, stored: str) -> bool:
if not stored:
return False
if "$" not in stored:
# Legacy plaintext — compare directly
return password == stored
salt, h = stored.split("$", 1)
return hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000).hex() == h
def _upgrade_password_if_needed(conn, user_id: int, password: str, stored: str):
"""If stored password is legacy plaintext, upgrade to hashed."""
if stored and "$" not in stored:
conn.execute("UPDATE users SET password = ? WHERE id = ?", (hash_password(password), user_id))
conn.commit()
# Periodic WAL checkpoint to ensure data is flushed to main DB file # Periodic WAL checkpoint to ensure data is flushed to main DB file
import threading, time as _time import threading, time as _time
def _wal_checkpoint_loop(): def _wal_checkpoint_loop():
@@ -312,7 +338,6 @@ def symptom_search(body: dict, user=Depends(get_current_user)):
# ── Register ──────────────────────────────────────────── # ── Register ────────────────────────────────────────────
@app.post("/api/register", status_code=201) @app.post("/api/register", status_code=201)
def register(body: dict): def register(body: dict):
import secrets
username = body.get("username", "").strip() username = body.get("username", "").strip()
password = body.get("password", "").strip() password = body.get("password", "").strip()
display_name = body.get("display_name", "").strip() display_name = body.get("display_name", "").strip()
@@ -320,12 +345,12 @@ def register(body: dict):
raise HTTPException(400, "用户名至少2个字符") raise HTTPException(400, "用户名至少2个字符")
if not password or len(password) < 4: if not password or len(password) < 4:
raise HTTPException(400, "密码至少4位") raise HTTPException(400, "密码至少4位")
token = secrets.token_hex(24) token = _secrets.token_hex(24)
conn = get_db() conn = get_db()
try: try:
conn.execute( conn.execute(
"INSERT INTO users (username, token, role, display_name, password) VALUES (?, ?, ?, ?, ?)", "INSERT INTO users (username, token, role, display_name, password) VALUES (?, ?, ?, ?, ?)",
(username, token, "viewer", display_name or username, password) (username, token, "viewer", display_name or username, hash_password(password))
) )
conn.commit() conn.commit()
except Exception: except Exception:
@@ -343,14 +368,19 @@ def login(body: dict):
if not username or not password: if not username or not password:
raise HTTPException(400, "请输入用户名和密码") raise HTTPException(400, "请输入用户名和密码")
conn = get_db() conn = get_db()
user = conn.execute("SELECT token, password, display_name, role FROM users WHERE username = ?", (username,)).fetchone() user = conn.execute("SELECT id, token, password, display_name, role FROM users WHERE username = ?", (username,)).fetchone()
conn.close()
if not user: if not user:
conn.close()
raise HTTPException(401, "用户名不存在") raise HTTPException(401, "用户名不存在")
if not user["password"]: if not user["password"]:
conn.close()
raise HTTPException(401, "该账号未设置密码,请使用链接登录后设置密码") raise HTTPException(401, "该账号未设置密码,请使用链接登录后设置密码")
if user["password"] != password: if not verify_password(password, user["password"]):
conn.close()
raise HTTPException(401, "密码错误") raise HTTPException(401, "密码错误")
# Auto-upgrade legacy plaintext password to hashed
_upgrade_password_if_needed(conn, user["id"], password, user["password"])
conn.close()
return {"token": user["token"], "display_name": user["display_name"], "role": user["role"]} return {"token": user["token"], "display_name": user["display_name"], "role": user["role"]}
@@ -385,11 +415,11 @@ def update_me(body: dict, user=Depends(get_current_user)):
raise HTTPException(400, "新密码至少4位") raise HTTPException(400, "新密码至少4位")
old_pw = body.get("old_password", "").strip() old_pw = body.get("old_password", "").strip()
current_pw = user.get("password") or "" current_pw = user.get("password") or ""
if current_pw and old_pw != current_pw: if current_pw and not verify_password(old_pw, current_pw):
conn.close() conn.close()
raise HTTPException(400, "当前密码不正确") raise HTTPException(400, "当前密码不正确")
if pw: if pw:
conn.execute("UPDATE users SET password = ? WHERE id = ?", (pw, user["id"])) conn.execute("UPDATE users SET password = ? WHERE id = ?", (hash_password(pw), user["id"]))
conn.commit() conn.commit()
conn.close() conn.close()
return {"ok": True} return {"ok": True}
@@ -404,7 +434,7 @@ def set_password(body: dict, user=Depends(get_current_user)):
if not pw or len(pw) < 4: if not pw or len(pw) < 4:
raise HTTPException(400, "密码至少4位") raise HTTPException(400, "密码至少4位")
conn = get_db() conn = get_db()
conn.execute("UPDATE users SET password = ? WHERE id = ?", (pw, user["id"])) conn.execute("UPDATE users SET password = ? WHERE id = ?", (hash_password(pw), user["id"]))
conn.commit() conn.commit()
conn.close() conn.close()
return {"ok": True} return {"ok": True}
@@ -896,8 +926,7 @@ def list_users(user=Depends(require_role("admin"))):
@app.post("/api/users", status_code=201) @app.post("/api/users", status_code=201)
def create_user(body: UserIn, user=Depends(require_role("admin"))): def create_user(body: UserIn, user=Depends(require_role("admin"))):
import secrets token = _secrets.token_hex(24)
token = secrets.token_hex(24)
conn = get_db() conn = get_db()
try: try:
conn.execute( conn.execute(