368 lines
19 KiB
Python
368 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
import unittest
|
|
from datetime import timedelta
|
|
from tempfile import TemporaryDirectory
|
|
from unittest.mock import patch
|
|
from urllib.parse import urlparse
|
|
|
|
from flask import session
|
|
|
|
from app import create_app
|
|
from app.config import Config
|
|
from app.email import EmailDeliveryError
|
|
from app.extensions import db
|
|
from app.models import EmailVerificationCode, PasswordResetToken, RegistrationInvitation, 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)
|
|
self.assertIn("al****ce@example.com", html)
|
|
self.assertNotIn("alice@example.com", 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 test_account_security_uses_custom_email_validation_and_password_autofill(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
|
|
|
|
client = app.test_client()
|
|
with app.app_context():
|
|
self.login_as(client, db.session.get(User, user_id))
|
|
response = client.get("/account/security")
|
|
html = response.get_data(as_text=True)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertIn('novalidate data-change-email-form', html)
|
|
self.assertIn('autocomplete="current-password"', html)
|
|
self.assertIn('autocomplete="email"', html)
|
|
self.assertIn('请输入有效的新登录邮箱。', html)
|
|
|
|
def create_app(self, directory: str):
|
|
class TestConfig(Config):
|
|
TESTING = True
|
|
SECRET_KEY = "test-secret"
|
|
SECRET_KEY_GENERATED = False
|
|
SQLALCHEMY_DATABASE_URI = f"sqlite:///{directory}/test.db"
|
|
ALLOW_REGISTRATION = True
|
|
ADMIN_PASSWORD = None
|
|
ADMIN_EMAIL = ""
|
|
RESEND_API_KEY = "test"
|
|
RESEND_FROM_EMAIL = "no-reply@waternetwork.cn"
|
|
SESSION_COOKIE_SECURE = False
|
|
return create_app(TestConfig, load_model_on_start=False)
|
|
|
|
def csrf(self, response) -> str:
|
|
return re.search(rb'name="csrf_token" value="([^"]+)"', response.data).group(1).decode()
|
|
|
|
def form(self, client, path: str, **data):
|
|
page = client.get(path)
|
|
data["csrf_token"] = self.csrf(page)
|
|
return client.post(path, data=data)
|
|
|
|
def login_as(self, client, user: User) -> None:
|
|
with client.session_transaction() as state:
|
|
state["_user_id"] = str(user.id)
|
|
state["_fresh"] = True
|
|
state["auth_version"] = user.auth_version
|
|
|
|
def test_admin_user_list_is_paginated_and_searchable(self):
|
|
with TemporaryDirectory() as directory:
|
|
app = self.create_app(directory)
|
|
with app.app_context():
|
|
admin = User(
|
|
username="Admin",
|
|
email="admin@example.com",
|
|
is_admin=True,
|
|
is_active_account=True,
|
|
)
|
|
admin.set_password("Password-1234!")
|
|
db.session.add(admin)
|
|
for index in range(21):
|
|
user = User(
|
|
username=f"User{index:02d}",
|
|
email=f"user{index:02d}@example.com",
|
|
is_active_account=True,
|
|
)
|
|
user.set_password("Password-1234!")
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
admin_id = admin.id
|
|
|
|
client = app.test_client()
|
|
with app.app_context():
|
|
self.login_as(client, db.session.get(User, admin_id))
|
|
|
|
response = client.get("/admin?user_page=2")
|
|
html = response.get_data(as_text=True)
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertIn("共 21 位用户,第 2 / 2 页", html)
|
|
self.assertIn("User00", html)
|
|
self.assertNotIn("User20", html)
|
|
|
|
response = client.get("/admin?q=user20@example.com")
|
|
html = response.get_data(as_text=True)
|
|
self.assertIn("共 1 位用户,第 1 / 1 页", html)
|
|
self.assertIn("User20", html)
|
|
|
|
@patch("app.routes.send_transactional_email")
|
|
@patch("app.routes.secrets.randbelow", return_value=123456)
|
|
def test_registration_requires_and_consumes_email_code(self, _random, _send):
|
|
with TemporaryDirectory() as directory:
|
|
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})
|
|
self.assertEqual(response.status_code, 302)
|
|
with app.app_context():
|
|
user = User.query.filter_by(email="alice@example.com").one()
|
|
self.assertFalse(user.is_active_account)
|
|
self.assertEqual(EmailVerificationCode.query.count(), 1)
|
|
verify = client.get("/verify/register")
|
|
response = client.post("/verify/register", data={"csrf_token": self.csrf(verify), "code": "123456"})
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(response.location, "/home")
|
|
self.assertEqual(client.get("/home").status_code, 200)
|
|
with app.app_context():
|
|
self.assertTrue(User.query.filter_by(email="alice@example.com").one().is_active_account)
|
|
self.assertEqual(TrustedDevice.query.count(), 0)
|
|
|
|
@patch("app.routes.send_transactional_email")
|
|
@patch("app.routes.secrets.randbelow", return_value=123456)
|
|
def test_login_accepts_username_or_email(self, _random, _send):
|
|
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()
|
|
|
|
for identifier in ("Alice", "alice@example.com"):
|
|
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), "identifier": identifier,
|
|
"password": "Password-1234!", "captcha": captcha,
|
|
})
|
|
self.assertEqual(response.location, "/verify/login")
|
|
with app.app_context():
|
|
EmailVerificationCode.query.delete()
|
|
db.session.commit()
|
|
|
|
def test_login_reports_verification_code_cooldown(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.flush()
|
|
db.session.add(EmailVerificationCode(
|
|
email=user.email,
|
|
purpose="login",
|
|
code_hash="a" * 64,
|
|
expires_at=utc_now() + timedelta(minutes=10),
|
|
requested_ip="127.0.0.1",
|
|
))
|
|
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), "identifier": "Alice",
|
|
"password": "Password-1234!", "captcha": captcha,
|
|
})
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(response.location, "/verify/login")
|
|
with app.app_context():
|
|
self.assertEqual(EmailVerificationCode.query.count(), 1)
|
|
|
|
verify_page = client.get("/verify/login")
|
|
html = verify_page.get_data(as_text=True)
|
|
self.assertIn("邮箱二次认证", html)
|
|
self.assertIn("邮箱验证码已发送,请输入验证码完成二次认证。", html)
|
|
self.assertIn('name="trust_device"', html)
|
|
|
|
def test_invalid_graphic_captcha_does_not_start_email_verification(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()
|
|
|
|
client = app.test_client()
|
|
page = client.get("/login")
|
|
response = client.post("/login", data={
|
|
"csrf_token": self.csrf(page), "identifier": "Alice",
|
|
"password": "Password-1234!", "captcha": "WRONG",
|
|
})
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertIn("图形验证码错误", response.get_data(as_text=True))
|
|
self.assertIn('window.__authErrorField = "captcha"', response.get_data(as_text=True))
|
|
with app.app_context():
|
|
self.assertEqual(EmailVerificationCode.query.count(), 0)
|
|
|
|
@patch("app.routes.send_transactional_email", side_effect=EmailDeliveryError("delivery failed"))
|
|
def test_email_delivery_failure_does_not_mark_graphic_captcha(self, _send):
|
|
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()
|
|
|
|
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), "identifier": "Alice",
|
|
"password": "Password-1234!", "captcha": captcha,
|
|
})
|
|
|
|
self.assertEqual(response.status_code, 503)
|
|
self.assertIn("验证码发送失败,请稍后重试。", response.get_data(as_text=True))
|
|
self.assertIn("window.__authErrorField = null", response.get_data(as_text=True))
|
|
|
|
@patch("app.routes.send_transactional_email")
|
|
@patch("app.routes.secrets.randbelow", return_value=123456)
|
|
def test_unknown_device_requires_email_mfa_and_creates_trusted_device(self, _random, _send):
|
|
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()
|
|
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), "identifier": "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", "trust_device": "on"})
|
|
self.assertEqual(response.status_code, 302)
|
|
with app.app_context(): self.assertEqual(TrustedDevice.query.count(), 1)
|
|
|
|
@patch("app.routes.send_transactional_email")
|
|
def test_password_reset_link_revokes_trusted_devices(self, send):
|
|
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()
|
|
client = app.test_client()
|
|
response = self.form(client, "/forgot-password", email="alice@example.com")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(response.location, "/forgot-password")
|
|
self.assertEqual(send.call_count, 1)
|
|
html = send.call_args.kwargs["html"]
|
|
reset_path = urlparse(re.search(r'href="([^"]+)"', html).group(1)).path
|
|
page = client.get(reset_path)
|
|
response = client.post(reset_path, 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.assertIsNotNone(PasswordResetToken.query.one().used_at)
|
|
|
|
@patch("app.routes.send_transactional_email")
|
|
def test_admin_can_invite_user_while_self_registration_is_disabled(self, send):
|
|
with TemporaryDirectory() as directory:
|
|
app = self.create_app(directory)
|
|
with app.app_context():
|
|
admin = User(username="Admin", email="admin@example.com", is_admin=True, is_active_account=True)
|
|
admin.set_password("Password-1234!")
|
|
db.session.add(admin)
|
|
db.session.commit()
|
|
admin_id = admin.id
|
|
client = app.test_client()
|
|
with app.app_context():
|
|
self.login_as(client, db.session.get(User, admin_id))
|
|
admin_page = client.get("/admin")
|
|
response = client.post(
|
|
"/admin/invitations",
|
|
data={"csrf_token": self.csrf(admin_page), "email": "invitee@example.com"},
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(send.call_count, 1)
|
|
with app.app_context():
|
|
self.assertEqual(RegistrationInvitation.query.count(), 1)
|
|
invite_html = send.call_args.kwargs["html"]
|
|
invite_path = urlparse(re.search(r'href="([^"]+)"', invite_html).group(1)).path
|
|
page = client.get(invite_path)
|
|
response = client.post(invite_path, data={"csrf_token": self.csrf(page), "username": "Invited User", "password": "Invited-password-1234!", "password_confirm": "Invited-password-1234!"})
|
|
self.assertEqual(response.status_code, 302)
|
|
with app.app_context():
|
|
invited = User.query.filter_by(email="invitee@example.com").one()
|
|
self.assertTrue(invited.is_active_account)
|
|
self.assertIsNotNone(invited.email_verified_at)
|
|
self.assertIsNotNone(RegistrationInvitation.query.one().used_at)
|
|
|
|
@patch("app.routes.send_transactional_email")
|
|
@patch("app.routes.secrets.randbelow", return_value=123456)
|
|
def test_invalid_code_attempts_are_limited(self, _random, _send):
|
|
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()
|
|
page = client.get("/login")
|
|
with client.session_transaction() as state: captcha = state["captcha"]
|
|
client.post("/login", data={"csrf_token": self.csrf(page), "identifier": "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"})
|
|
self.assertEqual(response.status_code, 400)
|