feat: harden account security flows

This commit is contained in:
2026-08-04 14:29:51 +08:00
parent 630252e2ff
commit c71b1351d6
14 changed files with 892 additions and 56 deletions
+56 -8
View File
@@ -2,16 +2,64 @@ from __future__ import annotations
import re
import unittest
from datetime import timedelta
from tempfile import TemporaryDirectory
from unittest.mock import patch
from flask import session
from app import create_app
from app.config import Config
from app.extensions import db
from app.models import EmailVerificationCode, TrustedDevice, User
from app.routes import grant_fresh_authorization, has_fresh_authorization, valid_password
from app.time_utils import utc_now
class EmailAuthenticationTest(unittest.TestCase):
def test_password_policy_requires_all_character_categories(self):
self.assertTrue(valid_password("Strong-password-123!"))
self.assertFalse(valid_password("lowercase-password-123!"))
self.assertFalse(valid_password("UPPERCASE-PASSWORD-123!"))
self.assertFalse(valid_password("NoSpecialPassword123"))
self.assertFalse(valid_password("NoWhitespace-123 !"))
def test_verification_page_uses_six_code_inputs_and_initial_resend_delay(self):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
client = app.test_client()
with client.session_transaction() as state:
state["pending_email"] = "alice@example.com"
state["pending_purpose"] = "login"
response = client.get("/verify/login")
html = response.get_data(as_text=True)
self.assertEqual(response.status_code, 200)
self.assertEqual(html.count("data-code-digit\n"), 6)
self.assertIn("60 秒后可重新发送", html)
self.assertIn('id="resendButton"', html)
def test_fresh_authorization_is_bound_to_user_and_expires(self):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
with app.app_context():
user = User(username="Alice", email="alice@example.com", is_active_account=True)
user.set_password("Password-1234!")
db.session.add(user)
db.session.commit()
user_id = user.id
with app.test_request_context():
user = db.session.get(User, user_id)
grant_fresh_authorization(user, "password_change")
self.assertTrue(has_fresh_authorization(user, "password_change"))
session["fresh_auth_password_change"]["expires_at"] = (
utc_now() - timedelta(seconds=1)
).isoformat()
self.assertFalse(has_fresh_authorization(user, "password_change"))
def create_app(self, directory: str):
class TestConfig(Config):
TESTING = True
@@ -41,7 +89,7 @@ class EmailAuthenticationTest(unittest.TestCase):
app = self.create_app(directory); client = app.test_client()
page = client.get("/register")
with client.session_transaction() as state: captcha = state["captcha"]
response = client.post("/register", data={"csrf_token": self.csrf(page), "username": "Alice", "email": "Alice@example.com", "password": "password-1234", "captcha": captcha})
response = client.post("/register", data={"csrf_token": self.csrf(page), "username": "Alice", "email": "Alice@example.com", "password": "Password-1234!", "captcha": captcha})
self.assertEqual(response.status_code, 302)
with app.app_context():
user = User.query.filter_by(email="alice@example.com").one()
@@ -58,10 +106,10 @@ class EmailAuthenticationTest(unittest.TestCase):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
with app.app_context():
user = User(username="Alice", email="alice@example.com", is_active_account=True); user.set_password("password-1234"); db.session.add(user); db.session.commit()
user = User(username="Alice", email="alice@example.com", is_active_account=True); user.set_password("Password-1234!"); db.session.add(user); db.session.commit()
client = app.test_client(); page = client.get("/login")
with client.session_transaction() as state: captcha = state["captcha"]
response = client.post("/login", data={"csrf_token": self.csrf(page), "email": "alice@example.com", "password": "password-1234", "captcha": captcha})
response = client.post("/login", data={"csrf_token": self.csrf(page), "email": "alice@example.com", "password": "Password-1234!", "captcha": captcha})
self.assertEqual(response.location, "/verify/login")
verify = client.get("/verify/login")
response = client.post("/verify/login", data={"csrf_token": self.csrf(verify), "code": "123456"})
@@ -74,7 +122,7 @@ class EmailAuthenticationTest(unittest.TestCase):
with TemporaryDirectory() as directory:
app = self.create_app(directory)
with app.app_context():
user = User(username="Alice", email="alice@example.com", is_active_account=True); user.set_password("password-1234"); db.session.add(user); db.session.commit(); db.session.add(TrustedDevice(user_id=user.id, token_hash="a" * 64, auth_version=1, expires_at=__import__('app.time_utils', fromlist=['utc_now']).utc_now())); db.session.commit()
user = User(username="Alice", email="alice@example.com", is_active_account=True); user.set_password("Password-1234!"); db.session.add(user); db.session.commit(); db.session.add(TrustedDevice(user_id=user.id, token_hash="a" * 64, auth_version=1, expires_at=__import__('app.time_utils', fromlist=['utc_now']).utc_now())); db.session.commit()
client = app.test_client()
response = self.form(client, "/forgot-password", email="alice@example.com")
self.assertEqual(response.status_code, 302)
@@ -82,11 +130,11 @@ class EmailAuthenticationTest(unittest.TestCase):
response = client.post("/verify/reset", data={"csrf_token": self.csrf(verify), "code": "123456"})
self.assertEqual(response.location, "/set-password")
page = client.get("/set-password")
response = client.post("/set-password", data={"csrf_token": self.csrf(page), "password": "new-password-1234", "password_confirm": "new-password-1234"})
response = client.post("/set-password", data={"csrf_token": self.csrf(page), "password": "New-password-1234!", "password_confirm": "New-password-1234!"})
self.assertEqual(response.status_code, 302)
with app.app_context():
user = User.query.filter_by(email="alice@example.com").one()
self.assertTrue(user.check_password("new-password-1234")); self.assertEqual(TrustedDevice.query.count(), 0); self.assertEqual(user.auth_version, 2)
self.assertTrue(user.check_password("New-password-1234!")); self.assertEqual(TrustedDevice.query.count(), 0); self.assertEqual(user.auth_version, 2)
@patch("app.routes.send_transactional_email")
@patch("app.routes.secrets.randbelow", return_value=123456)
@@ -94,10 +142,10 @@ class EmailAuthenticationTest(unittest.TestCase):
with TemporaryDirectory() as directory:
app = self.create_app(directory); client = app.test_client()
with app.app_context():
user = User(username="Alice", email="alice@example.com", is_active_account=True); user.set_password("password-1234"); db.session.add(user); db.session.commit()
user = User(username="Alice", email="alice@example.com", is_active_account=True); user.set_password("Password-1234!"); db.session.add(user); db.session.commit()
page = client.get("/login")
with client.session_transaction() as state: captcha = state["captcha"]
client.post("/login", data={"csrf_token": self.csrf(page), "email": "alice@example.com", "password": "password-1234", "captcha": captcha})
client.post("/login", data={"csrf_token": self.csrf(page), "email": "alice@example.com", "password": "Password-1234!", "captcha": captcha})
for _ in range(5):
page = client.get("/verify/login"); client.post("/verify/login", data={"csrf_token": self.csrf(page), "code": "000000"})
page = client.get("/verify/login"); response = client.post("/verify/login", data={"csrf_token": self.csrf(page), "code": "123456"})
+7
View File
@@ -0,0 +1,7 @@
from datetime import timedelta
from app.config import Config
def test_remember_login_duration_is_seven_days():
assert Config.REMEMBER_COOKIE_DURATION == timedelta(days=7)
+26 -1
View File
@@ -6,10 +6,35 @@ from unittest.mock import patch
from app import create_app
from app.config import Config
from app.email import EmailConfigurationError, send_transactional_email
from app.email import (
EmailConfigurationError,
password_reset_notice_email,
send_transactional_email,
verification_code_email,
)
class TransactionalEmailTest(unittest.TestCase):
def test_verification_email_has_branded_code_and_escapes_purpose(self):
html = verification_code_email(
code="123456",
minutes=10,
purpose="登录<script>",
)
self.assertIn("供水管道健康评估系统", html)
self.assertIn("123456", html)
self.assertIn("登录&lt;script&gt;", html)
self.assertNotIn("登录<script>", html)
def test_reset_notice_escapes_username_and_url(self):
html = password_reset_notice_email(
username="<管理员>",
reset_url="https://example.com/reset?x=1&y=2",
)
self.assertIn("&lt;管理员&gt;", html)
self.assertIn("x=1&amp;y=2", html)
def create_test_app(self, temp_dir: str, *, configured: bool):
class TestConfig(Config):
TESTING = True