diff --git a/.env.release.draft b/.env.release.draft index 8a0c28e27..400582667 100644 --- a/.env.release.draft +++ b/.env.release.draft @@ -80,3 +80,16 @@ DEVICE_AUTH_VERIFICATION_URI= # Leave both empty if you are not enabling GitHub login yet. OAUTH2_GITHUB_CLIENT_ID= OAUTH2_GITHUB_CLIENT_SECRET= + +# SMTP configuration for password reset verification emails. +SPRING_MAIL_HOST=smtp.example.com +SPRING_MAIL_PORT=587 +SPRING_MAIL_USERNAME=TODO_fill_smtp_username +SPRING_MAIL_PASSWORD=TODO_fill_smtp_password +SPRING_MAIL_SMTP_AUTH=true +SPRING_MAIL_SMTP_STARTTLS_ENABLE=true +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=false +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST= +SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M +SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@example.com +SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub diff --git a/.env.release.example b/.env.release.example index 366409e5f..9b863c23a 100644 --- a/.env.release.example +++ b/.env.release.example @@ -56,6 +56,19 @@ DEVICE_AUTH_VERIFICATION_URI= OAUTH2_GITHUB_CLIENT_ID= OAUTH2_GITHUB_CLIENT_SECRET= +# SMTP configuration for password reset verification emails. +SPRING_MAIL_HOST= +SPRING_MAIL_PORT=587 +SPRING_MAIL_USERNAME= +SPRING_MAIL_PASSWORD= +SPRING_MAIL_SMTP_AUTH=true +SPRING_MAIL_SMTP_STARTTLS_ENABLE=true +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=false +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST= +SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M +SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@example.com +SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub + # Security scanner is enabled by default. Set to false to disable scanning. SKILLHUB_SECURITY_SCANNER_ENABLED=true diff --git a/compose.release.yml b/compose.release.yml index cd51781f1..831ac5815 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -80,6 +80,17 @@ services: BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-admin@skillhub.local} OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID:-local-placeholder} OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET:-local-placeholder} + SPRING_MAIL_HOST: ${SPRING_MAIL_HOST:-} + SPRING_MAIL_PORT: ${SPRING_MAIL_PORT:-25} + SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME:-} + SPRING_MAIL_PASSWORD: ${SPRING_MAIL_PASSWORD:-} + SPRING_MAIL_SMTP_AUTH: ${SPRING_MAIL_SMTP_AUTH:-false} + SPRING_MAIL_SMTP_STARTTLS_ENABLE: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:-false} + SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE:-false} + SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST:-} + SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY: ${SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY:-PT10M} + SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS:-noreply@skillhub.local} + SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME:-SkillHub} volumes: - skillhub_storage:/var/lib/skillhub/storage depends_on: diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 13b159aee..a8da42048 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -195,6 +195,7 @@ docker compose --env-file .env.release -f compose.release.yml up -d - 外部对象存储通过 `SKILLHUB_STORAGE_S3_*` 注入 - 前端反代和运行时 API 地址通过 `SKILLHUB_API_UPSTREAM` / `SKILLHUB_WEB_API_BASE_URL` 注入 - 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET` +- 如果要启用密码重置验证码邮件,参见:`docs/19-smtp-password-reset-email-setup.md` ## 8 裸金属上线清单 diff --git a/docs/19-smtp-password-reset-email-setup.md b/docs/19-smtp-password-reset-email-setup.md new file mode 100644 index 000000000..07def9eff --- /dev/null +++ b/docs/19-smtp-password-reset-email-setup.md @@ -0,0 +1,317 @@ +# SkillHub SMTP 邮箱配置指南(验证码邮件) + +本文说明如何为 SkillHub 配置 SMTP,用于发送“密码重置验证码”邮件。 + +适用场景: +- 生产/预发布环境(`compose.release.yml` + `.env.release`) +- 本地联调环境(直接注入后端环境变量) + +补充说明: +- SMTP 本质是邮件传输协议,不是单一厂商产品。 +- 你可以使用企业邮箱、云邮箱或本地测试 SMTP 服务(例如 MailHog)作为 SMTP 服务端。 + +当前密码重置页面入口说明: +- 当前前端统一使用 `/reset-password` 页面。 +- 该页面同时包含“发送验证码”和“提交新密码”两步,不再单独使用 `/forgot-password`。 + +## 1. 需要配置的环境变量 + +以下变量已被后端读取: + +| 变量名 | 说明 | 示例 | +|---|---|---| +| `SPRING_MAIL_HOST` | SMTP 服务器地址 | `smtp.example.com` | +| `SPRING_MAIL_PORT` | SMTP 端口 | `465` | +| `SPRING_MAIL_USERNAME` | SMTP 用户名 | `noreply@example.com` | +| `SPRING_MAIL_PASSWORD` | SMTP 密码/授权码 | `xxxxxx` | +| `SPRING_MAIL_SMTP_AUTH` | 是否启用 SMTP AUTH | `true` | +| `SPRING_MAIL_SMTP_STARTTLS_ENABLE` | 是否启用 STARTTLS | `false` | +| `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE` | 是否启用 SMTP SSL 直连 | `true` | +| `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST` | SSL 信任主机(用于规避部分环境下证书链校验失败) | `smtp.mail.example` | +| `SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY` | 验证码有效期(ISO-8601 Duration) | `PT10M` | +| `SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS` | 发件人邮箱 | `noreply@example.com` | +| `SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME` | 发件人名称 | `SkillHub` | + +说明: +- 当前文档统一按 `465 + SSL` 配置,不再展开 `587 + STARTTLS` 方案。 +- 使用 `465` 时配置:`STARTTLS=false`、`SSL_ENABLE=true`。 +- 若出现 `PKIX path building failed` / `SSLHandshakeException`,可尝试增加 `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=`(本地联调常用)。 +- 生产环境默认不建议配置 `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST`,仅在证书链异常时临时启用。 +- `SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY` 支持如 `PT5M`、`PT10M`、`PT30M`。 + +## 1.1 配置方案速查(推荐) + +### A. 通用 SMTP 邮箱(本地直连真实邮箱) + +```dotenv +SPRING_MAIL_HOST=smtp.mail.example +SPRING_MAIL_PORT=465 +SPRING_MAIL_USERNAME=mailer@example.com +SPRING_MAIL_PASSWORD=your-smtp-app-password +SPRING_MAIL_SMTP_AUTH=true +SPRING_MAIL_SMTP_STARTTLS_ENABLE=false +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example +SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M +SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com +SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name +``` + +本地 `export` 示例写法: + +```bash +export SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example +export SPRING_MAIL_HOST=smtp.mail.example +export SPRING_MAIL_PORT=465 +export SPRING_MAIL_USERNAME=mailer@example.com +export SPRING_MAIL_PASSWORD=your-smtp-app-password +export SPRING_MAIL_SMTP_AUTH=true +export SPRING_MAIL_SMTP_STARTTLS_ENABLE=false +export SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true +export SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M +export SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com +export SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name +``` + +### B. MailHog(本地联调推荐) + +```dotenv +SPRING_MAIL_HOST=127.0.0.1 +SPRING_MAIL_PORT=1025 +SPRING_MAIL_USERNAME= +SPRING_MAIL_PASSWORD= +SPRING_MAIL_SMTP_AUTH=false +SPRING_MAIL_SMTP_STARTTLS_ENABLE=false +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=false +SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@skillhub.local +SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub +``` + +### C. 线上部署(465 端口示例) + +```dotenv +SPRING_MAIL_HOST=smtp.mail.example +SPRING_MAIL_PORT=465 +SPRING_MAIL_USERNAME=mailer@example.com +SPRING_MAIL_PASSWORD=your-smtp-app-password +SPRING_MAIL_SMTP_AUTH=true +SPRING_MAIL_SMTP_STARTTLS_ENABLE=false +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example +SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M +SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com +SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name +``` + +## 2. 单机交付(Compose)配置步骤 + +1. 复制环境模板(若尚未创建): + +```bash +cp .env.release.example .env.release +``` + +2. 编辑 `.env.release`,填写 SMTP 变量: + +```dotenv +SPRING_MAIL_HOST=smtp.mail.example +SPRING_MAIL_PORT=465 +SPRING_MAIL_USERNAME=mailer@example.com +SPRING_MAIL_PASSWORD=your-smtp-app-password +SPRING_MAIL_SMTP_AUTH=true +SPRING_MAIL_SMTP_STARTTLS_ENABLE=false +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example + +SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M +SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com +SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name +``` + +3. 重启后端容器使配置生效: + +```bash +docker compose --env-file .env.release -f compose.release.yml up -d server +``` + +4. 查看后端日志确认启动正常: + +```bash +docker compose --env-file .env.release -f compose.release.yml logs -f server +``` + +## 3. 本地开发配置与验证 + +### 3.1 一次性临时生效(推荐) + +适合当前终端临时测试,重开终端后失效。 + +```bash +SPRING_MAIL_HOST=smtp.mail.example \ +SPRING_MAIL_PORT=465 \ +SPRING_MAIL_USERNAME=mailer@example.com \ +SPRING_MAIL_PASSWORD=your-smtp-app-password \ +SPRING_MAIL_SMTP_AUTH=true \ +SPRING_MAIL_SMTP_STARTTLS_ENABLE=false \ +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true \ +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example \ +SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M \ +SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com \ +SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name \ +make dev-server +``` + +### 3.2 长期生效(shell 配置) + +如果你写到了 `~/.zshrc`,请注意: +- 必须 `source ~/.zshrc` 或重开终端后变量才会生效 +- 需要在“同一个终端”启动 `make dev-server` + +可先确认变量是否在当前 shell 中: + +```bash +env | rg '^(SPRING_MAIL_|SKILLHUB_AUTH_PASSWORD_RESET_)' +``` + +### 3.3 推荐联调方式(MailHog) + +如果你只是本地验证验证码链路,建议用 MailHog 作为本地 SMTP 服务: + +1. 启动 MailHog: + +```bash +docker run -d --name skillhub-mailhog \ + -p 1025:1025 \ + -p 8025:8025 \ + mailhog/mailhog +``` + +2. 启动依赖服务(Postgres/Redis): + +```bash +make dev +``` + +3. 启动后端时注入 SMTP 环境变量(示例): + +```bash +SPRING_MAIL_HOST=127.0.0.1 \ +SPRING_MAIL_PORT=1025 \ +SPRING_MAIL_USERNAME= \ +SPRING_MAIL_PASSWORD= \ +SPRING_MAIL_SMTP_AUTH=false \ +SPRING_MAIL_SMTP_STARTTLS_ENABLE=false \ +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=false \ +SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@skillhub.local \ +SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub \ +make dev-server +``` + +4. 打开 MailHog Web UI 查看邮件: + +```text +http://localhost:8025 +``` + +5. 在 SkillHub 页面验证流程: +- 打开 `/reset-password` +- 输入邮箱并点击“发送验证码” +- 在 MailHog 中查看验证码邮件 +- 输入邮箱 + 验证码 + 新密码完成重置 + +6. 也可使用接口做快速验证(示例): + +```bash +curl -X POST http://localhost:8080/api/v1/auth/local/password-reset/request \ + -H 'Content-Type: application/json' \ + -d '{"email":"your-email@example.com"}' +``` + +## 4. 功能验证(验证码邮件) + +### 4.1 用户自助找回 + +在 `/reset-password` 页面点击“发送验证码”后,系统会尝试发送验证码邮件。 + +说明: +- 为防止账号枚举,自助接口总是返回通用成功提示。 +- 即使邮件发送失败,接口也可能返回成功;请结合后端日志确认实际发送结果。 + +### 4.2 管理员触发重置 + +管理员在用户管理页触发“重置密码”时,系统会强制发送验证码; +若 SMTP 发送失败,会返回错误(便于运维排障)。 + +## 5. 常见问题排查 + +### 5.1 认证失败(`535 Authentication failed`) + +排查方向: +- 用户名/密码是否正确 +- 邮箱服务是否要求“客户端授权码”而非登录密码 +- 发件账号是否已开启 SMTP 服务 + +### 5.2 连接超时或拒绝连接 + +排查方向: +- 主机到 SMTP 服务端口 `465` 是否可达 +- 安全组/防火墙是否放行出站连接 +- SMTP 服务地址是否填写正确 + +### 5.3 本地明明配置了变量但不生效 + +排查方向: +- 是否只是编辑了 `~/.zshrc` 但没有 `source ~/.zshrc` +- 启动后端的终端是否与配置变量的终端是同一个 +- `8080` 是否被旧进程占用,导致新进程没启动成功 + +可执行以下命令快速检查: + +```bash +# 查看 8080 是否被旧进程占用 +lsof -nP -iTCP:8080 -sTCP:LISTEN + +# 查看当前 shell 是否有 SMTP 环境变量 +env | rg '^(SPRING_MAIL_|SKILLHUB_AUTH_PASSWORD_RESET_)' +``` + +### 5.4 发件人被拒绝 + +排查方向: +- `SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS` 是否与 SMTP 账号一致或已验证 +- 邮箱服务是否限制别名发件 + +### 5.5 健康检查是否校验 SMTP + +默认配置下,邮件健康检查关闭,不会因为 SMTP 不可达导致 `health` 失败。 + +若需要将 SMTP 连通性纳入健康检查,可设置: + +```dotenv +MANAGEMENT_HEALTH_MAIL_ENABLED=true +``` + +### 5.6 SMTP 报 `PKIX path building failed`(证书链校验失败) + +典型日志: +- `SSLHandshakeException` +- `unable to find valid certification path to requested target` + +处理建议(本地联调): +- 增加: + +```dotenv +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example +``` + +- 然后重启后端,再触发一次“发送验证码”。 + +补充: +- 该配置用于指定信任主机,适合本地排障与联调。 +- 生产环境默认不建议长期启用该配置,更推荐使用规范 CA 证书链或将企业 CA 导入 Java truststore。 + +## 6. 安全建议 + +- 不要把 SMTP 密码提交到仓库;仅写入受控的 `.env.release` 或密钥管理系统。 +- 使用专用发信账号,避免使用个人邮箱主密码。 +- 生产环境建议定期轮换 SMTP 授权码。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java index 8d6f5e4ff..8442939df 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.local.PasswordResetService; import com.iflytek.skillhub.auth.exception.AuthFlowException; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.session.PlatformSessionService; @@ -10,6 +11,8 @@ import com.iflytek.skillhub.dto.ChangePasswordRequest; import com.iflytek.skillhub.dto.LocalLoginRequest; import com.iflytek.skillhub.dto.LocalRegisterRequest; +import com.iflytek.skillhub.dto.PasswordResetConfirmRequest; +import com.iflytek.skillhub.dto.PasswordResetRequestDto; import com.iflytek.skillhub.exception.UnauthorizedException; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; @@ -34,17 +37,20 @@ public class LocalAuthController extends BaseApiController { private final SkillHubMetrics skillHubMetrics; private final PlatformSessionService platformSessionService; private final AuthFailureThrottleService authFailureThrottleService; + private final PasswordResetService passwordResetService; public LocalAuthController(ApiResponseFactory responseFactory, LocalAuthService localAuthService, SkillHubMetrics skillHubMetrics, PlatformSessionService platformSessionService, - AuthFailureThrottleService authFailureThrottleService) { + AuthFailureThrottleService authFailureThrottleService, + PasswordResetService passwordResetService) { super(responseFactory); this.localAuthService = localAuthService; this.skillHubMetrics = skillHubMetrics; this.platformSessionService = platformSessionService; this.authFailureThrottleService = authFailureThrottleService; + this.passwordResetService = passwordResetService; } @PostMapping("/register") @@ -92,6 +98,20 @@ public ApiResponse changePassword(@AuthenticationPrincipal PlatformPrincip return ok("response.success.updated", null); } + @PostMapping("/password-reset/request") + @RateLimit(category = "auth-password-reset-request", authenticated = 8, anonymous = 5, windowSeconds = 300) + public ApiResponse requestPasswordReset(@Valid @RequestBody PasswordResetRequestDto request) { + passwordResetService.requestPasswordReset(request.email()); + return ok("response.auth.password.reset.requested", null); + } + + @PostMapping("/password-reset/confirm") + @RateLimit(category = "auth-password-reset-confirm", authenticated = 10, anonymous = 10, windowSeconds = 300) + public ApiResponse confirmPasswordReset(@Valid @RequestBody PasswordResetConfirmRequest request) { + passwordResetService.confirmPasswordReset(request.email(), request.code(), request.newPassword()); + return ok("response.auth.password.reset.confirmed", null); + } + private String resolveClientIp(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java index efaf76477..627d413c3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller.admin; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.auth.local.PasswordResetService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.dto.AdminUserMutationResponse; import com.iflytek.skillhub.dto.AdminUserRoleUpdateRequest; @@ -9,6 +10,7 @@ import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.exception.UnauthorizedException; import com.iflytek.skillhub.service.AdminUserAppService; import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; @@ -24,11 +26,14 @@ public class UserManagementController extends BaseApiController { private final AdminUserAppService adminUserAppService; + private final PasswordResetService passwordResetService; public UserManagementController(AdminUserAppService adminUserAppService, + PasswordResetService passwordResetService, ApiResponseFactory responseFactory) { super(responseFactory); this.adminUserAppService = adminUserAppService; + this.passwordResetService = passwordResetService; } @GetMapping @@ -76,4 +81,15 @@ public ApiResponse disableUser(@PathVariable String u public ApiResponse enableUser(@PathVariable String userId) { return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "ACTIVE")); } + + @PostMapping("/{userId}/password-reset") + @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") + public ApiResponse triggerPasswordReset(@PathVariable String userId, + @AuthenticationPrincipal PlatformPrincipal principal) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + passwordResetService.adminTriggerPasswordReset(userId, principal.userId()); + return ok("response.auth.password.reset.requested", null); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java index 5a4a2749c..443a5b8e1 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java @@ -8,6 +8,7 @@ public record LocalRegisterRequest( String username, @NotBlank(message = "{validation.auth.local.password.notBlank}") String password, + @NotBlank(message = "{validation.auth.local.email.notBlank}") @Email(message = "{validation.auth.local.email.invalid}") String email ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PasswordResetConfirmRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PasswordResetConfirmRequest.java new file mode 100644 index 000000000..332f75e67 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PasswordResetConfirmRequest.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record PasswordResetConfirmRequest( + @NotBlank(message = "{validation.auth.password.reset.email.notBlank}") + @Email(message = "{validation.auth.password.reset.email.invalid}") + @Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$", message = "{validation.auth.password.reset.email.invalid}") + String email, + @NotBlank(message = "{validation.auth.password.reset.code.notBlank}") + @Pattern(regexp = "^\\d{6}$", message = "{validation.auth.password.reset.code.invalid}") + String code, + @NotBlank(message = "{validation.auth.password.reset.newPassword.notBlank}") + String newPassword +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PasswordResetRequestDto.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PasswordResetRequestDto.java new file mode 100644 index 000000000..f12f20d21 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PasswordResetRequestDto.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record PasswordResetRequestDto( + @NotBlank(message = "{validation.auth.password.reset.email.notBlank}") + @Email(message = "{validation.auth.password.reset.email.invalid}") + @Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$", message = "{validation.auth.password.reset.email.invalid}") + String email +) { +} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 8a3872afd..db4397f88 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -61,6 +61,17 @@ spring: multipart: max-file-size: 100MB max-request-size: 100MB + mail: + host: ${SPRING_MAIL_HOST:localhost} + port: ${SPRING_MAIL_PORT:25} + username: ${SPRING_MAIL_USERNAME:} + password: ${SPRING_MAIL_PASSWORD:} + properties: + mail: + smtp: + auth: ${SPRING_MAIL_SMTP_AUTH:false} + starttls: + enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:false} skillhub: auth: @@ -70,6 +81,10 @@ skillhub: enabled: ${SKILLHUB_AUTH_DIRECT_ENABLED:false} session-bootstrap: enabled: ${SKILLHUB_AUTH_SESSION_BOOTSTRAP_ENABLED:false} + password-reset: + code-expiry: ${SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY:PT10M} + email-from-address: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS:noreply@skillhub.local} + email-from-name: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME:SkillHub} public: base-url: ${SKILLHUB_PUBLIC_BASE_URL:} access-policy: @@ -168,6 +183,9 @@ skillhub: email: ${BOOTSTRAP_ADMIN_EMAIL:admin@skillhub.local} management: + health: + mail: + enabled: ${MANAGEMENT_HEALTH_MAIL_ENABLED:false} endpoints: web: exposure: diff --git a/server/skillhub-app/src/main/resources/db/migration/V39__password_reset_request.sql b/server/skillhub-app/src/main/resources/db/migration/V39__password_reset_request.sql new file mode 100644 index 000000000..c8915bbf8 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V39__password_reset_request.sql @@ -0,0 +1,20 @@ +-- Password reset verification code records for self-service and admin-triggered flows +CREATE TABLE password_reset_request ( + id BIGSERIAL PRIMARY KEY, + user_id VARCHAR(128) NOT NULL REFERENCES user_account(id) ON DELETE CASCADE, + email VARCHAR(255) NOT NULL, + code_hash VARCHAR(255) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + requested_by_admin BOOLEAN NOT NULL DEFAULT FALSE, + requested_by_user_id VARCHAR(128) REFERENCES user_account(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_password_reset_request_user_id ON password_reset_request(user_id); +CREATE INDEX idx_password_reset_request_expires_at ON password_reset_request(expires_at); + +COMMENT ON TABLE password_reset_request IS 'Stores password reset verification code requests for local account recovery'; +COMMENT ON COLUMN password_reset_request.code_hash IS 'BCrypt hash of the one-time verification code'; +COMMENT ON COLUMN password_reset_request.requested_by_admin IS 'True when the reset is triggered by an administrator'; +COMMENT ON COLUMN password_reset_request.requested_by_user_id IS 'Admin user who triggered the reset, if applicable'; diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 83ac024fa..d6c5032c4 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -16,6 +16,7 @@ validation.member.userId.notNull=User ID is required validation.member.role.notNull=Role is required validation.auth.local.username.notBlank=Username cannot be blank validation.auth.local.password.notBlank=Password cannot be blank +validation.auth.local.email.notBlank=Email cannot be blank validation.auth.local.currentPassword.notBlank=Current password cannot be blank validation.auth.local.newPassword.notBlank=New password cannot be blank validation.auth.local.email.invalid=Email format is invalid @@ -147,3 +148,16 @@ error.profileReview.commentRequired=Rejection reason is required error.profileReview.commentTooLong=Rejection reason must not exceed 500 characters error.profileReview.status.invalid=Invalid review status: {0} error.profileReview.userDisabled=Cannot apply changes — user account is disabled + +# Password reset +response.auth.password.reset.requested=If the account is eligible, a password reset verification code has been sent. +response.auth.password.reset.confirmed=Password has been reset successfully. Please sign in with your new password. +error.auth.password.reset.invalid.code=The verification code is invalid or has expired. +error.auth.password.reset.not.eligible=This account is not eligible for password reset. +error.auth.password.reset.no.credential=This account does not have a local credential. +error.auth.password.reset.email.failed=Failed to send password reset verification code. Please try again later. +validation.auth.password.reset.email.notBlank=Email cannot be blank +validation.auth.password.reset.email.invalid=Email format is invalid +validation.auth.password.reset.code.notBlank=Verification code cannot be blank +validation.auth.password.reset.code.invalid=Verification code must be 6 digits +validation.auth.password.reset.newPassword.notBlank=New password cannot be blank diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index abb834c34..489b5d7fd 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -16,6 +16,7 @@ validation.member.userId.notNull=用户 ID 不能为空 validation.member.role.notNull=角色不能为空 validation.auth.local.username.notBlank=用户名不能为空 validation.auth.local.password.notBlank=密码不能为空 +validation.auth.local.email.notBlank=邮箱不能为空 validation.auth.local.currentPassword.notBlank=当前密码不能为空 validation.auth.local.newPassword.notBlank=新密码不能为空 validation.auth.local.email.invalid=邮箱格式不正确 @@ -147,3 +148,16 @@ error.profileReview.commentRequired=拒绝原因不能为空 error.profileReview.commentTooLong=拒绝原因不能超过 500 个字符 error.profileReview.status.invalid=无效的审核状态:{0} error.profileReview.userDisabled=无法应用变更——用户账号已被禁用 + +# Password reset +response.auth.password.reset.requested=如果账号符合条件,密码重置验证码已发送。 +response.auth.password.reset.confirmed=密码已重置成功,请使用新密码登录。 +error.auth.password.reset.invalid.code=验证码无效或已过期。 +error.auth.password.reset.not.eligible=该账号不符合密码重置条件。 +error.auth.password.reset.no.credential=该账号没有本地凭证。 +error.auth.password.reset.email.failed=发送密码重置验证码失败,请稍后重试。 +validation.auth.password.reset.email.notBlank=邮箱不能为空 +validation.auth.password.reset.email.invalid=邮箱格式不正确 +validation.auth.password.reset.code.notBlank=验证码不能为空 +validation.auth.password.reset.code.invalid=验证码必须为 6 位数字 +validation.auth.password.reset.newPassword.notBlank=新密码不能为空 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java index bb5ee3ff7..7158cdfd9 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -12,6 +12,7 @@ import com.iflytek.skillhub.auth.exception.AuthFlowException; import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.local.PasswordResetService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.metrics.SkillHubMetrics; @@ -50,6 +51,9 @@ class LocalAuthControllerTest { @MockBean private AuthFailureThrottleService authFailureThrottleService; + @MockBean + private PasswordResetService passwordResetService; + @Test void login_returnsCurrentUserEnvelope() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( @@ -119,6 +123,23 @@ void register_rejectsInvalidEmailFormat() throws Exception { verify(localAuthService).register("bob", "Abcd123!", "not-an-email"); } + @Test + void register_rejectsBlankEmail() throws Exception { + given(localAuthService.register("bob", "Abcd123!", " ")) + .willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.notBlank")); + + mockMvc.perform(post("/api/v1/auth/local/register") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username":"bob","password":"Abcd123!","email":" "} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + + verify(localAuthService).register("bob", "Abcd123!", " "); + } + @Test void login_failure_recordsFailureMetric() throws Exception { given(localAuthService.login("alice", "wrong")) @@ -176,4 +197,66 @@ void changePassword_withAuthentication_returnsUpdated() throws Exception { .andExpect(jsonPath("$.code").value(0)); } + @Test + void requestPasswordReset_returnsGenericSuccessEnvelope() throws Exception { + mockMvc.perform(post("/api/v1/auth/local/password-reset/request") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email":"alice@example.com"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(passwordResetService).requestPasswordReset("alice@example.com"); + } + + @Test + void requestPasswordReset_rejectsInvalidEmailFormat() throws Exception { + willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.invalid")) + .given(passwordResetService).requestPasswordReset("alice"); + + mockMvc.perform(post("/api/v1/auth/local/password-reset/request") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email":"alice"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + + verify(passwordResetService).requestPasswordReset("alice"); + } + + @Test + void confirmPasswordReset_returnsUpdatedEnvelope() throws Exception { + mockMvc.perform(post("/api/v1/auth/local/password-reset/confirm") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email":"alice@example.com","code":"123456","newPassword":"Abcd123!"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(passwordResetService).confirmPasswordReset("alice@example.com", "123456", "Abcd123!"); + } + + @Test + void confirmPasswordReset_rejectsInvalidEmailFormat() throws Exception { + willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.invalid")) + .given(passwordResetService).confirmPasswordReset("alice", "123456", "Abcd123!"); + + mockMvc.perform(post("/api/v1/auth/local/password-reset/confirm") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email":"alice","code":"123456","newPassword":"Abcd123!"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + + verify(passwordResetService).confirmPasswordReset("alice", "123456", "Abcd123!"); + } + } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java index af279dc45..4bdc78994 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java @@ -3,18 +3,19 @@ import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.Namespace; -import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; -import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; -import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; -import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.user.UserAccount; -import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.MemberResponse; import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse; +import com.iflytek.skillhub.dto.NamespaceResponse; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.service.GovernanceWorkflowAppService; +import com.iflytek.skillhub.service.NamespacePortalCommandAppService; +import com.iflytek.skillhub.service.NamespacePortalQueryAppService; import com.iflytek.skillhub.service.NamespaceMemberCandidateService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -28,7 +29,6 @@ import org.springframework.test.web.servlet.request.RequestPostProcessor; import java.util.List; -import java.util.Optional; import java.util.Set; import static org.mockito.ArgumentMatchers.any; @@ -52,25 +52,19 @@ class NamespaceWorkflowContractTest { private MockMvc mockMvc; @MockBean - private NamespaceService namespaceService; + private NamespacePortalCommandAppService namespacePortalCommandAppService; @MockBean - private NamespaceGovernanceService namespaceGovernanceService; + private NamespacePortalQueryAppService namespacePortalQueryAppService; @MockBean - private NamespaceMemberService namespaceMemberService; - - @MockBean - private NamespaceRepository namespaceRepository; - - @MockBean - private NamespaceMemberRepository namespaceMemberRepository; + private GovernanceWorkflowAppService governanceWorkflowAppService; @MockBean private NamespaceMemberCandidateService namespaceMemberCandidateService; @MockBean - private UserAccountRepository userAccountRepository; + private NamespaceMemberRepository namespaceMemberRepository; @MockBean private DeviceAuthService deviceAuthService; @@ -82,24 +76,30 @@ void namespaceWorkflowEndpoints_shareExpectedEnvelopeShapes() throws Exception { Namespace archived = namespace(7L, "team-flow", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); NamespaceMember adminMember = new NamespaceMember(7L, "user-admin", NamespaceRole.ADMIN); setMemberId(adminMember, 11L); - - given(namespaceService.createNamespace(eq("team-flow"), eq("Team Flow"), eq("workflow"), eq("owner-1"))) - .willReturn(namespace); - given(namespaceService.getNamespaceBySlug("team-flow")).willReturn(namespace); - given(namespaceGovernanceService.freezeNamespace(eq("team-flow"), eq("owner-1"), eq(null), eq(null), any(), any())) - .willReturn(frozen); - given(namespaceGovernanceService.archiveNamespace(eq("team-flow"), eq("owner-1"), eq("cleanup"), eq(null), any(), any())) - .willReturn(archived); + NamespaceResponse namespaceResponse = NamespaceResponse.from(namespace); + NamespaceResponse frozenResponse = NamespaceResponse.from(frozen); + NamespaceResponse archivedResponse = NamespaceResponse.from(archived); + MemberResponse adminMemberResponse = MemberResponse.from( + adminMember, + new UserAccount("user-admin", "Admin", "admin@example.com", null) + ); + + given(namespacePortalCommandAppService.createNamespace(any(), any())) + .willReturn(namespaceResponse); + given(governanceWorkflowAppService.freezeNamespace(eq("team-flow"), any(), eq("owner-1"), any())) + .willReturn(frozenResponse); + given(governanceWorkflowAppService.archiveNamespace(eq("team-flow"), any(), eq("owner-1"), any())) + .willReturn(archivedResponse); given(namespaceMemberCandidateService.searchCandidates("team-flow", "admin", "owner-1", 10)) .willReturn(List.of(new NamespaceCandidateUserResponse("user-admin", "Admin", "admin@example.com", "ACTIVE"))); - given(namespaceMemberService.addMember(7L, "user-admin", NamespaceRole.ADMIN, "owner-1")) - .willReturn(adminMember); - given(namespaceMemberService.listMembers(eq(7L), any(org.springframework.data.domain.Pageable.class))) - .willReturn(new org.springframework.data.domain.PageImpl<>(List.of(adminMember))); - given(namespaceMemberService.updateMemberRole(7L, "user-admin", NamespaceRole.ADMIN, "owner-1")) - .willReturn(adminMember); - given(userAccountRepository.findById("user-admin")) - .willReturn(Optional.of(new UserAccount("user-admin", "Admin", "admin@example.com", null))); + given(namespacePortalCommandAppService.addMember("team-flow", "user-admin", NamespaceRole.ADMIN, "owner-1")) + .willReturn(adminMemberResponse); + given(namespacePortalQueryAppService.listMembers(eq("team-flow"), any(org.springframework.data.domain.Pageable.class), eq("owner-1"))) + .willReturn(new PageResponse<>(List.of(adminMemberResponse), 1, 0, 20)); + given(namespacePortalCommandAppService.updateMemberRole(eq("team-flow"), eq("user-admin"), any(), eq("owner-1"))) + .willReturn(adminMemberResponse); + given(namespacePortalCommandAppService.removeMember("team-flow", "user-admin", "owner-1")) + .willReturn(new com.iflytek.skillhub.dto.MessageResponse("Member removed successfully")); mockMvc.perform(post("/api/web/namespaces") .with(csrf()) @@ -127,14 +127,18 @@ void namespaceWorkflowEndpoints_shareExpectedEnvelopeShapes() throws Exception { .content("{\"userId\":\"user-admin\",\"role\":\"ADMIN\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.userId").value("user-admin")); + .andExpect(jsonPath("$.data.userId").value("user-admin")) + .andExpect(jsonPath("$.data.displayName").value("Admin")) + .andExpect(jsonPath("$.data.email").value("admin@example.com")); mockMvc.perform(get("/api/web/namespaces/team-flow/members") .with(auth("owner-1")) .requestAttr("userId", "owner-1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.items[0].userId").value("user-admin")); + .andExpect(jsonPath("$.data.items[0].userId").value("user-admin")) + .andExpect(jsonPath("$.data.items[0].displayName").value("Admin")) + .andExpect(jsonPath("$.data.items[0].email").value("admin@example.com")); mockMvc.perform(put("/api/web/namespaces/team-flow/members/user-admin/role") .with(csrf()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java index 2bb985678..1f01ff04d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller.admin; import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.local.PasswordResetService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; @@ -52,6 +53,9 @@ class UserManagementControllerTest { @MockBean private AdminUserAppService adminUserAppService; + @MockBean + private PasswordResetService passwordResetService; + @Test void listUsers_unauthenticated_returns401() throws Exception { mockMvc.perform(get("/api/v1/admin/users")) @@ -243,4 +247,22 @@ void enableUser_delegatesToActiveStatusMutation() throws Exception { verify(adminUserAppService).updateUserStatus("user-123", "ACTIVE"); } + + @Test + void triggerPasswordReset_withUserAdminRole_returns200() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) + ); + + mockMvc.perform(post("/api/v1/admin/users/user-123/password-reset") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(passwordResetService).adminTriggerPasswordReset("user-123", "user-42"); + } } diff --git a/server/skillhub-app/src/test/resources/application-test.yml b/server/skillhub-app/src/test/resources/application-test.yml index a3eb93b9d..cb660ee48 100644 --- a/server/skillhub-app/src/test/resources/application-test.yml +++ b/server/skillhub-app/src/test/resources/application-test.yml @@ -4,7 +4,8 @@ spring: banner-mode: "off" log-startup-info: false datasource: - url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;INIT=CREATE DOMAIN IF NOT EXISTS JSONB AS JSON;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + generate-unique-name: true + url: jdbc:h2:mem:testdb-${random.uuid};MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;INIT=CREATE DOMAIN IF NOT EXISTS JSONB AS JSON;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: diff --git a/server/skillhub-auth/pom.xml b/server/skillhub-auth/pom.xml index 5e50347d5..3bee6a9e9 100644 --- a/server/skillhub-auth/pom.xml +++ b/server/skillhub-auth/pom.xml @@ -35,6 +35,15 @@ org.springframework.boot spring-boot-starter-data-redis + + org.springframework.boot + spring-boot-starter-mail + + + org.springframework.boot + spring-boot-configuration-processor + true + org.springframework.boot spring-boot-starter-test diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java index 9d378e25f..df0b1867b 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -230,7 +230,7 @@ private void validateUsername(String username) { private void validateEmail(String email) { if (email == null) { - return; + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.notBlank"); } if (!EMAIL_PATTERN.matcher(email).matches()) { throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.invalid"); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetProperties.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetProperties.java new file mode 100644 index 000000000..00a031be3 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetProperties.java @@ -0,0 +1,38 @@ +package com.iflytek.skillhub.auth.local; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "skillhub.auth.password-reset") +public class PasswordResetProperties { + + private Duration codeExpiry = Duration.ofMinutes(10); + private String emailFromAddress = "noreply@skillhub.local"; + private String emailFromName = "SkillHub"; + + public Duration getCodeExpiry() { + return codeExpiry; + } + + public void setCodeExpiry(Duration codeExpiry) { + this.codeExpiry = codeExpiry; + } + + public String getEmailFromAddress() { + return emailFromAddress; + } + + public void setEmailFromAddress(String emailFromAddress) { + this.emailFromAddress = emailFromAddress; + } + + public String getEmailFromName() { + return emailFromName; + } + + public void setEmailFromName(String emailFromName) { + this.emailFromName = emailFromName; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java new file mode 100644 index 000000000..60b52accf --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java @@ -0,0 +1,244 @@ +package com.iflytek.skillhub.auth.local; + +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.domain.auth.PasswordResetRequest; +import com.iflytek.skillhub.domain.auth.PasswordResetRequestRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +/** + * Local-account password reset flow backed by one-time email verification + * codes. + */ +@Service +public class PasswordResetService { + + private static final Logger log = LoggerFactory.getLogger(PasswordResetService.class); + private static final int VERIFICATION_CODE_DIGITS = 6; + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final PasswordResetRequestRepository resetRequestRepository; + private final UserAccountRepository userAccountRepository; + private final LocalCredentialRepository credentialRepository; + private final PasswordPolicyValidator passwordPolicyValidator; + private final PasswordEncoder passwordEncoder; + private final JavaMailSender mailSender; + private final PasswordResetProperties properties; + + public PasswordResetService(PasswordResetRequestRepository resetRequestRepository, + UserAccountRepository userAccountRepository, + LocalCredentialRepository credentialRepository, + PasswordPolicyValidator passwordPolicyValidator, + PasswordEncoder passwordEncoder, + JavaMailSender mailSender, + PasswordResetProperties properties) { + this.resetRequestRepository = resetRequestRepository; + this.userAccountRepository = userAccountRepository; + this.credentialRepository = credentialRepository; + this.passwordPolicyValidator = passwordPolicyValidator; + this.passwordEncoder = passwordEncoder; + this.mailSender = mailSender; + this.properties = properties; + } + + /** + * Anonymous/self-service reset request. Always silent on ineligible users to + * avoid account enumeration. + */ + @Transactional + public void requestPasswordReset(String email) { + String normalizedEmail = normalizeEmail(email); + validateEmail(normalizedEmail); + Optional userOpt = findEligibleUserByEmail(normalizedEmail); + if (userOpt.isEmpty()) { + log.debug("Password reset requested for ineligible email"); + return; + } + + UserAccount user = userOpt.get(); + String code = generateVerificationCode(); + Instant now = Instant.now(); + Instant expiresAt = now.plus(properties.getCodeExpiry()); + + invalidatePendingRequests(user.getId(), now); + resetRequestRepository.save(new PasswordResetRequest( + user.getId(), + user.getEmail(), + passwordEncoder.encode(code), + expiresAt, + false, + null + )); + + sendVerificationCodeEmail(user.getEmail(), code, false); + } + + /** + * Admin-triggered reset request for a specific user. + */ + @Transactional + public void adminTriggerPasswordReset(String userId, String adminUserId) { + UserAccount user = userAccountRepository.findById(userId) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.admin.user.notFound", userId)); + + if (!isEligibleForReset(user)) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.password.reset.not.eligible"); + } + + String code = generateVerificationCode(); + Instant now = Instant.now(); + Instant expiresAt = now.plus(properties.getCodeExpiry()); + + invalidatePendingRequests(userId, now); + resetRequestRepository.save(new PasswordResetRequest( + userId, + user.getEmail(), + passwordEncoder.encode(code), + expiresAt, + true, + adminUserId + )); + + sendVerificationCodeEmail(user.getEmail(), code, true); + } + + /** + * Verifies a code and updates the local credential password. + */ + @Transactional + public void confirmPasswordReset(String email, String code, String newPassword) { + String normalizedEmail = normalizeEmail(email); + validateEmail(normalizedEmail); + UserAccount user = findUserByEmail(normalizedEmail) + .orElseThrow(() -> new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.password.reset.invalid.code")); + + List pendingRequests = resetRequestRepository + .findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(user.getId(), Instant.now()); + + PasswordResetRequest matchedRequest = pendingRequests.stream() + .filter(request -> passwordEncoder.matches(code, request.getCodeHash())) + .findFirst() + .orElseThrow(() -> new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.password.reset.invalid.code")); + + var passwordErrors = passwordPolicyValidator.validate(newPassword); + if (!passwordErrors.isEmpty()) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, passwordErrors.getFirst()); + } + + LocalCredential credential = credentialRepository.findByUserId(user.getId()) + .orElseThrow(() -> new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.password.reset.no.credential")); + + credential.setPasswordHash(passwordEncoder.encode(newPassword)); + credential.setFailedAttempts(0); + credential.setLockedUntil(null); + credentialRepository.save(credential); + + Instant now = Instant.now(); + matchedRequest.markConsumed(now); + resetRequestRepository.save(matchedRequest); + invalidatePendingRequests(user.getId(), now); + } + + private void invalidatePendingRequests(String userId, Instant now) { + List pending = resetRequestRepository + .findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(userId, now); + for (PasswordResetRequest request : pending) { + request.markConsumed(now); + resetRequestRepository.save(request); + } + } + + private Optional findEligibleUserByEmail(String normalizedEmail) { + return findUserByEmail(normalizedEmail) + .filter(this::isEligibleForReset); + } + + private Optional findUserByEmail(String normalizedEmail) { + if (!StringUtils.hasText(normalizedEmail)) { + return Optional.empty(); + } + return userAccountRepository.findByEmailIgnoreCase(normalizedEmail); + } + + private boolean isEligibleForReset(UserAccount user) { + if (user.getStatus() != UserStatus.ACTIVE) { + return false; + } + if (!StringUtils.hasText(user.getEmail())) { + return false; + } + return credentialRepository.findByUserId(user.getId()).isPresent(); + } + + private String normalizeEmail(String email) { + if (email == null || email.isBlank()) { + return null; + } + return email.trim().toLowerCase(Locale.ROOT); + } + + private void validateEmail(String email) { + if (email == null) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.notBlank"); + } + if (!EMAIL_PATTERN.matcher(email).matches()) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.invalid"); + } + } + + private String generateVerificationCode() { + int bound = (int) Math.pow(10, VERIFICATION_CODE_DIGITS); + int code = SECURE_RANDOM.nextInt(bound); + return String.format("%0" + VERIFICATION_CODE_DIGITS + "d", code); + } + + private void sendVerificationCodeEmail(String email, String code, boolean failOnError) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(resolveFromAddress()); + message.setTo(email); + message.setSubject("SkillHub password reset verification code"); + message.setText(buildVerificationCodeBody(code)); + try { + mailSender.send(message); + log.info("Password reset verification code sent to {}", email); + } catch (Exception ex) { + if (failOnError) { + log.error("Failed to send password reset verification code to {}", email, ex); + throw new AuthFlowException(HttpStatus.INTERNAL_SERVER_ERROR, "error.auth.password.reset.email.failed"); + } + log.warn("Failed to send password reset verification code to {}", email, ex); + } + } + + private String resolveFromAddress() { + String fromAddress = properties.getEmailFromAddress(); + if (!StringUtils.hasText(properties.getEmailFromName())) { + return fromAddress; + } + return properties.getEmailFromName() + " <" + fromAddress + ">"; + } + + private String buildVerificationCodeBody(String code) { + long expiryMinutes = Math.max(1L, properties.getCodeExpiry().toMinutes()); + return "Your SkillHub password reset verification code is: " + code + + "\n\nThis code expires in " + expiryMinutes + " minutes." + + "\n\nIf you did not request a password reset, please ignore this email."; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/package-info.java index 7aa1200b9..80486dfd1 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/package-info.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/package-info.java @@ -1,5 +1,5 @@ /** * Username-and-password authentication support, including registration, - * password changes, and local credential validation. + * password changes, password resets, and local credential validation. */ package com.iflytek.skillhub.auth.local; diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index b566b9225..6b9f43446 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -238,4 +238,13 @@ void register_rejectsInvalidEmailFormat() { .isInstanceOf(AuthFlowException.class) .hasMessageContaining("validation.auth.local.email.invalid"); } + + @Test + void register_rejectsBlankEmail() { + given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false); + + assertThatThrownBy(() -> service.register("Alice", "Abcd123!", " ")) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("validation.auth.local.email.notBlank"); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java new file mode 100644 index 000000000..aa78c1fbe --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java @@ -0,0 +1,218 @@ +package com.iflytek.skillhub.auth.local; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.BDDMockito.given; + +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.domain.auth.PasswordResetRequest; +import com.iflytek.skillhub.domain.auth.PasswordResetRequestRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.crypto.password.PasswordEncoder; + +@ExtendWith(MockitoExtension.class) +class PasswordResetServiceTest { + + @Mock + private PasswordResetRequestRepository resetRequestRepository; + + @Mock + private UserAccountRepository userAccountRepository; + + @Mock + private LocalCredentialRepository credentialRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private JavaMailSender mailSender; + + private PasswordResetService service; + + @BeforeEach + void setUp() { + PasswordResetProperties properties = new PasswordResetProperties(); + properties.setCodeExpiry(Duration.ofMinutes(10)); + properties.setEmailFromAddress("noreply@skillhub.local"); + properties.setEmailFromName("SkillHub"); + service = new PasswordResetService( + resetRequestRepository, + userAccountRepository, + credentialRepository, + new PasswordPolicyValidator(), + passwordEncoder, + mailSender, + properties + ); + } + + @Test + void requestPasswordReset_withEligibleEmail_savesRequestAndSendsEmail() { + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.of(user)); + given(credentialRepository.findByUserId("usr_1")).willReturn( + Optional.of(new LocalCredential("usr_1", "alice", "encoded")) + ); + given(resetRequestRepository.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc( + anyString(), any(Instant.class)) + ).willReturn(List.of()); + given(passwordEncoder.encode(anyString())).willReturn("encoded-value"); + + service.requestPasswordReset("alice@example.com"); + + verify(resetRequestRepository).save(any(PasswordResetRequest.class)); + verify(mailSender).send(any(SimpleMailMessage.class)); + } + + @Test + void requestPasswordReset_withUnknownEmail_doesNothing() { + given(userAccountRepository.findByEmailIgnoreCase("ghost@example.com")).willReturn(Optional.empty()); + + service.requestPasswordReset("ghost@example.com"); + + verify(resetRequestRepository, never()).save(any(PasswordResetRequest.class)); + verify(mailSender, never()).send(any(SimpleMailMessage.class)); + } + + @Test + void requestPasswordReset_withInvalidEmail_throwsBadRequest() { + assertThatThrownBy(() -> service.requestPasswordReset("alice")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.BAD_REQUEST); + + verifyNoInteractions(userAccountRepository, resetRequestRepository, mailSender); + } + + @Test + void requestPasswordReset_emailFailure_doesNotThrowForAnonymousFlow() { + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.of(user)); + given(credentialRepository.findByUserId("usr_1")).willReturn( + Optional.of(new LocalCredential("usr_1", "alice", "encoded")) + ); + given(resetRequestRepository.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc( + anyString(), any(Instant.class)) + ).willReturn(List.of()); + given(passwordEncoder.encode(anyString())).willReturn("encoded-value"); + + org.mockito.Mockito.doThrow(new RuntimeException("smtp down")).when(mailSender).send(any(SimpleMailMessage.class)); + + service.requestPasswordReset("alice@example.com"); + + verify(resetRequestRepository).save(any(PasswordResetRequest.class)); + } + + @Test + void adminTriggerPasswordReset_withUnknownUser_throwsNotFound() { + given(userAccountRepository.findById("missing")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.adminTriggerPasswordReset("missing", "admin_1")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void confirmPasswordReset_withValidCode_updatesCredential() { + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + LocalCredential credential = new LocalCredential("usr_1", "alice", "old-password"); + PasswordResetRequest request = new PasswordResetRequest( + "usr_1", + "alice@example.com", + "encoded-code", + Instant.now().plus(Duration.ofMinutes(5)), + false, + null + ); + + given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.of(user)); + given(resetRequestRepository.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc( + anyString(), any(Instant.class)) + ).willReturn(List.of(request)); + given(passwordEncoder.matches("123456", "encoded-code")).willReturn(true); + given(credentialRepository.findByUserId("usr_1")).willReturn(Optional.of(credential)); + given(passwordEncoder.encode("Abcd123!")).willReturn("new-password-hash"); + + service.confirmPasswordReset("alice@example.com", "123456", "Abcd123!"); + + assertThat(credential.getPasswordHash()).isEqualTo("new-password-hash"); + assertThat(credential.getFailedAttempts()).isZero(); + assertThat(credential.getLockedUntil()).isNull(); + verify(credentialRepository).save(credential); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PasswordResetRequest.class); + verify(resetRequestRepository, atLeastOnce()).save(requestCaptor.capture()); + assertThat(requestCaptor.getAllValues()) + .anySatisfy(captured -> assertThat(captured.getConsumedAt()).isNotNull()); + } + + @Test + void confirmPasswordReset_withInvalidCode_throwsBadRequest() { + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + LocalCredential credential = new LocalCredential("usr_1", "alice", "old-password"); + PasswordResetRequest request = new PasswordResetRequest( + "usr_1", + "alice@example.com", + "encoded-code", + Instant.now().plus(Duration.ofMinutes(5)), + false, + null + ); + + given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.of(user)); + given(resetRequestRepository.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc( + anyString(), any(Instant.class)) + ).willReturn(List.of(request)); + given(passwordEncoder.matches("654321", "encoded-code")).willReturn(false); + + assertThatThrownBy(() -> service.confirmPasswordReset("alice@example.com", "654321", "Abcd123!")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void confirmPasswordReset_withInvalidEmail_throwsBadRequest() { + assertThatThrownBy(() -> service.confirmPasswordReset("alice", "123456", "Abcd123!")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.BAD_REQUEST); + + verifyNoInteractions(userAccountRepository, resetRequestRepository, credentialRepository); + } + + @Test + void adminTriggerPasswordReset_forDisabledUser_throwsBadRequest() { + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + user.setStatus(UserStatus.DISABLED); + given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); + + assertThatThrownBy(() -> service.adminTriggerPasswordReset("usr_1", "admin_1")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.BAD_REQUEST); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/PasswordResetRequest.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/PasswordResetRequest.java new file mode 100644 index 000000000..2c0346751 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/PasswordResetRequest.java @@ -0,0 +1,108 @@ +package com.iflytek.skillhub.domain.auth; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Clock; +import java.time.Instant; + +@Entity +@Table(name = "password_reset_request") +public class PasswordResetRequest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false, length = 128) + private String userId; + + @Column(nullable = false, length = 255) + private String email; + + @Column(name = "code_hash", nullable = false, length = 255) + private String codeHash; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "consumed_at") + private Instant consumedAt; + + @Column(name = "requested_by_admin", nullable = false) + private boolean requestedByAdmin; + + @Column(name = "requested_by_user_id", length = 128) + private String requestedByUserId; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected PasswordResetRequest() { + } + + public PasswordResetRequest(String userId, + String email, + String codeHash, + Instant expiresAt, + boolean requestedByAdmin, + String requestedByUserId) { + this.userId = userId; + this.email = email; + this.codeHash = codeHash; + this.expiresAt = expiresAt; + this.requestedByAdmin = requestedByAdmin; + this.requestedByUserId = requestedByUserId; + } + + @PrePersist + void prePersist() { + if (createdAt == null) { + createdAt = Instant.now(Clock.systemUTC()); + } + } + + public void markConsumed(Instant timestamp) { + this.consumedAt = timestamp; + } + + public Long getId() { + return id; + } + + public String getUserId() { + return userId; + } + + public String getEmail() { + return email; + } + + public String getCodeHash() { + return codeHash; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public Instant getConsumedAt() { + return consumedAt; + } + + public boolean isRequestedByAdmin() { + return requestedByAdmin; + } + + public String getRequestedByUserId() { + return requestedByUserId; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/PasswordResetRequestRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/PasswordResetRequestRepository.java new file mode 100644 index 000000000..567bfa701 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/PasswordResetRequestRepository.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.domain.auth; + +import java.time.Instant; +import java.util.List; + +/** + * Domain repository contract for local-account password reset verification + * codes. + */ +public interface PasswordResetRequestRepository { + PasswordResetRequest save(PasswordResetRequest request); + + List findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc( + String userId, + Instant now + ); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/package-info.java new file mode 100644 index 000000000..3080fb67f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/package-info.java @@ -0,0 +1,4 @@ +/** + * Password-reset domain entities and repository contracts. + */ +package com.iflytek.skillhub.domain.auth; diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PasswordResetRequestJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PasswordResetRequestJpaRepository.java new file mode 100644 index 000000000..a69702b0e --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PasswordResetRequestJpaRepository.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.auth.PasswordResetRequest; +import com.iflytek.skillhub.domain.auth.PasswordResetRequestRepository; +import java.time.Instant; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * JPA-backed repository for password-reset verification-code requests. + */ +public interface PasswordResetRequestJpaRepository + extends JpaRepository, PasswordResetRequestRepository { + + List findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc( + String userId, + Instant now + ); +} diff --git a/web/e2e/helpers/auth-fixtures.ts b/web/e2e/helpers/auth-fixtures.ts index 31a9fb4af..fc89c8a1c 100644 --- a/web/e2e/helpers/auth-fixtures.ts +++ b/web/e2e/helpers/auth-fixtures.ts @@ -5,3 +5,12 @@ export async function setEnglishLocale(page: Page) { window.localStorage.setItem('i18nextLng', 'en') }) } + +export async function setUniqueClientIp(page: Page, seed: string) { + const suffix = Date.now() + Math.floor(Math.random() * 1000) + const thirdOctet = seed.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0) % 250 + const fourthOctet = suffix % 250 + await page.context().setExtraHTTPHeaders({ + 'X-Forwarded-For': `10.0.${thirdOctet}.${fourthOctet}`, + }) +} diff --git a/web/e2e/password-reset.spec.ts b/web/e2e/password-reset.spec.ts new file mode 100644 index 000000000..a37efcf5e --- /dev/null +++ b/web/e2e/password-reset.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale, setUniqueClientIp } from './helpers/auth-fixtures' + +test.describe('Password Reset (Real API)', () => { + function uniqueResetEmail(seed: string) { + return `nonexistent_${seed}_${Date.now()}@example.com` + } + + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('sends verification code from reset-password page', async ({ page }) => { + const email = uniqueResetEmail('request') + await setUniqueClientIp(page, 'password-reset-request') + + await page.goto('/reset-password') + + await expect(page.getByRole('heading', { name: 'Reset Password' })).toBeVisible() + await page.getByLabel('Email').fill(email) + await page.getByRole('button', { name: 'Send Verification Code' }).click() + + await expect(page.getByText('If the account is eligible, a verification code has been sent.')).toBeVisible() + }) + + test('shows backend validation error for an invalid reset code', async ({ page }) => { + const email = uniqueResetEmail('invalid-code') + await setUniqueClientIp(page, 'password-reset-invalid-code') + + await page.goto('/reset-password') + + await expect(page.getByRole('heading', { name: 'Reset Password' })).toBeVisible() + await page.getByLabel('Email').fill(email) + await page.getByRole('button', { name: 'Send Verification Code' }).click() + await expect(page.getByText('If the account is eligible, a verification code has been sent.')).toBeVisible() + await page.getByLabel('Verification Code').fill('123456') + await page.getByLabel('New Password').fill('Passw0rd!123') + await page.getByLabel('Confirm Password').fill('Passw0rd!123') + await page.getByRole('button', { name: 'Reset Password' }).click() + + await expect( + page.getByText(/The verification code is invalid or has expired\.|验证码无效或已过期。/) + ).toBeVisible() + }) +}) diff --git a/web/e2e/register-email-required.spec.ts b/web/e2e/register-email-required.spec.ts new file mode 100644 index 000000000..54d2476a1 --- /dev/null +++ b/web/e2e/register-email-required.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +function buildUniqueUser() { + const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}` + return { + username: `e2e_reg_${suffix}`, + email: `e2e_reg_${suffix}@example.test`, + password: 'Passw0rd!123', + } +} + +test.describe('Register Email Required (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('registers successfully when email is provided', async ({ page }) => { + const user = buildUniqueUser() + await page.goto('/register') + + await expect(page.getByRole('heading', { name: 'Create Account' })).toBeVisible() + await page.getByLabel('Username').fill(user.username) + await page.getByLabel('Email').fill(user.email) + await page.getByLabel('Password').fill(user.password) + await page.getByRole('button', { name: 'Register & Login' }).click() + + await expect(page).toHaveURL('/dashboard') + }) + + test('shows required validation when email is missing', async ({ page }) => { + await page.goto('/register') + + await page.getByLabel('Username').fill(`e2e_no_email_${Date.now().toString(36)}`) + await page.getByLabel('Password').fill('Passw0rd!123') + await page.getByRole('button', { name: 'Register & Login' }).click() + + const isEmailMissing = await page.getByLabel('Email').evaluate((element) => { + const input = element as HTMLInputElement + return input.validity.valueMissing + }) + + expect(isEmailMissing).toBeTruthy() + await expect(page).toHaveURL(/\/register/) + }) +}) diff --git a/web/e2e/register-login-validation.spec.ts b/web/e2e/register-login-validation.spec.ts index 2d0c85310..7ab8574ab 100644 --- a/web/e2e/register-login-validation.spec.ts +++ b/web/e2e/register-login-validation.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test' -import { setEnglishLocale } from './helpers/auth-fixtures' +import { setEnglishLocale, setUniqueClientIp } from './helpers/auth-fixtures' import { createFreshSession } from './helpers/session' // TC_UN_* 用户名输入框 / TC_EM_* 邮箱输入框 / TC_PW_* 密码输入框 @@ -10,14 +10,16 @@ const DUPLICATE_USERNAME_ERROR = /already.*exist|taken|username.*used/i const REGISTER_RATE_LIMIT_ERROR = /too many|too frequent|rate limit|请求过于频繁/ test.describe('Register - Username Validation (Real API)', () => { - test.beforeEach(async ({ page }) => { + test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) + await setUniqueClientIp(page, `register-validation-${testInfo.title}`) await page.goto('/register') }) // TC_UN_008 P0 test('TC_UN_008: shows required error when username is empty', async ({ page }) => { - await page.getByRole('button', { name: 'Register' }).click() + await page.getByLabel(/username/i).click() + await page.getByLabel(/email/i).click() await expect(page.getByText(/username.*required|required.*username/i)).toBeVisible() }) @@ -51,18 +53,19 @@ test.describe('Register - Username Validation (Real API)', () => { }) test.describe('Register - Email Validation (Real API)', () => { - test.beforeEach(async ({ page }) => { + test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) + await setUniqueClientIp(page, `register-validation-${testInfo.title}`) await page.goto('/register') }) - // TC_EM_007 P0 - email is optional - test('TC_EM_007: allows empty email (email is optional)', async ({ page }) => { + // TC_EM_007 P0 - email is required + test('TC_EM_007: shows required error when email is empty', async ({ page }) => { const emailField = page.getByLabel(/email/i) if (await emailField.isVisible()) { await emailField.clear() await emailField.blur() - await expect(page.getByText(/email.*required/i)).not.toBeVisible() + await expect(page.getByText(/email.*required|required.*email/i)).toBeVisible() } }) @@ -88,14 +91,16 @@ test.describe('Register - Email Validation (Real API)', () => { }) test.describe('Register - Password Validation (Real API)', () => { - test.beforeEach(async ({ page }) => { + test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) + await setUniqueClientIp(page, `register-validation-${testInfo.title}`) await page.goto('/register') }) // TC_PW_013 P0 - empty password test('TC_PW_013: shows required error when password is empty', async ({ page }) => { - await page.getByRole('button', { name: 'Register' }).click() + await page.getByLabel(/^password/i).click() + await page.getByLabel(/username/i).click() await expect(page.getByText(/password.*required|required.*password/i)).toBeVisible() }) @@ -123,8 +128,9 @@ test.describe('Register - Password Validation (Real API)', () => { }) test.describe('Register Flow (Real API)', () => { - test.beforeEach(async ({ page }) => { + test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) + await setUniqueClientIp(page, `register-validation-${testInfo.title}`) }) // TC_REG_001 P0 - successful registration with all fields @@ -138,7 +144,7 @@ test.describe('Register Flow (Real API)', () => { await emailField.fill(`test_${suffix}@example.test`) } await page.getByLabel(/^password/i).fill('Test123!@') - await page.getByRole('button', { name: 'Register' }).click() + await page.getByRole('button', { name: 'Register & Login' }).click() // Should redirect away from /register on success await expect(page).not.toHaveURL('/register') existingRegisteredUsername = username @@ -160,13 +166,14 @@ test.describe('Register Flow (Real API)', () => { await page.goto('/register') await setEnglishLocale(page) await page.getByLabel(/username/i).fill(username) + await page.getByLabel(/email/i).fill(`duplicate_${Date.now()}@example.test`) await page.getByLabel(/^password/i).fill('Test123!@') const main = page.getByRole('main') const duplicateUsernameError = main.getByText(DUPLICATE_USERNAME_ERROR).first() const registerRateLimitError = main.getByText(REGISTER_RATE_LIMIT_ERROR).first() for (let attempt = 0; attempt < 3; attempt += 1) { - await page.getByRole('button', { name: 'Register' }).click() + await page.getByRole('button', { name: 'Register & Login' }).click() if (await duplicateUsernameError.isVisible().catch(() => false)) { return @@ -184,27 +191,35 @@ test.describe('Register Flow (Real API)', () => { }) // TC_REG_002 P0 - registration without email - test('TC_REG_002: registers successfully without email (email is optional)', async ({ page }) => { + test('TC_REG_002: shows required validation when email is missing', async ({ page }) => { await page.goto('/register') const suffix = Date.now().toString(36) + Math.random().toString(36).slice(2, 5) - await page.getByLabel(/username/i).fill(`noemail_${suffix}`) + await page.getByLabel(/username/i).fill(`emailrequired_${suffix}`) await page.getByLabel(/^password/i).fill('Test123!@') - await page.getByRole('button', { name: 'Register' }).click() - await expect(page).not.toHaveURL('/register') + await page.getByLabel(/email/i).click() + await page.getByLabel(/username/i).click() + await page.getByRole('button', { name: 'Register & Login' }).click() + const emailField = page.getByLabel(/email/i) + await expect(emailField).toBeFocused() + await expect + .poll(async () => emailField.evaluate((input) => (input as HTMLInputElement).validity.valueMissing)) + .toBe(true) }) // TC_REG_005 P0 - required fields empty on submit test('TC_REG_005: shows validation errors when submitting empty required fields', async ({ page }) => { await page.goto('/register') - await page.getByRole('button', { name: 'Register' }).click() + await page.getByLabel(/email/i).fill(`required_fields_${Date.now()}@example.test`) + await page.getByRole('button', { name: 'Register & Login' }).click() await expect(page.getByText(/username.*required|required.*username/i)).toBeVisible() await expect(page.getByText(/password.*required|required.*password/i)).toBeVisible() }) }) test.describe('Login Flow (Real API)', () => { - test.beforeEach(async ({ page }) => { + test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) + await setUniqueClientIp(page, `register-validation-${testInfo.title}`) }) // TC_REG_006 P0 - successful login (already tested in auth-entry.spec.ts partially; extend here) @@ -223,8 +238,9 @@ test.describe('Login Flow (Real API)', () => { await page.goto('/register') await page.getByLabel(/username/i).fill(username) + await page.getByLabel(/email/i).fill(`login_${suffix}@example.test`) await page.getByLabel(/^password/i).fill('Test123!@') - await page.getByRole('button', { name: 'Register' }).click() + await page.getByRole('button', { name: 'Register & Login' }).click() await expect(page).not.toHaveURL('/register') await page.goto('/login') diff --git a/web/e2e/settings-pages.spec.ts b/web/e2e/settings-pages.spec.ts index 8c77919cd..de2abd6ec 100644 --- a/web/e2e/settings-pages.spec.ts +++ b/web/e2e/settings-pages.spec.ts @@ -13,6 +13,13 @@ test.describe('Settings Pages (Real API)', () => { await expect(page.getByRole('heading', { name: 'Profile Settings' })).toBeVisible() }) + test('navigates to reset-password page from profile settings', async ({ page }) => { + await page.goto('/settings/profile') + await page.getByRole('button', { name: 'Reset Password' }).click() + await expect(page).toHaveURL('/reset-password') + await expect(page.getByRole('heading', { name: 'Reset Password' })).toBeVisible() + }) + test('shows validation when current password is missing', async ({ page }) => { await page.goto('/settings/security') await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible() diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 928036a0a..97c5fa187 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -2,6 +2,8 @@ import createClient from 'openapi-fetch' import type { paths } from './generated/schema' import type { ChangePasswordRequest, + PasswordResetConfirmRequest, + PasswordResetRequest, ApiToken, CreateTokenRequest, CreateTokenResponse, @@ -354,6 +356,26 @@ export const authApi = { }) }, + async requestPasswordReset(request: PasswordResetRequest): Promise { + await fetchJson('/api/v1/auth/local/password-reset/request', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async confirmPasswordReset(request: PasswordResetConfirmRequest): Promise { + await fetchJson('/api/v1/auth/local/password-reset/confirm', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + async logout(): Promise { const response = await fetch('/api/v1/auth/logout', { method: 'POST', @@ -1063,6 +1085,13 @@ export const adminApi = { }) }, + async triggerPasswordReset(userId: string): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/password-reset`, { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, + async getAuditLogs(params: { action?: string userId?: string diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 5da2df9da..837eb5f55 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -1140,6 +1140,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/auth/local/password-reset/request": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["requestPasswordReset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/local/password-reset/confirm": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["confirmPasswordReset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/auth/local/login": { parameters: { query?: never; @@ -1220,6 +1252,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/users/{userId}/password-reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["triggerPasswordReset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/users/{userId}/enable": { parameters: { query?: never; @@ -3413,7 +3461,15 @@ export interface components { LocalRegisterRequest: { username: string; password: string; - email?: string; + email: string; + }; + PasswordResetRequestDto: { + email: string; + }; + PasswordResetConfirmRequest: { + email: string; + code: string; + newPassword: string; }; LocalLoginRequest: { username: string; @@ -6819,6 +6875,54 @@ export interface operations { }; }; }; + requestPasswordReset: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordResetRequestDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + confirmPasswordReset: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordResetConfirmRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; login: { parameters: { query?: never; @@ -6935,6 +7039,28 @@ export interface operations { }; }; }; + triggerPasswordReset: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; enableUser: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 61c9fa46b..2b2f93823 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -53,7 +53,7 @@ export interface LocalLoginRequest { } export interface LocalRegisterRequest extends LocalLoginRequest { - email?: string + email: string } export interface ChangePasswordRequest { @@ -61,6 +61,16 @@ export interface ChangePasswordRequest { newPassword: string } +export interface PasswordResetRequest { + email: string +} + +export interface PasswordResetConfirmRequest { + email: string + code: string + newPassword: string +} + export type CreateNamespaceRequest = Omit & { slug: string displayName: string diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index fd48292b6..d096987bd 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -65,6 +65,7 @@ const LandingPage = createLazyRouteComponent(() => import('@/pages/landing'), 'L const HomePage = createLazyRouteComponent(() => import('@/pages/home'), 'HomePage') const LoginPage = createLazyRouteComponent(() => import('@/pages/login'), 'LoginPage') const RegisterPage = createLazyRouteComponent(() => import('@/pages/register'), 'RegisterPage') +const ResetPasswordPage = createLazyRouteComponent(() => import('@/pages/reset-password'), 'ResetPasswordPage') const PrivacyPolicyPage = createLazyRouteComponent(() => import('@/pages/privacy'), 'PrivacyPolicyPage') const SearchPage = createLazyRouteComponent(() => import('@/pages/search'), 'SearchPage') const TermsOfServicePage = createLazyRouteComponent(() => import('@/pages/terms'), 'TermsOfServicePage') @@ -184,6 +185,12 @@ const registerRoute = createRoute({ component: RegisterPage, }) +const resetPasswordRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'reset-password', + component: ResetPasswordPage, +}) + const privacyRoute = createRoute({ getParentRoute: () => rootRoute, path: 'privacy', @@ -397,6 +404,7 @@ const routeTree = rootRoute.addChildren([ skillsRoute, loginRoute, registerRoute, + resetPasswordRoute, privacyRoute, searchRoute, termsRoute, diff --git a/web/src/features/admin/use-admin-users.ts b/web/src/features/admin/use-admin-users.ts index be9c77432..7c0abf73d 100644 --- a/web/src/features/admin/use-admin-users.ts +++ b/web/src/features/admin/use-admin-users.ts @@ -97,3 +97,13 @@ export function useEnableUser() { }, }) } + +export function useTriggerUserPasswordReset() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (userId: string) => adminApi.triggerPasswordReset(userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + }, + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1f88708e2..3e32bd337 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -209,6 +209,7 @@ "hidePassword": "Hide password", "submitting": "Logging in...", "submit": "Login", + "forgotPassword": "Forgot password?", "noAccount": "Don't have an account?", "register": "Sign up now", "oauthHint": "After GitHub authentication, you will be automatically redirected back to this site.", @@ -232,7 +233,8 @@ "email": "Email", "password": "Password", "usernamePlaceholder": "3-64 characters: letters, numbers, or underscores", - "emailPlaceholder": "Optional, for account identification", + "emailPlaceholder": "Enter your email", + "emailRequired": "Email is required", "passwordPlaceholder": "At least 8 characters with 3 character types", "usernameRequired": "Username is required", "usernameInvalid": "Username must be 3-64 characters and contain only letters, numbers, or underscores", @@ -248,6 +250,31 @@ "login": "Back to login", "oauthHint": "Sign in directly with your existing OAuth account, no local password needed." }, + "resetPassword": { + "title": "Reset Password", + "subtitle": "Enter your email, verification code, and new password.", + "email": "Email", + "emailPlaceholder": "Enter email", + "emailRequired": "Please enter your email", + "emailInvalid": "Please enter a valid email address", + "code": "Verification Code", + "codePlaceholder": "Enter 6-digit verification code", + "sendCode": "Send Verification Code", + "sendingCode": "Sending...", + "codeSentMessage": "If the account is eligible, a verification code has been sent.", + "codeRequired": "Please enter the verification code", + "newPassword": "New Password", + "newPasswordPlaceholder": "Enter new password", + "newPasswordRequired": "Please enter a new password", + "confirmPassword": "Confirm Password", + "confirmPasswordPlaceholder": "Re-enter new password", + "passwordMismatch": "The two passwords do not match", + "submit": "Reset Password", + "submitting": "Resetting...", + "successMessage": "Password reset successful. Please sign in with your new password.", + "genericError": "Failed to reset password", + "backToLogin": "Back to login" + }, "device": { "title": "Device Authorization", "subtitle": "Enter the 8-digit user code shown on your device", @@ -529,6 +556,7 @@ "approveUser": "Approve", "disable": "Disable", "enable": "Enable", + "resetPassword": "Reset Password", "totalRecords": "Total {{total}} records, page {{page}}", "prevPage": "Previous", "nextPage": "Next", @@ -543,7 +571,8 @@ "roleSuperAdmin": "Super Admin", "confirmAction": "Confirm Action", "confirmDisable": "Are you sure you want to disable user {{username}}?", - "confirmEnable": "Are you sure you want to enable user {{username}}?" + "confirmEnable": "Are you sure you want to enable user {{username}}?", + "confirmResetPassword": "Send password reset verification code to user {{username}}?" }, "adminLabels": { "title": "Label Management", @@ -650,6 +679,7 @@ "subtitle": "Manage your display name and personal information.", "displayName": "Display Name", "email": "Email", + "resetPassword": "Reset Password", "edit": "Edit", "save": "Save", "saving": "Saving...", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 92920e8fb..9e47ae855 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -209,6 +209,7 @@ "hidePassword": "隐藏密码", "submitting": "登录中...", "submit": "登录", + "forgotPassword": "忘记密码?", "noAccount": "还没有账号?", "register": "立即注册", "oauthHint": "使用 GitHub 登录时,认证完成后会自动返回当前站点。", @@ -232,7 +233,8 @@ "email": "邮箱", "password": "密码", "usernamePlaceholder": "3-64 位字母、数字或下划线", - "emailPlaceholder": "可选,用于后续账号识别", + "emailPlaceholder": "请输入邮箱", + "emailRequired": "请输入邮箱", "passwordPlaceholder": "至少 8 位,包含 3 种字符类型", "usernameRequired": "请输入用户名", "usernameInvalid": "用户名需为 3-64 位,且只能包含字母、数字或下划线", @@ -248,6 +250,31 @@ "login": "返回登录", "oauthHint": "直接使用现有 OAuth 账户进入平台,无需再创建本地密码。" }, + "resetPassword": { + "title": "重置密码", + "subtitle": "输入邮箱、验证码和新密码以完成重置。", + "email": "邮箱", + "emailPlaceholder": "请输入邮箱", + "emailRequired": "请输入邮箱", + "emailInvalid": "请输入正确的邮箱格式", + "code": "验证码", + "codePlaceholder": "请输入 6 位验证码", + "sendCode": "发送验证码", + "sendingCode": "发送中...", + "codeSentMessage": "如果账号符合条件,验证码已发送。", + "codeRequired": "请输入验证码", + "newPassword": "新密码", + "newPasswordPlaceholder": "请输入新密码", + "newPasswordRequired": "请输入新密码", + "confirmPassword": "确认新密码", + "confirmPasswordPlaceholder": "请再次输入新密码", + "passwordMismatch": "两次输入的密码不一致", + "submit": "重置密码", + "submitting": "重置中...", + "successMessage": "密码重置成功,请使用新密码登录。", + "genericError": "重置密码失败", + "backToLogin": "返回登录" + }, "device": { "title": "设备授权", "subtitle": "请输入设备上显示的 8 位用户码", @@ -529,6 +556,7 @@ "approveUser": "审批通过", "disable": "禁用", "enable": "启用", + "resetPassword": "重置密码", "totalRecords": "共 {{total}} 条记录,第 {{page}} 页", "prevPage": "上一页", "nextPage": "下一页", @@ -543,7 +571,8 @@ "roleSuperAdmin": "超级管理员", "confirmAction": "确认操作", "confirmDisable": "确定要禁用用户 {{username}} 吗?", - "confirmEnable": "确定要启用用户 {{username}} 吗?" + "confirmEnable": "确定要启用用户 {{username}} 吗?", + "confirmResetPassword": "确定给用户 {{username}} 发送密码重置验证码吗?" }, "adminLabels": { "title": "标签管理", @@ -650,6 +679,7 @@ "subtitle": "管理你的昵称和个人信息。", "displayName": "昵称", "email": "邮箱", + "resetPassword": "重置密码", "edit": "编辑", "save": "保存", "saving": "保存中...", diff --git a/web/src/pages/admin/users.test.tsx b/web/src/pages/admin/users.test.tsx index a47ade76e..1b27359c6 100644 --- a/web/src/pages/admin/users.test.tsx +++ b/web/src/pages/admin/users.test.tsx @@ -64,6 +64,7 @@ vi.mock('@/features/admin/use-admin-users', () => ({ useApproveUser: () => ({ mutate: vi.fn(), isPending: false }), useDisableUser: () => ({ mutateAsync: vi.fn(), isPending: false }), useEnableUser: () => ({ mutateAsync: vi.fn(), isPending: false }), + useTriggerUserPasswordReset: () => ({ mutateAsync: vi.fn(), isPending: false }), useUpdateUserRole: () => ({ mutateAsync: vi.fn(), isPending: false }), })) diff --git a/web/src/pages/admin/users.tsx b/web/src/pages/admin/users.tsx index 29caf4958..1dc051f29 100644 --- a/web/src/pages/admin/users.tsx +++ b/web/src/pages/admin/users.tsx @@ -29,7 +29,14 @@ import { DialogTitle, } from '@/shared/ui/dialog' import { Label } from '@/shared/ui/label' -import { useAdminUsers, useApproveUser, useDisableUser, useEnableUser, useUpdateUserRole } from '@/features/admin/use-admin-users' +import { + useAdminUsers, + useApproveUser, + useDisableUser, + useEnableUser, + useTriggerUserPasswordReset, + useUpdateUserRole, +} from '@/features/admin/use-admin-users' import type { AdminUser } from '@/features/admin/use-admin-users' /** @@ -54,7 +61,7 @@ export function AdminUsersPage() { const [roleDialogOpen, setRoleDialogOpen] = useState(false) const [newRole, setNewRole] = useState('') const [confirmDialogOpen, setConfirmDialogOpen] = useState(false) - const [actionType, setActionType] = useState<'ban' | 'unban'>('ban') + const [actionType, setActionType] = useState<'ban' | 'unban' | 'reset'>('ban') const { data, isLoading } = useAdminUsers({ search, @@ -67,6 +74,7 @@ export function AdminUsersPage() { const approveUserMutation = useApproveUser() const disableUserMutation = useDisableUser() const enableUserMutation = useEnableUser() + const triggerPasswordResetMutation = useTriggerUserPasswordReset() const formatDate = (dateString: string) => { return formatLocalDateTime(dateString, i18n.language) @@ -105,6 +113,12 @@ export function AdminUsersPage() { setConfirmDialogOpen(true) } + const handleTriggerPasswordReset = (user: AdminUser) => { + setSelectedUser(user) + setActionType('reset') + setConfirmDialogOpen(true) + } + const confirmRoleChange = async () => { if (!selectedUser || !newRole || newRole === (selectedUser.platformRoles[0] || 'USER')) return try { @@ -116,18 +130,20 @@ export function AdminUsersPage() { } } - const confirmStatusChange = async () => { + const confirmUserAction = async () => { if (!selectedUser) return try { if (actionType === 'ban') { await disableUserMutation.mutateAsync(selectedUser.userId) - } else { + } else if (actionType === 'unban') { await enableUserMutation.mutateAsync(selectedUser.userId) + } else { + await triggerPasswordResetMutation.mutateAsync(selectedUser.userId) } setConfirmDialogOpen(false) setSelectedUser(null) } catch (error) { - console.error('Failed to update status:', error) + console.error('Failed to apply user action:', error) } } @@ -259,6 +275,13 @@ export function AdminUsersPage() { {t('adminUsers.enable')} )} + @@ -342,14 +365,21 @@ export function AdminUsersPage() { {t('adminUsers.confirmAction')} - {actionType === 'ban' ? t('adminUsers.confirmDisable', { username: selectedUser?.username }) : t('adminUsers.confirmEnable', { username: selectedUser?.username })} + {actionType === 'ban' + ? t('adminUsers.confirmDisable', { username: selectedUser?.username }) + : actionType === 'unban' + ? t('adminUsers.confirmEnable', { username: selectedUser?.username }) + : t('adminUsers.confirmResetPassword', { username: selectedUser?.username })} - diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index a23d0e3ef..1aab99c48 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -160,6 +160,11 @@ export function LoginPage() { +

+ + {t('login.forgotPassword')} + +

{t('login.noAccount')} {' '} diff --git a/web/src/pages/register.tsx b/web/src/pages/register.tsx index de6b8cf62..a3a0aa6aa 100644 --- a/web/src/pages/register.tsx +++ b/web/src/pages/register.tsx @@ -77,7 +77,7 @@ export function RegisterPage() { function validateEmail(value: string) { const trimmed = value.trim().toLowerCase() if (!trimmed) { - return undefined + return t('register.emailRequired') } if (!EMAIL_PATTERN.test(trimmed)) { return t('register.emailInvalid') @@ -112,6 +112,8 @@ export function RegisterPage() { return { fieldErrors: { username: t('register.usernameRequired') } } case 'validation.auth.local.password.notBlank': return { fieldErrors: { password: t('register.passwordRequired') } } + case 'validation.auth.local.email.notBlank': + return { fieldErrors: { email: t('register.emailRequired') } } case 'validation.auth.local.email.invalid': return { fieldErrors: { email: t('register.emailInvalid') } } case 'error.auth.local.username.invalid': @@ -218,6 +220,7 @@ export function RegisterPage() { } }} placeholder={t('register.emailPlaceholder')} + required aria-invalid={fieldErrors.email ? 'true' : 'false'} onBlur={() => { setFieldErrors((current) => ({ ...current, email: validateEmail(email) })) diff --git a/web/src/pages/reset-password.test.tsx b/web/src/pages/reset-password.test.tsx new file mode 100644 index 000000000..1a919aa31 --- /dev/null +++ b/web/src/pages/reset-password.test.tsx @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@tanstack/react-router', () => ({ + Link: ({ children }: { children: unknown }) => children, +})) + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => key, + }), + } +}) + +vi.mock('@/api/client', () => ({ + authApi: { + requestPasswordReset: vi.fn(), + confirmPasswordReset: vi.fn(), + }, +})) + +vi.mock('@/shared/ui/button', () => ({ + Button: ({ children }: { children: unknown }) => children, +})) + +vi.mock('@/shared/ui/card', () => ({ + Card: ({ children }: { children: unknown }) => children, + CardContent: ({ children }: { children: unknown }) => children, + CardDescription: ({ children }: { children: unknown }) => children, + CardHeader: ({ children }: { children: unknown }) => children, + CardTitle: ({ children }: { children: unknown }) => children, +})) + +vi.mock('@/shared/ui/input', () => ({ + Input: () => null, +})) + +import { renderToStaticMarkup } from 'react-dom/server' +import { ResetPasswordPage } from './reset-password' + +describe('ResetPasswordPage', () => { + it('exports a named component function', () => { + expect(typeof ResetPasswordPage).toBe('function') + }) + + it('renders reset-password title and submit action', () => { + const html = renderToStaticMarkup() + expect(html).toContain('resetPassword.title') + expect(html).toContain('resetPassword.sendCode') + expect(html).toContain('resetPassword.submit') + }) +}) diff --git a/web/src/pages/reset-password.tsx b/web/src/pages/reset-password.tsx new file mode 100644 index 000000000..ef4777de9 --- /dev/null +++ b/web/src/pages/reset-password.tsx @@ -0,0 +1,187 @@ +import { Link } from '@tanstack/react-router' +import { FormEvent, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { authApi } from '@/api/client' +import { Button } from '@/shared/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' + +/** + * Public page for verifying a reset code and setting a new password. + */ +export function ResetPasswordPage() { + const { t } = useTranslation() + const [email, setEmail] = useState('') + const [code, setCode] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [isSendingCode, setIsSendingCode] = useState(false) + const [isSuccess, setIsSuccess] = useState(false) + const [codeSentMessage, setCodeSentMessage] = useState(null) + const [errorMessage, setErrorMessage] = useState(null) + const emailPattern = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/ + + async function handleSendCode() { + const normalizedEmail = email.trim().toLowerCase() + if (!normalizedEmail) { + setErrorMessage(t('resetPassword.emailRequired')) + return + } + if (!emailPattern.test(normalizedEmail)) { + setErrorMessage(t('resetPassword.emailInvalid')) + return + } + + setIsSendingCode(true) + setErrorMessage(null) + setCodeSentMessage(null) + try { + await authApi.requestPasswordReset({ email: normalizedEmail }) + setCodeSentMessage(t('resetPassword.codeSentMessage')) + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : t('resetPassword.genericError')) + } finally { + setIsSendingCode(false) + } + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + + const normalizedEmail = email.trim().toLowerCase() + if (!normalizedEmail) { + setErrorMessage(t('resetPassword.emailRequired')) + return + } + if (!emailPattern.test(normalizedEmail)) { + setErrorMessage(t('resetPassword.emailInvalid')) + return + } + if (!code.trim()) { + setErrorMessage(t('resetPassword.codeRequired')) + return + } + if (!newPassword) { + setErrorMessage(t('resetPassword.newPasswordRequired')) + return + } + if (newPassword !== confirmPassword) { + setErrorMessage(t('resetPassword.passwordMismatch')) + return + } + + setIsSubmitting(true) + setErrorMessage(null) + try { + await authApi.confirmPasswordReset({ + email: normalizedEmail, + code: code.trim(), + newPassword, + }) + setIsSuccess(true) + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : t('resetPassword.genericError')) + } finally { + setIsSubmitting(false) + } + } + + return ( +

+ + + {t('resetPassword.title')} + {t('resetPassword.subtitle')} + + + {isSuccess ? ( +
+

+ {t('resetPassword.successMessage')} +

+ + {t('resetPassword.backToLogin')} + +
+ ) : ( +
+
+ +
+ setEmail(event.target.value)} + placeholder={t('resetPassword.emailPlaceholder')} + autoComplete="email" + required + /> + +
+
+
+ + setCode(event.target.value)} + placeholder={t('resetPassword.codePlaceholder')} + autoComplete="one-time-code" + /> +
+
+ + setNewPassword(event.target.value)} + placeholder={t('resetPassword.newPasswordPlaceholder')} + autoComplete="new-password" + /> +
+
+ + setConfirmPassword(event.target.value)} + placeholder={t('resetPassword.confirmPasswordPlaceholder')} + autoComplete="new-password" + /> +
+ {errorMessage ? ( +

{errorMessage}

+ ) : null} + {codeSentMessage ? ( +

{codeSentMessage}

+ ) : null} + +
+ )} +
+
+
+ ) +} diff --git a/web/src/pages/settings/profile.test.ts b/web/src/pages/settings/profile.test.ts index ceb412d4a..9e6e56a8c 100644 --- a/web/src/pages/settings/profile.test.ts +++ b/web/src/pages/settings/profile.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import React from 'react' vi.mock('react-i18next', async () => { const actual = await vi.importActual('react-i18next') @@ -10,6 +11,10 @@ vi.mock('react-i18next', async () => { } }) +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => vi.fn(), +})) + vi.mock('@tanstack/react-query', () => ({ useQuery: () => ({ data: null }), useQueryClient: () => ({ invalidateQueries: vi.fn(), setQueryData: vi.fn() }), @@ -53,10 +58,16 @@ vi.mock('@/shared/ui/input', () => ({ Input: () => null, })) +import { renderToStaticMarkup } from 'react-dom/server' import { ProfileSettingsPage } from './profile' describe('ProfileSettingsPage', () => { it('exports a named component function', () => { expect(typeof ProfileSettingsPage).toBe('function') }) + + it('renders reset-password entry action', () => { + const html = renderToStaticMarkup(React.createElement(ProfileSettingsPage)) + expect(html).toContain('profile.resetPassword') + }) }) diff --git a/web/src/pages/settings/profile.tsx b/web/src/pages/settings/profile.tsx index 8fb07f7f4..dfb4a09c0 100644 --- a/web/src/pages/settings/profile.tsx +++ b/web/src/pages/settings/profile.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useQuery, useQueryClient } from '@tanstack/react-query' import { ApiError, profileApi } from '@/api/client' @@ -38,6 +39,7 @@ function getFieldValue( export function ProfileSettingsPage() { const { t } = useTranslation() const { user } = useAuth() + const navigate = useNavigate() const queryClient = useQueryClient() const [isEditing, setIsEditing] = useState(false) @@ -180,10 +182,17 @@ export function ProfileSettingsPage() { {t('profile.title')} {t('profile.subtitle')} - {!isEditing && hasEditableFields ? ( - + {!isEditing ? ( +
+ + {hasEditableFields ? ( + + ) : null} +
) : null}