diff --git a/README.zh-CN.md b/README.zh-CN.md index 299f55c..64acbb0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 演示: diff --git a/pigway_frame_flow/core/accounts.py b/pigway_frame_flow/core/accounts.py index 52ec692..a166932 100644 --- a/pigway_frame_flow/core/accounts.py +++ b/pigway_frame_flow/core/accounts.py @@ -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,60 +107,88 @@ 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): - identity = hashlib.sha256( - (request.client.host if request.client else "unknown").encode() - ).hexdigest() - attempt = db.one("SELECT * FROM login_attempts WHERE identity=?", (identity,)) - if attempt and attempt["blocked_until"] > time.time(): - raise FlowError( - E.RATE_LIMITED, "Too many login attempts; try again later", 429 + 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() + # Account throttling also applies when an attacker changes source IPs. + identities = ( + identity, + "account:" + hashlib.sha256(payload.username.encode()).hexdigest(), ) - row = db.one( - "SELECT * FROM users WHERE username=? AND enabled=1", (payload.username,) - ) - # Perform a password calculation even for unknown usernames. - fallback = "scrypt$" + "00" * 16 + "$" + "00" * 64 - valid = verify_password( - payload.password, row["password_hash"] if row else fallback - ) - if not row or not valid: - failures = ( - attempt["failures"] if attempt and attempt["blocked_until"] == 0 else 0 - ) + 1 + 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,), + ) + # Perform a password calculation even for unknown usernames. + 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 = ( + previous["failures"] + if previous and previous["blocked_until"] == 0 + else 0 + ) + 1 + db.execute( + "INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)", + (key, failures, time.time() + 300 if failures >= 5 else 0), + ) + raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401) + 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(),)) + old = request.cookies.get(COOKIE) + if old: + db.execute( + "DELETE FROM sessions WHERE token_hash=?", + (hashlib.sha256(old.encode()).hexdigest(),), + ) db.execute( - "INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)", - (identity, failures, time.time() + 300 if failures >= 5 else 0), + "INSERT INTO sessions VALUES(?,?,?,?)", + ( + hashlib.sha256(raw.encode()).hexdigest(), + row["username"], + csrf, + time.time() + lifetime, + ), ) - raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401) - db.execute("DELETE FROM login_attempts WHERE identity=?", (identity,)) - 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(),)) - old = request.cookies.get(COOKIE) - if old: - db.execute( - "DELETE FROM sessions WHERE token_hash=?", - (hashlib.sha256(old.encode()).hexdigest(),), + response.set_cookie( + COOKIE, + raw, + max_age=lifetime if payload.remember else None, + httponly=True, + samesite="strict", + secure=request.url.scheme == "https", + path="/", ) - db.execute( - "INSERT INTO sessions VALUES(?,?,?,?)", - ( - hashlib.sha256(raw.encode()).hexdigest(), - row["username"], - csrf, - time.time() + lifetime, - ), - ) - response.set_cookie( - COOKIE, - raw, - max_age=lifetime if payload.remember else None, - httponly=True, - samesite="strict", - secure=request.url.scheme == "https", - path="/", - ) - return AccountView(username=row["username"], csrf_token=csrf) + return AccountView(username=row["username"], csrf_token=csrf) def current(request): row = session_user(request) @@ -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( - "UPDATE users SET password_hash=? WHERE username=?", - (password_hash(payload.new_password), session["username"]), - ) - db.execute("DELETE FROM sessions WHERE username=?", (session["username"],)) + with db.transaction() as connection: + connection.execute( + "UPDATE users SET password_hash=? WHERE username=?", + (password_hash(payload.new_password), session["username"]), + ) + connection.execute( + "DELETE FROM sessions WHERE username=?", (session["username"],) + ) response.delete_cookie(COOKIE, path="/") diff --git a/scripts/create-account.py b/scripts/create-account.py index 165e273..fb5e685 100644 --- a/scripts/create-account.py +++ b/scripts/create-account.py @@ -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) diff --git a/tests/test_accounts.py b/tests/test_accounts.py index b05a766..2fc63a5 100644 --- a/tests/test_accounts.py +++ b/tests/test_accounts.py @@ -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")), + )