@@ -45,6 +45,8 @@ SHA-256 内容哈希不可变保存,重复上传返回 `duplicate` 状态并
|
|||||||
公司账号创建时生成随机一次性初始密码,只在创建响应中显示一次,
|
公司账号创建时生成随机一次性初始密码,只在创建响应中显示一次,
|
||||||
首次登录强制改密(「重置密码」同样生成随机一次性密码并吊销会话)。
|
首次登录强制改密(「重置密码」同样生成随机一次性密码并吊销会话)。
|
||||||
- 会话有效期 8 小时;同一账号同一 IP 10 分钟内登录失败 5 次将被限流。
|
- 会话有效期 8 小时;同一账号同一 IP 10 分钟内登录失败 5 次将被限流。
|
||||||
|
内网测试环境可设 `APP_LOGIN_RATE_LIMIT_DISABLED=1` 暂时关闭该锁定
|
||||||
|
(默认未设置 = 保持限流,正式环境不得开启该变量)。
|
||||||
|
|
||||||
访问地址(服务默认监听 `0.0.0.0:4173`,同局域网设备把 `127.0.0.1` 换成本机局域网 IP 即可访问;可用环境变量 `APP_HOST` / `APP_PORT` 覆盖):
|
访问地址(服务默认监听 `0.0.0.0:4173`,同局域网设备把 `127.0.0.1` 换成本机局域网 IP 即可访问;可用环境变量 `APP_HOST` / `APP_PORT` 覆盖):
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,9 @@
|
|||||||
- 计数来自 `login_attempts` 表,窗口为滚动 10 分钟;触发后返回 429,
|
- 计数来自 `login_attempts` 表,窗口为滚动 10 分钟;触发后返回 429,
|
||||||
且在窗口内不再记录新尝试,行为确定、可测试。
|
且在窗口内不再记录新尝试,行为确定、可测试。
|
||||||
- 失败提示统一为「账号或密码不正确」,不泄露是哪一部分错误。
|
- 失败提示统一为「账号或密码不正确」,不泄露是哪一部分错误。
|
||||||
|
- 运维开关:环境变量 `APP_LOGIN_RATE_LIMIT_DISABLED` 设为 `1/true/yes/on`
|
||||||
|
时完全跳过限流检查,仅限内网测试环境临时使用;默认未设置,保持
|
||||||
|
「5 次失败锁 10 分钟」的生产安全策略不变。
|
||||||
|
|
||||||
## 角色与公司绑定
|
## 角色与公司绑定
|
||||||
|
|
||||||
|
|||||||
+25
-12
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import string
|
import string
|
||||||
@@ -28,6 +29,17 @@ RATE_LIMIT_WINDOW_MINUTES = 10
|
|||||||
INITIAL_PASSWORD_LENGTH = 12
|
INITIAL_PASSWORD_LENGTH = 12
|
||||||
|
|
||||||
|
|
||||||
|
def _env_flag(value: str | None) -> bool:
|
||||||
|
"""Parse a yes/no style environment flag; blank/absent means False."""
|
||||||
|
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
# Ops switch for internal test environments: APP_LOGIN_RATE_LIMIT_DISABLED=1
|
||||||
|
# turns off the login-failure lockout entirely. The default (unset) keeps the
|
||||||
|
# production policy — 5 failures within 10 minutes lock the (账号, IP) pair.
|
||||||
|
RATE_LIMIT_DISABLED = _env_flag(os.environ.get("APP_LOGIN_RATE_LIMIT_DISABLED"))
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
"""Hash ``password`` as ``pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>``."""
|
"""Hash ``password`` as ``pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>``."""
|
||||||
salt = secrets.token_bytes(16)
|
salt = secrets.token_bytes(16)
|
||||||
@@ -146,18 +158,19 @@ def authenticate(
|
|||||||
Every non-rate-limited attempt is recorded in ``login_attempts`` and
|
Every non-rate-limited attempt is recorded in ``login_attempts`` and
|
||||||
``audit_log``; the password itself is never stored anywhere.
|
``audit_log``; the password itself is never stored anywhere.
|
||||||
"""
|
"""
|
||||||
window_start = (
|
if not RATE_LIMIT_DISABLED:
|
||||||
datetime.now(timezone.utc) - timedelta(minutes=RATE_LIMIT_WINDOW_MINUTES)
|
window_start = (
|
||||||
).isoformat()
|
datetime.now(timezone.utc) - timedelta(minutes=RATE_LIMIT_WINDOW_MINUTES)
|
||||||
failures = connection.execute(
|
).isoformat()
|
||||||
"""
|
failures = connection.execute(
|
||||||
SELECT COUNT(*) AS n FROM login_attempts
|
"""
|
||||||
WHERE username = ? AND ip = ? AND success = 0 AND created_at >= ?
|
SELECT COUNT(*) AS n FROM login_attempts
|
||||||
""",
|
WHERE username = ? AND ip = ? AND success = 0 AND created_at >= ?
|
||||||
(username, ip, window_start),
|
""",
|
||||||
).fetchone()
|
(username, ip, window_start),
|
||||||
if failures["n"] >= RATE_LIMIT_MAX_FAILURES:
|
).fetchone()
|
||||||
return None, "rate_limited"
|
if failures["n"] >= RATE_LIMIT_MAX_FAILURES:
|
||||||
|
return None, "rate_limited"
|
||||||
|
|
||||||
user = connection.execute(
|
user = connection.execute(
|
||||||
"SELECT * FROM users WHERE username = ?", (username,)
|
"SELECT * FROM users WHERE username = ?", (username,)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
import hashlib
|
import hashlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from bank_importer import auth
|
from bank_importer import auth
|
||||||
from bank_importer.db import connect, migrate, utc_now
|
from bank_importer.db import connect, migrate, utc_now
|
||||||
@@ -167,6 +168,40 @@ class AuthenticateTests(AuthTestCase):
|
|||||||
self.assertIsNotNone(user)
|
self.assertIsNotNone(user)
|
||||||
self.assertIsNone(reason)
|
self.assertIsNone(reason)
|
||||||
|
|
||||||
|
def test_env_flag_parses_truthy_and_falsy_values(self) -> None:
|
||||||
|
cases = {
|
||||||
|
"1": True,
|
||||||
|
"true": True,
|
||||||
|
"YES": True,
|
||||||
|
" on ": True,
|
||||||
|
"": False,
|
||||||
|
"0": False,
|
||||||
|
"false": False,
|
||||||
|
"off": False,
|
||||||
|
None: False,
|
||||||
|
}
|
||||||
|
for value, expected in cases.items():
|
||||||
|
with self.subTest(value=value):
|
||||||
|
self.assertEqual(expected, auth._env_flag(value))
|
||||||
|
|
||||||
|
def test_rate_limit_enabled_by_default(self) -> None:
|
||||||
|
self.assertFalse(auth.RATE_LIMIT_DISABLED)
|
||||||
|
|
||||||
|
def test_env_switch_disables_rate_limit_but_not_credential_checks(self) -> None:
|
||||||
|
self.create_company_user()
|
||||||
|
with mock.patch.object(auth, "RATE_LIMIT_DISABLED", True):
|
||||||
|
for _ in range(auth.RATE_LIMIT_MAX_FAILURES + 2):
|
||||||
|
user, reason = auth.authenticate(
|
||||||
|
self.connection, "cashier-a", "Wrong999", "10.0.0.1"
|
||||||
|
)
|
||||||
|
self.assertIsNone(user)
|
||||||
|
self.assertEqual("bad_credentials", reason)
|
||||||
|
user, reason = auth.authenticate(
|
||||||
|
self.connection, "cashier-a", "Init1234", "10.0.0.1"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(user)
|
||||||
|
self.assertIsNone(reason)
|
||||||
|
|
||||||
|
|
||||||
class SessionTests(AuthTestCase):
|
class SessionTests(AuthTestCase):
|
||||||
def test_create_and_resolve_roundtrip(self) -> None:
|
def test_create_and_resolve_roundtrip(self) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user