feat: harden single-user account authentication
This commit is contained in:
@@ -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 分开。
|
||||
|
||||
网页仅支持一个用户,不提供公开注册。密码为 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)。不会自动修改防火墙、路由器或反向代理。
|
||||
|
||||
使用 Python 3.14 运行本地 Mock 演示:
|
||||
|
||||
@@ -21,16 +21,22 @@ cookie = APIKeyCookie(name=COOKIE, auto_error=False, scheme_name="AccountSession
|
||||
def password_hash(password: str) -> str:
|
||||
salt = secrets.token_hex(16)
|
||||
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()
|
||||
return f"scrypt${salt}${digest}"
|
||||
return f"scrypt-v2${salt}${digest}"
|
||||
|
||||
|
||||
def verify_password(password: str, encoded: str) -> bool:
|
||||
try:
|
||||
_, salt, expected = encoded.split("$")
|
||||
algorithm, salt, expected = encoded.split("$")
|
||||
if algorithm not in {"scrypt", "scrypt-v2"}:
|
||||
return False
|
||||
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()
|
||||
return hmac.compare_digest(actual, expected)
|
||||
except (ValueError, TypeError):
|
||||
@@ -57,6 +63,7 @@ def seed_accounts(db, config_dir: Path):
|
||||
db.execute(
|
||||
"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(
|
||||
"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"
|
||||
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(
|
||||
"INSERT OR IGNORE INTO users VALUES(?,?,1)",
|
||||
(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():
|
||||
return None
|
||||
if request.method in {"POST", "PUT", "DELETE"}:
|
||||
if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
|
||||
if not hmac.compare_digest(
|
||||
request.headers.get("X-CSRF-Token", ""), row["csrf"]
|
||||
):
|
||||
@@ -94,32 +107,60 @@ def session_user(request: Request):
|
||||
def install_accounts(app, db):
|
||||
@app.post("/api/v1/auth/login", response_model=AccountView, tags=["Accounts"])
|
||||
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(
|
||||
(request.client.host if request.client else "unknown").encode()
|
||||
).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():
|
||||
raise FlowError(
|
||||
E.RATE_LIMITED, "Too many login attempts; try again later", 429
|
||||
)
|
||||
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.
|
||||
fallback = "scrypt$" + "00" * 16 + "$" + "00" * 64
|
||||
fallback = "scrypt-v2$" + "00" * 16 + "$" + "00" * 64
|
||||
valid = verify_password(
|
||||
payload.password, row["password_hash"] if row else fallback
|
||||
)
|
||||
if not row or not valid:
|
||||
for key, previous in zip(identities, attempts, strict=True):
|
||||
failures = (
|
||||
attempt["failures"] if attempt and attempt["blocked_until"] == 0 else 0
|
||||
previous["failures"]
|
||||
if previous and previous["blocked_until"] == 0
|
||||
else 0
|
||||
) + 1
|
||||
db.execute(
|
||||
"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)
|
||||
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)
|
||||
lifetime = 30 * 86400 if payload.remember else 12 * 3600
|
||||
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"],))
|
||||
if not verify_password(payload.current_password, user["password_hash"]):
|
||||
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=?",
|
||||
(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="/")
|
||||
|
||||
@@ -19,8 +19,8 @@ if len(password) < 8 or password != getpass.getpass("Confirm password: "):
|
||||
args.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = args.config_dir / "accounts.json"
|
||||
users = json.loads(path.read_text()).get("users", []) if path.exists() else []
|
||||
if any(u["username"] == args.username for u in users):
|
||||
sys.exit("Bootstrap user already exists. Change the password in Settings.")
|
||||
if users:
|
||||
sys.exit("Single-user account already exists. Change the password in Settings.")
|
||||
users.append({"username": args.username, "password_hash": password_hash(password)})
|
||||
path.write_text(json.dumps({"users": users}) + "\n")
|
||||
path.chmod(0o600)
|
||||
|
||||
@@ -141,3 +141,104 @@ def test_browser_admin_can_read_other_clients_operation(client, app, service):
|
||||
assert operation_id in [
|
||||
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")),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user