mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-07 18:17:14 +00:00
fix(security): harden password recovery with usable eight-character codes (#2477)
Use eight securely random recovery-code characters with concurrency-safe online throttling. Preserve existing keys and verify recovery through browser and real SQLite integration tests. Co-authored-by: zhangjinpeng@mail.tuchong.com <zhangjinpeng@mail.tuchong.com> Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
@@ -2,6 +2,8 @@ import quart
|
||||
import argon2
|
||||
import asyncio
|
||||
import datetime
|
||||
import hmac
|
||||
import time
|
||||
import uuid
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
@@ -11,6 +13,33 @@ from ...context import RequestContext
|
||||
from .....cloud.launch import SpaceLaunchError
|
||||
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
|
||||
|
||||
# Fixed-window admission quota for the unauthenticated reset-password endpoint (#2392).
|
||||
# The admission check and slot bump share ONE synchronous critical section with no await
|
||||
# points, so concurrent bursts within a single event loop cannot slip past accounting.
|
||||
# Every admitted attempt consumes quota (regardless of success), which throttles both the
|
||||
# legacy 24-bit keyspace exhaustion and brute-force on modern high-entropy keys.
|
||||
# NOTE: this state is process-local; multi-worker deployments need a shared limiter upstream.
|
||||
_MAX_RESET_ATTEMPTS_PER_WINDOW = 5
|
||||
_RESET_WINDOW_SECONDS = 15 * 60
|
||||
|
||||
_reset_password_state: dict = {'window_started_at': 0.0, 'attempts': 0}
|
||||
|
||||
|
||||
def _admit_reset_attempt(now: float) -> bool:
|
||||
"""Atomically reserve one reset-password admission slot.
|
||||
|
||||
Must stay await-free: running to completion without suspension makes the
|
||||
check-and-increment atomic under the single-threaded event loop.
|
||||
"""
|
||||
st = _reset_password_state
|
||||
if now - st['window_started_at'] >= _RESET_WINDOW_SECONDS:
|
||||
st['window_started_at'] = now
|
||||
st['attempts'] = 0
|
||||
if st['attempts'] >= _MAX_RESET_ATTEMPTS_PER_WINDOW:
|
||||
return False
|
||||
st['attempts'] += 1
|
||||
return True
|
||||
|
||||
|
||||
@group.group_class('user', '/api/v1/user')
|
||||
class UserRouterGroup(group.RouterGroup):
|
||||
@@ -81,6 +110,12 @@ class UserRouterGroup(group.RouterGroup):
|
||||
|
||||
@self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
# Admit (or reject) BEFORE touching the body or any service call (#2392):
|
||||
# rejecting requests never reach the slow path, and quota accounting happens
|
||||
# synchronously at entry, closing the post-await race of burst requests.
|
||||
if not _admit_reset_attempt(time.monotonic()):
|
||||
return self.http_status(429, -1, 'Too many attempts, try again later')
|
||||
|
||||
json_data = await quart.request.json
|
||||
|
||||
user_email = json_data['user']
|
||||
@@ -98,7 +133,18 @@ class UserRouterGroup(group.RouterGroup):
|
||||
if user_obj is None:
|
||||
return self.http_status(400, -1, 'User not found')
|
||||
|
||||
if recovery_key != self.ap.instance_config.data['system']['recovery_key']:
|
||||
stored_key = self.ap.instance_config.data['system']['recovery_key']
|
||||
try:
|
||||
key_matches = (
|
||||
isinstance(recovery_key, str)
|
||||
and isinstance(stored_key, str)
|
||||
and hmac.compare_digest(recovery_key.encode(), stored_key.encode())
|
||||
)
|
||||
except UnicodeEncodeError:
|
||||
# JSON can contain lone surrogates, which are not valid UTF-8.
|
||||
key_matches = False
|
||||
|
||||
if not key_matches:
|
||||
return self.http_status(403, -1, 'Invalid recovery key')
|
||||
|
||||
await self.ap.user_service.reset_password(user_email, new_password)
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from .. import stage, app
|
||||
|
||||
# This stage runs before SetupLoggerStage, so ap.logger is still None here;
|
||||
# the module logger falls back to the stderr lastResort handler.
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# 32 symbols without 0/O or 1/I; eight independent draws provide 40 random bits.
|
||||
_RECOVERY_KEY_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'
|
||||
_RECOVERY_KEY_LENGTH = 8
|
||||
|
||||
|
||||
@stage.stage_class('GenKeysStage')
|
||||
class GenKeysStage(stage.BootingStage):
|
||||
@@ -20,5 +29,15 @@ class GenKeysStage(stage.BootingStage):
|
||||
ap.instance_config.data['system']['recovery_key'] = ''
|
||||
|
||||
if not ap.instance_config.data['system']['recovery_key']:
|
||||
ap.instance_config.data['system']['recovery_key'] = secrets.token_hex(3).upper()
|
||||
# Keep recovery practical to type. Security also requires the reset
|
||||
# endpoint's concurrency-safe quota (five admissions per 15 minutes).
|
||||
ap.instance_config.data['system']['recovery_key'] = ''.join(
|
||||
secrets.choice(_RECOVERY_KEY_ALPHABET) for _ in range(_RECOVERY_KEY_LENGTH)
|
||||
)
|
||||
await ap.instance_config.dump_config()
|
||||
elif len(ap.instance_config.data['system']['recovery_key']) < _RECOVERY_KEY_LENGTH:
|
||||
_logger.warning(
|
||||
'Low-entropy legacy recovery key detected (length < 8); '
|
||||
'regenerate system.recovery_key in the configuration file '
|
||||
'with a strong random value (#2392)'
|
||||
)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from quart import Quart
|
||||
|
||||
from langbot.pkg.api.http.controller.groups import user as user_module
|
||||
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
|
||||
from langbot.pkg.api.http.service.user import UserService
|
||||
from langbot.pkg.core.stages.genkeys import GenKeysStage
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||
from langbot.pkg.utils import constants
|
||||
from langbot.pkg.workspace.collaboration import WorkspaceCollaborationService
|
||||
from langbot.pkg.workspace.service import WorkspaceService
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
|
||||
|
||||
|
||||
async def test_generated_recovery_code_resets_real_sqlite_account(tmp_path, monkeypatch):
|
||||
"""Exercise generation, reset, and old/new password login without mocked user services."""
|
||||
monkeypatch.setattr(constants, 'instance_id', 'recovery-journey')
|
||||
monkeypatch.setattr(user_module, '_reset_password_state', {'window_started_at': 0.0, 'attempts': 0})
|
||||
monkeypatch.setattr(user_module, 'asyncio', SimpleNamespace(sleep=AsyncMock()))
|
||||
application = SimpleNamespace(
|
||||
logger=logging.getLogger('recovery-password-journey'),
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'database': {'use': 'sqlite', 'sqlite': {'path': str(tmp_path / 'recovery.db')}},
|
||||
'system': {
|
||||
'jwt': {'secret': 'recovery-journey-test-secret-only', 'expire': 3600},
|
||||
'recovery_key': '',
|
||||
},
|
||||
},
|
||||
dump_config=AsyncMock(),
|
||||
),
|
||||
)
|
||||
await GenKeysStage().run(application)
|
||||
key = application.instance_config.data['system']['recovery_key']
|
||||
assert len(key) == 8
|
||||
assert set(key) <= set('23456789ABCDEFGHJKLMNPQRSTUVWXYZ')
|
||||
persistence = PersistenceManager(application)
|
||||
application.persistence_mgr = persistence
|
||||
try:
|
||||
await persistence.initialize()
|
||||
application.workspace_service = WorkspaceService(application, instance_uuid='recovery-journey')
|
||||
application.workspace_collaboration_service = WorkspaceCollaborationService(
|
||||
application, application.workspace_service
|
||||
)
|
||||
application.user_service = UserService(application)
|
||||
quart_app = Quart(__name__)
|
||||
await UserRouterGroup(application, quart_app).initialize()
|
||||
client = quart_app.test_client()
|
||||
|
||||
initial = await client.post(
|
||||
'/api/v1/user/init', json={'user': 'owner@example.com', 'password': 'OriginalPass1!'}
|
||||
)
|
||||
assert initial.status_code == 200
|
||||
assert (await initial.get_json())['code'] == 0
|
||||
|
||||
payload = {'user': 'owner@example.com', 'recovery_key': 'WRONG', 'new_password': 'RecoveredPass1!'}
|
||||
wrong = await client.post('/api/v1/user/reset-password', json=payload)
|
||||
assert wrong.status_code == 403
|
||||
unchanged = await client.post(
|
||||
'/api/v1/user/auth', json={'user': 'owner@example.com', 'password': 'OriginalPass1!'}
|
||||
)
|
||||
assert (await unchanged.get_json())['code'] == 0
|
||||
|
||||
reset = await client.post('/api/v1/user/reset-password', json={**payload, 'recovery_key': key})
|
||||
assert reset.status_code == 200
|
||||
assert (await reset.get_json())['code'] == 0
|
||||
old_login = await client.post(
|
||||
'/api/v1/user/auth', json={'user': 'owner@example.com', 'password': 'OriginalPass1!'}
|
||||
)
|
||||
assert (await old_login.get_json())['code'] != 0
|
||||
new_login = await client.post(
|
||||
'/api/v1/user/auth', json={'user': 'owner@example.com', 'password': 'RecoveredPass1!'}
|
||||
)
|
||||
new_data = await new_login.get_json()
|
||||
assert new_data['code'] == 0
|
||||
assert new_data['data']['token']
|
||||
finally:
|
||||
await persistence.get_db_engine().dispose()
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Regression tests for recovery-key hardening (#2392).
|
||||
|
||||
Covers two attack surfaces reported in GHSA-4xcp-6758-rxqv:
|
||||
|
||||
1. ``genkeys.py`` generated ``system.recovery_key`` with only 24 bits of
|
||||
entropy (``secrets.token_hex(3)``), making the whole keyspace brute-forceable.
|
||||
2. ``POST /api/v1/user/reset-password`` (unauthenticated) checked its failure
|
||||
counter across ``await`` points, so concurrent guesses all passed the gate
|
||||
before any accounting happened; admission is now a synchronous fixed-window
|
||||
quota consumed at entry, plus constant-time key comparison.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import quart
|
||||
|
||||
from langbot.pkg.api.http.controller.groups import user as user_module
|
||||
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
|
||||
from langbot.pkg.core.stages.genkeys import GenKeysStage
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
STORED_KEY = 'ABCD2345'
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_quota_state():
|
||||
"""Reset the module-level admission-quota state before each test."""
|
||||
user_module._reset_password_state['window_started_at'] = 0.0
|
||||
user_module._reset_password_state['attempts'] = 0
|
||||
yield
|
||||
user_module._reset_password_state['window_started_at'] = 0.0
|
||||
user_module._reset_password_state['attempts'] = 0
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fast_sleep(monkeypatch):
|
||||
"""Neutralize the fixed 3s delay so tests run instantly."""
|
||||
monkeypatch.setattr(user_module, 'asyncio', SimpleNamespace(sleep=AsyncMock()))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# genkeys.py: recovery-key generation and compatibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_genkeys_ap(existing_key: str) -> SimpleNamespace:
|
||||
"""Build a minimal Application mock for GenKeysStage.
|
||||
|
||||
Mirrors the real boot order: no ``logger`` attribute is set because
|
||||
GenKeysStage runs before SetupLoggerStage.
|
||||
"""
|
||||
return SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={'system': {'jwt': {'secret': 'jwt-secret'}, 'recovery_key': existing_key}},
|
||||
dump_config=AsyncMock(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_recovery_key_generation_is_short_and_unambiguous():
|
||||
"""Eight random base32 characters balance manual entry and online throttling."""
|
||||
ap = _make_genkeys_ap(existing_key='')
|
||||
|
||||
await GenKeysStage().run(ap)
|
||||
|
||||
key = ap.instance_config.data['system']['recovery_key']
|
||||
assert len(key) == 8
|
||||
assert set(key) <= set('23456789ABCDEFGHJKLMNPQRSTUVWXYZ')
|
||||
assert ap.instance_config.dump_config.called
|
||||
|
||||
|
||||
async def test_legacy_low_entropy_key_preserved_with_warning(caplog):
|
||||
"""A legacy 6-char key must keep working but emit a warning, without ap.logger."""
|
||||
ap = _make_genkeys_ap(existing_key='ABC123')
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger='langbot.pkg.core.stages.genkeys'):
|
||||
await GenKeysStage().run(ap)
|
||||
|
||||
assert ap.instance_config.data['system']['recovery_key'] == 'ABC123'
|
||||
assert any('Low-entropy' in record.message for record in caplog.records)
|
||||
assert not ap.instance_config.dump_config.called
|
||||
|
||||
|
||||
@pytest.mark.parametrize('existing_key', ['ABC123', 'ABCD2345', 'aB-_' * 10 + 'xYz', '自定义恢复密钥'])
|
||||
async def test_recovery_key_generation_preserves_existing_key(existing_key):
|
||||
"""An explicitly configured recovery key must not be regenerated on boot."""
|
||||
ap = _make_genkeys_ap(existing_key=existing_key)
|
||||
|
||||
await GenKeysStage().run(ap)
|
||||
|
||||
assert ap.instance_config.data['system']['recovery_key'] == existing_key
|
||||
assert not ap.instance_config.dump_config.called
|
||||
|
||||
|
||||
async def test_generated_key_is_preserved_without_legacy_warning(caplog):
|
||||
"""A restart must not warn about or replace the new eight-character key."""
|
||||
ap = _make_genkeys_ap(existing_key='')
|
||||
await GenKeysStage().run(ap)
|
||||
key = ap.instance_config.data['system']['recovery_key']
|
||||
assert len(key) == 8
|
||||
ap.instance_config.dump_config.reset_mock()
|
||||
with caplog.at_level(logging.WARNING, logger='langbot.pkg.core.stages.genkeys'):
|
||||
await GenKeysStage().run(ap)
|
||||
assert ap.instance_config.data['system']['recovery_key'] == key
|
||||
assert not caplog.records
|
||||
ap.instance_config.dump_config.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_eight_character_key_does_not_trigger_legacy_warning(caplog):
|
||||
ap = _make_genkeys_ap(existing_key='ABCD2345')
|
||||
with caplog.at_level(logging.WARNING, logger='langbot.pkg.core.stages.genkeys'):
|
||||
await GenKeysStage().run(ap)
|
||||
assert not caplog.records
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/v1/user/reset-password: admission quota + constant-time compare
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _create_client(stored_key: str = STORED_KEY):
|
||||
"""Create a Quart test client with a mocked Application."""
|
||||
quart_app = quart.Quart(__name__)
|
||||
|
||||
user_obj = SimpleNamespace(uuid='user-uuid', user='admin@example.com')
|
||||
reset_password = AsyncMock()
|
||||
get_user_by_email = AsyncMock(return_value=user_obj)
|
||||
|
||||
ap = SimpleNamespace(
|
||||
user_service=SimpleNamespace(
|
||||
is_initialized=AsyncMock(return_value=True),
|
||||
get_user_by_email=get_user_by_email,
|
||||
reset_password=reset_password,
|
||||
),
|
||||
instance_config=SimpleNamespace(
|
||||
data={'system': {'recovery_key': stored_key}},
|
||||
),
|
||||
)
|
||||
|
||||
router = UserRouterGroup(ap, quart_app)
|
||||
await router.initialize()
|
||||
|
||||
client = quart_app.test_client()
|
||||
return client, reset_password, get_user_by_email
|
||||
|
||||
|
||||
def _payload(key: str = STORED_KEY) -> dict:
|
||||
return {'user': 'admin@example.com', 'recovery_key': key, 'new_password': 'NewPass1!'}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', [STORED_KEY, 'ABC123', 'aB-_' * 10 + 'xYz', '自定义恢复密钥'])
|
||||
async def test_correct_key_resets_password(key):
|
||||
"""New, legacy and explicitly configured keys all remain usable verbatim."""
|
||||
client, reset_password, _ = await _create_client(stored_key=key)
|
||||
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload(key))
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())['code'] == 0
|
||||
reset_password.assert_awaited_once_with('admin@example.com', 'NewPass1!')
|
||||
|
||||
|
||||
async def test_wrong_key_rejected_without_reset():
|
||||
"""A wrong recovery key returns 403 and never touches the password."""
|
||||
client, reset_password, _ = await _create_client()
|
||||
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload(key='WRONG'))
|
||||
|
||||
assert resp.status_code == 403
|
||||
reset_password.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_non_string_recovery_key_does_not_crash():
|
||||
"""Malformed recovery-key payloads must be rejected, not raise a 500.
|
||||
|
||||
Constant-time comparison via hmac.compare_digest on bytes requires the
|
||||
input to be a str; other JSON types must fail closed.
|
||||
"""
|
||||
client, reset_password, _ = await _create_client()
|
||||
|
||||
resp = await client.post(
|
||||
'/api/v1/user/reset-password',
|
||||
json={'user': 'admin@example.com', 'recovery_key': 12345, 'new_password': 'NewPass1!'},
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
reset_password.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', ['奇数密钥不是ASCII', '\ud800', '\udfff'])
|
||||
async def test_non_ascii_recovery_key_does_not_crash(key):
|
||||
"""Non-ASCII keys must compare safely (encode-based constant-time compare)."""
|
||||
client, _, _ = await _create_client()
|
||||
|
||||
resp = await client.post(
|
||||
'/api/v1/user/reset-password',
|
||||
json={'user': 'admin@example.com', 'recovery_key': key, 'new_password': 'NewPass1!'},
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_quota_exhausted_after_max_attempts():
|
||||
"""After MAX admitted attempts even a correct key must be rejected with 429 (#2392).
|
||||
|
||||
Every admission consumes quota regardless of outcome; the legacy endpoint
|
||||
accepted every guess independently, exhausting the 24-bit keyspace via bursts.
|
||||
"""
|
||||
client, reset_password, _ = await _create_client()
|
||||
|
||||
for _ in range(user_module._MAX_RESET_ATTEMPTS_PER_WINDOW):
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload(key='WRONG'))
|
||||
assert resp.status_code == 403
|
||||
|
||||
# The very next request carries the CORRECT key but has no quota left.
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload())
|
||||
assert resp.status_code == 429
|
||||
reset_password.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_quota_rejects_before_touching_user_lookup():
|
||||
"""An exhausted quota must reject early, before the sleep and any service calls."""
|
||||
client, _, get_user_by_email = await _create_client()
|
||||
|
||||
user_module._reset_password_state['attempts'] = user_module._MAX_RESET_ATTEMPTS_PER_WINDOW
|
||||
user_module._reset_password_state['window_started_at'] = time.monotonic()
|
||||
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload())
|
||||
|
||||
assert resp.status_code == 429
|
||||
get_user_by_email.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_window_rolls_over_and_admits_again():
|
||||
"""Once the fixed window elapses, the quota resets and a correct key works again."""
|
||||
client, reset_password, _ = await _create_client()
|
||||
|
||||
user_module._reset_password_state['attempts'] = user_module._MAX_RESET_ATTEMPTS_PER_WINDOW
|
||||
user_module._reset_password_state['window_started_at'] = time.monotonic() - user_module._RESET_WINDOW_SECONDS - 1
|
||||
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload())
|
||||
|
||||
assert resp.status_code == 200
|
||||
reset_password.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_success_does_not_restore_quota():
|
||||
"""A successful reset does NOT restore quota: brute-force budget survives wins (#2392).
|
||||
|
||||
The legacy clear-on-success let attackers interleave correct-looking states;
|
||||
success only proves knowledge of the key once, it must not refill attempts.
|
||||
"""
|
||||
client, _, _ = await _create_client()
|
||||
|
||||
for _ in range(user_module._MAX_RESET_ATTEMPTS_PER_WINDOW - 1):
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload(key='WRONG'))
|
||||
assert resp.status_code == 403
|
||||
|
||||
# Last slot is spent on the genuine reset.
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload())
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Quota is exhausted; even a correct key waits for the next window.
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload())
|
||||
assert resp.status_code == 429
|
||||
|
||||
|
||||
async def test_concurrent_burst_cannot_bypass_quota(monkeypatch):
|
||||
"""A 20-request burst yields exactly {403: 5, 429: 15} (#2392 regression).
|
||||
|
||||
The vulnerable version accounted failures after several awaits, letting all
|
||||
concurrent requests pass the gate ({403: 20}). Admission is now synchronous
|
||||
and await-free, so total admissions are capped regardless of scheduling.
|
||||
"""
|
||||
|
||||
# Swap the AsyncMock sleep for a real cooperative yield so tasks actually
|
||||
# interleave mid-handler like they do under production load.
|
||||
async def _yield_sleep(_seconds):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
monkeypatch.setattr(user_module, 'asyncio', SimpleNamespace(sleep=_yield_sleep))
|
||||
|
||||
client, reset_password, _ = await _create_client()
|
||||
|
||||
responses = await asyncio.gather(
|
||||
*(client.post('/api/v1/user/reset-password', json=_payload(key='WRONG')) for _ in range(20))
|
||||
)
|
||||
|
||||
status_counts: dict[int, int] = {}
|
||||
for resp in responses:
|
||||
status_counts[resp.status_code] = status_counts.get(resp.status_code, 0) + 1
|
||||
|
||||
assert status_counts == {403: 5, 429: 15}
|
||||
reset_password.assert_not_awaited()
|
||||
@@ -7,12 +7,6 @@ import {
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
InputOTPSeparator,
|
||||
} from '@/components/ui/input-otp';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
@@ -28,14 +22,12 @@ import {
|
||||
import { useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Mail, Lock, ArrowLeft } from 'lucide-react';
|
||||
import { Mail, Lock, ArrowLeft, KeyRound } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
|
||||
const REGEXP_ONLY_DIGITS_AND_CHARS = /^[0-9a-zA-Z]+$/;
|
||||
|
||||
const formSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
email: z.string().email(t('common.invalidEmail')),
|
||||
@@ -136,28 +128,17 @@ export default function ResetPassword() {
|
||||
{t('resetPassword.recoveryKeyDescription')}
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={field.value}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS.source}
|
||||
onChange={(value) => {
|
||||
// 将输入的值转换为大写
|
||||
const upperValue = value.toUpperCase();
|
||||
field.onChange(upperValue);
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
{/* Recovery keys are case-sensitive base64url strings; send them verbatim */}
|
||||
<div className="relative">
|
||||
<KeyRound className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder={t('resetPassword.enterRecoveryKey')}
|
||||
className="pl-10 font-mono"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
const resetEndpoint = '**/api/v1/user/reset-password';
|
||||
const email = 'reset-password@example.com';
|
||||
const newPassword = 'Regression-password-2026!';
|
||||
const successMessage = 'Password reset successfully, please login';
|
||||
const failureMessage =
|
||||
'Password reset failed, please check your email and recovery key';
|
||||
|
||||
async function fillResetForm(page: Page, recoveryKey: string) {
|
||||
await page.goto('/reset-password');
|
||||
await page.getByPlaceholder('Enter email address').fill(email);
|
||||
const recoveryInput = page.getByPlaceholder('Enter recovery key');
|
||||
await recoveryInput.fill(recoveryKey);
|
||||
await expect(recoveryInput).toHaveValue(recoveryKey);
|
||||
await page.getByPlaceholder('Enter new password').fill(newPassword);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: false });
|
||||
});
|
||||
|
||||
const recoveryKeys = [
|
||||
{ name: 'eight-character recovery code', value: '2A3B4C5D' },
|
||||
{ name: 'six-character legacy recovery key', value: 'ABC123' },
|
||||
{
|
||||
name: '43-character mixed-case base64url recovery key',
|
||||
value: 'aB-_'.repeat(10) + 'xYz',
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, value } of recoveryKeys) {
|
||||
test(`submits the ${name} verbatim and returns to login`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const requests: { method: string; body: unknown }[] = [];
|
||||
await page.route(resetEndpoint, async (route) => {
|
||||
requests.push({
|
||||
method: route.request().method(),
|
||||
body: route.request().postDataJSON(),
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
json: { code: 0, msg: 'ok', data: { user: email } },
|
||||
});
|
||||
});
|
||||
|
||||
await fillResetForm(page, value);
|
||||
await page
|
||||
.getByRole('button', { name: 'Reset Password', exact: true })
|
||||
.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
await expect(page.getByText(successMessage, { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Login with password', exact: true }),
|
||||
).toBeVisible();
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
method: 'POST',
|
||||
body: { user: email, recovery_key: value, new_password: newPassword },
|
||||
},
|
||||
]);
|
||||
await expect(page.getByText(failureMessage, { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('HTTP 429 shows failure, stays on reset-password, and reenables submission', async ({
|
||||
page,
|
||||
}) => {
|
||||
const recoveryKey = '2A3B4C5D';
|
||||
const requests: { method: string; body: unknown }[] = [];
|
||||
let releaseResponse!: () => void;
|
||||
const responseGate = new Promise<void>((resolve) => {
|
||||
releaseResponse = resolve;
|
||||
});
|
||||
await page.route(resetEndpoint, async (route) => {
|
||||
requests.push({
|
||||
method: route.request().method(),
|
||||
body: route.request().postDataJSON(),
|
||||
});
|
||||
await responseGate;
|
||||
await route.fulfill({
|
||||
status: 429,
|
||||
json: { code: -1, msg: 'Too many attempts, try again later' },
|
||||
});
|
||||
});
|
||||
|
||||
await fillResetForm(page, recoveryKey);
|
||||
const submit = page.locator('button[type="submit"]');
|
||||
await submit.click();
|
||||
try {
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
await expect(submit).toBeDisabled();
|
||||
await expect(submit).toHaveText('Resetting...');
|
||||
} finally {
|
||||
releaseResponse();
|
||||
}
|
||||
|
||||
await expect(page.getByText(failureMessage, { exact: true })).toBeVisible();
|
||||
await expect(submit).toBeEnabled();
|
||||
await expect(submit).toHaveText('Reset Password');
|
||||
await expect(page).toHaveURL(/\/reset-password$/);
|
||||
await expect(page.getByText(successMessage, { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByPlaceholder('Enter recovery key')).toHaveValue(
|
||||
recoveryKey,
|
||||
);
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
user: email,
|
||||
recovery_key: recoveryKey,
|
||||
new_password: newPassword,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user