feat: gate overseas receiving and harden phase4 workflows
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
|
||||
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
|
||||
| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只保留平台海外仓/海外业务/海外主数据及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 |
|
||||
| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 |
|
||||
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
|
||||
| 注册协作 | `service/MerchantRegistrationService.java` |
|
||||
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
|
||||
|
||||
@@ -3,12 +3,16 @@ package com.baiyue.auth.controller;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.*;
|
||||
import com.baiyue.auth.service.AccountManagementService;
|
||||
import com.baiyue.auth.service.AuthService;
|
||||
import com.baiyue.auth.service.OverseasSessionValidationService;
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -18,11 +22,14 @@ import java.security.MessageDigest;
|
||||
/** 仅供可信内部服务调用的账号管理接口。 */
|
||||
@RestController
|
||||
@RequestMapping("/internal/auth/accounts")
|
||||
@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and overseas PDA session APIs")
|
||||
public class InternalAccountController {
|
||||
@Resource
|
||||
private AccountManagementService service;
|
||||
@Resource
|
||||
private AuthService authService;
|
||||
@Resource
|
||||
private OverseasSessionValidationService overseasSessionValidationService;
|
||||
|
||||
@Value("${baiyue.security.internal-service-token}")
|
||||
private String configuredToken;
|
||||
@@ -64,6 +71,15 @@ public class InternalAccountController {
|
||||
@Valid @RequestBody RevokeAccountSessionsRequest request) {
|
||||
verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null);
|
||||
}
|
||||
@PostMapping("/overseas-session/validate")
|
||||
@Operation(summary = "Validate verified overseas PDA claims / 校验已验签的海外PDA声明",
|
||||
description = "Accepts only claims extracted after Gateway JWT verification; raw tokens and user credentials are forbidden.")
|
||||
public ApiResponse<OverseasSessionValidationDtos.Response> validateOverseasSession(
|
||||
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@Valid @RequestBody OverseasSessionValidationDtos.Request request) {
|
||||
verify(token);
|
||||
return ApiResponse.success(overseasSessionValidationService.validate(request));
|
||||
}
|
||||
@PutMapping("/{id}/profile")
|
||||
public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/** 海外 PDA 已验签身份的实时会话校验契约;绝不接收 raw JWT 或用户凭据。 */
|
||||
public final class OverseasSessionValidationDtos {
|
||||
private OverseasSessionValidationDtos() { }
|
||||
|
||||
@Schema(name = "OverseasSessionValidationRequest",
|
||||
description = "Claims extracted only after overseas Gateway verifies the JWT signature")
|
||||
public record Request(
|
||||
@NotNull Long userId,
|
||||
@NotNull Integer tokenVersion,
|
||||
Long overseasWarehouseId) { }
|
||||
|
||||
@Schema(name = "OverseasSessionValidationResponse")
|
||||
public record Response(boolean valid, FailureReason failureReason) {
|
||||
public static Response accepted() { return new Response(true, null); }
|
||||
public static Response rejected(FailureReason reason) { return new Response(false, reason); }
|
||||
}
|
||||
|
||||
public enum FailureReason {
|
||||
ACCOUNT_INVALID,
|
||||
TOKEN_VERSION_MISMATCH,
|
||||
WAREHOUSE_UNBOUND,
|
||||
WAREHOUSE_MISMATCH
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.auth.client.AuthorizationLoader;
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason;
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request;
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Response;
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** 实时核对海外 PDA token 的账号版本与当前 IAM 仓绑定。 */
|
||||
@Service
|
||||
public class OverseasSessionValidationService {
|
||||
@Resource private AuthAccountRepository accounts;
|
||||
@Resource private AuthorizationLoader authorizationLoader;
|
||||
|
||||
public Response validate(Request request) {
|
||||
AuthAccount account = accounts.findById(request.userId());
|
||||
if (account == null || !"ACTIVE".equals(account.getAccountStatus())) {
|
||||
return Response.rejected(FailureReason.ACCOUNT_INVALID);
|
||||
}
|
||||
int currentVersion = account.getTokenVersion() == null ? 0 : account.getTokenVersion();
|
||||
if (currentVersion != request.tokenVersion()) {
|
||||
return Response.rejected(FailureReason.TOKEN_VERSION_MISMATCH);
|
||||
}
|
||||
Long currentWarehouseId = authorizationLoader.load(account).getOverseasWarehouseId();
|
||||
if (currentWarehouseId == null || request.overseasWarehouseId() == null) {
|
||||
return Response.rejected(FailureReason.WAREHOUSE_UNBOUND);
|
||||
}
|
||||
if (!Objects.equals(currentWarehouseId, request.overseasWarehouseId())) {
|
||||
return Response.rejected(FailureReason.WAREHOUSE_MISMATCH);
|
||||
}
|
||||
return Response.accepted();
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ public class OverseasTokenService {
|
||||
.claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType())
|
||||
.claim("roles", authorization.getRoles())
|
||||
.claim("authorities", overseasAuthorities(authorization.getPermissions()))
|
||||
.claim("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion())
|
||||
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion());
|
||||
if (authorization.getOverseasWarehouseId() != null) {
|
||||
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.auth.controller;
|
||||
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OverseasSessionValidationControllerContractTest {
|
||||
@Test
|
||||
void internalEndpointAcceptsOnlyVerifiedClaimValuesAndIsDocumented() {
|
||||
var method = Arrays.stream(InternalAccountController.class.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals("validateOverseasSession"))
|
||||
.findFirst().orElseThrow();
|
||||
|
||||
assertThat(method.getAnnotation(PostMapping.class).value())
|
||||
.containsExactly("/overseas-session/validate");
|
||||
assertThat(method.getAnnotation(Operation.class).description())
|
||||
.contains("Gateway JWT verification", "raw tokens and user credentials are forbidden");
|
||||
assertThat(OverseasSessionValidationDtos.Request.class.getRecordComponents())
|
||||
.extracting(java.lang.reflect.RecordComponent::getName)
|
||||
.containsExactly("userId", "tokenVersion", "overseasWarehouseId")
|
||||
.doesNotContain("token", "accessToken", "authorization", "password");
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,10 @@ import java.time.Duration;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -124,12 +127,36 @@ class AuthServiceSmallJwtTest {
|
||||
var response = service.refresh("refresh-old");
|
||||
|
||||
assertThat(response.accessToken()).isEqualTo("domestic-refreshed");
|
||||
assertThat(response.tokenType()).isEqualTo("Bearer");
|
||||
assertThat(response.expiresIn()).isEqualTo(1800);
|
||||
assertThat(response.refreshToken()).isNotBlank().isNotEqualTo("refresh-old");
|
||||
assertThat(response.refreshExpiresIn()).isEqualTo(604800);
|
||||
assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed");
|
||||
assertThat(response.overseasExpiresIn()).isEqualTo(900);
|
||||
verify(tokenSessionStore).consume("refresh-old");
|
||||
verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
|
||||
org.mockito.ArgumentMatchers.eq(response.refreshToken()),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||
verify(authorizationLoader).load(account);
|
||||
verify(overseasTokenService).issue(account, snapshot);
|
||||
}
|
||||
|
||||
@Test
|
||||
void consumedRefreshTokenCannotMintAnotherDomesticOrOverseasPair() {
|
||||
when(tokenSessionStore.consume("refresh-consumed")).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.refresh("refresh-consumed"))
|
||||
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
|
||||
.hasMessageContaining("刷新令牌无效或已过期");
|
||||
|
||||
verify(repository, never()).findById(any());
|
||||
verify(jwtEncoder, never()).encode(any());
|
||||
verify(overseasTokenService, never()).issue(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void impersonationTokenAlsoKeepsRolesAndPermissionsOutOfJwt() {
|
||||
AuthAccount account = new AuthAccount();
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.auth.client.AuthorizationLoader;
|
||||
import com.baiyue.auth.client.AuthorizationSnapshot;
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason;
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request;
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class OverseasSessionValidationServiceTest {
|
||||
private final AuthAccountRepository accounts = mock(AuthAccountRepository.class);
|
||||
private final AuthorizationLoader authorizationLoader = mock(AuthorizationLoader.class);
|
||||
private final OverseasSessionValidationService service = new OverseasSessionValidationService();
|
||||
private AuthAccount account;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
inject("accounts", accounts);
|
||||
inject("authorizationLoader", authorizationLoader);
|
||||
account = new AuthAccount();
|
||||
account.setId(42L);
|
||||
account.setAccountStatus("ACTIVE");
|
||||
account.setTokenVersion(8);
|
||||
when(accounts.findById(42L)).thenReturn(account);
|
||||
}
|
||||
|
||||
@Test
|
||||
void oldTokenIsRejectedAfterWarehouseChangeBumpsAccountVersion() {
|
||||
assertThat(service.validate(new Request(42L, 7, 900L)))
|
||||
.extracting("valid", "failureReason")
|
||||
.containsExactly(false, FailureReason.TOKEN_VERSION_MISMATCH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void newlyIssuedTokenWithCurrentWarehousePasses() {
|
||||
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L));
|
||||
|
||||
assertThat(service.validate(new Request(42L, 8, 901L)).valid()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOrChangedWarehouseBindingFailsClosed() {
|
||||
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, null));
|
||||
assertThat(service.validate(new Request(42L, 8, null)).failureReason())
|
||||
.isEqualTo(FailureReason.WAREHOUSE_UNBOUND);
|
||||
|
||||
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L));
|
||||
assertThat(service.validate(new Request(42L, 8, 900L)).failureReason())
|
||||
.isEqualTo(FailureReason.WAREHOUSE_MISMATCH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledAccountFailsClosedBeforeIamLookup() {
|
||||
account.setAccountStatus("DISABLED");
|
||||
assertThat(service.validate(new Request(42L, 8, 901L)).failureReason())
|
||||
.isEqualTo(FailureReason.ACCOUNT_INVALID);
|
||||
}
|
||||
|
||||
private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) {
|
||||
return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA",
|
||||
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), version, warehouseId);
|
||||
}
|
||||
|
||||
private void inject(String fieldName, Object value) throws Exception {
|
||||
var field = OverseasSessionValidationService.class.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(service, value);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import java.util.Set;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class})
|
||||
@@ -71,10 +72,61 @@ class OverseasTokenServiceTest {
|
||||
.containsEntry("membership_id", 84L)
|
||||
.containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB")
|
||||
.containsEntry("permission_version", 7L)
|
||||
.containsEntry("token_version", 0)
|
||||
.containsEntry("overseas_warehouse_id", 900L)
|
||||
.containsEntry("roles", Set.of("WAREHOUSE_PICKER"))
|
||||
.containsEntry("authorities", Set.of(authority, lastlegAuthority))
|
||||
.doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion");
|
||||
assertThat(response.expiresIn()).isEqualTo(900);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshedAuthorizationSnapshotReplacesWarehouseBindingAndTokenIdentity() {
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(43L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(0L);
|
||||
account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM");
|
||||
account.setTokenVersion(8);
|
||||
AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
|
||||
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 8L, 900L);
|
||||
AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
|
||||
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 9L, 901L);
|
||||
Jwt first = org.mockito.Mockito.mock(Jwt.class);
|
||||
Jwt second = org.mockito.Mockito.mock(Jwt.class);
|
||||
when(encoder.encode(any())).thenReturn(first, second);
|
||||
when(first.getTokenValue()).thenReturn("overseas-old-binding");
|
||||
when(second.getTokenValue()).thenReturn("overseas-new-binding");
|
||||
|
||||
var oldToken = service.issue(account, oldBinding);
|
||||
var newToken = service.issue(account, newBinding);
|
||||
|
||||
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||
verify(encoder, times(2)).encode(captor.capture());
|
||||
var oldClaims = captor.getAllValues().get(0).getClaims();
|
||||
var newClaims = captor.getAllValues().get(1).getClaims();
|
||||
assertThat(oldClaims.getId()).isNotEqualTo(newClaims.getId());
|
||||
assertThat(oldClaims.getClaims()).containsEntry("overseas_warehouse_id", 900L)
|
||||
.containsEntry("permission_version", 8L);
|
||||
assertThat(newClaims.getClaims()).containsEntry("overseas_warehouse_id", 901L)
|
||||
.containsEntry("permission_version", 9L);
|
||||
assertThat(newClaims.getClaims()).containsEntry("token_version", 8);
|
||||
assertThat(oldToken.accessToken()).isNotEqualTo(newToken.accessToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unboundWarehouseIsOmittedSoPdaIdentityMustFailClosed() {
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(44L); account.setAccountStatus("ACTIVE");
|
||||
AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA",
|
||||
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 10L, null);
|
||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||
when(encoder.encode(any())).thenReturn(encoded);
|
||||
when(encoded.getTokenValue()).thenReturn("overseas-unbound");
|
||||
|
||||
service.issue(account, unbound);
|
||||
|
||||
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||
verify(encoder).encode(captor.capture());
|
||||
assertThat(captor.getValue().getClaims().getClaims())
|
||||
.doesNotContainKey("overseas_warehouse_id");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user