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 分开。
|
打开 `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 演示:
|
||||||
|
|||||||
@@ -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="/")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user