mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-09 03:17:52 +00:00
Merge pull request #927 from iflytek/fix/issue-921-secure-account-merge-dco-20261008
fix(auth): require secondary approval before account merge
This commit is contained in:
commit
45228996c7
21 changed files with 959 additions and 131 deletions
|
|
@ -434,10 +434,17 @@ public class OAuthClaimsExtractor {
|
|||
|
||||
同一个员工通过不同 OAuth Provider 登录时,可能产生多个 `user_account`。
|
||||
|
||||
一期策略:默认关闭自动合并,仅支持管理员手动合并。
|
||||
默认不按邮箱自动合并。当前自助合并要求两个账号分别完成身份确认:
|
||||
发起账号创建 30 分钟内有效的请求;待合并账号在自己的已认证会话中查看合并目标并批准;
|
||||
最后发起账号在批准后 30 分钟内确认迁移;任一账号可在完成前撤销请求。
|
||||
请求 ID 只用于定位请求,不是第二个账号的所有权凭据。
|
||||
确认合并时,对两个账号行按 ID 顺序加锁并重新检查状态,防止多个已批准请求并发完成。
|
||||
待合并账号已签发的 API Token 在合并时吊销,不转移给主账号;否则旧 Token 会继承主账号权限。
|
||||
主账号原有 Token 不变,需要自动化访问的调用方应重新签发 Token。
|
||||
账号合并页面当前仍未开放,`/settings/accounts` 保持重定向;上述流程由 API 强制执行。
|
||||
|
||||
- 一期 GitHub-only:不需要自动合并,每个 Provider 登录独立创建用户
|
||||
- 多 Provider 上线时,再引入显式绑定/合并流程(用户主动发起 + 邮箱验证确认)
|
||||
- 多 Provider 场景使用显式绑定/合并流程;单独验证邮箱不能证明同时控制两个账号
|
||||
- 管理员可在后台手动合并两个 user_account(合并 identity_binding、迁移 skill ownership、合并角色取并集)
|
||||
|
||||
合并操作规则:
|
||||
|
|
|
|||
|
|
@ -7,11 +7,14 @@ import com.iflytek.skillhub.dto.ApiResponseFactory;
|
|||
import com.iflytek.skillhub.dto.MergeInitiateRequest;
|
||||
import com.iflytek.skillhub.dto.MergeInitiateResponse;
|
||||
import com.iflytek.skillhub.dto.MergeVerifyRequest;
|
||||
import com.iflytek.skillhub.dto.MergeApprovalDetailsResponse;
|
||||
import com.iflytek.skillhub.dto.MessageResponse;
|
||||
import com.iflytek.skillhub.exception.UnauthorizedException;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
|
@ -42,11 +45,24 @@ public class AccountMergeController extends BaseApiController {
|
|||
return ok("response.success.created", new MergeInitiateResponse(
|
||||
result.mergeRequestId(),
|
||||
result.secondaryUserId(),
|
||||
result.verificationToken(),
|
||||
result.expiresAt().toString()
|
||||
));
|
||||
}
|
||||
|
||||
@GetMapping("/requests/{mergeRequestId}")
|
||||
public ApiResponse<MergeApprovalDetailsResponse> approvalDetails(
|
||||
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
@PathVariable Long mergeRequestId
|
||||
) {
|
||||
if (principal == null) {
|
||||
throw new UnauthorizedException("error.auth.required");
|
||||
}
|
||||
var details = accountMergeService.getApprovalDetails(principal.userId(), mergeRequestId);
|
||||
return ok("response.success.read", new MergeApprovalDetailsResponse(
|
||||
details.mergeRequestId(), details.primaryUserId(), details.primaryDisplayName(), details.expiresAt().toString()
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/verify")
|
||||
public ApiResponse<MessageResponse> verify(@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
@Valid @RequestBody MergeVerifyRequest request) {
|
||||
|
|
@ -55,8 +71,7 @@ public class AccountMergeController extends BaseApiController {
|
|||
}
|
||||
accountMergeService.verify(
|
||||
principal.userId(),
|
||||
request.mergeRequestId(),
|
||||
request.verificationToken()
|
||||
request.mergeRequestId()
|
||||
);
|
||||
return ok("response.success.updated", new MessageResponse("Account merge verified"));
|
||||
}
|
||||
|
|
@ -71,5 +86,16 @@ public class AccountMergeController extends BaseApiController {
|
|||
return ok("response.success.updated", new MessageResponse("Account merge completed"));
|
||||
}
|
||||
|
||||
@PostMapping("/cancel")
|
||||
public ApiResponse<MessageResponse> cancel(@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
@Valid @RequestBody CancelMergeRequest request) {
|
||||
if (principal == null) {
|
||||
throw new UnauthorizedException("error.auth.required");
|
||||
}
|
||||
accountMergeService.cancel(principal.userId(), request.mergeRequestId());
|
||||
return ok("response.success.updated", new MessageResponse("Account merge cancelled"));
|
||||
}
|
||||
|
||||
public record ConfirmMergeRequest(@jakarta.validation.constraints.NotNull Long mergeRequestId) {}
|
||||
public record CancelMergeRequest(@jakarta.validation.constraints.NotNull Long mergeRequestId) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record MergeApprovalDetailsResponse(
|
||||
Long mergeRequestId,
|
||||
String primaryUserId,
|
||||
String primaryDisplayName,
|
||||
String expiresAt
|
||||
) {}
|
||||
|
|
@ -3,6 +3,5 @@ package com.iflytek.skillhub.dto;
|
|||
public record MergeInitiateResponse(
|
||||
Long mergeRequestId,
|
||||
String secondaryUserId,
|
||||
String verificationToken,
|
||||
String expiresAt
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record MergeVerifyRequest(
|
||||
@NotNull(message = "合并请求 ID 不能为空")
|
||||
Long mergeRequestId,
|
||||
@NotBlank(message = "验证 token 不能为空")
|
||||
String verificationToken
|
||||
Long mergeRequestId
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import static org.mockito.Mockito.verify;
|
|||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
|
|
@ -40,11 +41,11 @@ class AccountMergeControllerTest {
|
|||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@Test
|
||||
void initiate_returnsVerificationToken() throws Exception {
|
||||
void initiate_doesNotReturnVerificationToken() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of());
|
||||
var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of());
|
||||
given(accountMergeService.initiate("usr_primary", "secondary"))
|
||||
.willReturn(new AccountMergeService.InitiationResult(1L, "usr_secondary", "merge-token", Instant.parse("2026-03-12T22:30:00Z")));
|
||||
.willReturn(new AccountMergeService.InitiationResult(1L, "usr_secondary", Instant.parse("2026-03-12T22:30:00Z")));
|
||||
|
||||
mockMvc.perform(post("/api/v1/account/merge/initiate")
|
||||
.with(authentication(auth))
|
||||
|
|
@ -57,13 +58,26 @@ class AccountMergeControllerTest {
|
|||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.mergeRequestId").value(1))
|
||||
.andExpect(jsonPath("$.data.secondaryUserId").value("usr_secondary"))
|
||||
.andExpect(jsonPath("$.data.verificationToken").value("merge-token"))
|
||||
.andExpect(jsonPath("$.data.verificationToken").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.expiresAt").value("2026-03-12T22:30:00Z"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvalDetails_returnsDestinationForSecondaryAccount() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal("usr_secondary", "secondary", "s@example.com", "", "local", Set.of());
|
||||
var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of());
|
||||
given(accountMergeService.getApprovalDetails("usr_secondary", 1L))
|
||||
.willReturn(new AccountMergeService.ApprovalDetails(1L, "usr_primary", "Primary Name", Instant.parse("2026-03-12T22:30:00Z")));
|
||||
|
||||
mockMvc.perform(get("/api/v1/account/merge/requests/1").with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.primaryUserId").value("usr_primary"))
|
||||
.andExpect(jsonPath("$.data.primaryDisplayName").value("Primary Name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void verify_returnsSuccessMessage() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of());
|
||||
PlatformPrincipal principal = new PlatformPrincipal("usr_secondary", "secondary", "s@example.com", "", "local", Set.of());
|
||||
var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")));
|
||||
|
||||
mockMvc.perform(post("/api/v1/account/merge/verify")
|
||||
|
|
@ -71,13 +85,13 @@ class AccountMergeControllerTest {
|
|||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"mergeRequestId":1,"verificationToken":"merge-token"}
|
||||
{"mergeRequestId":1}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.message").value("Account merge verified"));
|
||||
|
||||
verify(accountMergeService).verify("usr_primary", 1L, "merge-token");
|
||||
verify(accountMergeService).verify("usr_secondary", 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -98,4 +112,22 @@ class AccountMergeControllerTest {
|
|||
|
||||
verify(accountMergeService).confirm("usr_primary", 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancel_usesAuthenticatedAccount() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal("usr_secondary", "secondary", "s@example.com", "", "local", Set.of());
|
||||
var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of());
|
||||
|
||||
mockMvc.perform(post("/api/v1/account/merge/cancel")
|
||||
.with(authentication(auth))
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"mergeRequestId":1}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.message").value("Account merge cancelled"));
|
||||
|
||||
verify(accountMergeService).cancel("usr_secondary", 1L);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,425 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.auth.local.LocalCredential;
|
||||
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
|
||||
import com.iflytek.skillhub.auth.merge.AccountMergeRequest;
|
||||
import com.iflytek.skillhub.auth.merge.AccountMergeRequestRepository;
|
||||
import com.iflytek.skillhub.auth.merge.AccountMergeService;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.token.ApiTokenService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserStatus;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
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.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
@Testcontainers
|
||||
class AccountMergeFlowIntegrationTest {
|
||||
|
||||
@Container
|
||||
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
|
||||
|
||||
@DynamicPropertySource
|
||||
static void configurePostgres(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", POSTGRES::getUsername);
|
||||
registry.add("spring.datasource.password", POSTGRES::getPassword);
|
||||
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
|
||||
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect");
|
||||
registry.add("spring.flyway.enabled", () -> true);
|
||||
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
|
||||
registry.add("skillhub.builtin-skills.enabled", () -> false);
|
||||
}
|
||||
|
||||
@Autowired private MockMvc mockMvc;
|
||||
@Autowired private ObjectMapper objectMapper;
|
||||
@Autowired private UserAccountRepository userAccountRepository;
|
||||
@Autowired private LocalCredentialRepository localCredentialRepository;
|
||||
@Autowired private AccountMergeRequestRepository mergeRequestRepository;
|
||||
@Autowired private AccountMergeService mergeService;
|
||||
@Autowired private ApiTokenService apiTokenService;
|
||||
@Autowired private JdbcTemplate jdbcTemplate;
|
||||
@Autowired private PlatformTransactionManager transactionManager;
|
||||
@MockBean private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@Test
|
||||
void secondaryAccountMustApproveBeforeInitiatorCanMerge() throws Exception {
|
||||
String suffix = UUID.randomUUID().toString();
|
||||
String primaryId = "merge-primary-" + suffix;
|
||||
String secondaryId = "merge-secondary-" + suffix;
|
||||
String secondaryUsername = "merge-" + suffix;
|
||||
userAccountRepository.save(new UserAccount(primaryId, "Primary", null, null));
|
||||
userAccountRepository.save(new UserAccount(secondaryId, "Secondary", null, null));
|
||||
localCredentialRepository.save(new LocalCredential(secondaryId, secondaryUsername, "hash"));
|
||||
String secondaryToken = apiTokenService.createToken(secondaryId, "secondary-automation", "[]").rawToken();
|
||||
String primaryToken = apiTokenService.createToken(primaryId, "primary-automation", "[]").rawToken();
|
||||
|
||||
String initiateResponse = mockMvc.perform(post("/api/v1/account/merge/initiate")
|
||||
.with(authentication(auth(primaryId)))
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(java.util.Map.of("secondaryIdentifier", secondaryUsername))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.verificationToken").doesNotExist())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
long requestId = objectMapper.readTree(initiateResponse).path("data").path("mergeRequestId").asLong();
|
||||
String requestBody = objectMapper.writeValueAsString(java.util.Map.of("mergeRequestId", requestId));
|
||||
|
||||
mockMvc.perform(get("/api/v1/account/merge/requests/{id}", requestId)
|
||||
.with(authentication(auth(primaryId))))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/v1/account/merge/verify")
|
||||
.with(authentication(auth(primaryId))).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(requestBody))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(get("/api/v1/account/merge/requests/{id}", requestId)
|
||||
.with(authentication(auth(secondaryId))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.primaryUserId").value(primaryId));
|
||||
mockMvc.perform(post("/api/v1/account/merge/verify")
|
||||
.with(authentication(auth(secondaryId))).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(requestBody))
|
||||
.andExpect(status().isOk());
|
||||
mockMvc.perform(post("/api/v1/account/merge/confirm")
|
||||
.with(authentication(auth(secondaryId))).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(requestBody))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/v1/account/merge/confirm")
|
||||
.with(authentication(auth(primaryId))).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(requestBody))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
assertThat(userAccountRepository.findById(secondaryId).orElseThrow().getStatus()).isEqualTo(UserStatus.MERGED);
|
||||
assertThat(localCredentialRepository.findByUsernameIgnoreCase(secondaryUsername).orElseThrow().getUserId())
|
||||
.isEqualTo(primaryId);
|
||||
assertThat(apiTokenService.validateToken(secondaryToken)).isEmpty();
|
||||
assertThat(apiTokenService.validateToken(primaryToken)).isPresent();
|
||||
mockMvc.perform(get("/api/v1/auth/me").header("Authorization", "Bearer " + secondaryToken))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void directApiCallsCannotSpoofAnotherAccountOrBypassCsrf() throws Exception {
|
||||
String suffix = UUID.randomUUID().toString();
|
||||
String primaryId = "merge-primary-" + suffix;
|
||||
String secondaryId = "merge-secondary-" + suffix;
|
||||
String outsiderId = "merge-outsider-" + suffix;
|
||||
String secondaryUsername = "merge-" + suffix;
|
||||
userAccountRepository.save(new UserAccount(primaryId, "Primary", null, null));
|
||||
userAccountRepository.save(new UserAccount(secondaryId, "Secondary", null, null));
|
||||
userAccountRepository.save(new UserAccount(outsiderId, "Outsider", null, null));
|
||||
localCredentialRepository.save(new LocalCredential(secondaryId, secondaryUsername, "hash"));
|
||||
|
||||
String initiateResponse = mockMvc.perform(post("/api/v1/account/merge/initiate")
|
||||
.with(authentication(auth(primaryId))).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(java.util.Map.of(
|
||||
"primaryUserId", outsiderId, "secondaryIdentifier", secondaryUsername))))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
long requestId = objectMapper.readTree(initiateResponse).path("data").path("mergeRequestId").asLong();
|
||||
AccountMergeRequest request = mergeRequestRepository.findById(requestId).orElseThrow();
|
||||
assertThat(request.getPrimaryUserId()).isEqualTo(primaryId);
|
||||
String spoofedBody = objectMapper.writeValueAsString(java.util.Map.of(
|
||||
"mergeRequestId", requestId, "secondaryUserId", secondaryId, "primaryUserId", primaryId));
|
||||
|
||||
mockMvc.perform(get("/api/v1/account/merge/requests/{id}", requestId))
|
||||
.andExpect(status().isUnauthorized());
|
||||
mockMvc.perform(get("/api/v1/account/merge/requests/{id}", requestId)
|
||||
.with(authentication(auth(outsiderId))))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/v1/account/merge/verify")
|
||||
.with(authentication(auth(outsiderId))).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(spoofedBody))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/v1/account/merge/confirm")
|
||||
.with(authentication(auth(outsiderId))).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(spoofedBody))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/v1/account/merge/cancel")
|
||||
.with(authentication(auth(outsiderId))).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(spoofedBody))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/v1/account/merge/verify")
|
||||
.with(authentication(auth(secondaryId)))
|
||||
.contentType(MediaType.APPLICATION_JSON).content(spoofedBody))
|
||||
.andExpect(status().is4xxClientError());
|
||||
assertThat(mergeRequestRepository.findById(requestId).orElseThrow().getStatus())
|
||||
.isEqualTo(AccountMergeRequest.STATUS_PENDING);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredRequestCanBeReplacedUnderThePartialUniqueIndex() throws Exception {
|
||||
String suffix = UUID.randomUUID().toString();
|
||||
String primaryId = "merge-primary-" + suffix;
|
||||
String secondaryId = "merge-secondary-" + suffix;
|
||||
String secondaryUsername = "merge-" + suffix;
|
||||
userAccountRepository.save(new UserAccount(primaryId, "Primary", null, null));
|
||||
userAccountRepository.save(new UserAccount(secondaryId, "Secondary", null, null));
|
||||
localCredentialRepository.save(new LocalCredential(secondaryId, secondaryUsername, "hash"));
|
||||
AccountMergeRequest expired = mergeRequestRepository.save(new AccountMergeRequest(
|
||||
primaryId, secondaryId, null, Instant.now().minusSeconds(1)));
|
||||
|
||||
Integer indexCount = jdbcTemplate.queryForObject(
|
||||
"select count(*) from pg_indexes where indexname = 'idx_merge_secondary_pending'", Integer.class);
|
||||
assertThat(indexCount).isEqualTo(1);
|
||||
|
||||
String response = mockMvc.perform(post("/api/v1/account/merge/initiate")
|
||||
.with(authentication(auth(primaryId))).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(java.util.Map.of("secondaryIdentifier", secondaryUsername))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.verificationToken").doesNotExist())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
long newRequestId = objectMapper.readTree(response).path("data").path("mergeRequestId").asLong();
|
||||
|
||||
assertThat(newRequestId).isNotEqualTo(expired.getId());
|
||||
assertThat(mergeRequestRepository.findById(expired.getId()).orElseThrow().getStatus())
|
||||
.isEqualTo(AccountMergeRequest.STATUS_CANCELLED);
|
||||
assertThat(mergeRequestRepository.findById(newRequestId).orElseThrow().getStatus())
|
||||
.isEqualTo(AccountMergeRequest.STATUS_PENDING);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelAndConfirmCannotBothWin() throws Exception {
|
||||
String suffix = UUID.randomUUID().toString();
|
||||
String primaryId = "merge-primary-" + suffix;
|
||||
String secondaryId = "merge-secondary-" + suffix;
|
||||
String secondaryUsername = "merge-" + suffix;
|
||||
userAccountRepository.save(new UserAccount(primaryId, "Primary", null, null));
|
||||
userAccountRepository.save(new UserAccount(secondaryId, "Secondary", null, null));
|
||||
localCredentialRepository.save(new LocalCredential(secondaryId, secondaryUsername, "hash"));
|
||||
AccountMergeRequest request = new AccountMergeRequest(
|
||||
primaryId, secondaryId, null, Instant.now().plusSeconds(1800));
|
||||
request.setStatus(AccountMergeRequest.STATUS_VERIFIED);
|
||||
long requestId = mergeRequestRepository.save(request).getId();
|
||||
|
||||
CountDownLatch locked = new CountDownLatch(1);
|
||||
CountDownLatch releaseLock = new CountDownLatch(1);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
Future<?> lockHolder = executor.submit(() -> new TransactionTemplate(transactionManager).execute(status -> {
|
||||
jdbcTemplate.execute("set local lock_timeout = '5s'");
|
||||
jdbcTemplate.queryForObject(
|
||||
"select id from account_merge_request where id = ? for update", Long.class, requestId);
|
||||
locked.countDown();
|
||||
try {
|
||||
releaseLock.await();
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
if (!locked.await(10, TimeUnit.SECONDS)) {
|
||||
releaseLock.countDown();
|
||||
lockHolder.cancel(true);
|
||||
throw new AssertionError("Could not acquire the fixture row lock");
|
||||
}
|
||||
Future<Boolean> confirm = executor.submit(() -> {
|
||||
start.await();
|
||||
try {
|
||||
mergeService.confirm(primaryId, requestId);
|
||||
return true;
|
||||
} catch (com.iflytek.skillhub.auth.exception.AuthFlowException expected) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
Future<Boolean> cancel = executor.submit(() -> {
|
||||
start.await();
|
||||
try {
|
||||
mergeService.cancel(secondaryId, requestId);
|
||||
return true;
|
||||
} catch (com.iflytek.skillhub.auth.exception.AuthFlowException expected) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
start.countDown();
|
||||
int waiting = 0;
|
||||
try {
|
||||
for (int attempt = 0; attempt < 50 && waiting < 2; attempt++) {
|
||||
waiting = jdbcTemplate.queryForObject(
|
||||
"select count(*) from pg_stat_activity where wait_event_type = 'Lock' "
|
||||
+ "and query like '%account_merge_request%'", Integer.class);
|
||||
if (waiting < 2) {
|
||||
Thread.sleep(100);
|
||||
}
|
||||
}
|
||||
assertThat(waiting).isGreaterThanOrEqualTo(2);
|
||||
} finally {
|
||||
releaseLock.countDown();
|
||||
}
|
||||
lockHolder.get();
|
||||
assertThat(confirm.get()).isNotEqualTo(cancel.get());
|
||||
}
|
||||
|
||||
String finalStatus = mergeRequestRepository.findById(requestId).orElseThrow().getStatus();
|
||||
String credentialOwner = localCredentialRepository.findByUsernameIgnoreCase(secondaryUsername)
|
||||
.orElseThrow().getUserId();
|
||||
if (AccountMergeRequest.STATUS_COMPLETED.equals(finalStatus)) {
|
||||
assertThat(userAccountRepository.findById(secondaryId).orElseThrow().getStatus())
|
||||
.isEqualTo(UserStatus.MERGED);
|
||||
assertThat(credentialOwner).isEqualTo(primaryId);
|
||||
} else {
|
||||
assertThat(finalStatus).isEqualTo(AccountMergeRequest.STATUS_CANCELLED);
|
||||
assertThat(userAccountRepository.findById(secondaryId).orElseThrow().getStatus())
|
||||
.isEqualTo(UserStatus.ACTIVE);
|
||||
assertThat(credentialOwner).isEqualTo(secondaryId);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoApprovedDestinationsCannotBothMergeTheSameAccount() throws Exception {
|
||||
String suffix = UUID.randomUUID().toString();
|
||||
String firstPrimaryId = "merge-first-" + suffix;
|
||||
String secondPrimaryId = "merge-second-" + suffix;
|
||||
String secondaryId = "merge-secondary-" + suffix;
|
||||
String secondaryUsername = "merge-" + suffix;
|
||||
userAccountRepository.save(new UserAccount(firstPrimaryId, "First", null, null));
|
||||
userAccountRepository.save(new UserAccount(secondPrimaryId, "Second", null, null));
|
||||
userAccountRepository.save(new UserAccount(secondaryId, "Secondary", null, null));
|
||||
localCredentialRepository.save(new LocalCredential(secondaryId, secondaryUsername, "hash"));
|
||||
AccountMergeRequest first = new AccountMergeRequest(
|
||||
firstPrimaryId, secondaryId, null, Instant.now().plusSeconds(1800));
|
||||
first.setStatus(AccountMergeRequest.STATUS_VERIFIED);
|
||||
AccountMergeRequest second = new AccountMergeRequest(
|
||||
secondPrimaryId, secondaryId, null, Instant.now().plusSeconds(1800));
|
||||
second.setStatus(AccountMergeRequest.STATUS_VERIFIED);
|
||||
long firstId = mergeRequestRepository.save(first).getId();
|
||||
long secondId = mergeRequestRepository.save(second).getId();
|
||||
|
||||
CountDownLatch locked = new CountDownLatch(1);
|
||||
CountDownLatch releaseLock = new CountDownLatch(1);
|
||||
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
Future<?> lockHolder = executor.submit(() -> new TransactionTemplate(transactionManager).execute(status -> {
|
||||
jdbcTemplate.execute("set local lock_timeout = '5s'");
|
||||
jdbcTemplate.queryForObject(
|
||||
"select id from user_account where id = ? for update", String.class, secondaryId);
|
||||
locked.countDown();
|
||||
try {
|
||||
releaseLock.await();
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
if (!locked.await(10, TimeUnit.SECONDS)) {
|
||||
releaseLock.countDown();
|
||||
lockHolder.cancel(true);
|
||||
throw new AssertionError("Could not acquire the account row lock");
|
||||
}
|
||||
Future<Boolean> firstConfirm = executor.submit(() -> confirmOrReject(firstPrimaryId, firstId));
|
||||
Future<Boolean> secondConfirm = executor.submit(() -> confirmOrReject(secondPrimaryId, secondId));
|
||||
int waiting = 0;
|
||||
try {
|
||||
for (int attempt = 0; attempt < 50 && waiting < 2; attempt++) {
|
||||
waiting = jdbcTemplate.queryForObject(
|
||||
"select count(*) from pg_stat_activity where wait_event_type = 'Lock' "
|
||||
+ "and query like '%user_account%'", Integer.class);
|
||||
if (waiting < 2) {
|
||||
Thread.sleep(100);
|
||||
}
|
||||
}
|
||||
assertThat(waiting).isGreaterThanOrEqualTo(2);
|
||||
} finally {
|
||||
releaseLock.countDown();
|
||||
}
|
||||
lockHolder.get();
|
||||
assertThat(firstConfirm.get()).isNotEqualTo(secondConfirm.get());
|
||||
}
|
||||
|
||||
String winner = localCredentialRepository.findByUsernameIgnoreCase(secondaryUsername)
|
||||
.orElseThrow().getUserId();
|
||||
assertThat(winner).isIn(firstPrimaryId, secondPrimaryId);
|
||||
assertThat(userAccountRepository.findById(secondaryId).orElseThrow().getMergedToUserId())
|
||||
.isEqualTo(winner);
|
||||
assertThat(List.of(firstId, secondId).stream()
|
||||
.filter(id -> AccountMergeRequest.STATUS_COMPLETED.equals(
|
||||
mergeRequestRepository.findById(id).orElseThrow().getStatus())).count()).isEqualTo(1);
|
||||
}
|
||||
|
||||
private boolean confirmOrReject(String primaryUserId, long requestId) {
|
||||
try {
|
||||
mergeService.confirm(primaryUserId, requestId);
|
||||
return true;
|
||||
} catch (com.iflytek.skillhub.auth.exception.AuthFlowException expected) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondaryCancellationAfterApprovalBlocksConfirmation() throws Exception {
|
||||
String suffix = UUID.randomUUID().toString();
|
||||
String primaryId = "merge-primary-" + suffix;
|
||||
String secondaryId = "merge-secondary-" + suffix;
|
||||
String secondaryUsername = "merge-" + suffix;
|
||||
userAccountRepository.save(new UserAccount(primaryId, "Primary", null, null));
|
||||
userAccountRepository.save(new UserAccount(secondaryId, "Secondary", null, null));
|
||||
localCredentialRepository.save(new LocalCredential(secondaryId, secondaryUsername, "hash"));
|
||||
AccountMergeRequest request = mergeRequestRepository.save(new AccountMergeRequest(
|
||||
primaryId, secondaryId, null, Instant.now().plusSeconds(1800)));
|
||||
String body = objectMapper.writeValueAsString(java.util.Map.of("mergeRequestId", request.getId()));
|
||||
|
||||
mockMvc.perform(post("/api/v1/account/merge/verify")
|
||||
.with(authentication(auth(secondaryId))).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(body))
|
||||
.andExpect(status().isOk());
|
||||
mockMvc.perform(post("/api/v1/account/merge/cancel")
|
||||
.with(authentication(auth(secondaryId))).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(body))
|
||||
.andExpect(status().isOk());
|
||||
mockMvc.perform(post("/api/v1/account/merge/confirm")
|
||||
.with(authentication(auth(primaryId))).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(body))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
assertThat(mergeRequestRepository.findById(request.getId()).orElseThrow().getStatus())
|
||||
.isEqualTo(AccountMergeRequest.STATUS_CANCELLED);
|
||||
assertThat(userAccountRepository.findById(secondaryId).orElseThrow().getStatus())
|
||||
.isEqualTo(UserStatus.ACTIVE);
|
||||
assertThat(localCredentialRepository.findByUsernameIgnoreCase(secondaryUsername).orElseThrow().getUserId())
|
||||
.isEqualTo(secondaryId);
|
||||
}
|
||||
|
||||
private static UsernamePasswordAuthenticationToken auth(String userId) {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(userId, userId, null, "", "local", Set.of());
|
||||
return new UsernamePasswordAuthenticationToken(principal, null, List.of());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
package com.iflytek.skillhub.auth.merge;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
|
|
@ -10,7 +14,13 @@ import org.springframework.stereotype.Repository;
|
|||
@Repository
|
||||
public interface AccountMergeRequestRepository extends JpaRepository<AccountMergeRequest, Long> {
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
Optional<AccountMergeRequest> findByIdAndPrimaryUserId(Long id, String primaryUserId);
|
||||
|
||||
boolean existsBySecondaryUserIdAndStatus(String secondaryUserId, String status);
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select request from AccountMergeRequest request where request.id = :id")
|
||||
Optional<AccountMergeRequest> findLockedById(@Param("id") Long id);
|
||||
|
||||
Optional<AccountMergeRequest> findBySecondaryUserIdAndStatus(String secondaryUserId, String status);
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,9 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
|||
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.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
|
@ -28,7 +26,6 @@ import java.util.Locale;
|
|||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
|
|
@ -52,9 +49,7 @@ public class AccountMergeService {
|
|||
private final UserRoleBindingRepository userRoleBindingRepository;
|
||||
private final ApiTokenRepository apiTokenRepository;
|
||||
private final NamespaceMemberRepository namespaceMemberRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final Clock clock;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public AccountMergeService(AccountMergeRequestRepository mergeRequestRepository,
|
||||
UserAccountRepository userAccountRepository,
|
||||
|
|
@ -63,7 +58,6 @@ public class AccountMergeService {
|
|||
UserRoleBindingRepository userRoleBindingRepository,
|
||||
ApiTokenRepository apiTokenRepository,
|
||||
NamespaceMemberRepository namespaceMemberRepository,
|
||||
PasswordEncoder passwordEncoder,
|
||||
Clock clock) {
|
||||
this.mergeRequestRepository = mergeRequestRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
|
|
@ -72,11 +66,12 @@ public class AccountMergeService {
|
|||
this.userRoleBindingRepository = userRoleBindingRepository;
|
||||
this.apiTokenRepository = apiTokenRepository;
|
||||
this.namespaceMemberRepository = namespaceMemberRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public record InitiationResult(Long mergeRequestId, String secondaryUserId, String verificationToken, Instant expiresAt) {}
|
||||
public record InitiationResult(Long mergeRequestId, String secondaryUserId, Instant expiresAt) {}
|
||||
|
||||
public record ApprovalDetails(Long mergeRequestId, String primaryUserId, String primaryDisplayName, Instant expiresAt) {}
|
||||
|
||||
@Transactional
|
||||
public InitiationResult initiate(String primaryUserId, String secondaryIdentifier) {
|
||||
|
|
@ -84,12 +79,15 @@ public class AccountMergeService {
|
|||
UserAccount secondaryUser = resolveSecondaryUser(secondaryIdentifier);
|
||||
validateMergePair(primaryUser, secondaryUser);
|
||||
|
||||
if (mergeRequestRepository.existsBySecondaryUserIdAndStatus(
|
||||
secondaryUser.getId(),
|
||||
AccountMergeRequest.STATUS_PENDING
|
||||
)) {
|
||||
throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.pendingExists");
|
||||
}
|
||||
mergeRequestRepository.findBySecondaryUserIdAndStatus(secondaryUser.getId(), AccountMergeRequest.STATUS_PENDING)
|
||||
.ifPresent(existing -> {
|
||||
if (existing.getTokenExpiresAt() != null && !existing.getTokenExpiresAt().isBefore(currentTime())) {
|
||||
throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.pendingExists");
|
||||
}
|
||||
existing.setStatus(AccountMergeRequest.STATUS_CANCELLED);
|
||||
existing.setVerificationToken(null);
|
||||
mergeRequestRepository.saveAndFlush(existing);
|
||||
});
|
||||
|
||||
Optional<LocalCredential> primaryCredential = localCredentialRepository.findByUserId(primaryUserId);
|
||||
Optional<LocalCredential> secondaryCredential = localCredentialRepository.findByUserId(secondaryUser.getId());
|
||||
|
|
@ -97,38 +95,55 @@ public class AccountMergeService {
|
|||
throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.localCredentialConflict");
|
||||
}
|
||||
|
||||
String rawToken = generateVerificationToken();
|
||||
AccountMergeRequest request = new AccountMergeRequest(
|
||||
primaryUserId,
|
||||
secondaryUser.getId(),
|
||||
passwordEncoder.encode(rawToken),
|
||||
null,
|
||||
currentTime().plus(Duration.ofMinutes(30))
|
||||
);
|
||||
request = mergeRequestRepository.save(request);
|
||||
return new InitiationResult(request.getId(), secondaryUser.getId(), rawToken, request.getTokenExpiresAt());
|
||||
return new InitiationResult(request.getId(), secondaryUser.getId(), request.getTokenExpiresAt());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ApprovalDetails getApprovalDetails(String secondaryUserId, Long mergeRequestId) {
|
||||
AccountMergeRequest request = loadPendingApproval(secondaryUserId, mergeRequestId);
|
||||
UserAccount primaryUser = loadActiveUser(request.getPrimaryUserId());
|
||||
return new ApprovalDetails(request.getId(), primaryUser.getId(), primaryUser.getDisplayName(), request.getTokenExpiresAt());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void verify(String primaryUserId, Long mergeRequestId, String verificationToken) {
|
||||
AccountMergeRequest request = mergeRequestRepository.findByIdAndPrimaryUserId(mergeRequestId, primaryUserId)
|
||||
public void verify(String secondaryUserId, Long mergeRequestId) {
|
||||
AccountMergeRequest request = loadPendingApproval(secondaryUserId, mergeRequestId, true);
|
||||
loadActiveUser(request.getPrimaryUserId());
|
||||
request.setStatus(AccountMergeRequest.STATUS_VERIFIED);
|
||||
request.setTokenExpiresAt(currentTime().plus(Duration.ofMinutes(30)));
|
||||
request.setVerificationToken(null);
|
||||
mergeRequestRepository.save(request);
|
||||
}
|
||||
|
||||
private AccountMergeRequest loadPendingApproval(String secondaryUserId, Long mergeRequestId) {
|
||||
return loadPendingApproval(secondaryUserId, mergeRequestId, false);
|
||||
}
|
||||
|
||||
private AccountMergeRequest loadPendingApproval(String secondaryUserId, Long mergeRequestId, boolean lock) {
|
||||
AccountMergeRequest request = (lock
|
||||
? mergeRequestRepository.findLockedById(mergeRequestId)
|
||||
: mergeRequestRepository.findById(mergeRequestId))
|
||||
.orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound"));
|
||||
if (!request.getSecondaryUserId().equals(secondaryUserId)) {
|
||||
throw new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound");
|
||||
}
|
||||
if (!AccountMergeRequest.STATUS_PENDING.equals(request.getStatus())) {
|
||||
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.requestNotPending");
|
||||
}
|
||||
if (request.getTokenExpiresAt() == null || request.getTokenExpiresAt().isBefore(currentTime())) {
|
||||
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.tokenExpired");
|
||||
}
|
||||
if (!passwordEncoder.matches(verificationToken, request.getVerificationToken())) {
|
||||
throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.merge.invalidToken");
|
||||
}
|
||||
|
||||
loadActiveUser(primaryUserId);
|
||||
UserAccount secondaryUser = userAccountRepository.findById(request.getSecondaryUserId())
|
||||
.orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound"));
|
||||
validateMergePair(loadActiveUser(primaryUserId), secondaryUser);
|
||||
|
||||
request.setStatus(AccountMergeRequest.STATUS_VERIFIED);
|
||||
mergeRequestRepository.save(request);
|
||||
UserAccount secondaryUser = loadActiveUser(secondaryUserId);
|
||||
UserAccount primaryUser = loadActiveUser(request.getPrimaryUserId());
|
||||
validateMergePair(primaryUser, secondaryUser);
|
||||
return request;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
|
@ -138,14 +153,26 @@ public class AccountMergeService {
|
|||
if (!AccountMergeRequest.STATUS_VERIFIED.equals(request.getStatus())) {
|
||||
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.requestNotVerified");
|
||||
}
|
||||
if (request.getTokenExpiresAt() == null || request.getTokenExpiresAt().isBefore(currentTime())) {
|
||||
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.tokenExpired");
|
||||
}
|
||||
|
||||
UserAccount primaryUser = loadActiveUser(primaryUserId);
|
||||
UserAccount secondaryUser = userAccountRepository.findById(request.getSecondaryUserId())
|
||||
.orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound"));
|
||||
String secondaryUserId = request.getSecondaryUserId();
|
||||
String firstUserId = primaryUserId.compareTo(secondaryUserId) < 0 ? primaryUserId : secondaryUserId;
|
||||
String secondUserId = primaryUserId.compareTo(secondaryUserId) < 0 ? secondaryUserId : primaryUserId;
|
||||
UserAccount firstUser = userAccountRepository.findLockedById(firstUserId)
|
||||
.orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound"));
|
||||
UserAccount secondUser = userAccountRepository.findLockedById(secondUserId)
|
||||
.orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound"));
|
||||
UserAccount primaryUser = firstUser.getId().equals(primaryUserId) ? firstUser : secondUser;
|
||||
UserAccount secondaryUser = firstUser.getId().equals(secondaryUserId) ? firstUser : secondUser;
|
||||
if (primaryUser.getStatus() != UserStatus.ACTIVE) {
|
||||
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.primaryNotActive");
|
||||
}
|
||||
validateMergePair(primaryUser, secondaryUser);
|
||||
|
||||
migrateIdentityBindings(primaryUser.getId(), secondaryUser.getId());
|
||||
migrateApiTokens(primaryUser.getId(), secondaryUser.getId());
|
||||
revokeSecondaryApiTokens(secondaryUser.getId());
|
||||
migrateUserRoles(primaryUser.getId(), secondaryUser.getId());
|
||||
migrateNamespaceMemberships(primaryUser.getId(), secondaryUser.getId());
|
||||
migrateLocalCredential(primaryUser.getId(), secondaryUser.getId());
|
||||
|
|
@ -166,6 +193,23 @@ public class AccountMergeService {
|
|||
mergeRequestRepository.save(request);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void cancel(String actorUserId, Long mergeRequestId) {
|
||||
AccountMergeRequest request = mergeRequestRepository.findLockedById(mergeRequestId)
|
||||
.orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound"));
|
||||
if (!actorUserId.equals(request.getPrimaryUserId()) && !actorUserId.equals(request.getSecondaryUserId())) {
|
||||
throw new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound");
|
||||
}
|
||||
if (AccountMergeRequest.STATUS_COMPLETED.equals(request.getStatus())) {
|
||||
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.requestNotPending");
|
||||
}
|
||||
if (!AccountMergeRequest.STATUS_CANCELLED.equals(request.getStatus())) {
|
||||
request.setStatus(AccountMergeRequest.STATUS_CANCELLED);
|
||||
request.setVerificationToken(null);
|
||||
mergeRequestRepository.save(request);
|
||||
}
|
||||
}
|
||||
|
||||
private UserAccount resolveSecondaryUser(String identifier) {
|
||||
String normalized = identifier == null ? "" : identifier.trim();
|
||||
if (normalized.isBlank()) {
|
||||
|
|
@ -214,12 +258,11 @@ public class AccountMergeService {
|
|||
identityBindingRepository.saveAll(bindings);
|
||||
}
|
||||
|
||||
private void migrateApiTokens(String primaryUserId, String secondaryUserId) {
|
||||
private void revokeSecondaryApiTokens(String secondaryUserId) {
|
||||
List<ApiToken> tokens = apiTokenRepository.findByUserId(secondaryUserId);
|
||||
for (ApiToken token : tokens) {
|
||||
token.setUserId(primaryUserId);
|
||||
if ("USER".equals(token.getSubjectType())) {
|
||||
token.setSubjectId(primaryUserId);
|
||||
if (token.getRevokedAt() == null) {
|
||||
token.setRevokedAt(currentTime());
|
||||
}
|
||||
}
|
||||
apiTokenRepository.saveAll(tokens);
|
||||
|
|
@ -276,12 +319,6 @@ public class AccountMergeService {
|
|||
});
|
||||
}
|
||||
|
||||
private String generateVerificationToken() {
|
||||
byte[] tokenBytes = new byte[24];
|
||||
secureRandom.nextBytes(tokenBytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
|
||||
}
|
||||
|
||||
private Instant currentTime() {
|
||||
return Instant.now(clock);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ import org.junit.jupiter.api.Test;
|
|||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AccountMergeServiceTest {
|
||||
|
|
@ -53,9 +52,6 @@ class AccountMergeServiceTest {
|
|||
private ApiTokenRepository apiTokenRepository;
|
||||
@Mock
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
@Mock
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
private AccountMergeService service;
|
||||
private Clock clock;
|
||||
|
||||
|
|
@ -70,7 +66,6 @@ class AccountMergeServiceTest {
|
|||
userRoleBindingRepository,
|
||||
apiTokenRepository,
|
||||
namespaceMemberRepository,
|
||||
passwordEncoder,
|
||||
clock
|
||||
);
|
||||
}
|
||||
|
|
@ -83,41 +78,92 @@ class AccountMergeServiceTest {
|
|||
given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary));
|
||||
given(localCredentialRepository.findByUsernameIgnoreCase("secondary")).willReturn(Optional.of(secondaryCredential));
|
||||
given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary));
|
||||
given(mergeRequestRepository.existsBySecondaryUserIdAndStatus("usr_secondary", AccountMergeRequest.STATUS_PENDING))
|
||||
.willReturn(false);
|
||||
given(mergeRequestRepository.findBySecondaryUserIdAndStatus("usr_secondary", AccountMergeRequest.STATUS_PENDING))
|
||||
.willReturn(Optional.empty());
|
||||
given(localCredentialRepository.findByUserId("usr_primary")).willReturn(Optional.empty());
|
||||
given(localCredentialRepository.findByUserId("usr_secondary")).willReturn(Optional.of(secondaryCredential));
|
||||
given(passwordEncoder.encode(any())).willReturn("encoded-token");
|
||||
given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
var result = service.initiate("usr_primary", "secondary");
|
||||
|
||||
assertThat(result.secondaryUserId()).isEqualTo("usr_secondary");
|
||||
assertThat(result.verificationToken()).isNotBlank();
|
||||
assertThat(result.expiresAt()).isEqualTo(Instant.parse("2026-03-18T00:30:00Z"));
|
||||
verify(mergeRequestRepository).save(any(AccountMergeRequest.class));
|
||||
org.mockito.ArgumentCaptor<AccountMergeRequest> saved = org.mockito.ArgumentCaptor.forClass(AccountMergeRequest.class);
|
||||
verify(mergeRequestRepository).save(saved.capture());
|
||||
assertThat(saved.getValue().getVerificationToken()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void verify_marksRequestVerifiedWhenTokenMatches() throws Exception {
|
||||
void initiate_cancelsExpiredPendingRequestBeforeRetry() throws Exception {
|
||||
UserAccount primary = new UserAccount("usr_primary", "primary", null, null);
|
||||
UserAccount secondary = new UserAccount("usr_secondary", "secondary", null, null);
|
||||
LocalCredential credential = new LocalCredential("usr_secondary", "secondary", "hash");
|
||||
AccountMergeRequest expired = request("usr_primary", "usr_secondary", null);
|
||||
expired.setTokenExpiresAt(Instant.parse("2026-03-17T23:59:00Z"));
|
||||
given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary));
|
||||
given(localCredentialRepository.findByUsernameIgnoreCase("secondary")).willReturn(Optional.of(credential));
|
||||
given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary));
|
||||
given(mergeRequestRepository.findBySecondaryUserIdAndStatus("usr_secondary", AccountMergeRequest.STATUS_PENDING))
|
||||
.willReturn(Optional.of(expired));
|
||||
given(localCredentialRepository.findByUserId("usr_primary")).willReturn(Optional.empty());
|
||||
given(localCredentialRepository.findByUserId("usr_secondary")).willReturn(Optional.of(credential));
|
||||
given(mergeRequestRepository.saveAndFlush(expired)).willReturn(expired);
|
||||
given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
service.initiate("usr_primary", "secondary");
|
||||
|
||||
assertThat(expired.getStatus()).isEqualTo(AccountMergeRequest.STATUS_CANCELLED);
|
||||
verify(mergeRequestRepository).saveAndFlush(expired);
|
||||
}
|
||||
|
||||
@Test
|
||||
void verify_marksRequestVerifiedForSecondaryAccount() throws Exception {
|
||||
UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null);
|
||||
UserAccount secondary = new UserAccount("usr_secondary", "secondary", "", null);
|
||||
AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded");
|
||||
|
||||
given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request));
|
||||
given(mergeRequestRepository.findLockedById(7L)).willReturn(Optional.of(request));
|
||||
given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary));
|
||||
given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary));
|
||||
given(passwordEncoder.matches("raw-token", "encoded")).willReturn(true);
|
||||
given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
service.verify("usr_primary", 7L, "raw-token");
|
||||
service.verify("usr_secondary", 7L);
|
||||
|
||||
assertThat(request.getStatus()).isEqualTo(AccountMergeRequest.STATUS_VERIFIED);
|
||||
assertThat(request.getTokenExpiresAt()).isEqualTo(Instant.parse("2026-03-18T00:30:00Z"));
|
||||
verify(mergeRequestRepository).save(request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirm_migratesBindingsRolesTokensAndMemberships() throws Exception {
|
||||
void verify_rejectsInitiatorEvenWhenTheyKnowTheRequestId() throws Exception {
|
||||
AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded");
|
||||
given(mergeRequestRepository.findLockedById(7L)).willReturn(Optional.of(request));
|
||||
|
||||
assertThatThrownBy(() -> service.verify("usr_primary", 7L))
|
||||
.isInstanceOf(AuthFlowException.class)
|
||||
.hasMessageContaining("error.auth.merge.requestNotFound");
|
||||
assertThat(request.getStatus()).isEqualTo(AccountMergeRequest.STATUS_PENDING);
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvalDetails_showsDestinationOnlyToSecondaryAccount() throws Exception {
|
||||
AccountMergeRequest request = request("usr_primary", "usr_secondary", null);
|
||||
given(mergeRequestRepository.findById(7L)).willReturn(Optional.of(request));
|
||||
given(userAccountRepository.findById("usr_primary"))
|
||||
.willReturn(Optional.of(new UserAccount("usr_primary", "Primary Name", null, null)));
|
||||
given(userAccountRepository.findById("usr_secondary"))
|
||||
.willReturn(Optional.of(new UserAccount("usr_secondary", "Secondary Name", null, null)));
|
||||
|
||||
var details = service.getApprovalDetails("usr_secondary", 7L);
|
||||
assertThat(details.primaryUserId()).isEqualTo("usr_primary");
|
||||
assertThat(details.primaryDisplayName()).isEqualTo("Primary Name");
|
||||
assertThatThrownBy(() -> service.getApprovalDetails("usr_other", 7L))
|
||||
.isInstanceOf(AuthFlowException.class)
|
||||
.hasMessageContaining("error.auth.merge.requestNotFound");
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirm_migratesBindingsRolesAndMembershipsButRevokesSecondaryTokens() throws Exception {
|
||||
UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null);
|
||||
UserAccount secondary = new UserAccount("usr_secondary", "secondary", "", null);
|
||||
AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded");
|
||||
|
|
@ -130,8 +176,8 @@ class AccountMergeServiceTest {
|
|||
NamespaceMember secondaryMembership = new NamespaceMember(1L, "usr_secondary", NamespaceRole.ADMIN);
|
||||
|
||||
given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request));
|
||||
given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary));
|
||||
given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary));
|
||||
given(userAccountRepository.findLockedById("usr_primary")).willReturn(Optional.of(primary));
|
||||
given(userAccountRepository.findLockedById("usr_secondary")).willReturn(Optional.of(secondary));
|
||||
given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0));
|
||||
given(identityBindingRepository.findByUserId("usr_secondary")).willReturn(List.of(binding));
|
||||
given(apiTokenRepository.findByUserId("usr_secondary")).willReturn(List.of(token));
|
||||
|
|
@ -145,8 +191,9 @@ class AccountMergeServiceTest {
|
|||
service.confirm("usr_primary", 7L);
|
||||
|
||||
assertThat(binding.getUserId()).isEqualTo("usr_primary");
|
||||
assertThat(token.getUserId()).isEqualTo("usr_primary");
|
||||
assertThat(token.getSubjectId()).isEqualTo("usr_primary");
|
||||
assertThat(token.getUserId()).isEqualTo("usr_secondary");
|
||||
assertThat(token.getSubjectId()).isEqualTo("usr_secondary");
|
||||
assertThat(token.getRevokedAt()).isEqualTo(Instant.parse("2026-03-18T00:00:00Z"));
|
||||
assertThat(secondaryMembership.getUserId()).isEqualTo("usr_primary");
|
||||
assertThat(secondary.getStatus()).isEqualTo(com.iflytek.skillhub.domain.user.UserStatus.MERGED);
|
||||
assertThat(secondary.getMergedToUserId()).isEqualTo("usr_primary");
|
||||
|
|
@ -158,18 +205,56 @@ class AccountMergeServiceTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void verify_rejectsInvalidToken() throws Exception {
|
||||
AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded");
|
||||
given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request));
|
||||
given(passwordEncoder.matches("bad-token", "encoded")).willReturn(false);
|
||||
void verify_rejectsExpiredApproval() throws Exception {
|
||||
AccountMergeRequest request = request("usr_primary", "usr_secondary", null);
|
||||
request.setTokenExpiresAt(Instant.parse("2026-03-17T23:59:00Z"));
|
||||
given(mergeRequestRepository.findLockedById(7L)).willReturn(Optional.of(request));
|
||||
|
||||
assertThatThrownBy(() -> service.verify("usr_primary", 7L, "bad-token"))
|
||||
assertThatThrownBy(() -> service.verify("usr_secondary", 7L))
|
||||
.isInstanceOf(AuthFlowException.class)
|
||||
.hasMessageContaining("error.auth.merge.invalidToken");
|
||||
.hasMessageContaining("error.auth.merge.tokenExpired");
|
||||
|
||||
verify(identityBindingRepository, never()).saveAll(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirm_rejectsExpiredApprovalWithoutMigratingData() throws Exception {
|
||||
AccountMergeRequest request = request("usr_primary", "usr_secondary", null);
|
||||
request.setStatus(AccountMergeRequest.STATUS_VERIFIED);
|
||||
request.setTokenExpiresAt(Instant.parse("2026-03-17T23:59:00Z"));
|
||||
given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request));
|
||||
|
||||
assertThatThrownBy(() -> service.confirm("usr_primary", 7L))
|
||||
.isInstanceOf(AuthFlowException.class)
|
||||
.hasMessageContaining("error.auth.merge.tokenExpired");
|
||||
verify(identityBindingRepository, never()).saveAll(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancel_allowsSecondaryAccountToRevokeApproval() throws Exception {
|
||||
AccountMergeRequest request = request("usr_primary", "usr_secondary", null);
|
||||
request.setStatus(AccountMergeRequest.STATUS_VERIFIED);
|
||||
given(mergeRequestRepository.findLockedById(7L)).willReturn(Optional.of(request));
|
||||
given(mergeRequestRepository.save(request)).willReturn(request);
|
||||
|
||||
service.cancel("usr_secondary", 7L);
|
||||
|
||||
assertThat(request.getStatus()).isEqualTo(AccountMergeRequest.STATUS_CANCELLED);
|
||||
verify(mergeRequestRepository).save(request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancel_rejectsUnrelatedAccountWithoutChangingState() throws Exception {
|
||||
AccountMergeRequest request = request("usr_primary", "usr_secondary", null);
|
||||
given(mergeRequestRepository.findLockedById(7L)).willReturn(Optional.of(request));
|
||||
|
||||
assertThatThrownBy(() -> service.cancel("usr_other", 7L))
|
||||
.isInstanceOf(AuthFlowException.class)
|
||||
.hasMessageContaining("error.auth.merge.requestNotFound");
|
||||
assertThat(request.getStatus()).isEqualTo(AccountMergeRequest.STATUS_PENDING);
|
||||
verify(mergeRequestRepository, never()).save(any());
|
||||
}
|
||||
|
||||
private AccountMergeRequest request(String primaryUserId, String secondaryUserId, String token) throws Exception {
|
||||
AccountMergeRequest request = new AccountMergeRequest(
|
||||
primaryUserId,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import java.util.Optional;
|
|||
*/
|
||||
public interface UserAccountRepository {
|
||||
Optional<UserAccount> findById(String id);
|
||||
Optional<UserAccount> findLockedById(String id);
|
||||
List<UserAccount> findByIdIn(List<String> ids);
|
||||
Optional<UserAccount> findByEmailIgnoreCase(String email);
|
||||
Page<UserAccount> search(String keyword, UserStatus status, Pageable pageable);
|
||||
|
|
|
|||
|
|
@ -3,14 +3,18 @@ package com.iflytek.skillhub.infra.jpa;
|
|||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserStatus;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* JPA-backed user-account repository that provides filtered admin search over account records.
|
||||
*/
|
||||
|
|
@ -18,6 +22,11 @@ import org.springframework.stereotype.Repository;
|
|||
public interface UserAccountJpaRepository
|
||||
extends JpaRepository<UserAccount, String>, JpaSpecificationExecutor<UserAccount>, UserAccountRepository {
|
||||
|
||||
@Override
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select user from UserAccount user where user.id = :id")
|
||||
Optional<UserAccount> findLockedById(@Param("id") String id);
|
||||
|
||||
@Override
|
||||
@Query("""
|
||||
SELECT u
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
LocalRegisterRequest,
|
||||
MergeInitiateRequest,
|
||||
MergeInitiateResponse,
|
||||
MergeApprovalDetails,
|
||||
MergeVerifyRequest,
|
||||
ReviewSkillDetail,
|
||||
ReviewProgressPage,
|
||||
|
|
@ -458,6 +459,10 @@ export const accountApi = {
|
|||
})
|
||||
},
|
||||
|
||||
async getMergeApprovalDetails(mergeRequestId: number): Promise<MergeApprovalDetails> {
|
||||
return fetchJson<MergeApprovalDetails>(`/api/v1/account/merge/requests/${mergeRequestId}`)
|
||||
},
|
||||
|
||||
async verifyMerge(request: MergeVerifyRequest): Promise<void> {
|
||||
await fetchJson<void>('/api/v1/account/merge/verify', {
|
||||
method: 'POST',
|
||||
|
|
@ -468,6 +473,16 @@ export const accountApi = {
|
|||
})
|
||||
},
|
||||
|
||||
async cancelMerge(request: MergeConfirmRequest): Promise<void> {
|
||||
await fetchJson<void>('/api/v1/account/merge/cancel', {
|
||||
method: 'POST',
|
||||
headers: await ensureCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
},
|
||||
|
||||
async confirmMerge(request: MergeConfirmRequest): Promise<void> {
|
||||
await fetchJson<void>('/api/v1/account/merge/confirm', {
|
||||
method: 'POST',
|
||||
|
|
|
|||
100
web/src/api/generated/schema.d.ts
vendored
100
web/src/api/generated/schema.d.ts
vendored
|
|
@ -2573,6 +2573,22 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/account/merge/cancel": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["cancel"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/cli/v1/skills/{namespace}/publish": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -4609,6 +4625,22 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/account/merge/requests/{mergeRequestId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["approvalDetails"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -5866,7 +5898,6 @@ export interface components {
|
|||
MergeVerifyRequest: {
|
||||
/** Format: int64 */
|
||||
mergeRequestId: number;
|
||||
verificationToken: string;
|
||||
};
|
||||
MergeInitiateRequest: {
|
||||
secondaryIdentifier: string;
|
||||
|
|
@ -5884,13 +5915,16 @@ export interface components {
|
|||
/** Format: int64 */
|
||||
mergeRequestId?: number;
|
||||
secondaryUserId?: string;
|
||||
verificationToken?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
ConfirmMergeRequest: {
|
||||
/** Format: int64 */
|
||||
mergeRequestId: number;
|
||||
};
|
||||
CancelMergeRequest: {
|
||||
/** Format: int64 */
|
||||
mergeRequestId: number;
|
||||
};
|
||||
ApiResponseCliPublishResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
|
|
@ -7468,6 +7502,22 @@ export interface components {
|
|||
/** Format: int32 */
|
||||
size?: number;
|
||||
};
|
||||
ApiResponseMergeApprovalDetailsResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: components["schemas"]["MergeApprovalDetailsResponse"];
|
||||
/** Format: date-time */
|
||||
timestamp?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
MergeApprovalDetailsResponse: {
|
||||
/** Format: int64 */
|
||||
mergeRequestId?: number;
|
||||
primaryUserId?: string;
|
||||
primaryDisplayName?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
ApiResponseCliResolveResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
|
|
@ -12401,6 +12451,30 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
cancel: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CancelMergeRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseMessageResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
publish_3: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -15528,6 +15602,28 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
approvalDetails: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
mergeRequestId: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseMergeApprovalDetailsResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
downloadVersion_2: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -84,13 +84,18 @@ export interface MergeInitiateRequest {
|
|||
export interface MergeInitiateResponse {
|
||||
mergeRequestId: number
|
||||
secondaryUserId: string
|
||||
verificationToken: string
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export interface MergeApprovalDetails {
|
||||
mergeRequestId: number
|
||||
primaryUserId: string
|
||||
primaryDisplayName: string
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export interface MergeVerifyRequest {
|
||||
mergeRequestId: number
|
||||
verificationToken: string
|
||||
}
|
||||
|
||||
export interface MergeConfirmRequest {
|
||||
|
|
|
|||
|
|
@ -8,12 +8,24 @@ export function useInitiateAccountMerge() {
|
|||
})
|
||||
}
|
||||
|
||||
export function useInspectAccountMerge() {
|
||||
return useMutation({
|
||||
mutationFn: (mergeRequestId: number) => accountApi.getMergeApprovalDetails(mergeRequestId),
|
||||
})
|
||||
}
|
||||
|
||||
export function useVerifyAccountMerge() {
|
||||
return useMutation({
|
||||
mutationFn: (request: MergeVerifyRequest) => accountApi.verifyMerge(request),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCancelAccountMerge() {
|
||||
return useMutation({
|
||||
mutationFn: (request: MergeConfirmRequest) => accountApi.cancelMerge(request),
|
||||
})
|
||||
}
|
||||
|
||||
export function useConfirmAccountMerge() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
|
|
|
|||
|
|
@ -486,6 +486,7 @@
|
|||
"account": "Account",
|
||||
"profile": "Profile",
|
||||
"security": "Security",
|
||||
"accounts": "Account Merge",
|
||||
"notifications": "Notifications",
|
||||
"skillsAndData": "Skills & Data",
|
||||
"mySkills": "My Skills",
|
||||
|
|
@ -992,20 +993,26 @@
|
|||
"secondaryPlaceholder": "e.g.: other_user or github:123456",
|
||||
"initiating": "Initiating...",
|
||||
"initiate": "Initiate Merge",
|
||||
"initiateSuccess": "Merge request created, secondary={{secondaryUserId}}",
|
||||
"initiateSuccess": "Merge request created. Save the request ID, then sign in to {{secondaryUserId}} in another browser to approve it.",
|
||||
"initiateError": "Failed to initiate merge",
|
||||
"verifyTitle": "Verify & Complete Merge",
|
||||
"verifyDesc": "Complete token verification first, then confirm to execute data migration.",
|
||||
"verifyTitle": "Approve from the second account",
|
||||
"verifyDesc": "Sign in to the second account in another browser, enter the request ID, review the destination, then approve.",
|
||||
"mergeRequestId": "Merge Request ID",
|
||||
"verificationToken": "Verification Token",
|
||||
"inspect": "View destination",
|
||||
"inspectError": "Unable to view merge request",
|
||||
"destinationWarning": "Approval will transfer this account's sign-in methods, API tokens and permissions to {{name}} ({{id}}), then deactivate this account. Confirm that you own both accounts.",
|
||||
"verifying": "Verifying...",
|
||||
"verify": "Complete Merge",
|
||||
"verifySuccess": "Verification successful, confirm to execute the merge",
|
||||
"verify": "Approve merge",
|
||||
"verifySuccess": "Approved. Return to the initiating account and confirm within 30 minutes.",
|
||||
"verifyError": "Merge verification failed",
|
||||
"confirming": "Confirming...",
|
||||
"confirmDesc": "Only the initiating account can confirm the merge. Return to it after approval.",
|
||||
"confirm": "Confirm & Complete Merge",
|
||||
"confirmSuccess": "Account merge completed",
|
||||
"confirmError": "Merge confirmation failed"
|
||||
"confirmError": "Merge confirmation failed",
|
||||
"cancel": "Cancel merge request",
|
||||
"cancelSuccess": "Merge request cancelled",
|
||||
"cancelError": "Failed to cancel merge request"
|
||||
},
|
||||
"namespace": {
|
||||
"notFound": "Namespace not found",
|
||||
|
|
|
|||
|
|
@ -486,6 +486,7 @@
|
|||
"account": "Аккаунт",
|
||||
"profile": "Профиль",
|
||||
"security": "Безопасность",
|
||||
"accounts": "Объединение аккаунтов",
|
||||
"notifications": "Уведомления",
|
||||
"skillsAndData": "Скиллы и данные",
|
||||
"mySkills": "Мои скиллы",
|
||||
|
|
@ -1057,20 +1058,26 @@
|
|||
"secondaryPlaceholder": "например: other_user или github:123456",
|
||||
"initiating": "Инициализация...",
|
||||
"initiate": "Начать объединение",
|
||||
"initiateSuccess": "Запрос на объединение создан, secondary={{secondaryUserId}}",
|
||||
"initiateSuccess": "Запрос создан. Сохраните его ID и войдите в аккаунт {{secondaryUserId}} в другом браузере для подтверждения.",
|
||||
"initiateError": "Не удалось начать объединение",
|
||||
"verifyTitle": "Проверить и завершить объединение",
|
||||
"verifyDesc": "Сначала завершите проверку токена, затем подтвердите выполнение миграции данных.",
|
||||
"verifyTitle": "Подтверждение со второго аккаунта",
|
||||
"verifyDesc": "Войдите во второй аккаунт в другом браузере, введите ID запроса и проверьте целевой аккаунт.",
|
||||
"mergeRequestId": "ID запроса на объединение",
|
||||
"verificationToken": "Токен проверки",
|
||||
"inspect": "Показать целевой аккаунт",
|
||||
"inspectError": "Не удалось открыть запрос",
|
||||
"destinationWarning": "Подтверждение перенесёт способы входа, API-токены и права этого аккаунта в {{name}} ({{id}}), затем отключит текущий аккаунт. Убедитесь, что оба аккаунта принадлежат вам.",
|
||||
"verifying": "Проверка...",
|
||||
"verify": "Завершить объединение",
|
||||
"verifySuccess": "Проверка успешна, подтвердите выполнение объединения",
|
||||
"verify": "Подтвердить объединение",
|
||||
"verifySuccess": "Подтверждено. Вернитесь в аккаунт, создавший запрос, и завершите объединение в течение 30 минут.",
|
||||
"verifyError": "Проверка объединения не удалась",
|
||||
"confirming": "Подтверждение...",
|
||||
"confirmDesc": "Завершить объединение может только аккаунт, создавший запрос. Вернитесь в него после подтверждения.",
|
||||
"confirm": "Подтвердить и завершить объединение",
|
||||
"confirmSuccess": "Объединение учётных записей завершено",
|
||||
"confirmError": "Подтверждение объединения не удалось"
|
||||
"confirmError": "Подтверждение объединения не удалось",
|
||||
"cancel": "Отменить запрос",
|
||||
"cancelSuccess": "Запрос отменён",
|
||||
"cancelError": "Не удалось отменить запрос"
|
||||
},
|
||||
"namespace": {
|
||||
"notFound": "Пространство имён не найдено",
|
||||
|
|
|
|||
|
|
@ -486,6 +486,7 @@
|
|||
"account": "账号管理",
|
||||
"profile": "个人设置",
|
||||
"security": "安全设置",
|
||||
"accounts": "账号合并",
|
||||
"notifications": "通知设置",
|
||||
"skillsAndData": "技能与数据",
|
||||
"mySkills": "我的技能",
|
||||
|
|
@ -992,20 +993,26 @@
|
|||
"secondaryPlaceholder": "例如:other_user 或 github:123456",
|
||||
"initiating": "发起中...",
|
||||
"initiate": "发起合并",
|
||||
"initiateSuccess": "已创建合并请求,secondary={{secondaryUserId}}",
|
||||
"initiateSuccess": "已创建合并请求。请记录请求 ID,并在另一个浏览器登录账号 {{secondaryUserId}} 后批准。",
|
||||
"initiateError": "发起合并失败",
|
||||
"verifyTitle": "验证并完成合并",
|
||||
"verifyDesc": "先完成 token 验证,再单独确认执行数据迁移。",
|
||||
"mergeRequestId": "Merge Request ID",
|
||||
"verificationToken": "Verification Token",
|
||||
"verifyTitle": "由待合并账号批准",
|
||||
"verifyDesc": "请在另一个浏览器登录待合并账号,输入请求 ID,核对合并目标后批准。",
|
||||
"mergeRequestId": "合并请求 ID",
|
||||
"inspect": "查看合并目标",
|
||||
"inspectError": "无法查看合并请求",
|
||||
"destinationWarning": "批准后,当前账号的登录方式、API Token 和权限将转移给 {{name}}({{id}});当前账号将停用。请确认这是你自己的账号。",
|
||||
"verifying": "验证中...",
|
||||
"verify": "完成合并",
|
||||
"verifySuccess": "验证成功,确认后将执行正式合并",
|
||||
"verify": "批准合并",
|
||||
"verifySuccess": "已批准。请在 30 分钟内回到发起合并的账号完成确认。",
|
||||
"verifyError": "验证合并失败",
|
||||
"confirming": "确认中...",
|
||||
"confirmDesc": "只有发起合并的账号能完成最后确认。批准后请回到该账号操作。",
|
||||
"confirm": "确认并完成合并",
|
||||
"confirmSuccess": "账号合并已完成",
|
||||
"confirmError": "确认合并失败"
|
||||
"confirmError": "确认合并失败",
|
||||
"cancel": "撤销合并请求",
|
||||
"cancelSuccess": "合并请求已撤销",
|
||||
"cancelError": "撤销合并失败"
|
||||
},
|
||||
"namespace": {
|
||||
"notFound": "命名空间不存在",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ vi.mock('react-i18next', async () => {
|
|||
|
||||
vi.mock('@/features/auth/use-account-merge', () => ({
|
||||
useInitiateAccountMerge: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useInspectAccountMerge: () => ({ mutateAsync: vi.fn(), isPending: false, data: null, reset: vi.fn() }),
|
||||
useVerifyAccountMerge: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useCancelAccountMerge: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useConfirmAccountMerge: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useConfirmAccountMerge, useInitiateAccountMerge, useVerifyAccountMerge } from '@/features/auth/use-account-merge'
|
||||
import { useCancelAccountMerge, useConfirmAccountMerge, useInitiateAccountMerge, useInspectAccountMerge, useVerifyAccountMerge } from '@/features/auth/use-account-merge'
|
||||
import { truncateErrorMessage } from '@/shared/lib/error-display'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
|
|
@ -9,22 +9,24 @@ import { Input } from '@/shared/ui/input'
|
|||
/**
|
||||
* Account linking settings page for the multi-step account merge workflow.
|
||||
* The route intentionally keeps all three steps visible because operators often
|
||||
* need to paste ids or tokens across systems while completing the merge.
|
||||
* need to carry a request id between their two authenticated accounts.
|
||||
*/
|
||||
export function AccountSettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const [secondaryIdentifier, setSecondaryIdentifier] = useState('')
|
||||
const [mergeRequestId, setMergeRequestId] = useState('')
|
||||
const [verificationToken, setVerificationToken] = useState('')
|
||||
const [statusMessage, setStatusMessage] = useState('')
|
||||
|
||||
const initiateMutation = useInitiateAccountMerge()
|
||||
const inspectMutation = useInspectAccountMerge()
|
||||
const verifyMutation = useVerifyAccountMerge()
|
||||
const cancelMutation = useCancelAccountMerge()
|
||||
const confirmMutation = useConfirmAccountMerge()
|
||||
const validMergeRequestId = Number.isSafeInteger(Number(mergeRequestId)) && Number(mergeRequestId) > 0
|
||||
const approvalDetails = inspectMutation.data?.mergeRequestId === Number(mergeRequestId) ? inspectMutation.data : null
|
||||
|
||||
/**
|
||||
* Starts the merge flow and surfaces the request id plus verification token
|
||||
* returned by the backend for the following steps.
|
||||
* Starts the merge flow and surfaces the request id for the second account.
|
||||
*/
|
||||
async function handleInitiate(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
|
@ -32,7 +34,7 @@ export function AccountSettingsPage() {
|
|||
try {
|
||||
const result = await initiateMutation.mutateAsync({ secondaryIdentifier })
|
||||
setMergeRequestId(String(result.mergeRequestId))
|
||||
setVerificationToken(result.verificationToken)
|
||||
inspectMutation.reset()
|
||||
setStatusMessage(t('accounts.initiateSuccess', { secondaryUserId: result.secondaryUserId }))
|
||||
} catch (error) {
|
||||
setStatusMessage(
|
||||
|
|
@ -42,7 +44,22 @@ export function AccountSettingsPage() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Verifies ownership of the secondary account before the final merge step.
|
||||
* Loads the destination while signed in as the second account.
|
||||
*/
|
||||
async function handleInspect() {
|
||||
setStatusMessage('')
|
||||
inspectMutation.reset()
|
||||
try {
|
||||
await inspectMutation.mutateAsync(Number(mergeRequestId))
|
||||
} catch (error) {
|
||||
setStatusMessage(
|
||||
truncateErrorMessage(error instanceof Error ? error.message : t('accounts.inspectError')) ?? t('accounts.inspectError'),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Approves the merge only from the second account's authenticated session.
|
||||
*/
|
||||
async function handleVerify(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
|
@ -50,8 +67,8 @@ export function AccountSettingsPage() {
|
|||
try {
|
||||
await verifyMutation.mutateAsync({
|
||||
mergeRequestId: Number(mergeRequestId),
|
||||
verificationToken,
|
||||
})
|
||||
inspectMutation.reset()
|
||||
setStatusMessage(t('accounts.verifySuccess'))
|
||||
} catch (error) {
|
||||
setStatusMessage(
|
||||
|
|
@ -75,6 +92,19 @@ export function AccountSettingsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
setStatusMessage('')
|
||||
try {
|
||||
await cancelMutation.mutateAsync({ mergeRequestId: Number(mergeRequestId) })
|
||||
inspectMutation.reset()
|
||||
setStatusMessage(t('accounts.cancelSuccess'))
|
||||
} catch (error) {
|
||||
setStatusMessage(
|
||||
truncateErrorMessage(error instanceof Error ? error.message : t('accounts.cancelError')) ?? t('accounts.cancelError'),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<Card className="glass-strong">
|
||||
|
|
@ -112,25 +142,36 @@ export function AccountSettingsPage() {
|
|||
<Input
|
||||
id="merge-request-id"
|
||||
value={mergeRequestId}
|
||||
onChange={(event) => setMergeRequestId(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setMergeRequestId(event.target.value)
|
||||
inspectMutation.reset()
|
||||
}}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="merge-token">{t('accounts.verificationToken')}</label>
|
||||
<Input
|
||||
id="merge-token"
|
||||
value={verificationToken}
|
||||
onChange={(event) => setVerificationToken(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={verifyMutation.isPending}>
|
||||
<Button type="button" onClick={handleInspect} disabled={!validMergeRequestId || inspectMutation.isPending}>
|
||||
{t('accounts.inspect')}
|
||||
</Button>
|
||||
{approvalDetails ? (
|
||||
<p className="text-sm" role="status">
|
||||
{t('accounts.destinationWarning', {
|
||||
name: approvalDetails.primaryDisplayName,
|
||||
id: approvalDetails.primaryUserId,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" disabled={verifyMutation.isPending || !approvalDetails || !validMergeRequestId}>
|
||||
{verifyMutation.isPending ? t('accounts.verifying') : t('accounts.verify')}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="mt-4">
|
||||
<Button type="button" onClick={handleConfirm} disabled={confirmMutation.isPending || !mergeRequestId}>
|
||||
<p className="mb-2 text-sm text-muted-foreground">{t('accounts.confirmDesc')}</p>
|
||||
<Button type="button" onClick={handleConfirm} disabled={confirmMutation.isPending || !validMergeRequestId}>
|
||||
{confirmMutation.isPending ? t('accounts.confirming') : t('accounts.confirm')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={handleCancel} disabled={cancelMutation.isPending || !validMergeRequestId}>
|
||||
{t('accounts.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
{statusMessage ? <p className="mt-4 text-sm text-muted-foreground">{statusMessage}</p> : null}
|
||||
</CardContent>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue