From 1140ed7129015ada4af97dae7c5198bf082bf839 Mon Sep 17 00:00:00 2001 From: xyh202131 Date: Thu, 27 Aug 2026 13:39:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A5=E9=BD=90=E9=82=AE=E7=AE=B1?= =?UTF-8?q?=E6=A0=B8=E9=AA=8C=E7=9A=84=E5=AF=86=E7=A0=81=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/windup_app/server/user/interface.py | 11 +- .../app/src/windup_app/server/user/model.py | 12 + .../app/src/windup_app/server/user/service.py | 139 ++++++++--- .../app/src/windup_app/web/api/auth.py | 39 ++- .../app/src/windup_app/web/middleware/auth.py | 30 ++- .../20260827_add_user_auth_version.sql | 8 + backend/tests/conftest.py | 17 +- backend/tests/test_agent_api.py | 12 + backend/tests/test_auth_api.py | 75 +++++- backend/tests/test_quota.py | 4 +- backend/tests/test_user_service.py | 179 ++++++++++++-- frontend/src/app/app.test.tsx | 6 + frontend/src/app/layout/app-header.test.tsx | 6 + frontend/src/app/layout/app-header.tsx | 7 + frontend/src/entities/user/api.test.ts | 43 +++- frontend/src/entities/user/api.ts | 27 +++ frontend/src/entities/user/index.ts | 3 + .../src/features/account-panel/index.test.tsx | 41 ++++ frontend/src/features/account-panel/index.tsx | 186 ++++++++++++++- .../src/features/auth-session/index.test.tsx | 10 +- frontend/src/features/auth-session/index.tsx | 23 +- frontend/src/pages/account/index.test.tsx | 78 ++++-- frontend/src/pages/account/index.tsx | 224 ++++++++++++------ frontend/src/pages/account/state.test.ts | 43 +++- frontend/src/pages/account/state.ts | 41 +++- frontend/src/test/auth-session.tsx | 8 + openapi.json | 87 +++++++ 27 files changed, 1150 insertions(+), 209 deletions(-) create mode 100644 backend/scripts/migrations/20260827_add_user_auth_version.sql diff --git a/backend/packages/app/src/windup_app/server/user/interface.py b/backend/packages/app/src/windup_app/server/user/interface.py index 15abef64..d403f3f7 100644 --- a/backend/packages/app/src/windup_app/server/user/interface.py +++ b/backend/packages/app/src/windup_app/server/user/interface.py @@ -12,6 +12,7 @@ from windup_app.server.user.model import ( ChangePasswordInput, + EmailChangePasswordInput, LoginByCodeInput, LoginByPasswordInput, LoginResult, @@ -69,11 +70,11 @@ def logout(self, refresh_token: str) -> None: # -- 会话管理 --------------------------------------------------------- @abstractmethod - def validate_access_token(self, token: str) -> UserView | None: + def validate_access_token(self, session: Session, token: str) -> UserView | None: """校验 access_token 并返回用户,过期 / 无效返回 ``None``。""" @abstractmethod - def refresh_tokens(self, refresh_token: str) -> LoginResult: + def refresh_tokens(self, session: Session, refresh_token: str) -> LoginResult: """刷新 token,返回新的 access+refresh。 :raises windup_common.exceptions.BizException: refresh token 无效 / 已撤销。 @@ -99,6 +100,12 @@ def set_password( :raises windup_common.exceptions.BizException: 密码已设置。 """ + @abstractmethod + def change_password_by_email( + self, session: Session, user_id: int, input: EmailChangePasswordInput + ) -> None: + """通过当前登录账号的邮箱验证码修改密码。""" + # -- 查询 ------------------------------------------------------------ @abstractmethod diff --git a/backend/packages/app/src/windup_app/server/user/model.py b/backend/packages/app/src/windup_app/server/user/model.py index e25147c6..4c8a2558 100644 --- a/backend/packages/app/src/windup_app/server/user/model.py +++ b/backend/packages/app/src/windup_app/server/user/model.py @@ -11,6 +11,7 @@ ├── id BigInteger PK: 自增主键 ├── email String(255) UNIQUE: 邮箱 ├── password_hash String(255): bcrypt 哈希 + ├── auth_version Integer: 账号会话版本 ├── nickname String(50) NULL: 昵称 ├── email_verified_at DateTime(tz) NULL: 邮箱验证时间 ├── status SmallInteger: 0=正常, 1=封禁 @@ -44,6 +45,9 @@ class User(Base): ) email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) password_hash: Mapped[str] = mapped_column(String(255), nullable=False, default="") + auth_version: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) nickname: Mapped[str | None] = mapped_column(String(50), nullable=True) email_verified_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True @@ -150,6 +154,14 @@ class SetPasswordInput: new_password: str +@dataclass +class EmailChangePasswordInput: + """已登录用户通过当前邮箱验证码修改密码。""" + + code: str + new_password: str + + @dataclass class UpdateNicknameInput: """修改昵称入参。""" diff --git a/backend/packages/app/src/windup_app/server/user/service.py b/backend/packages/app/src/windup_app/server/user/service.py index 1f354dea..dac64d45 100644 --- a/backend/packages/app/src/windup_app/server/user/service.py +++ b/backend/packages/app/src/windup_app/server/user/service.py @@ -30,6 +30,7 @@ from windup_app.server.mq.catalog import EMAIL_STREAM, MSG_TYPE_VERIFICATION_CODE from windup_app.server.user.model import ( ChangePasswordInput, + EmailChangePasswordInput, LoginByCodeInput, LoginByPasswordInput, LoginResult, @@ -78,11 +79,13 @@ def _verify_password(password: str, hashed: str) -> bool: VERIFY_COOLDOWN_KEY = "verify:cooldown:{email}" VERIFY_CODE_KEY = "verify:{purpose}:{email}" +VERIFY_ATTEMPT_KEY = "verify:attempt:{purpose}:{email}" REFRESH_TOKEN_KEY = "refresh:{token_hash}" LOGIN_FAIL_KEY = "login:fail:{email}" LOGIN_LOCK_KEY = "login:lock:{email}" VERIFY_CODE_TTL = 300 # 5 分钟 +VERIFY_ATTEMPT_LIMIT = 5 COOLDOWN_TTL = 60 # 60 秒 LOGIN_FAIL_LIMIT = 5 # 连续错误密码上限 @@ -121,20 +124,23 @@ def _to_view(user: User) -> UserView: # -- JWT 工具函数 --------------------------------------------------------- -def create_access_token(user_id: int, email: str) -> str: +def create_access_token(user_id: int, email: str, auth_version: int = 0) -> str: """签发 access_token。""" now = datetime.now(timezone.utc) payload = { "sub": str(user_id), "email": email, "type": "access", + "av": auth_version, "iat": now, "exp": now.timestamp() + ACCESS_TOKEN_EXPIRE_SECONDS, } return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) -def create_refresh_token(user_id: int, email: str = "") -> tuple[str, str]: +def create_refresh_token( + user_id: int, email: str = "", auth_version: int = 0 +) -> tuple[str, str]: """签发 refresh_token,返回 (token, jti)。""" now = datetime.now(timezone.utc) jti = str(uuid.uuid4()) @@ -142,6 +148,7 @@ def create_refresh_token(user_id: int, email: str = "") -> tuple[str, str]: "sub": str(user_id), "email": email, "type": "refresh", + "av": auth_version, "jti": jti, "iat": now, "exp": now.timestamp() + REFRESH_TOKEN_EXPIRE_SECONDS, @@ -215,8 +222,9 @@ def register_by_email(self, session: Session, input: RegisterInput) -> LoginResu quota_service.redeem_invite_code(session, user.id, invite_code) # 注册即登录,签发 token - access_token = create_access_token(user.id, user.email) - refresh_token, jti = create_refresh_token(user.id, user.email) + auth_version = user.auth_version + access_token = create_access_token(user.id, user.email, auth_version) + refresh_token, jti = create_refresh_token(user.id, user.email, auth_version) self._store_refresh_token(jti, user.id) logger.info("[WINDUP] 用户注册成功 | user_id=%s email=%s", user.id, user.email) @@ -284,8 +292,9 @@ def login_by_password( user.last_login_at = datetime.now(timezone.utc) session.flush() - access_token = create_access_token(user.id, user.email) - refresh_token, jti = create_refresh_token(user.id, user.email) + auth_version = user.auth_version + access_token = create_access_token(user.id, user.email, auth_version) + refresh_token, jti = create_refresh_token(user.id, user.email, auth_version) self._store_refresh_token(jti, user.id) logger.info("[WINDUP] 用户登录成功 | user_id=%s email=%s", user.id, user.email) @@ -308,9 +317,11 @@ def send_verification_code(self, email: str, purpose: str) -> None: code = _generate_code() code_key = VERIFY_CODE_KEY.format(purpose=purpose, email=email) + attempt_key = VERIFY_ATTEMPT_KEY.format(purpose=purpose, email=email) # 存储验证码 + 设置冷却 pipe = self.redis.pipeline() + pipe.delete(attempt_key) pipe.setex(code_key, VERIFY_CODE_TTL, code) pipe.setex(cooldown_key, COOLDOWN_TTL, "1") pipe.execute() @@ -331,23 +342,55 @@ def send_verification_code(self, email: str, purpose: str) -> None: dedupe_key=dedupe_key, ) except Exception as exc: - logger.exception("[WINDUP] 验证码邮件入队失败 | email=%s purpose=%s", email, purpose) - raise BizException("验证码发送失败,请稍后重试", code=BizCode.INTERNAL_ERROR) from exc + logger.exception( + "[WINDUP] 验证码邮件入队失败 | email=%s purpose=%s", email, purpose + ) + raise BizException( + "验证码发送失败,请稍后重试", code=BizCode.INTERNAL_ERROR + ) from exc finally: session.close() logger.info("[WINDUP] 验证码已入队 | email=%s purpose=%s", email, purpose) + # verify-code-atomic: 比较、失败计数和成功消费必须在同一个 Redis 操作中完成。 + _VERIFY_CODE_SCRIPT = """ + -- verify-code-atomic + local stored = redis.call('GET', KEYS[1]) + if stored == false then + return 0 + end + if stored == ARGV[1] then + redis.call('DEL', KEYS[1]) + redis.call('DEL', KEYS[2]) + return 1 + end + local attempts = redis.call('INCR', KEYS[2]) + if attempts == 1 then + redis.call('EXPIRE', KEYS[2], tonumber(ARGV[2])) + end + if attempts >= tonumber(ARGV[3]) then + redis.call('DEL', KEYS[1]) + return -1 + end + return -2 + """ + def _verify_code(self, email: str, code: str, purpose: str) -> None: - """校验验证码,失败抛 BizException。""" + """原子校验并消费验证码,失败信息统一且最多允许五次尝试。""" code_key = VERIFY_CODE_KEY.format(purpose=purpose, email=email) - stored_code = self.redis.get(code_key) - if stored_code is None: - raise BizException("验证码已过期", code=BizCode.BAD_REQUEST) - if stored_code != code: - raise BizException("验证码错误", code=BizCode.BAD_REQUEST) - # 验证通过,删除验证码 - self.redis.delete(code_key) + attempt_key = VERIFY_ATTEMPT_KEY.format(purpose=purpose, email=email) + result = self.redis.eval( + self._VERIFY_CODE_SCRIPT, + 2, + code_key, + attempt_key, + code, + VERIFY_CODE_TTL, + VERIFY_ATTEMPT_LIMIT, + ) + if int(result or 0) != 1: + raise BizException("验证码无效或已过期", code=BizCode.BAD_REQUEST) def login_by_code(self, session: Session, input: LoginByCodeInput) -> LoginResult: """邮箱+验证码登录。未知邮箱自动建号并赠送注册积分。""" @@ -373,8 +416,9 @@ def login_by_code(self, session: Session, input: LoginByCodeInput) -> LoginResul user.last_login_at = datetime.now(timezone.utc) session.flush() - access_token = create_access_token(user.id, user.email) - refresh_token, jti = create_refresh_token(user.id, user.email) + auth_version = user.auth_version + access_token = create_access_token(user.id, user.email, auth_version) + refresh_token, jti = create_refresh_token(user.id, user.email, auth_version) self._store_refresh_token(jti, user.id) return LoginResult( @@ -400,7 +444,7 @@ def logout(self, refresh_token: str) -> None: # -- Token 验证 ------------------------------------------------------ - def validate_access_token(self, token: str) -> UserView | None: + def validate_access_token(self, session: Session, token: str) -> UserView | None: """校验 access_token,返回 UserView 或 None。""" try: payload = decode_token(token) @@ -410,10 +454,14 @@ def validate_access_token(self, token: str) -> UserView | None: if payload.get("type") != "access": return None - return UserView( - id=int(payload["sub"]), - email=payload.get("email", ""), - ) + user_id = int(payload["sub"]) + user = session.get(User, user_id) + if user is None or user.status == UserStatus.BANNED: + return None + if int(payload.get("av", 0)) != user.auth_version: + return None + + return _to_view(user) # -- Lua: 原子 检查-删除-存储 refresh token -------------------------------- # KEYS[1] = old_token_key, KEYS[2] = new_token_key @@ -433,7 +481,7 @@ def validate_access_token(self, token: str) -> UserView | None: return cur """ - def refresh_tokens(self, refresh_token: str) -> LoginResult: + def refresh_tokens(self, session: Session, refresh_token: str) -> LoginResult: """刷新 token。""" payload = decode_token(refresh_token) if payload.get("type") != "refresh": @@ -445,11 +493,17 @@ def refresh_tokens(self, refresh_token: str) -> LoginResult: # user_id 来自已验签的 JWT,可信 user_id = int(payload["sub"]) - email = payload.get("email", "") + user = session.get(User, user_id) + if user is None or user.status == UserStatus.BANNED: + raise BizException("refresh token 已失效", code=BizCode.UNAUTHORIZED) + email = user.email + auth_version = user.auth_version + if int(payload.get("av", 0)) != auth_version: + raise BizException("refresh token 已失效", code=BizCode.UNAUTHORIZED) # 签发新 token - new_access = create_access_token(user_id, email) - new_refresh, new_jti = create_refresh_token(user_id, email) + new_access = create_access_token(user_id, email, auth_version) + new_refresh, new_jti = create_refresh_token(user_id, email, auth_version) # Lua 原子操作:GET old → 存在则 DEL old + SETEX new → 返回 user_id token_hash = _hash_token(jti) @@ -471,7 +525,7 @@ def refresh_tokens(self, refresh_token: str) -> LoginResult: logger.info("[WINDUP] token 已刷新 | user_id=%s", user_id) return LoginResult( - user=UserView(id=user_id, email=email), + user=_to_view(user), access_token=new_access, refresh_token=new_refresh, ) @@ -482,7 +536,7 @@ def change_password( self, session: Session, user_id: int, input: ChangePasswordInput ) -> None: """修改密码。""" - user = session.get(User, user_id) + user = session.get(User, user_id, with_for_update=True) if user is None: raise BizException("用户不存在", code=BizCode.NOT_FOUND) @@ -493,6 +547,7 @@ def change_password( raise BizException("旧密码错误", code=BizCode.BAD_REQUEST) user.password_hash = _hash_password(input.new_password) + user.auth_version += 1 session.flush() # 修改密码后撤销该用户所有 refresh_token @@ -503,7 +558,7 @@ def set_password( self, session: Session, user_id: int, input: SetPasswordInput ) -> None: """设置初始密码(仅未设密码用户)。""" - user = session.get(User, user_id) + user = session.get(User, user_id, with_for_update=True) if user is None: raise BizException("用户不存在", code=BizCode.NOT_FOUND) @@ -511,17 +566,38 @@ def set_password( raise BizException("密码已设置,请使用修改密码", code=BizCode.BAD_REQUEST) user.password_hash = _hash_password(input.new_password) + user.auth_version += 1 session.flush() self._revoke_all_user_tokens(user_id) logger.info("[WINDUP] 密码已设置 | user_id=%s", user_id) + def change_password_by_email( + self, session: Session, user_id: int, input: EmailChangePasswordInput + ) -> None: + """验证当前登录账号的邮箱后修改密码。""" + user = session.get(User, user_id, with_for_update=True) + if user is None: + raise BizException("用户不存在", code=BizCode.NOT_FOUND) + if user.status == UserStatus.BANNED: + raise BizException("账号已被封禁", code=BizCode.BAD_REQUEST) + + self._verify_code(user.email, input.code, "change_password") + user.password_hash = _hash_password(input.new_password) + user.auth_version += 1 + session.flush() + + self._revoke_all_user_tokens(user_id) + logger.info("[WINDUP] 邮箱核验改密成功 | user_id=%s", user_id) + def reset_password(self, session: Session, input: ResetPasswordInput) -> None: """邮箱+验证码重置密码(忘记密码场景)。""" # 校验验证码(purpose 必须为 reset_password) self._verify_code(input.email, input.code, "reset_password") - user = session.scalar(select(User).where(User.email == input.email)) + user = session.scalar( + select(User).where(User.email == input.email).with_for_update() + ) if user is None: raise BizException("用户不存在", code=BizCode.NOT_FOUND) @@ -529,6 +605,7 @@ def reset_password(self, session: Session, input: ResetPasswordInput) -> None: raise BizException("账号已被封禁", code=BizCode.BAD_REQUEST) user.password_hash = _hash_password(input.new_password) + user.auth_version += 1 session.flush() # 重置密码后撤销该用户所有 refresh_token diff --git a/backend/packages/app/src/windup_app/web/api/auth.py b/backend/packages/app/src/windup_app/web/api/auth.py index 165b2758..b8164b59 100644 --- a/backend/packages/app/src/windup_app/web/api/auth.py +++ b/backend/packages/app/src/windup_app/web/api/auth.py @@ -14,6 +14,7 @@ from windup_framework.db import get_session from windup_app.server.user.model import ( + EmailChangePasswordInput, RegisterInput, ResetPasswordInput, SetPasswordInput, @@ -108,6 +109,15 @@ class ResetPasswordRequest(BaseModel): new_password: str = Field(min_length=8, max_length=128) +class EmailChangePasswordRequest(BaseModel): + """当前登录账号的邮箱验证码改密请求。""" + + model_config = ConfigDict(extra="forbid") + + code: str = Field(pattern=r"^\d{6}$") + new_password: str = Field(min_length=8, max_length=128) + + # -- 响应模型 ------------------------------------------------------------ @@ -230,9 +240,9 @@ def login_by_code(body: LoginByCodeRequest, session: Session = Depends(get_sessi @router.post("/refresh", response_model=Response[TokenResponse]) -def refresh(body: RefreshRequest): +def refresh(body: RefreshRequest, session: Session = Depends(get_session)): """刷新 token。""" - result = service.refresh_tokens(body.refresh_token) + result = service.refresh_tokens(session, body.refresh_token) return Response.success( TokenResponse( access_token=result.access_token, @@ -298,6 +308,31 @@ def set_password( return Response.success(None, message="密码设置成功") +@router.post("/change-password/send-code", response_model=Response[None]) +def send_password_change_code(request: Request): + """向当前登录账号的邮箱发送改密验证码。""" + service.send_verification_code( + request.state.current_user.email, + "change_password", + ) + return Response.success(None, message="验证码已发送") + + +@router.post("/change-password/confirm", response_model=Response[None]) +def change_password_by_email( + body: EmailChangePasswordRequest, + request: Request, + session: Session = Depends(get_session), +): + """核验当前登录账号的邮箱验证码并修改密码。""" + service.change_password_by_email( + session, + request.state.current_user.id, + EmailChangePasswordInput(code=body.code, new_password=body.new_password), + ) + return Response.success(None, message="密码修改成功") + + @router.post("/reset-password", response_model=Response[None]) def reset_password(body: ResetPasswordRequest, session: Session = Depends(get_session)): """邮箱+验证码重置密码(忘记密码)。""" diff --git a/backend/packages/app/src/windup_app/web/middleware/auth.py b/backend/packages/app/src/windup_app/web/middleware/auth.py index 8bd0cceb..7e2b43f3 100644 --- a/backend/packages/app/src/windup_app/web/middleware/auth.py +++ b/backend/packages/app/src/windup_app/web/middleware/auth.py @@ -8,12 +8,20 @@ from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import Response +from starlette.concurrency import run_in_threadpool from windup_common.enums.biz_code import BizCode from windup_common.exceptions import BizException from windup_common.result import Response as Resp -from windup_app.server.user.service import decode_token +from windup_app.server.user.service import decode_token, service +from windup_framework.db import SessionLocal + + +def _validate_access_token(session_factory, token: str): + """在线程池中使用独立会话校验 token 与账号版本。""" + with session_factory() as session: + return service.validate_access_token(session, token) def _biz_error(msg: str, code: int) -> JSONResponse: @@ -85,18 +93,28 @@ async def dispatch(self, request: Request, call_next) -> Response: token = auth_header[7:] # 去掉 "Bearer " 前缀 - # 解码 + 验证 + # 先保留既有的明确错误信息,再校验账号会话版本。 try: payload = decode_token(token) - except BizException as e: - return _auth_error(request, e.message, e.code) - + except BizException as exc: + return _auth_error(request, exc.message, exc.code) if payload.get("type") != "access": return _auth_error(request, "token 类型错误", BizCode.UNAUTHORIZED) + session_factory = getattr( + request.app.state, "auth_session_factory", SessionLocal + ) + current_user = await run_in_threadpool( + _validate_access_token, session_factory, token + ) + if current_user is None: + return _auth_error(request, "token 无效或已失效", BizCode.UNAUTHORIZED) + # 注入当前用户到 request.state request.state.current_user = type( - "CurrentUser", (), {"id": int(payload["sub"]), "email": payload.get("email", "")} + "CurrentUser", + (), + {"id": current_user.id, "email": current_user.email or ""}, )() return await call_next(request) diff --git a/backend/scripts/migrations/20260827_add_user_auth_version.sql b/backend/scripts/migrations/20260827_add_user_auth_version.sql new file mode 100644 index 00000000..931dcbea --- /dev/null +++ b/backend/scripts/migrations/20260827_add_user_auth_version.sql @@ -0,0 +1,8 @@ +-- 在切换包含 auth_version 的后端和 Worker 之前执行。 +-- ADD COLUMN IF NOT EXISTS 使该迁移可以安全重复运行。 +BEGIN; + +ALTER TABLE windup_user + ADD COLUMN IF NOT EXISTS auth_version INTEGER NOT NULL DEFAULT 0; + +COMMIT; diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d9193d0f..6a0176e7 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -7,6 +7,7 @@ import os import pathlib +from contextlib import nullcontext # CI 环境可能未配置真实凭据,在 import 触发 Settings 实例化前提供测试默认值。 # setdefault 不覆盖已有的环境变量(本地 .env 或 CI secrets 优先生效)。 @@ -187,12 +188,17 @@ def override_get_session(): @pytest.fixture() -def auth_client(engine): +def auth_client(engine, db_session): """带认证 token 的 FastAPI TestClient。 自动在请求头中添加 Authorization Bearer token,绕过鉴权中间件。 """ session_local = sessionmaker(bind=engine, expire_on_commit=False) + if db_session.get(User, 1) is None: + db_session.add( + User(id=1, email="test@example.com", password_hash="", nickname="旧昵称") + ) + db_session.flush() def override_get_session(): session = session_local() @@ -206,6 +212,7 @@ def override_get_session(): session.close() app = create_app() + app.state.auth_session_factory = lambda: nullcontext(db_session) _disable_generation_execution(app) from windup_app.server.orchestrator import task_repo from windup_app.web.api import generation as generation_api @@ -218,7 +225,6 @@ def override_get_session(): # 生成测试用 token token = create_access_token(1, "test@example.com") client = TestClient(app, headers={"Authorization": f"Bearer {token}"}) - try: yield client finally: @@ -227,9 +233,12 @@ def override_get_session(): @pytest.fixture() -def auth_client_b(engine): +def auth_client_b(engine, db_session): """另一个用户的认证 TestClient(user_id=2),用于跨用户权限测试。""" session_local = sessionmaker(bind=engine, expire_on_commit=False) + if db_session.get(User, 2) is None: + db_session.add(User(id=2, email="other@example.com", password_hash="")) + db_session.flush() def override_get_session(): session = session_local() @@ -243,6 +252,7 @@ def override_get_session(): session.close() app = create_app() + app.state.auth_session_factory = lambda: nullcontext(db_session) _disable_generation_execution(app) from windup_app.server.orchestrator import task_repo from windup_app.web.api import generation as generation_api @@ -254,7 +264,6 @@ def override_get_session(): token = create_access_token(2, "other@example.com") client = TestClient(app, headers={"Authorization": f"Bearer {token}"}) - try: yield client finally: diff --git a/backend/tests/test_agent_api.py b/backend/tests/test_agent_api.py index e0a465d6..12d270b5 100644 --- a/backend/tests/test_agent_api.py +++ b/backend/tests/test_agent_api.py @@ -6,13 +6,16 @@ import json import logging from collections.abc import Callable +from contextlib import nullcontext from functools import partial from typing import Any +from unittest.mock import MagicMock import httpx import pytest from windup_app.bootstrap.app import create_app +from windup_app.server.user.model import User from windup_app.server.user.service import create_access_token from windup_app.web.api import agent as agent_api from windup_common.enums.biz_code import BizCode @@ -460,6 +463,15 @@ async def chunks(): yield b'"}]}' app = create_app() + auth_session = MagicMock() + auth_session.get.return_value = User( + id=1, + email="test@example.com", + password_hash="", + auth_version=0, + status=0, + ) + app.state.auth_session_factory = lambda: nullcontext(auth_session) token = create_access_token(1, "test@example.com") transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient( diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py index 5204ac6b..20dd01d5 100644 --- a/backend/tests/test_auth_api.py +++ b/backend/tests/test_auth_api.py @@ -1,6 +1,6 @@ """认证 API:覆盖 login / 验证码登录 / 改密 / 重置密码 / 改昵称调用链。""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -8,6 +8,7 @@ from windup_app.server.user.model import User from windup_app.server.user.service import _hash_password, service +from windup_app.server.user.service import _verify_password @pytest.fixture() @@ -18,6 +19,19 @@ def mock_user_redis(): redis_mock.setex.return_value = True redis_mock.delete.return_value = True redis_mock.scan_iter.return_value = iter([]) + redis_mock.hget.return_value = None + redis_mock._verify_result = None + redis_mock._rotate_result = None + + def eval_script(script, _key_count, *args): + if "verify-code-atomic" in script: + if redis_mock._verify_result is not None: + return redis_mock._verify_result + stored = redis_mock.get.return_value + return 1 if stored is not None and stored == args[2] else 0 + return redis_mock._rotate_result + + redis_mock.eval.side_effect = eval_script previous = service._redis service._redis = redis_mock yield redis_mock @@ -33,7 +47,7 @@ def seeded_user(db_session): password_hash=_hash_password("password123"), nickname="旧昵称", ) - db_session.add(user) + user = db_session.merge(user) db_session.flush() return user @@ -115,6 +129,59 @@ def test_set_password_endpoint(auth_client, db_session, mock_user_redis): assert body["message"] == "密码设置成功" +def test_send_password_change_code_uses_current_user_email(auth_client): + with patch.object(service, "send_verification_code") as send_code: + resp = auth_client.post("/auth/change-password/send-code") + + assert resp.status_code == 200 + assert resp.json()["code"] == 200 + send_code.assert_called_once_with("test@example.com", "change_password") + + +def test_change_password_by_email_rejects_email_override( + auth_client, seeded_user, mock_user_redis +): + resp = auth_client.post( + "/auth/change-password/confirm", + json={ + "email": "victim@example.com", + "code": "654321", + "new_password": "ownernewpass", + }, + ) + + assert resp.status_code == 200 + assert resp.json()["code"] == 400 + + +def test_change_password_by_email_uses_current_user( + auth_client, db_session, seeded_user, mock_user_redis +): + victim = User( + id=2, + email="victim@example.com", + password_hash=_hash_password("victimpass"), + ) + db_session.add(victim) + db_session.flush() + mock_user_redis._verify_result = 1 + + resp = auth_client.post( + "/auth/change-password/confirm", + json={ + "code": "654321", + "new_password": "ownernewpass", + }, + ) + + assert resp.status_code == 200 + assert resp.json()["code"] == 200 + db_session.refresh(seeded_user) + db_session.refresh(victim) + assert _verify_password("ownernewpass", seeded_user.password_hash) + assert _verify_password("victimpass", victim.password_hash) + + def test_register_endpoint_success(client, db_session, mock_user_redis): seed_invite_code(db_session) db_session.commit() @@ -155,7 +222,9 @@ def test_register_endpoint_success_without_invite_code(client, mock_user_redis): assert body["data"]["access_token"] -def test_login_by_code_endpoint_creates_unknown_email(client, db_session, mock_user_redis): +def test_login_by_code_endpoint_creates_unknown_email( + client, db_session, mock_user_redis +): mock_user_redis.get.return_value = "123456" resp = client.post( "/auth/login-by-code", diff --git a/backend/tests/test_quota.py b/backend/tests/test_quota.py index a58d7f18..bcf229ca 100644 --- a/backend/tests/test_quota.py +++ b/backend/tests/test_quota.py @@ -52,9 +52,10 @@ def user_with_account(db_session: Session): @pytest.fixture() -def auth_quota_client(engine, user_with_account): +def auth_quota_client(engine, db_session, user_with_account): """带认证且预置积分账户的 TestClient。""" from fastapi.testclient import TestClient + from contextlib import nullcontext from sqlalchemy.orm import sessionmaker from windup_app.bootstrap.app import create_app @@ -75,6 +76,7 @@ def override_get_session(): session.close() app = create_app() + app.state.auth_session_factory = lambda: nullcontext(db_session) app.dependency_overrides[get_session] = override_get_session token = create_access_token(1, "quota_test@example.com") diff --git a/backend/tests/test_user_service.py b/backend/tests/test_user_service.py index 8978af8d..7ff7631c 100644 --- a/backend/tests/test_user_service.py +++ b/backend/tests/test_user_service.py @@ -10,6 +10,7 @@ from windup_app.server.user.model import ( ChangePasswordInput, + EmailChangePasswordInput, LoginByCodeInput, LoginByPasswordInput, RegisterInput, @@ -45,9 +46,21 @@ def mock_redis(): redis_mock.get.return_value = None redis_mock.setex.return_value = True redis_mock.delete.return_value = True - redis_mock.eval.return_value = None # Lua 脚本默认返回 None + redis_mock.hget.return_value = None + redis_mock._verify_result = None + redis_mock._rotate_result = None + + def eval_script(script, _key_count, *args): + if "verify-code-atomic" in script: + if redis_mock._verify_result is not None: + return redis_mock._verify_result + stored = redis_mock.get.return_value + return 1 if stored is not None and stored == args[2] else 0 + return redis_mock._rotate_result + + redis_mock.eval.side_effect = eval_script redis_mock.pipeline.return_value = MagicMock( - execute=MagicMock(return_value=[True, True]) + execute=MagicMock(return_value=[True, True, True]) ) return redis_mock @@ -128,6 +141,33 @@ def test_decode_invalid_token(): decode_token("invalid-token") +def test_access_token_auth_version_invalidates_old_sessions(db_session, service): + user = User( + email="test@example.com", + password_hash=_hash_password("password123"), + auth_version=4, + ) + db_session.add(user) + db_session.flush() + token = create_access_token(user.id, user.email, auth_version=3) + + assert service.validate_access_token(db_session, token) is None + + +def test_refresh_token_auth_version_invalidates_old_sessions(db_session, service): + user = User( + email="test@example.com", + password_hash=_hash_password("password123"), + auth_version=4, + ) + db_session.add(user) + db_session.flush() + token, _ = create_refresh_token(user.id, user.email, auth_version=3) + + with pytest.raises(BizException, match="refresh token 已失效"): + service.refresh_tokens(db_session, token) + + # -- 注册测试 ------------------------------------------------------------ @@ -259,7 +299,7 @@ def test_register_wrong_code(db_session, service): invite_code="AB23CD45", ) - with pytest.raises(BizException, match="验证码错误"): + with pytest.raises(BizException, match="验证码无效或已过期"): service.register_by_email(db_session, input_data) @@ -273,7 +313,7 @@ def test_register_expired_code(db_session, service): invite_code="AB23CD45", ) - with pytest.raises(BizException, match="验证码已过期"): + with pytest.raises(BizException, match="验证码无效或已过期"): service.register_by_email(db_session, input_data) @@ -333,7 +373,9 @@ def test_register_expired_invite_code(db_session, service): with pytest.raises(BizException, match="邀请码已过期") as exc: service.register_by_email(db_session, input_data) assert exc.value.code == BizCode.NOT_FOUND - assert db_session.scalar(select(User).where(User.email == "late@example.com")) is None + assert ( + db_session.scalar(select(User).where(User.email == "late@example.com")) is None + ) # -- 登录测试 ------------------------------------------------------------ @@ -516,7 +558,7 @@ def test_login_by_code_wrong_code(db_session, service): input_data = LoginByCodeInput(email="code@example.com", code="999999") - with pytest.raises(BizException, match="验证码错误"): + with pytest.raises(BizException, match="验证码无效或已过期"): service.login_by_code(db_session, input_data) @@ -539,6 +581,24 @@ def test_send_verification_code_cooldown(service, mock_mq_publish): service.send_verification_code("test@example.com", "login") +@pytest.mark.parametrize("result", [0, -1, -2]) +def test_verify_code_rejects_expired_wrong_and_exhausted_with_one_message( + service, mock_redis, result +): + mock_redis._verify_result = result + + with pytest.raises(BizException, match="验证码无效或已过期"): + service._verify_code("test@example.com", "000000", "change_password") + + +def test_verify_code_consumes_atomically(service, mock_redis): + mock_redis._verify_result = 1 + + service._verify_code("test@example.com", "654321", "change_password") + + mock_redis.eval.assert_called_once() + + # -- 登出测试 ------------------------------------------------------------ @@ -559,45 +619,54 @@ def test_logout_invalid_token(service): # -- 刷新 token 测试 ---------------------------------------------------- -def test_refresh_tokens(service, mock_redis): +def test_refresh_tokens(db_session, service, mock_redis): + user = User(email="test@example.com", password_hash="") + db_session.add(user) + db_session.flush() # 先创建一个 refresh token - token, jti = create_refresh_token(1, "test@example.com") + token, jti = create_refresh_token(user.id, user.email) # Mock Redis eval 返回 user_id(Lua 脚本成功) - mock_redis.eval.return_value = "1" + mock_redis._rotate_result = "1" - result = service.refresh_tokens(token) + result = service.refresh_tokens(db_session, token) assert result.access_token is not None assert result.refresh_token is not None - assert result.user.id == 1 + assert result.user.id == user.id # 验证调用了 eval(Lua 脚本),而不是 get mock_redis.eval.assert_called_once() -def test_refresh_tokens_revoked(service, mock_redis): - token, jti = create_refresh_token(1, "test@example.com") +def test_refresh_tokens_revoked(db_session, service, mock_redis): + user = User(email="test@example.com", password_hash="") + db_session.add(user) + db_session.flush() + token, jti = create_refresh_token(user.id, user.email) # Mock Redis eval 返回 None(Lua 脚本:旧 token 不存在) - mock_redis.eval.return_value = None + mock_redis._rotate_result = None with pytest.raises(BizException, match="refresh token 已失效"): - service.refresh_tokens(token) + service.refresh_tokens(db_session, token) -def test_refresh_tokens_concurrent_reuse(service, mock_redis): +def test_refresh_tokens_concurrent_reuse(db_session, service, mock_redis): + user = User(email="test@example.com", password_hash="") + db_session.add(user) + db_session.flush() """并发重放:同一个 refresh token 第二次调用应失败。""" - token, jti = create_refresh_token(1, "test@example.com") + token, jti = create_refresh_token(user.id, user.email) # 第一次调用成功 - mock_redis.eval.return_value = "1" - result1 = service.refresh_tokens(token) + mock_redis._rotate_result = "1" + result1 = service.refresh_tokens(db_session, token) assert result1.access_token is not None # 第二次调用(并发重放)失败 - mock_redis.eval.return_value = None + mock_redis._rotate_result = None with pytest.raises(BizException, match="refresh token 已失效"): - service.refresh_tokens(token) + service.refresh_tokens(db_session, token) # -- 修改密码测试 -------------------------------------------------------- @@ -684,9 +753,11 @@ def test_set_password_success(db_session, service, mock_mq_publish): db_session, LoginByCodeInput(email="setpwd@example.com", code="123456") ) + session = MagicMock(wraps=db_session) service.set_password( - db_session, result.user.id, SetPasswordInput(new_password="newpass123") + session, result.user.id, SetPasswordInput(new_password="newpass123") ) + session.get.assert_called_once_with(User, result.user.id, with_for_update=True) service._redis.get.return_value = None login_result = service.login_by_password( @@ -714,6 +785,68 @@ def test_set_password_already_set(db_session, service, mock_mq_publish): ) +def test_change_password_by_email_is_bound_to_current_user( + db_session, service, mock_redis +): + user = User(email="owner@example.com", password_hash=_hash_password("oldpass123")) + db_session.add(user) + db_session.flush() + mock_redis._verify_result = 1 + + session = MagicMock(wraps=db_session) + service.change_password_by_email( + session, + user.id, + EmailChangePasswordInput(code="654321", new_password="newpass123"), + ) + + session.get.assert_called_once_with(User, user.id, with_for_update=True) + assert _verify_password("newpass123", user.password_hash) + assert user.auth_version == 1 + + +def test_password_changes_lock_user_row(db_session, service, mock_redis): + """改密必须锁定用户行,避免并发事务丢失 auth_version 递增。""" + user = User(email="locked@example.com", password_hash=_hash_password("oldpass123")) + db_session.add(user) + db_session.flush() + session = MagicMock(wraps=db_session) + + service.change_password( + session, + user.id, + ChangePasswordInput(old_password="oldpass123", new_password="newpass123"), + ) + + session.get.assert_called_once_with(User, user.id, with_for_update=True) + + +def test_reset_password_query_locks_user_row(db_session, service, mock_redis): + """忘记密码按邮箱查找时也必须生成 FOR UPDATE。""" + from sqlalchemy.dialects import postgresql + + user = User( + email="reset-lock@example.com", password_hash=_hash_password("oldpass123") + ) + db_session.add(user) + db_session.flush() + mock_redis._verify_result = 1 + session = MagicMock(wraps=db_session) + + service.reset_password( + session, + ResetPasswordInput( + email=user.email, + code="654321", + new_password="newpass123", + ), + ) + + statement = session.scalar.call_args.args[0] + compiled = str(statement.compile(dialect=postgresql.dialect())) + assert "FOR UPDATE" in compiled + + # -- 昵称修改测试 -------------------------------------------------------- @@ -809,7 +942,7 @@ def test_reset_password_wrong_code(db_session, service, mock_mq_publish): email="reset2@example.com", code="000000", new_password="newpass123" ) - with pytest.raises(BizException, match="验证码已过期"): + with pytest.raises(BizException, match="验证码无效或已过期"): service.reset_password(db_session, reset_input) diff --git a/frontend/src/app/app.test.tsx b/frontend/src/app/app.test.tsx index 0aa30ab9..be6992cc 100644 --- a/frontend/src/app/app.test.tsx +++ b/frontend/src/app/app.test.tsx @@ -243,6 +243,9 @@ describe('AppRoutes authentication boundary', () => { updateNickname: async () => Promise.reject(new Error('not used')), setPassword: async () => Promise.reject(new Error('not used')), changePassword: async () => Promise.reject(new Error('not used')), + resetPassword: async () => Promise.reject(new Error('not used')), + sendPasswordChangeCode: async () => Promise.reject(new Error('not used')), + changePasswordWithCode: async () => Promise.reject(new Error('not used')), } window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'expired-refresh-token') @@ -272,6 +275,9 @@ describe('AppRoutes authentication boundary', () => { updateNickname: async () => Promise.reject(new Error('not used')), setPassword: async () => Promise.reject(new Error('not used')), changePassword: async () => Promise.reject(new Error('not used')), + resetPassword: async () => Promise.reject(new Error('not used')), + sendPasswordChangeCode: async () => Promise.reject(new Error('not used')), + changePasswordWithCode: async () => Promise.reject(new Error('not used')), } window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'expired-refresh-token') diff --git a/frontend/src/app/layout/app-header.test.tsx b/frontend/src/app/layout/app-header.test.tsx index bd8e7d66..61bd4041 100644 --- a/frontend/src/app/layout/app-header.test.tsx +++ b/frontend/src/app/layout/app-header.test.tsx @@ -33,6 +33,9 @@ function createApis(): UserApis & Record user), setPassword: vi.fn(async () => undefined), changePassword: vi.fn(async () => undefined), + resetPassword: vi.fn(async () => undefined), + sendPasswordChangeCode: vi.fn(async () => undefined), + changePasswordWithCode: vi.fn(async () => undefined), } } @@ -496,6 +499,9 @@ describe('AppHeader', () => { expect(menuSurface.getAttribute('aria-hidden')).toBeNull() const account = screen.getByRole('link', { name: '打开账号中心' }) expect(account.getAttribute('href')).toBe('/account') + expect(screen.getByRole('link', { name: '修改密码' }).getAttribute('href')).toBe( + '/account?section=security', + ) expect(account.getAttribute('aria-current')).toBe('page') expect(accountMenu.textContent).toContain('Reader') expect(screen.queryByText('资料与登录安全')).toBeNull() diff --git a/frontend/src/app/layout/app-header.tsx b/frontend/src/app/layout/app-header.tsx index da9eabf8..b7dc3b9d 100644 --- a/frontend/src/app/layout/app-header.tsx +++ b/frontend/src/app/layout/app-header.tsx @@ -445,6 +445,13 @@ export function AppHeader({ > 账号中心 + + 修改密码 + + ) : showForgotPassword ? ( +
+ + setCode(value.replace(/\D/g, '').slice(0, 6))} + action={ + + } + /> + setShowSetPassword((visible) => !visible)} + /> + } + /> + + {feedback} + + + ) : (
)} + {!isRegister && mode === 'password' && ( + + )} + {isRegister && registerStep === 3 && ( )} - {!showSetPasswordPrompt && ( + {!showSetPasswordPrompt && !showForgotPassword && (

{isRegister ? '已有账号?' : '还没有账号?'}{' '}

)} - {isRegister && !showSetPasswordPrompt && ( + {isRegister && !showSetPasswordPrompt && !showForgotPassword && (

{normalizedInviteCode ? '填写邀请码,注册后共得 500 积分。' : '注册即赠 300 积分。'}

diff --git a/frontend/src/features/auth-session/index.test.tsx b/frontend/src/features/auth-session/index.test.tsx index 91ffe866..e9892e8a 100644 --- a/frontend/src/features/auth-session/index.test.tsx +++ b/frontend/src/features/auth-session/index.test.tsx @@ -51,6 +51,9 @@ function createApis(): UserApis & Record user), setPassword: vi.fn(async () => undefined), changePassword: vi.fn(async () => undefined), + resetPassword: vi.fn(async () => undefined), + sendPasswordChangeCode: vi.fn(async () => undefined), + changePasswordWithCode: vi.fn(async () => undefined), } } @@ -191,14 +194,17 @@ describe('AuthSessionProvider', () => { await expectState('guest:logged-out:') }) - it('clears the session with a password-changed reason after changing the password', async () => { + it('clears the session with a password-changed reason after email-verified password reset', async () => { const apis = createApis() renderSession(apis) await expectState('guest::') await act(async () => session().loginByCode({ email: 'reader@example.com', code: '123456' })) await act(async () => - session().changePassword({ oldPassword: 'password-123', newPassword: 'new-password-123' }), + session().changePasswordWithCode({ + code: '123456', + newPassword: 'new-password-123', + }), ) await expectState('guest:password-changed:') diff --git a/frontend/src/features/auth-session/index.tsx b/frontend/src/features/auth-session/index.tsx index 93cc0476..d78e40b8 100644 --- a/frontend/src/features/auth-session/index.tsx +++ b/frontend/src/features/auth-session/index.tsx @@ -42,6 +42,9 @@ export interface AuthSessionValue { options?: { keepSession?: boolean }, ): Promise changePassword(input: Parameters[0]): Promise + resetPassword(input: Parameters[0]): Promise + sendPasswordChangeCode(): Promise + changePasswordWithCode(input: Parameters[0]): Promise logout(): Promise } @@ -368,6 +371,18 @@ export function AuthSessionProvider({ apis, children }: AuthSessionProviderProps }, [apis, clearSession], ) + const resetPassword = useCallback( + (input: Parameters[0]) => apis.resetPassword(input), + [apis], + ) + const sendPasswordChangeCode = useCallback(() => apis.sendPasswordChangeCode(), [apis]) + const changePasswordWithCode = useCallback( + async (input: Parameters[0]) => { + await apis.changePasswordWithCode(input) + clearSession('password-changed') + }, + [apis, clearSession], + ) const logout = useCallback(async () => { const refreshToken = refreshTokenRef.current clearSession('logged-out') @@ -385,15 +400,21 @@ export function AuthSessionProvider({ apis, children }: AuthSessionProviderProps updateNickname, setPassword, changePassword, + resetPassword, + sendPasswordChangeCode, + changePasswordWithCode, logout, }), [ - changePassword, login, loginByCode, logout, refreshCurrentUser, register, + changePassword, + resetPassword, + changePasswordWithCode, + sendPasswordChangeCode, sendCode, setPassword, state, diff --git a/frontend/src/pages/account/index.test.tsx b/frontend/src/pages/account/index.test.tsx index b4bad5aa..6fb60314 100644 --- a/frontend/src/pages/account/index.test.tsx +++ b/frontend/src/pages/account/index.test.tsx @@ -43,6 +43,9 @@ function createApis(): UserApis & Record ({ ...user, nickname })), setPassword: vi.fn(async () => undefined), changePassword: vi.fn(async () => undefined), + resetPassword: vi.fn(async () => undefined), + sendPasswordChangeCode: vi.fn(async () => undefined), + changePasswordWithCode: vi.fn(async () => undefined), } } @@ -130,26 +133,28 @@ describe('AccountPage', () => { expect(badgeButton.className).toContain('account-badge-shake') expect(screen.getByRole('navigation', { name: '账号设置' })).toBeTruthy() expect(screen.getByRole('heading', { name: '个人资料' })).toBeTruthy() - expect(screen.queryByLabelText('当前密码')).toBeNull() + expect(screen.queryByLabelText('邮箱验证码')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: '登录安全' })) - expect(screen.getByRole('heading', { name: '登录安全' })).toBeTruthy() - const oldPassword = screen.getByLabelText('当前密码') + fireEvent.click(screen.getByRole('button', { name: '修改密码' })) + expect(screen.getByRole('heading', { name: '修改密码' })).toBeTruthy() + const code = screen.getByLabelText('邮箱验证码') const newPassword = screen.getByLabelText('新密码') - expect(oldPassword).toBeTruthy() + expect(code).toBeTruthy() + expect(screen.getByText('reader@example.com')).toBeTruthy() + expect(screen.queryByLabelText('当前密码')).toBeNull() expect(screen.queryByLabelText('昵称')).toBeNull() - fireEvent.change(oldPassword, { target: { value: 'old-password' } }) + fireEvent.change(code, { target: { value: '123456' } }) fireEvent.change(newPassword, { target: { value: 'short' } }) - fireEvent.click(screen.getByRole('button', { name: '修改密码' })) + fireEvent.click(screen.getByRole('button', { name: '验证并修改密码' })) expect(await screen.findByText('新密码需为 8–128 位')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: '个人资料' })) expect(screen.getByLabelText('昵称')).toBeTruthy() expect(screen.queryByText('新密码需为 8–128 位')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: '登录安全' })) - expect((screen.getByLabelText('当前密码') as HTMLInputElement).value).toBe('') + fireEvent.click(screen.getByRole('button', { name: '修改密码' })) + expect((screen.getByLabelText('邮箱验证码') as HTMLInputElement).value).toBe('') expect((screen.getByLabelText('新密码') as HTMLInputElement).value).toBe('') }) @@ -482,43 +487,66 @@ describe('AccountPage', () => { expect((nickname as HTMLInputElement).value).toBe('Taken Name') }) - it('validates the new password locally and preserves both fields on backend error', async () => { + it('sends a reset code to the current account email and enforces a resend cooldown', async () => { + const { apis } = renderAccount(createApis(), '/account?section=security') + + expect(await screen.findByRole('heading', { name: '修改密码' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '发送验证码' })) + + await waitFor(() => expect(apis.sendPasswordChangeCode).toHaveBeenCalledTimes(1)) + expect(await screen.findByText('验证码已发送,请在 5 分钟内使用。')).toBeTruthy() + expect((screen.getByRole('button', { name: '60s 后重发' }) as HTMLButtonElement).disabled).toBe( + true, + ) + }) + + it('validates verification and matching passwords while preserving fields on backend error', async () => { const apis = createApis() - apis.changePassword.mockRejectedValue(new Error('当前密码错误')) + apis.changePasswordWithCode.mockRejectedValue(new Error('验证码无效或已过期')) renderAccount(apis) - fireEvent.click(await screen.findByRole('button', { name: '登录安全' })) - const oldPassword = await screen.findByLabelText('当前密码') + fireEvent.click(await screen.findByRole('button', { name: '修改密码' })) + const code = await screen.findByLabelText('邮箱验证码') const newPassword = screen.getByLabelText('新密码') + const confirmPassword = screen.getByLabelText('确认新密码') - fireEvent.change(oldPassword, { target: { value: 'old-password' } }) + fireEvent.change(code, { target: { value: '123456' } }) fireEvent.change(newPassword, { target: { value: 'short' } }) - fireEvent.click(screen.getByRole('button', { name: '修改密码' })) + fireEvent.click(screen.getByRole('button', { name: '验证并修改密码' })) expect(await screen.findByText('新密码需为 8–128 位')).toBeTruthy() - expect(apis.changePassword).not.toHaveBeenCalled() + expect(apis.changePasswordWithCode).not.toHaveBeenCalled() fireEvent.change(newPassword, { target: { value: 'new-password-123' } }) - fireEvent.click(screen.getByRole('button', { name: '修改密码' })) + fireEvent.change(confirmPassword, { target: { value: 'different-password' } }) + fireEvent.click(screen.getByRole('button', { name: '验证并修改密码' })) + expect(await screen.findByText('两次输入的新密码不一致')).toBeTruthy() - expect(await screen.findByText('当前密码错误')).toBeTruthy() - expect((oldPassword as HTMLInputElement).value).toBe('old-password') + fireEvent.change(confirmPassword, { target: { value: 'new-password-123' } }) + fireEvent.click(screen.getByRole('button', { name: '验证并修改密码' })) + + expect(await screen.findByText('验证码无效或已过期')).toBeTruthy() + expect((code as HTMLInputElement).value).toBe('123456') expect((newPassword as HTMLInputElement).value).toBe('new-password-123') + expect((confirmPassword as HTMLInputElement).value).toBe('new-password-123') }) - it('clears the session after changing the password and asks for login before returning', async () => { + it('clears the session after an email-verified password reset and asks for login', async () => { renderAccount() - fireEvent.click(await screen.findByRole('button', { name: '登录安全' })) - fireEvent.change(await screen.findByLabelText('当前密码'), { - target: { value: 'old-password' }, + fireEvent.click(await screen.findByRole('button', { name: '修改密码' })) + fireEvent.change(await screen.findByLabelText('邮箱验证码'), { + target: { value: '123456' }, }) fireEvent.change(screen.getByLabelText('新密码'), { target: { value: 'new-password-123' }, }) - fireEvent.click(screen.getByRole('button', { name: '修改密码' })) + fireEvent.change(screen.getByLabelText('确认新密码'), { + target: { value: 'new-password-123' }, + }) + fireEvent.click(screen.getByRole('button', { name: '验证并修改密码' })) await waitFor(() => expect(screen.getByTestId('location').textContent).toBe( - '/?account=login&returnTo=%2Faccount', + '/?account=login&returnTo=%2Faccount%3Fsection%3Dsecurity', ), ) expect(await screen.findByRole('dialog', { name: '登录 Windup' })).toBeTruthy() diff --git a/frontend/src/pages/account/index.tsx b/frontend/src/pages/account/index.tsx index 4bccff8f..8717dab7 100644 --- a/frontend/src/pages/account/index.tsx +++ b/frontend/src/pages/account/index.tsx @@ -28,6 +28,13 @@ import './account.css' import { createProfileState, initialSecurityState, profileReducer, securityReducer } from './state' const MAX_NICKNAME_LENGTH = 50 +const SECURITY_CODE_COOLDOWN_MS = 60_000 + +type AccountSection = 'profile' | 'security' | 'quota' | 'invite' + +function accountSection(value: string | null): AccountSection { + return value === 'security' || value === 'quota' || value === 'invite' ? value : 'profile' +} function localDateStart(value: string): Date | null { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) @@ -483,14 +490,14 @@ function QuotaSection() { /** 账号页以 /auth/me 为事实来源;会话层负责把刷新和编辑结果同步给 Header。 */ export function AccountPage() { - const [searchParams] = useSearchParams() + const [searchParams, setSearchParams] = useSearchParams() const requestedSection = searchParams.get('section') const session = useAuthSession() const { - changePassword: changeSessionPassword, - setPassword: setSessionPassword, + changePasswordWithCode, logout, refreshCurrentUser, + sendPasswordChangeCode, updateNickname, } = session const currentUser = session.state.status === 'authenticated' ? session.state.user : null @@ -501,11 +508,12 @@ export function AccountPage() { createProfileState, ) const [security, dispatchSecurity] = useReducer(securityReducer, initialSecurityState) - const [activeSection, setActiveSection] = useState<'profile' | 'security' | 'quota' | 'invite'>( - requestedSection === 'invite' ? 'invite' : 'profile', + const [activeSection, setActiveSection] = useState(() => + accountSection(requestedSection), ) + const [securityNow, setSecurityNow] = useState(() => Date.now()) const nicknameId = useId() - const oldPasswordId = useId() + const securityCodeId = useId() const newPasswordId = useId() const confirmPasswordId = useId() @@ -528,9 +536,20 @@ export function AccountPage() { }, [refreshCurrentUser]) useEffect(() => { - if (requestedSection === 'invite') selectSection('invite') + const section = accountSection(requestedSection) + setActiveSection(section) + dispatchProfile({ type: 'sectionChanged' }) + dispatchSecurity({ type: 'sectionChanged' }) }, [requestedSection]) + useEffect(() => { + const cooldownUntil = security.cooldownUntil + const remaining = cooldownUntil === null ? 0 : cooldownUntil - Date.now() + if (remaining <= 0) return + const timer = window.setTimeout(() => setSecurityNow(Date.now()), Math.min(1_000, remaining)) + return () => window.clearTimeout(timer) + }, [security.cooldownUntil, securityNow]) + async function saveNickname(event: FormEvent) { event.preventDefault() if (profile.isSaving) return @@ -553,35 +572,46 @@ export function AccountPage() { } } - async function submitSecurityForm(event: FormEvent) { - event.preventDefault() - if (security.isChanging) return + async function sendSecurityCode() { + const cooldownSeconds = securityCooldownSeconds() + if (security.isSendingCode || security.isChanging || cooldownSeconds > 0 || !currentUser) return - const hasPassword = currentUser?.hasPassword ?? true + dispatchSecurity({ type: 'sendStarted' }) + try { + await sendPasswordChangeCode() + const sentAt = Date.now() + setSecurityNow(sentAt) + dispatchSecurity({ + type: 'sendSucceeded', + cooldownUntil: sentAt + SECURITY_CODE_COOLDOWN_MS, + }) + } catch (error) { + dispatchSecurity({ type: 'sendFailed', error: errorMessage(error) }) + } + } - if (hasPassword && !security.oldPassword) { - dispatchSecurity({ type: 'validationFailed', error: '请输入当前密码' }) + async function changePassword(event: FormEvent) { + event.preventDefault() + if (security.isChanging || !currentUser) return + if (!/^\d{6}$/.test(security.code)) { + dispatchSecurity({ type: 'validationFailed', error: '请输入 6 位邮箱验证码' }) return } if (security.newPassword.length < 8 || security.newPassword.length > 128) { dispatchSecurity({ type: 'validationFailed', error: '新密码需为 8–128 位' }) return } - if (!hasPassword && security.newPassword !== security.confirmPassword) { - dispatchSecurity({ type: 'validationFailed', error: '两次输入的密码不一致' }) + if (security.newPassword !== security.confirmPassword) { + dispatchSecurity({ type: 'validationFailed', error: '两次输入的新密码不一致' }) return } dispatchSecurity({ type: 'changeStarted' }) try { - if (hasPassword) { - await changeSessionPassword({ - oldPassword: security.oldPassword, - newPassword: security.newPassword, - }) - } else { - await setSessionPassword({ newPassword: security.newPassword }) - } + await changePasswordWithCode({ + code: security.code, + newPassword: security.newPassword, + }) } catch (error) { dispatchSecurity({ type: 'changeFailed', error: errorMessage(error) }) } @@ -591,10 +621,20 @@ export function AccountPage() { void logout().catch(() => undefined) } - function selectSection(section: 'profile' | 'security' | 'quota' | 'invite') { + function securityCooldownSeconds(): number { + return security.cooldownUntil === null + ? 0 + : Math.max(0, Math.ceil((security.cooldownUntil - securityNow) / 1_000)) + } + + function selectSection(section: AccountSection) { setActiveSection(section) dispatchProfile({ type: 'sectionChanged' }) dispatchSecurity({ type: 'sectionChanged' }) + const next = new URLSearchParams(searchParams) + if (section === 'profile') next.delete('section') + else next.set('section', section) + setSearchParams(next, { replace: true }) } if (!currentUser) return null @@ -602,6 +642,7 @@ export function AccountPage() { const hasPassword = currentUser.hasPassword const displayName = currentUser.nickname || currentUser.email.split('@')[0] const initial = Array.from(displayName)[0]?.toUpperCase() ?? 'W' + const cooldownSeconds = securityCooldownSeconds() return (
@@ -652,7 +693,7 @@ export function AccountPage() { {( [ ['profile', '个人资料'], - ['security', '登录安全'], + ['security', '修改密码'], ['quota', '积分账户'], ['invite', '邀请奖励'], ] as const @@ -782,38 +823,63 @@ export function AccountPage() {

- {hasPassword ? '登录安全' : '设置密码'} + 修改密码

{hasPassword - ? '修改密码后,当前会话会退出。' - : '设置密码后,当前会话会退出,之后可使用密码登录。'} + ? '验证当前账号邮箱后设置新密码。修改成功后,当前会话会退出。' + : '验证当前账号邮箱后设置密码。设置成功后,当前会话会退出,之后可使用密码登录。'}

- - {hasPassword && ( -
diff --git a/frontend/src/pages/account/state.test.ts b/frontend/src/pages/account/state.test.ts index 3cb831ff..22b5f187 100644 --- a/frontend/src/pages/account/state.test.ts +++ b/frontend/src/pages/account/state.test.ts @@ -41,39 +41,62 @@ describe('account profile state', () => { }) describe('account security state', () => { - it('preserves password fields when a password change fails', () => { - const withOldPassword = securityReducer(initialSecurityState, { - type: 'oldPasswordChanged', - password: 'old-password', + it('preserves verification and password fields when a password reset fails', () => { + const withCode = securityReducer(initialSecurityState, { + type: 'codeChanged', + code: '123456', }) - const withPasswords = securityReducer(withOldPassword, { + const withPassword = securityReducer(withCode, { type: 'newPasswordChanged', password: 'new-password-123', }) + const withPasswords = securityReducer(withPassword, { + type: 'confirmPasswordChanged', + password: 'new-password-123', + }) const changing = securityReducer(withPasswords, { type: 'changeStarted' }) - const failed = securityReducer(changing, { type: 'changeFailed', error: '当前密码错误' }) + const failed = securityReducer(changing, { type: 'changeFailed', error: '验证码错误' }) expect(failed).toEqual({ - oldPassword: 'old-password', + code: '123456', newPassword: 'new-password-123', - confirmPassword: '', + confirmPassword: 'new-password-123', + isSendingCode: false, isChanging: false, - error: '当前密码错误', + cooldownUntil: null, + error: '验证码错误', + success: null, + }) + }) + + it('tracks a sent verification code and its resend cooldown', () => { + const sending = securityReducer(initialSecurityState, { type: 'sendStarted' }) + const sent = securityReducer(sending, { type: 'sendSucceeded', cooldownUntil: 123_000 }) + + expect(sent).toMatchObject({ + isSendingCode: false, + cooldownUntil: 123_000, + error: null, + success: '验证码已发送,请在 5 分钟内使用。', }) }) it('clears sensitive fields and feedback when the active section changes', () => { const populated = { - oldPassword: 'old-password', + code: '123456', newPassword: 'new-password-123', confirmPassword: 'new-password-123', + isSendingCode: false, isChanging: true, + cooldownUntil: 123_000, error: '旧错误', + success: '旧提示', } expect(securityReducer(populated, { type: 'sectionChanged' })).toEqual({ ...initialSecurityState, isChanging: true, + cooldownUntil: 123_000, }) }) }) diff --git a/frontend/src/pages/account/state.ts b/frontend/src/pages/account/state.ts index 93da175a..21404bf5 100644 --- a/frontend/src/pages/account/state.ts +++ b/frontend/src/pages/account/state.ts @@ -62,45 +62,70 @@ export function profileReducer(state: ProfileState, action: ProfileAction): Prof } export type SecurityState = { - oldPassword: string + code: string newPassword: string confirmPassword: string + isSendingCode: boolean isChanging: boolean + cooldownUntil: number | null error: string | null + success: string | null } export type SecurityAction = - | { type: 'oldPasswordChanged'; password: string } + | { type: 'codeChanged'; code: string } | { type: 'newPasswordChanged'; password: string } | { type: 'confirmPasswordChanged'; password: string } + | { type: 'sendStarted' } + | { type: 'sendSucceeded'; cooldownUntil: number } + | { type: 'sendFailed'; error: string } | { type: 'validationFailed'; error: string } | { type: 'changeStarted' } | { type: 'changeFailed'; error: string } | { type: 'sectionChanged' } export const initialSecurityState: SecurityState = { - oldPassword: '', + code: '', newPassword: '', confirmPassword: '', + isSendingCode: false, isChanging: false, + cooldownUntil: null, error: null, + success: null, } export function securityReducer(state: SecurityState, action: SecurityAction): SecurityState { switch (action.type) { - case 'oldPasswordChanged': - return { ...state, oldPassword: action.password } + case 'codeChanged': + return { ...state, code: action.code } case 'newPasswordChanged': return { ...state, newPassword: action.password } case 'confirmPasswordChanged': return { ...state, confirmPassword: action.password } + case 'sendStarted': + return { ...state, isSendingCode: true, error: null, success: null } + case 'sendSucceeded': + return { + ...state, + isSendingCode: false, + cooldownUntil: action.cooldownUntil, + error: null, + success: '验证码已发送,请在 5 分钟内使用。', + } + case 'sendFailed': + return { ...state, isSendingCode: false, error: action.error, success: null } case 'validationFailed': - return { ...state, error: action.error } + return { ...state, error: action.error, success: null } case 'changeStarted': - return { ...state, isChanging: true, error: null } + return { ...state, isChanging: true, error: null, success: null } case 'changeFailed': return { ...state, isChanging: false, error: action.error } case 'sectionChanged': - return { ...initialSecurityState, isChanging: state.isChanging } + return { + ...initialSecurityState, + isChanging: state.isChanging, + cooldownUntil: state.cooldownUntil, + } } } diff --git a/frontend/src/test/auth-session.tsx b/frontend/src/test/auth-session.tsx index e7f65374..b843e315 100644 --- a/frontend/src/test/auth-session.tsx +++ b/frontend/src/test/auth-session.tsx @@ -33,6 +33,9 @@ export function createAuthenticatedTestApis(): UserApis { updateNickname: async () => testUser, setPassword: async () => undefined, changePassword: async () => undefined, + resetPassword: async () => undefined, + sendPasswordChangeCode: async () => undefined, + changePasswordWithCode: async () => undefined, } } @@ -48,6 +51,11 @@ const guestApis: UserApis = { setPassword: async () => Promise.reject(new Error('guest test session cannot set password')), changePassword: async () => Promise.reject(new Error('guest test session cannot change password')), + resetPassword: async () => Promise.reject(new Error('guest test session cannot reset password')), + sendPasswordChangeCode: async () => + Promise.reject(new Error('guest test session cannot send password change code')), + changePasswordWithCode: async () => + Promise.reject(new Error('guest test session cannot change password')), } /** 为直接渲染 AppRoutes 的页面测试补齐生产组合根中的访客会话。 */ diff --git a/openapi.json b/openapi.json index 6b3b93a5..88d89fdc 100644 --- a/openapi.json +++ b/openapi.json @@ -1522,6 +1522,29 @@ "title": "CreditTransactionOut", "type": "object" }, + "EmailChangePasswordRequest": { + "additionalProperties": false, + "description": "当前登录账号的邮箱验证码改密请求。", + "properties": { + "code": { + "pattern": "^\\d{6}$", + "title": "Code", + "type": "string" + }, + "new_password": { + "maxLength": 128, + "minLength": 8, + "title": "New Password", + "type": "string" + } + }, + "required": [ + "code", + "new_password" + ], + "title": "EmailChangePasswordRequest", + "type": "object" + }, "FunctionCallResponse": { "properties": { "arguments": { @@ -3778,6 +3801,70 @@ ] } }, + "/auth/change-password/confirm": { + "post": { + "description": "核验当前登录账号的邮箱验证码并修改密码。", + "operationId": "change_password_by_email_auth_change_password_confirm_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailChangePasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_NoneType_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Change Password By Email", + "tags": [ + "auth" + ] + } + }, + "/auth/change-password/send-code": { + "post": { + "description": "向当前登录账号的邮箱发送改密验证码。", + "operationId": "send_password_change_code_auth_change_password_send_code_post", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_NoneType_" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Send Password Change Code", + "tags": [ + "auth" + ] + } + }, "/auth/login": { "post": { "description": "邮箱+密码+验证码登录。",