forked from baskbuddy/zhangshun
25 changed files with 490 additions and 22 deletions
@ -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 保持运行。本批未暂存、提交、推送或部署;真实短信与前端设置密码页面联调仍未执行,不代表线上已有此接口。 |
|||
@ -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]"; } |
|||
} |
|||
@ -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]"; } |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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<PasswordSetupChallenge, Long> { |
|||
@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); |
|||
} |
|||
@ -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); } |
|||
} |
|||
@ -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 |
|||
); |
|||
@ -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<String, Object> redis; |
|||
static final AtomicInteger SEQ = new AtomicInteger(); |
|||
final List<Long> ids = new ArrayList<>(); |
|||
final List<Long> 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<Integer> 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"); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue