feat: harden single-user account authentication
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m47s

This commit is contained in:
渡口浪人
2026-10-20 16:06:24 +08:00
parent ecfa17607f
commit 09c6773d76
4 changed files with 210 additions and 63 deletions
+2
View File
@@ -28,6 +28,8 @@ docker compose up -d
打开 `http://127.0.0.1:6005`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。 打开 `http://127.0.0.1:6005`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。
网页仅支持一个用户,不提供公开注册。密码为 8–256 位,支持密码管理器;使用带随机盐的 scrypt(N=16384、r=8、p=5)保存,旧哈希登录成功后自动升级。连续 5 次失败后按账号和来源 IP 限制登录 5 分钟。修改密码需验证当前密码,成功后撤销全部会话;写入请求校验 CSRF Token,登录拒绝跨来源请求。公网访问应通过 HTTPS,HTTPS 下会话 Cookie 自动启用 Secure。
默认端口只绑定本机回环地址。需要局域网访问时,显式将 `WEB_BIND` 设置为 NAS 的局域网地址,并通过 `WEB_PORT` 选择空闲端口。参见[群晖部署](docs/NAS.md)、[构建镜像源与缓存](docs/BUILDING.md)。不会自动修改防火墙、路由器或反向代理。 默认端口只绑定本机回环地址。需要局域网访问时,显式将 `WEB_BIND` 设置为 NAS 的局域网地址,并通过 `WEB_PORT` 选择空闲端口。参见[群晖部署](docs/NAS.md)、[构建镜像源与缓存](docs/BUILDING.md)。不会自动修改防火墙、路由器或反向代理。
使用 Python 3.14 运行本地 Mock 演示: 使用 Python 3.14 运行本地 Mock 演示:
+58 -14
View File
@@ -21,16 +21,22 @@ cookie = APIKeyCookie(name=COOKIE, auto_error=False, scheme_name="AccountSession
def password_hash(password: str) -> str: def password_hash(password: str) -> str:
salt = secrets.token_hex(16) salt = secrets.token_hex(16)
digest = hashlib.scrypt( digest = hashlib.scrypt(
password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1 password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=5
).hex() ).hex()
return f"scrypt${salt}${digest}" return f"scrypt-v2${salt}${digest}"
def verify_password(password: str, encoded: str) -> bool: def verify_password(password: str, encoded: str) -> bool:
try: try:
_, salt, expected = encoded.split("$") algorithm, salt, expected = encoded.split("$")
if algorithm not in {"scrypt", "scrypt-v2"}:
return False
actual = hashlib.scrypt( actual = hashlib.scrypt(
password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1 password.encode(),
salt=bytes.fromhex(salt),
n=16384,
r=8,
p=5 if algorithm == "scrypt-v2" else 1,
).hex() ).hex()
return hmac.compare_digest(actual, expected) return hmac.compare_digest(actual, expected)
except (ValueError, TypeError): except (ValueError, TypeError):
@@ -57,6 +63,7 @@ def seed_accounts(db, config_dir: Path):
db.execute( db.execute(
"CREATE TABLE IF NOT EXISTS users (username TEXT PRIMARY KEY, password_hash TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1)" "CREATE TABLE IF NOT EXISTS users (username TEXT PRIMARY KEY, password_hash TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1)"
) )
db.execute("CREATE UNIQUE INDEX IF NOT EXISTS single_browser_account ON users((1))")
db.execute( db.execute(
"CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, username TEXT NOT NULL, csrf TEXT NOT NULL, expires_at REAL NOT NULL)" "CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, username TEXT NOT NULL, csrf TEXT NOT NULL, expires_at REAL NOT NULL)"
) )
@@ -65,7 +72,13 @@ def seed_accounts(db, config_dir: Path):
) )
path = config_dir / "accounts.json" path = config_dir / "accounts.json"
if path.exists(): if path.exists():
for user in json.loads(path.read_text()).get("users", []): users = json.loads(path.read_text()).get("users", [])
if len(users) > 1:
raise ValueError("Only one browser account is supported")
for user in users:
existing = db.one("SELECT username FROM users LIMIT 1")
if existing and existing["username"] != user["username"]:
raise ValueError("A browser account already exists")
db.execute( db.execute(
"INSERT OR IGNORE INTO users VALUES(?,?,1)", "INSERT OR IGNORE INTO users VALUES(?,?,1)",
(user["username"], user["password_hash"]), (user["username"], user["password_hash"]),
@@ -83,7 +96,7 @@ def session_user(request: Request):
) )
if not row or not row["enabled"] or row["expires_at"] <= time.time(): if not row or not row["enabled"] or row["expires_at"] <= time.time():
return None return None
if request.method in {"POST", "PUT", "DELETE"}: if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
if not hmac.compare_digest( if not hmac.compare_digest(
request.headers.get("X-CSRF-Token", ""), row["csrf"] request.headers.get("X-CSRF-Token", ""), row["csrf"]
): ):
@@ -94,32 +107,60 @@ def session_user(request: Request):
def install_accounts(app, db): def install_accounts(app, db):
@app.post("/api/v1/auth/login", response_model=AccountView, tags=["Accounts"]) @app.post("/api/v1/auth/login", response_model=AccountView, tags=["Accounts"])
def login(payload: Login, request: Request, response: Response): def login(payload: Login, request: Request, response: Response):
with db.lock:
# Reject login CSRF before checking credentials. Never trust forwarded hosts.
origin = request.headers.get("origin")
if request.headers.get("sec-fetch-site") == "cross-site" or (
origin and origin != str(request.base_url).rstrip("/")
):
raise FlowError(E.FORBIDDEN, "Cross-origin login is not allowed", 403)
identity = hashlib.sha256( identity = hashlib.sha256(
(request.client.host if request.client else "unknown").encode() (request.client.host if request.client else "unknown").encode()
).hexdigest() ).hexdigest()
attempt = db.one("SELECT * FROM login_attempts WHERE identity=?", (identity,)) # Account throttling also applies when an attacker changes source IPs.
identities = (
identity,
"account:" + hashlib.sha256(payload.username.encode()).hexdigest(),
)
attempts = [
db.one("SELECT * FROM login_attempts WHERE identity=?", (key,))
for key in identities
]
attempt = next(
(a for a in attempts if a and a["blocked_until"] > time.time()), None
)
if attempt and attempt["blocked_until"] > time.time(): if attempt and attempt["blocked_until"] > time.time():
raise FlowError( raise FlowError(
E.RATE_LIMITED, "Too many login attempts; try again later", 429 E.RATE_LIMITED, "Too many login attempts; try again later", 429
) )
row = db.one( row = db.one(
"SELECT * FROM users WHERE username=? AND enabled=1", (payload.username,) "SELECT * FROM users WHERE username=? AND enabled=1",
(payload.username,),
) )
# Perform a password calculation even for unknown usernames. # Perform a password calculation even for unknown usernames.
fallback = "scrypt$" + "00" * 16 + "$" + "00" * 64 fallback = "scrypt-v2$" + "00" * 16 + "$" + "00" * 64
valid = verify_password( valid = verify_password(
payload.password, row["password_hash"] if row else fallback payload.password, row["password_hash"] if row else fallback
) )
if not row or not valid: if not row or not valid:
for key, previous in zip(identities, attempts, strict=True):
failures = ( failures = (
attempt["failures"] if attempt and attempt["blocked_until"] == 0 else 0 previous["failures"]
if previous and previous["blocked_until"] == 0
else 0
) + 1 ) + 1
db.execute( db.execute(
"INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)", "INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)",
(identity, failures, time.time() + 300 if failures >= 5 else 0), (key, failures, time.time() + 300 if failures >= 5 else 0),
) )
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401) raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
db.execute("DELETE FROM login_attempts WHERE identity=?", (identity,)) for key in identities:
db.execute("DELETE FROM login_attempts WHERE identity=?", (key,))
if row["password_hash"].startswith("scrypt$"):
db.execute(
"UPDATE users SET password_hash=? WHERE username=?",
(password_hash(payload.password), row["username"]),
)
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32) raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
lifetime = 30 * 86400 if payload.remember else 12 * 3600 lifetime = 30 * 86400 if payload.remember else 12 * 3600
db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),)) db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),))
@@ -187,9 +228,12 @@ def install_accounts(app, db):
user = db.one("SELECT * FROM users WHERE username=?", (session["username"],)) user = db.one("SELECT * FROM users WHERE username=?", (session["username"],))
if not verify_password(payload.current_password, user["password_hash"]): if not verify_password(payload.current_password, user["password_hash"]):
raise FlowError(E.UNAUTHORIZED, "Current password is incorrect", 401) raise FlowError(E.UNAUTHORIZED, "Current password is incorrect", 401)
db.execute( with db.transaction() as connection:
connection.execute(
"UPDATE users SET password_hash=? WHERE username=?", "UPDATE users SET password_hash=? WHERE username=?",
(password_hash(payload.new_password), session["username"]), (password_hash(payload.new_password), session["username"]),
) )
db.execute("DELETE FROM sessions WHERE username=?", (session["username"],)) connection.execute(
"DELETE FROM sessions WHERE username=?", (session["username"],)
)
response.delete_cookie(COOKIE, path="/") response.delete_cookie(COOKIE, path="/")
+2 -2
View File
@@ -19,8 +19,8 @@ if len(password) < 8 or password != getpass.getpass("Confirm password: "):
args.config_dir.mkdir(parents=True, exist_ok=True) args.config_dir.mkdir(parents=True, exist_ok=True)
path = args.config_dir / "accounts.json" path = args.config_dir / "accounts.json"
users = json.loads(path.read_text()).get("users", []) if path.exists() else [] users = json.loads(path.read_text()).get("users", []) if path.exists() else []
if any(u["username"] == args.username for u in users): if users:
sys.exit("Bootstrap user already exists. Change the password in Settings.") sys.exit("Single-user account already exists. Change the password in Settings.")
users.append({"username": args.username, "password_hash": password_hash(password)}) users.append({"username": args.username, "password_hash": password_hash(password)})
path.write_text(json.dumps({"users": users}) + "\n") path.write_text(json.dumps({"users": users}) + "\n")
path.chmod(0o600) path.chmod(0o600)
+101
View File
@@ -141,3 +141,104 @@ def test_browser_admin_can_read_other_clients_operation(client, app, service):
assert operation_id in [ assert operation_id in [
op["operation_id"] for op in client.get("/api/v1/operations").json() op["operation_id"] for op in client.get("/api/v1/operations").json()
] ]
def test_password_minimum_and_https_cookie(client, app):
response = login(client, app)
client.headers["X-CSRF-Token"] = response.json()["csrf_token"]
assert (
client.put(
"/api/v1/auth/password",
json={"current_password": "fixture-password", "new_password": "1234567"},
).status_code
== 422
)
assert (
client.put(
"/api/v1/auth/password",
json={"current_password": "fixture-password", "new_password": "12345678"},
).status_code
== 204
)
response = client.post(
"https://testserver/api/v1/auth/login",
json={"username": "test-user", "password": "12345678"},
)
assert response.status_code == 200
assert "Secure" in response.headers["set-cookie"]
assert response.headers["cache-control"] == "no-store"
def test_login_rejects_cross_origin(client, app):
account(app)
assert (
client.post(
"/api/v1/auth/login",
headers={"Origin": "https://evil.example"},
json={"username": "test-user", "password": "fixture-password"},
).status_code
== 403
)
def test_account_throttle_across_source_addresses(client, app):
account(app)
for _ in range(5):
assert (
client.post(
"/api/v1/auth/login",
json={"username": "test-user", "password": "wrong"},
).status_code
== 401
)
app.state.service.db.execute(
"DELETE FROM login_attempts WHERE identity NOT LIKE 'account:%'"
)
assert (
client.post(
"/api/v1/auth/login",
json={"username": "test-user", "password": "fixture-password"},
).status_code
== 429
)
def test_legacy_hash_upgrade(client, app):
import hmac
from pigway_frame_flow.core.accounts import verify_password
salt = "12" * 16
digest = hashlib.scrypt(
b"fixture-password", salt=bytes.fromhex(salt), n=16384, r=8, p=1
).hex()
legacy = f"scrypt${salt}${digest}"
app.state.service.db.execute(
"INSERT INTO users VALUES(?,?,1)", ("test-user", legacy)
)
assert verify_password("fixture-password", legacy)
assert (
client.post(
"/api/v1/auth/login",
json={"username": "test-user", "password": "fixture-password"},
).status_code
== 200
)
stored = app.state.service.db.one("SELECT password_hash FROM users")[
"password_hash"
]
assert stored.startswith("scrypt-v2$") and not hmac.compare_digest(stored, legacy)
assert verify_password("fixture-password", stored)
def test_only_one_browser_account(app):
import sqlite3
import pytest
account(app)
with pytest.raises(sqlite3.IntegrityError):
app.state.service.db.execute(
"INSERT INTO users VALUES(?,?,1)",
("other", password_hash("another-password")),
)