diff --git a/docs/first-password-setup-implementation.md b/docs/first-password-setup-implementation.md new file mode 100644 index 0000000..a4f68df --- /dev/null +++ b/docs/first-password-setup-implementation.md @@ -0,0 +1,73 @@ +# 短信登录后首次设置密码:实施与联调指令 + +本批面向后端与前端开发人员,实现首次设置引导。设置密码后,短信与密码登录继续并存;不新增必须设置密码才能使用业务的门禁。 + +## 1. 登录与密码状态 + +在 POST /auth/login、POST /auth/password/login 和 GET /auth/me 中读取 passwordConfigured。它由 password_hash 是否存在派生,不增加 users 状态列。 + +无密码账号只有在 login 用途短信验证成功后,才获得 passwordSetupToken 和 passwordSetupExpiresIn=600。凭证不是 JWT;使用安全随机数生成 32 字节,数据库只保存带 password-setup 域前缀的 HMAC 摘要。 + +凭证绑定 userId、sessionId 和固定到期时间。密码登录、刷新、身份选择都不会签发设置凭证;刷新不能延长有效期。当前会话退出或被撤销后,凭证不可使用。 + +## 2. 请求与响应 + +前置地址为既有 /api/v1。新接口 POST /auth/password/setup 必须携带当前 Access Token。 + +```json +{"passwordSetupToken":"短信登录返回的一次性凭证","newPassword":"用户输入的新密码"} +``` + +成功返回现有 ApiResponse 包装,data 含 passwordConfigured=true、reauthenticationRequired=false、identitySelectionRequired、accessToken、refreshToken、expiresIn。有效期沿用既有认证配置与契约,不写死为一小时。 + +前端应先替换 Access/Refresh Token 并清除设置凭证,再根据 identitySelectionRequired 进入身份选择或原角色首页。已有密码不重复弹设置页。凭证过期或响应丢失时,重新短信登录并读取密码状态;不得反复提交创建操作。 + +参数/密码策略错误为 400,无效会话或设置凭证为 401,已有密码冲突为 409,限流为 429,安全依赖不可用为 503。不得把 SQL、密码、令牌或凭证摘要返回给客户端。 + +## 3. 后端事务顺序 + +新增 V54 password_setup_challenges,不修改 V1–V53。核查 MySQL 5.7/8.4 和 H2 的兼容性。 + +先锁定用户并校验 active/注销状态,再锁定当前服务端会话记录,串行处理并发登出;检查操作限流与尚无密码,复用现有密码策略。数据库条件更新必须同时匹配用户、会话、摘要、未消费、未过期,且更新行数恰好为 1。 + +随后 BCrypt 保存密码及 passwordChangedAt,flush 用户,撤销全部旧会话,建立新会话,并写固定、脱敏审计。上述操作使用同一数据库事务;任何步骤失败均回滚。Redis 限流计数不宣称自动回滚。 + +未选身份账号只新增放行 POST /auth/password/setup,继续保留其他业务门禁。客户端不传 userId、role 或 sessionId。 + +## 4. 兼容与库存 + +保留 /auth/password/set 的 password-set 短信用途与成功后重新认证语义;保留修改、找回密码及原有登录、刷新、登出接口。设置成功不能覆盖企业认证或账号角色。 + +静态 OpenAPI 与运行时均增加一个路径、一个操作,目标 198 路径、241 操作。接口库存测试随真实新增接口更新;不降低原有身份门禁断言。 + +## 5. 验收步骤 + +用明确假号段准备无密码账号,经真实验证码消费逻辑短信登录,检查未设置状态和凭证有效期。首次设置后检查 BCrypt 匹配、新会话可用、旧 Access/Refresh Token 被撤销;再次短信登录不发设置凭证,密码登录也应成功。 + +测试跨用户、跨会话、匿名、过期、已消费、弱密码和安全依赖异常。两个并发请求同时使用凭证,只允许一个成功;审计或会话创建失败应回滚密码、消费状态、会话变更。 + +同一套断言在 H2 和真实 MySQL 8.4 执行,并运行 MySQL 5.7 迁移及认证关联回归。记录真实 F/E/S 与 OpenAPI 结果后再安排独立复验、提交和部署。 + +当前文档描述本地实现;测试结果以本轮实际报告为准,不表示已上线或已完成前端接入。 + +## 6. 本轮验证结果与交付边界 + +2026-10-12:认证关联回归执行 68 项,Failures=0、Errors=0、Skipped=0,覆盖旧密码接口、刷新、会话、统一注册、企业注册,以及真实 MySQL 5.7/8.4 迁移和运行时接口库存。 + +最终补充数据库异常安全响应与 DTO 脱敏后,重新编译最新源码并执行 H2 和真实 MySQL 8.4 的首次设置密码测试,共 20 项,Failures=0、Errors=0、Skipped=0;定向 mvn verify 成功并生成最新 JAR。两组测试存在重叠,不合计为 88 个独立用例。 + +静态 OpenAPI 检查通过:198 路径、241 操作。历史 V1–V53 无内容修改;新增 V54。git diff --check 通过,暂存区为空。 + +2026-10-12 补充全量与最新 JAR 验收:`mvn -o clean verify` 成功,3278 项,Failures=0、Errors=0、Skipped=0。首次全量发现 MySql57MigrationConfigurationTest 仍断言 53 个迁移,与新增 V54 不一致;仅更新计数为 54,定向 4/4 通过后重跑全量成功。全量日志位于本机临时目录 password-setup-full-verify-final.log。 + +验收对象为上述 clean verify 生成的 mydreamjob-server-0.0.1-SNAPSHOT.jar,SHA256:`635EB9F268771D266EED8965FCD39F03F9B24E8B7219945980FB54037D303822`。 + +使用独立、一次性 MySQL 8.4、Redis 7.4、RabbitMQ 3.13 容器与该 JAR 做真实 HTTP 黑盒,应用及管理健康检查均为 200,Flyway 达到 V54。使用假号段测试账号,短信发送通过 Mock sender,但验证码生成、数据库消费、Redis 限流及认证会话均执行真实实现;验证码从隔离数据库读取,没有调用运营商。 + +黑盒通过:无密码账号短信登录返回首次设置凭证;弱密码 400、匿名设置 401;设置成功返回新会话且无需重新登录;旧 Access 与 Refresh 均 401,新 Access 可用;再次使用设置凭证 409;设置后选择 STUDENT 成功;密码登录成功;再次发送验证码并短信登录成功,返回 passwordConfigured=true 且不再返回设置凭证。 + +数据库逐项证据:测试用户 1 行、STUDENT 主角色与 user_roles 各 1 条、BCrypt 格式密码与 password_changed_at 均已保存;设置凭证 1 条且已消费;两条短信验证码均已消费。四个 RabbitMQ 队列(短信、通知、审计、通知死信)ready/unacknowledged 全为 0。应用日志扫描未发现测试手机号、明文测试密码或 JWT/Bearer。 + +静态及运行时 OpenAPI 均为 198 路径、241 操作,方法/路径集合完全一致。黑盒脚本最初使用 PowerShell 属性集合的 .Count 导致误报,改用数组计数复核后通过。首次 JAR 启动遇到默认管理端口 8081 与前端 Metro 冲突,将本次管理端口设为 18081 后启动成功;应用端口为 18080。 + +验收后已停止本次 JAR,并删除三个一次性容器及其测试数据;13307、16379、25672、18080、18081 无本次残留监听。前端 Metro 保持运行。本批未暂存、提交、推送或部署;真实短信与前端设置密码页面联调仍未执行,不代表线上已有此接口。 diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index afb6fe9..8954181 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -1436,7 +1436,7 @@ "认证" ], "summary": "手机号验证码登录", - "description": "手机号验证码登录;新手机号创建 UNASSIGNED 统一账号,identitySelectionRequired=true 时前端必须进入首次身份选择;旧角色保持不变。保留原字段及成功码 200。", + "description": "手机号验证码登录;返回 passwordConfigured。无密码且密码功能开启时,login 短信验证成功还返回一次性的 passwordSetupToken 与 passwordSetupExpiresIn=600;设置成功替换 Token 后根据 identitySelectionRequired 选择身份。旧角色、验证码登录及成功码 200 保持不变。", "operationId": "mockLogin", "requestBody": { "content": { @@ -9406,6 +9406,17 @@ } } }, + "/auth/password/setup": { + "post": { + "tags": ["认证"], + "summary": "短信登录后首次设置密码", + "description": "必须携带当前 Access Token 与 login 登录返回的 10 分钟一次性 passwordSetupToken;成功后替换两枚 Token,验证码登录继续可用。", + "operationId": "setupPassword", + "security": [{"bearerAuth": []}], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasswordSetupRequest"}}}}, + "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponsePasswordSetupResponse"}}}}, "400": {"description": "参数或密码策略错误"}, "401": {"description": "会话或设置凭证无效"}, "409": {"description": "密码已设置"}, "429": {"description": "操作限流"}, "503": {"description": "安全依赖不可用"}} + } + }, "/auth/password/set": { "post": { "tags": [ @@ -18298,6 +18309,18 @@ "newPassword" ] }, + "PasswordSetupRequest": { + "type": "object", "required": ["passwordSetupToken", "newPassword"], + "properties": {"passwordSetupToken": {"type": "string", "maxLength": 512, "writeOnly": true}, "newPassword": {"type": "string", "maxLength": 256, "writeOnly": true}} + }, + "PasswordSetupResponse": { + "type": "object", + "properties": {"passwordConfigured": {"type": "boolean"}, "reauthenticationRequired": {"type": "boolean"}, "identitySelectionRequired": {"type": "boolean"}, "accessToken": {"type": "string"}, "refreshToken": {"type": "string"}, "expiresIn": {"type": "integer", "format": "int64"}} + }, + "ApiResponsePasswordSetupResponse": { + "type": "object", + "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"$ref": "#/components/schemas/PasswordSetupResponse"}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}} + }, "PasswordOperationResponse": { "type": "object", "properties": { diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 82f9f4e..80c0ceb 100644 --- a/docs/verify_openapi.py +++ b/docs/verify_openapi.py @@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json' d = json.load(open(p, encoding='utf-8')) print('paths:', len(d['paths'])) -assert len(d['paths']) == 197, f"Expected 197 paths (184 baseline + 13 module paths), got {len(d['paths'])}" +assert len(d['paths']) == 198, f"Expected 198 paths (185 baseline + 13 module paths), got {len(d['paths'])}" paths = d['paths'] @@ -649,7 +649,7 @@ assert _auth_paths == { '/auth/sms/send', '/auth/login', '/auth/me', '/auth/refresh', '/auth/logout', '/auth/password/login', '/auth/password/set', '/auth/password/change', '/auth/password/reset', - '/auth/company/register', '/auth/identity/select', + '/auth/company/register', '/auth/identity/select', '/auth/password/setup', }, f'Unexpected /auth paths: {sorted(_auth_paths)}' print(' S02 refresh/logout and password authentication operations validated') @@ -716,10 +716,12 @@ for _p in paths: assert not _p.startswith('/actuator'), f'Actuator must not appear in the business contract: {_p}' print(' S03 company resume download contract validated; candidate DTOs remain redacted') -# 统计 operation 总数并校验当前基线 222 个既有 operation 加 18 个模块 operation。 +# 统计 operation 总数并校验当前基线 223 个既有 operation 加 18 个模块 operation。 operations = sum(len(methods) for methods in d['paths'].values()) print('operations:', operations) -assert operations == 240, f"Expected 240 operations (222 baseline + 18 module operations), got {operations}" +assert operations == 241, f"Expected 241 operations (223 baseline + 18 module operations), got {operations}" +assert paths['/auth/password/setup']['post']['operationId'] == 'setupPassword' +assert 'bearerAuth' in json.dumps(paths['/auth/password/setup']['post']['security']) # 统一账号身份选择:只允许两种业务身份,保持旧登录字段引用。 _select = paths['/auth/identity/select']['post'] diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java index f7e62b0..0c3a193 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java @@ -38,6 +38,7 @@ public class AuthService { private final AccountDeletionPhoneHasher accountDeletionPhoneHasher; private final AuthSessionService authSessionService; private final Clock clock; + private final com.mydreamjob.auth.password.PasswordSetupService passwordSetupService; /** * Mock 登录:验证短信验证码后自动创建/登录用户,并建立一次登录会话。 @@ -99,6 +100,14 @@ public class AuthService { result.put("nickname", user.getNickname()); result.put("role", user.getRole()); result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED); + result.put("passwordConfigured", user.getPasswordHash() != null); + if ("login".equals(purpose) && user.getPasswordHash() == null) { + String setupProof = passwordSetupService.issue(user, tokens.sessionId(), capturedNow); + if (setupProof != null) { + result.put("passwordSetupToken", setupProof); + result.put("passwordSetupExpiresIn", 600); + } + } result.put("accessToken", tokens.accessToken()); result.put("refreshToken", tokens.refreshToken()); result.put("expiresIn", EXPIRES_IN_SECONDS); @@ -141,6 +150,7 @@ public class AuthService { result.put("role", user.getRole()); result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED); result.put("active", user.getActive()); + result.put("passwordConfigured", user.getPasswordHash() != null); result.put("verified", user.getVerified()); result.put("createdAt", user.getCreatedAt()); return result; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java index 0b36c25..01fb9a5 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java @@ -336,7 +336,7 @@ public class AuthSessionService { return authTokenHasher.hash("sid:" + sessionId).substring(0, 16); } - static LocalDateTime toLocal(Instant instant) { + public static LocalDateTime toLocal(Instant instant) { return LocalDateTime.ofInstant(instant, BUSINESS_ZONE); } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java index d16ef71..4efc65c 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java @@ -53,6 +53,11 @@ public interface AuthTokenRepository extends JpaRepository { List findByUserIdAndSessionIdOrderByIdAsc(Long userId, String sessionId); + /** 首次密码设置持有当前会话行锁,让并发登出与设置按事务顺序生效。 */ + @org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT t FROM AuthToken t WHERE t.userId = :userId AND t.sessionId = :sessionId ORDER BY t.id") + List lockSession(@Param("userId") Long userId, @Param("sessionId") String sessionId); + // ── S02 原子条件更新(返回值必须被严格检查)──────────────────── /** diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetupRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetupRequest.java new file mode 100644 index 0000000..7432e27 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetupRequest.java @@ -0,0 +1,11 @@ +package com.mydreamjob.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record PasswordSetupRequest(@NotBlank @Size(max = 512) + @io.swagger.v3.oas.annotations.media.Schema(accessMode = io.swagger.v3.oas.annotations.media.Schema.AccessMode.WRITE_ONLY) String passwordSetupToken, + @NotBlank @Size(max = 256) + @io.swagger.v3.oas.annotations.media.Schema(accessMode = io.swagger.v3.oas.annotations.media.Schema.AccessMode.WRITE_ONLY) String newPassword) { + @Override public String toString() { return "PasswordSetupRequest[redacted]"; } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetupResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetupResponse.java new file mode 100644 index 0000000..9448384 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetupResponse.java @@ -0,0 +1,6 @@ +package com.mydreamjob.auth.dto; + +public record PasswordSetupResponse(boolean passwordConfigured, boolean reauthenticationRequired, + boolean identitySelectionRequired, String accessToken, String refreshToken, long expiresIn) { + @Override public String toString() { return "PasswordSetupResponse[redacted]"; } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java index c2df10c..34f6e91 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java @@ -24,7 +24,7 @@ import java.util.Set; @RequiredArgsConstructor public class IdentityAccessFilter extends OncePerRequestFilter { private static final Set AUTH_POST = Set.of("/auth/identity/select", "/auth/logout", - "/auth/password/set", "/auth/password/change", "/auth/login", "/auth/password/login", + "/auth/password/set", "/auth/password/setup", "/auth/password/change", "/auth/login", "/auth/password/login", "/auth/password/reset", "/auth/refresh", "/auth/sms/send", "/auth/company/register"); private static final Set PUBLIC_GET = Set.of("/", "/health", "/health/**", "/jobs", "/jobs/search", "/jobs/filters", "/jobs/compare", "/jobs/public/**", "/jobs/*/reviews", diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationService.java index 1543cf9..a7657c2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationService.java @@ -60,6 +60,7 @@ public class PasswordAuthenticationService { result.put("nickname", user.getNickname()); result.put("role", user.getRole()); result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED); + result.put("passwordConfigured", true); result.put("accessToken", tokens.accessToken()); result.put("refreshToken", tokens.refreshToken()); result.put("expiresIn", AuthService.EXPIRES_IN_SECONDS); @@ -117,7 +118,8 @@ public class PasswordAuthenticationService { return new PasswordOperationResult(true, true); } - private User lockedUsableUser(Long userId) { + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public User lockedUsableUser(Long userId) { User user = userRepository.findByIdForUpdate(userId) .orElseThrow(() -> new BusinessException(ErrorCode.CREDENTIALS_INVALID, "手机号或密码错误")); if (!usable(user)) { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupChallenge.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupChallenge.java new file mode 100644 index 0000000..1d499d0 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupChallenge.java @@ -0,0 +1,18 @@ +package com.mydreamjob.auth.password; + +import jakarta.persistence.*; +import java.time.LocalDateTime; +import lombok.*; + +@Entity +@Table(name = "password_setup_challenges") +@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor +public class PasswordSetupChallenge { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(nullable = false) private Long userId; + @Column(nullable = false, length = 36) private String sessionId; + @Column(nullable = false, unique = true, length = 64) private String tokenHash; + @Column(nullable = false) private LocalDateTime createdAt; + @Column(nullable = false) private LocalDateTime expiresAt; + private LocalDateTime consumedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupChallengeRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupChallengeRepository.java new file mode 100644 index 0000000..e95ffc6 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupChallengeRepository.java @@ -0,0 +1,16 @@ +package com.mydreamjob.auth.password; + +import java.time.LocalDateTime; +import org.springframework.data.jpa.repository.*; +import org.springframework.data.repository.query.Param; + +public interface PasswordSetupChallengeRepository extends JpaRepository { + @Modifying(flushAutomatically = true) + @Query(""" + update PasswordSetupChallenge c set c.consumedAt = :now + where c.userId = :user and c.sessionId = :session and c.tokenHash = :hash + and c.consumedAt is null and c.expiresAt > :now + """) + int consume(@Param("user") Long userId, @Param("session") String sessionId, + @Param("hash") String hash, @Param("now") LocalDateTime now); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupService.java new file mode 100644 index 0000000..65dbf03 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupService.java @@ -0,0 +1,73 @@ +package com.mydreamjob.auth.password; + +import com.mydreamjob.auth.*; +import com.mydreamjob.auth.dto.PasswordSetupResponse; +import com.mydreamjob.audit.*; +import com.mydreamjob.common.exception.*; +import java.security.SecureRandom; +import java.time.*; +import java.util.Base64; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** 短信登录后的首次密码设置;凭证只绑定当前会话,不用会话创建时间推断认证来源。 */ +@Service @RequiredArgsConstructor +public class PasswordSetupService { + private static final SecureRandom RANDOM = new SecureRandom(); + private final PasswordSetupChallengeRepository challenges; + private final AuthTokenHasher hasher; + private final AuthTokenRepository tokens; + private final PasswordAuthenticationService passwords; + private final PasswordRateLimiter limiter; + private final PasswordPolicy policy; + private final PasswordEncoder encoder; + private final UserRepository users; + private final AuthSessionService sessions; + private final AuditService audit; + private final Clock clock; + private final PasswordAuthenticationProperties properties; + + @Transactional + public String issue(User user, String sessionId, Instant now) { + if (!properties.isEnabled() || user.getPasswordHash() != null) return null; + byte[] random = new byte[32]; + RANDOM.nextBytes(random); + String raw = Base64.getUrlEncoder().withoutPadding().encodeToString(random); + challenges.save(PasswordSetupChallenge.builder().userId(user.getId()).sessionId(sessionId) + .tokenHash(hash(raw)).createdAt(AuthSessionService.toLocal(now)) + .expiresAt(AuthSessionService.toLocal(now.plusSeconds(600))).build()); + return raw; + } + + @Transactional + public PasswordSetupResponse setup(Long userId, String sessionId, String raw, String newPassword, AuditContext ctx) { + User user = passwords.lockedUsableUser(userId); + Instant now = clock.instant(); + boolean active = tokens.lockSession(userId, sessionId).stream().anyMatch(t -> + Boolean.FALSE.equals(t.getRevoked()) && t.getConsumedAt() == null + && t.getExpiredAt().isAfter(AuthSessionService.toLocal(now))); + if (!active) + throw new InvalidTokenException(); + limiter.checkOperationAllowed(user.getPhone()); + if (user.getPasswordHash() != null) throw new BusinessException(ErrorCode.PASSWORD_STATE_CONFLICT); + policy.validateNewPassword(newPassword, user.getPhone(), null, encoder); + if (raw == null || raw.length() != 43 || challenges.consume(userId, sessionId, hash(raw), + AuthSessionService.toLocal(now)) != 1) + throw new BusinessException(ErrorCode.CREDENTIALS_INVALID, "invalid password setup proof"); + user.setPasswordHash(encoder.encode(policy.normalize(newPassword))); + user.setPasswordChangedAt(AuthSessionService.toLocal(now)); + users.saveAndFlush(user); + sessions.revokeAllUserSessions(userId, "PASSWORD_SET", now); + var pair = sessions.createSession(user, now, ctx); + audit.log(userId, "PASSWORD_SET", "AUTH", null, "First password configured", ctx, + AuthSessionService.toLocal(now)); + audit.log(userId, "AUTH_SESSIONS_REVOKED", "AUTH", null, "PASSWORD_SET", ctx, + AuthSessionService.toLocal(now)); + return new PasswordSetupResponse(true, false, user.getRole() == User.Role.UNASSIGNED, + pair.accessToken(), pair.refreshToken(), AuthService.EXPIRES_IN_SECONDS); + } + + private String hash(String raw) { return hasher.hash("password-setup:" + raw); } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index b94eec5..c9d27ff 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java @@ -100,6 +100,7 @@ public class SecurityConfig { // S02:登出必须已认证 .requestMatchers("/auth/logout").authenticated() .requestMatchers("/auth/password/set").authenticated() + .requestMatchers("/auth/password/setup").authenticated() .requestMatchers("/auth/password/change").authenticated() // 企业资料(需登录) .requestMatchers("/companies/me/**").authenticated() diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java b/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java index ef743a6..990e061 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java @@ -59,6 +59,29 @@ public class AuthController { private final PasswordAuthenticationService passwordAuthenticationService; private final CompanyRegistrationService companyRegistrationService; private final IdentitySelectionService identitySelectionService; + private final com.mydreamjob.auth.password.PasswordSetupService passwordSetupService; + + @Operation(summary = "短信登录后首次设置密码", description = "需要当前 Access Token 和 10 分钟一次性设置凭证;成功后必须替换旧 Token") + @PostMapping("/password/setup") + public ResponseEntity> setupPassword( + @Valid @RequestBody com.mydreamjob.auth.dto.PasswordSetupRequest body, HttpServletRequest request) { + try { + var result = passwordSetupService.setup(currentUserAccessor.requireUserId(), + currentUserAccessor.requireSessionId(), body.passwordSetupToken(), body.newPassword(), AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(result, requestIdAccessor.get(request))); + } catch (InvalidTokenException e) { return unauthorized(request); } + catch (BusinessException e) { + if (e.getErrorCodeInt() == ErrorCode.SERVICE_UNAVAILABLE.getCode()) { + return ResponseEntity.status(503).body(ApiResponse.error(503, + "authentication service temporarily unavailable", requestIdAccessor.get(request))); + } + throw e; + } + catch (org.springframework.dao.DataAccessException | org.springframework.transaction.TransactionException e) { + return ResponseEntity.status(503).body(ApiResponse.error(503, + "authentication service temporarily unavailable", requestIdAccessor.get(request))); + } + } @Operation(summary = "首次选择账号身份", description = "统一账号一次性选择求职者或企业,成功后替换旧登录凭证") @PostMapping("/identity/select") @@ -120,8 +143,8 @@ public class AuthController { return ResponseEntity.badRequest().body( ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request))); } catch (Exception e) { - return ResponseEntity.badRequest().body( - ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request))); + return ResponseEntity.status(503).body( + ApiResponse.error(503, "authentication service temporarily unavailable", requestIdAccessor.get(request))); } } diff --git a/mydreamjob-server/src/main/resources/db/migration/V54__password_setup_challenges.sql b/mydreamjob-server/src/main/resources/db/migration/V54__password_setup_challenges.sql new file mode 100644 index 0000000..fc4db4c --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V54__password_setup_challenges.sql @@ -0,0 +1,11 @@ +CREATE TABLE password_setup_challenges ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + session_id VARCHAR(36) NOT NULL, + token_hash VARCHAR(64) NOT NULL, + created_at DATETIME(6) NOT NULL, + expires_at DATETIME(6) NOT NULL, + consumed_at DATETIME(6) NULL, + CONSTRAINT uk_password_setup_hash UNIQUE (token_hash), + CONSTRAINT fk_password_setup_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java index 4257541..997927f 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java @@ -462,8 +462,8 @@ class CompanyResumeDownloadIntegrationTest { } assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13); assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18); - assertThat(pathCount - modulePaths).as("既有 184 paths 保持不变").isEqualTo(184); - assertThat(opCount - moduleOperations).as("既有 222 operations 保持不变").isEqualTo(222); + assertThat(pathCount - modulePaths).as("185 paths 包含首次密码设置").isEqualTo(185); + assertThat(opCount - moduleOperations).as("223 operations 包含首次密码设置").isEqualTo(223); var download = paths.path("/company/candidates/{applicationId}/resume").path("get"); assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse(); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthPasswordSetupIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthPasswordSetupIntegrationTest.java new file mode 100644 index 0000000..36d32a0 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthPasswordSetupIntegrationTest.java @@ -0,0 +1,172 @@ +package com.mydreamjob.auth; + +import com.fasterxml.jackson.databind.*; +import com.mydreamjob.audit.*; +import com.mydreamjob.auth.password.*; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.*; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.*; +import org.springframework.test.web.servlet.MockMvc; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; + +@SpringBootTest @AutoConfigureMockMvc(print = org.springframework.boot.test.autoconfigure.web.servlet.MockMvcPrint.NONE) @ActiveProfiles("integration-test") +@TestPropertySource(properties = {"app.auth.password.enabled=true", "app.auth.password.rate-key-secret=01234567890123456789012345678901"}) +class AuthPasswordSetupIntegrationTest { + @Autowired MockMvc mvc; + @Autowired ObjectMapper mapper; + @Autowired UserRepository users; + @Autowired AuthTokenRepository tokens; + @Autowired SmsVerificationCodeRepository codes; + @Autowired PasswordSetupChallengeRepository challenges; + @Autowired PasswordEncoder encoder; + @SpyBean AuthSessionService sessions; + @SpyBean AuditService audit; + @MockBean PasswordRateLimiter limiter; + @MockBean org.springframework.data.redis.core.RedisTemplate redis; + static final AtomicInteger SEQ = new AtomicInteger(); + final List ids = new ArrayList<>(); + final List codeIds = new ArrayList<>(); + static final String PASSWORD = "Fixture-Strong-2026!"; + + @AfterEach void cleanup() { + reset(audit); + reset(sessions); + challenges.deleteAll(challenges.findAll().stream().filter(c -> ids.contains(c.getUserId())).toList()); + tokens.deleteAll(tokens.findAll().stream().filter(t -> ids.contains(t.getUserId())).toList()); + codeIds.forEach(codes::deleteById); + ids.forEach(users::deleteById); + } + User user() { + User u = users.saveAndFlush(User.builder().phone("1990077" + String.format("%04d",SEQ.incrementAndGet())) + .role(User.Role.UNASSIGNED).roles(Set.of()).active(true).verified(true).build()); + ids.add(u.getId()); return u; + } + JsonNode login(User u) throws Exception { + codeIds.add(codes.saveAndFlush(SmsVerificationCode.builder().phone(u.getPhone()).code("654321") + .purpose("login").expiredAt(LocalDateTime.now().plusMinutes(5)).build()).getId()); + var r=mvc.perform(post("/auth/login").contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(Map.of("phone",u.getPhone(),"code","654321")))).andReturn().getResponse(); + assertThat(r.getStatus()).isEqualTo(200); + return mapper.readTree(r.getContentAsString()).path("data"); + } + org.springframework.mock.web.MockHttpServletResponse setup(JsonNode login, String proof) throws Exception { + return mvc.perform(post("/auth/password/setup").header("Authorization","Bearer "+login.path("accessToken").asText()) + .contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(Map.of( + "passwordSetupToken",proof,"newPassword",PASSWORD)))).andReturn().getResponse(); + } + @Test void setsPasswordRotatesSessionsAndKeepsSmsLogin() throws Exception { + User u=user(); JsonNode l=login(u); + assertThat(l.path("passwordConfigured").asBoolean()).isFalse(); + String proof=l.path("passwordSetupToken").asText(); + assertThat(proof).hasSize(43); + assertThat(challenges.findAll().stream().filter(c->c.getUserId().equals(u.getId())).findFirst().orElseThrow().getTokenHash()).isNotEqualTo(proof); + var r=setup(l,proof); assertThat(r.getStatus()).isEqualTo(200); + JsonNode n=mapper.readTree(r.getContentAsString()).path("data"); + assertThat(n.path("passwordConfigured").asBoolean()).isTrue(); + assertThat(n.path("reauthenticationRequired").asBoolean()).isFalse(); + assertThat(n.path("identitySelectionRequired").asBoolean()).isTrue(); + assertThat(encoder.matches(PASSWORD,users.findById(u.getId()).orElseThrow().getPasswordHash())).isTrue(); + assertThat(mvc.perform(get("/auth/me").header("Authorization","Bearer "+l.path("accessToken").asText())).andReturn().getResponse().getStatus()).isEqualTo(401); + assertThat(mvc.perform(get("/auth/me").header("Authorization","Bearer "+n.path("accessToken").asText())).andReturn().getResponse().getStatus()).isEqualTo(200); + assertThat(mvc.perform(post("/auth/refresh").contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(Map.of("refreshToken",l.path("refreshToken").asText())))).andReturn().getResponse().getStatus()).isEqualTo(401); + assertThat(setup(n,proof).getStatus()).isEqualTo(409); + JsonNode again=login(users.findById(u.getId()).orElseThrow()); + assertThat(again.path("passwordConfigured").asBoolean()).isTrue(); + assertThat(again.has("passwordSetupToken")).isFalse(); + assertThat(mvc.perform(post("/auth/password/login").contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(Map.of("phone",u.getPhone(),"password",PASSWORD)))).andReturn().getResponse().getStatus()).isEqualTo(200); + } + @Test void bindsProofToUserAndSessionAndExpires() throws Exception { + User u=user(); JsonNode a=login(u), b=login(u), other=login(user()); + String proof=a.path("passwordSetupToken").asText(); + assertThat(setup(b,proof).getStatus()).isEqualTo(401); + assertThat(setup(other,proof).getStatus()).isEqualTo(401); + challenges.findAll().stream().filter(c->c.getUserId().equals(u.getId())).forEach(c->{c.setExpiresAt(LocalDateTime.now().minusMinutes(1));challenges.saveAndFlush(c);}); + assertThat(setup(a,proof).getStatus()).isEqualTo(401); + assertThat(users.findById(u.getId()).orElseThrow().getPasswordHash()).isNull(); + } + @Test void ordinarySessionHasNoSetupAuthorityAndAnonymousIsRejected() throws Exception { + User u=user(); var pair=sessions.createSession(u,Instant.now(),new AuditContext()); + JsonNode plain=mapper.valueToTree(Map.of("accessToken",pair.accessToken())); + assertThat(setup(plain,"A".repeat(43)).getStatus()).isEqualTo(401); + assertThat(mvc.perform(post("/auth/password/setup").contentType(MediaType.APPLICATION_JSON).content("{}")) + .andReturn().getResponse().getStatus()).isEqualTo(401); + } + @Test void concurrentRequestsOnlyOneSucceeds() throws Exception { + User u=user(); JsonNode l=login(u); String proof=l.path("passwordSetupToken").asText(); + var pool=Executors.newFixedThreadPool(2); var start=new CountDownLatch(1); + try { + Callable task=()->{start.await();return setup(l,proof).getStatus();}; + var a=pool.submit(task);var b=pool.submit(task);start.countDown(); + var results=List.of(a.get(30,TimeUnit.SECONDS),b.get(30,TimeUnit.SECONDS)); + assertThat(results).contains(200).containsAnyOf(401,409); + assertThat(results.stream().filter(s->s==200).count()).isEqualTo(1); + assertThat(tokens.findByUserIdAndRevokedFalse(u.getId())).hasSize(1); + } finally {pool.shutdownNow();} + } + @Test void auditFailureRollsBackProofPasswordAndRevocation() throws Exception { + User u=user();JsonNode l=login(u);String proof=l.path("passwordSetupToken").asText(); + doThrow(new IllegalStateException("fixture audit failure")).when(audit).log(eq(u.getId()),eq("PASSWORD_SET"),eq("AUTH"),isNull(),anyString(),any(AuditContext.class),any(LocalDateTime.class)); + var r=setup(l,proof);assertThat(r.getStatus()).isGreaterThanOrEqualTo(500); + assertThat(users.findById(u.getId()).orElseThrow().getPasswordHash()).isNull(); + assertThat(challenges.findAll().stream().filter(c->c.getUserId().equals(u.getId())).findFirst().orElseThrow().getConsumedAt()).isNull(); + assertThat(tokens.findByUserIdAndRevokedFalse(u.getId())).hasSize(1); + reset(audit);assertThat(setup(l,proof).getStatus()).isEqualTo(200); + } + @Test void refreshDoesNotIssueOrExtendProof() throws Exception { + User u=user(); JsonNode l=login(u); + var before=challenges.findAll().stream().filter(c->c.getUserId().equals(u.getId())).findFirst().orElseThrow(); + var r=mvc.perform(post("/auth/refresh").contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(Map.of("refreshToken",l.path("refreshToken").asText())))).andReturn().getResponse(); + assertThat(r.getStatus()).isEqualTo(200); + JsonNode refreshed=mapper.readTree(r.getContentAsString()).path("data"); + assertThat(refreshed.has("passwordSetupToken")).isFalse(); + assertThat(challenges.findById(before.getId()).orElseThrow().getExpiresAt()).isEqualTo(before.getExpiresAt()); + assertThat(setup(refreshed,l.path("passwordSetupToken").asText()).getStatus()).isEqualTo(200); + } + @Test void sessionCreationFailureRollsBackAllChanges() throws Exception { + User u=user();JsonNode l=login(u);String proof=l.path("passwordSetupToken").asText(); + doThrow(new org.springframework.dao.DataAccessResourceFailureException("fixture session failure")).when(sessions).createSession(any(User.class),any(Instant.class),any(AuditContext.class)); + assertThat(setup(l,proof).getStatus()).isEqualTo(503); + assertThat(users.findById(u.getId()).orElseThrow().getPasswordHash()).isNull(); + assertThat(tokens.findByUserIdAndRevokedFalse(u.getId())).hasSize(1); + assertThat(challenges.findAll().stream().filter(c->c.getUserId().equals(u.getId())).findFirst().orElseThrow().getConsumedAt()).isNull(); + reset(sessions);assertThat(setup(l,proof).getStatus()).isEqualTo(200); + } + @Test void weakPasswordOrUnavailableLimiterDoesNotConsumeProof() throws Exception { + User u=user();JsonNode l=login(u);String proof=l.path("passwordSetupToken").asText(); + var r=mvc.perform(post("/auth/password/setup").header("Authorization","Bearer "+l.path("accessToken").asText()) + .contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(Map.of("passwordSetupToken",proof,"newPassword","123")))).andReturn().getResponse(); + assertThat(r.getStatus()).isEqualTo(400); + doThrow(new com.mydreamjob.common.exception.BusinessException(com.mydreamjob.common.exception.ErrorCode.SERVICE_UNAVAILABLE)).when(limiter).checkOperationAllowed(u.getPhone()); + assertThat(setup(l,proof).getStatus()).isEqualTo(503); + assertThat(users.findById(u.getId()).orElseThrow().getPasswordHash()).isNull(); + assertThat(challenges.findAll().stream().filter(c->c.getUserId().equals(u.getId())).findFirst().orElseThrow().getConsumedAt()).isNull(); + reset(limiter);assertThat(setup(l,proof).getStatus()).isEqualTo(200); + } + @Test void loggedOutSessionCannotUseUnconsumedProof() throws Exception { + User u=user();JsonNode l=login(u); + assertThat(mvc.perform(post("/auth/logout").header("Authorization","Bearer "+l.path("accessToken").asText())).andReturn().getResponse().getStatus()).isEqualTo(200); + assertThat(setup(l,l.path("passwordSetupToken").asText()).getStatus()).isEqualTo(401); + assertThat(users.findById(u.getId()).orElseThrow().getPasswordHash()).isNull(); + } + @Test void configuredIdentityIsPreserved() throws Exception { + User u=user();u.setRole(User.Role.STUDENT);u.setRoles(Set.of(User.Role.STUDENT));users.saveAndFlush(u); + JsonNode l=login(u);var r=setup(l,l.path("passwordSetupToken").asText()); + assertThat(r.getStatus()).isEqualTo(200); + JsonNode n=mapper.readTree(r.getContentAsString()).path("data"); + assertThat(n.path("identitySelectionRequired").asBoolean()).isFalse(); + var me=mvc.perform(get("/auth/me").header("Authorization","Bearer "+n.path("accessToken").asText())).andReturn().getResponse(); + assertThat(mapper.readTree(me.getContentAsString()).path("data").path("role").asText()).isEqualTo("STUDENT"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthPasswordSetupMySqlTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthPasswordSetupMySqlTest.java new file mode 100644 index 0000000..0ffaca0 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthPasswordSetupMySqlTest.java @@ -0,0 +1,20 @@ +package com.mydreamjob.auth; + +import org.springframework.test.context.*; +import org.springframework.test.annotation.DirtiesContext; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.*; + +/** 与 H2 使用相同验收断言,在真实 MySQL 上验证迁移、并发和事务回滚。 */ +@Testcontainers(disabledWithoutDocker = false) +@ActiveProfiles(value = "mysql-it", inheritProfiles = false) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class AuthPasswordSetupMySqlTest extends AuthPasswordSetupIntegrationTest { + @Container static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("password_setup_test").withUsername("setup_test").withPassword("setup-fixture-only"); + @DynamicPropertySource static void database(DynamicPropertyRegistry r) { + r.add("spring.datasource.url",MYSQL::getJdbcUrl); + r.add("spring.datasource.username",MYSQL::getUsername); + r.add("spring.datasource.password",MYSQL::getPassword); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java index 851dfd7..a5b29ab 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java @@ -57,6 +57,8 @@ class AuthServiceTest { @Mock private Clock clock; + @Mock + private com.mydreamjob.auth.password.PasswordSetupService passwordSetupService; @InjectMocks private AuthService authService; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java index 64294a0..171cf23 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java @@ -52,8 +52,8 @@ public final class UnifiedIdentityJarAcceptanceProbe { JsonNode runtime=call("GET","/v3/api-docs",null,null,200); JsonNode contract=JSON.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); assertThat(operations(runtime)).isEqualTo(operations(contract)); checks++; - assertThat(runtime.path("paths").size()).isEqualTo(197); checks++; - assertThat(operations(runtime)).hasSize(240); checks++; + assertThat(runtime.path("paths").size()).isEqualTo(198); checks++; + assertThat(operations(runtime)).hasSize(241); checks++; JsonNode pending=login("19900009602"); assertThat(pending.path("role").asText()).isEqualTo("UNASSIGNED"); checks++; assertThat(pending.path("identitySelectionRequired").asBoolean()).isTrue(); checks++; @@ -125,9 +125,9 @@ public final class UnifiedIdentityJarAcceptanceProbe { } Files.writeString(Path.of("target/identity-probe-result.json"),JSON.writeValueAsString(Map.of( "checks",checks,"failures",0,"mysql","8.4","redis","7.4","rabbitmq","3", - "paths",197,"operations",240,"smsDelivery","mock sender; not real provider", + "paths",198,"operations",241,"smsDelivery","mock sender; not real provider", "fixtureResidual",0,"auditPolicy","append-only; isolated container lifecycle"))); - System.out.println("Unified identity latest-JAR acceptance PASS: "+checks+" checks; 197 paths/240 operations; fixture residual 0"); + System.out.println("Unified identity latest-JAR acceptance PASS: "+checks+" checks; 198 paths/241 operations; fixture residual 0"); } finally {stop();} } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java index ac3fb32..c87dc24 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java @@ -187,7 +187,7 @@ class UnifiedRegistrationIntegrationTest { "/jobs/compare","/jobs/public/{jobId}","/jobs/{jobId}/reviews","/jobs/{jobId}/reviews/{reviewId}", "/jobs/{jobId}/reviews/summary","/companies/{companyId}","/companies/{companyId}/jobs", "/job-recommendation-taxonomies","/auth/me"); - Set accountPost=Set.of("/auth/identity/select","/auth/logout","/auth/password/set", + Set accountPost=Set.of("/auth/identity/select","/auth/logout","/auth/password/set","/auth/password/setup", "/auth/password/change","/auth/login","/auth/password/login","/auth/password/reset", "/auth/refresh","/auth/sms/send","/auth/company/register"); int denied=0; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/config/MySql57MigrationConfigurationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/config/MySql57MigrationConfigurationTest.java index 3e3bac8..414b184 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/config/MySql57MigrationConfigurationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/config/MySql57MigrationConfigurationTest.java @@ -27,7 +27,7 @@ class MySql57MigrationConfigurationTest { var provider = config.getResourceProvider(); assertThat(provider).isNotNull(); var resources = provider.getResources("V", new String[]{".sql"}); - assertThat(resources).hasSize(53); + assertThat(resources).hasSize(54); for (var resource : resources) { String path = "V49__skill_cloud_boolean_type.sql".equals(resource.getFilename()) ? "db/mysql57/" : "db/migration/"; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java index 9beeb92..a42634c 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java @@ -58,8 +58,8 @@ class LxqModulesMySql57IntegrationTest { JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs")) .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()); JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); - assertThat(operations(runtime)).hasSize(240).isEqualTo(operations(contract)); - assertThat(runtime.path("paths").size()).isEqualTo(197); + assertThat(operations(runtime)).hasSize(241).isEqualTo(operations(contract)); + assertThat(runtime.path("paths").size()).isEqualTo(198); } private Set operations(JsonNode document) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java index 1ca8793..2cfd33b 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java @@ -59,8 +59,8 @@ class LxqModulesMySqlIntegrationTest { JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs")) .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()); JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); - assertThat(operations(runtime)).hasSize(240).isEqualTo(operations(contract)); - assertThat(runtime.path("paths").size()).isEqualTo(197); + assertThat(operations(runtime)).hasSize(241).isEqualTo(operations(contract)); + assertThat(runtime.path("paths").size()).isEqualTo(198); } private Set operations(JsonNode document) {