This commit is contained in:
2026-08-08 09:17:42 +08:00
parent 4a6f976a91
commit 5951e6aa68
27 changed files with 598 additions and 13 deletions
@@ -8,6 +8,8 @@ import com.baiyue.auth.dto.MerchantRegisterRequest;
import com.baiyue.auth.dto.MerchantRegisterResponse; import com.baiyue.auth.dto.MerchantRegisterResponse;
import com.baiyue.auth.service.MerchantRegistrationService; import com.baiyue.auth.service.MerchantRegistrationService;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.security.SecurityRequirements;
@@ -18,6 +20,7 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest; import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
import com.baiyue.auth.service.AccountManagementService; import com.baiyue.auth.service.AccountManagementService;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -40,10 +43,22 @@ public class AuthController {
@Operation(summary = "修改当前用户密码") @Operation(summary = "修改当前用户密码")
public ApiResponse<Void> changePassword(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Void> changePassword(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ChangePasswordRequest request) { @Valid @RequestBody ChangePasswordRequest request) {
if (user.impersonated()) throw new BizException(ResultCode.FORBIDDEN, "代登录会话不能修改密码");
accountManagementService.changePassword(user.userId(), request); accountManagementService.changePassword(user.userId(), request);
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@PutMapping("/password/forced")
@PreAuthorize("isAuthenticated()")
@Operation(summary = "完成强制改密")
public ApiResponse<Void> forcedChangePassword(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ForcedPasswordChangeRequest request) {
if (user.impersonated()) throw new BizException(ResultCode.FORBIDDEN, "代登录会话不能修改密码");
if (!user.passwordChangeRequired()) throw new BizException(ResultCode.CONFLICT, "当前账号不需要强制改密");
accountManagementService.forcedChangePassword(user.userId(), request);
return ApiResponse.success(null);
}
@SecurityRequirements @SecurityRequirements
@PostMapping("/register") @PostMapping("/register")
@Operation(summary = "Merchant registration / 客户注册", @Operation(summary = "Merchant registration / 客户注册",
@@ -82,4 +97,11 @@ public class AuthController {
authService.logout(request.refreshToken()); authService.logout(request.refreshToken());
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@SecurityRequirements
@PostMapping("/impersonations/exchange")
public ApiResponse<AuthService.ImpersonationTokenResponse> exchangeImpersonation(
@Valid @RequestBody AccountManagementDtos.ImpersonationExchangeRequest request) {
return ApiResponse.success(authService.exchangeImpersonationTicket(request.code()));
}
} }
@@ -63,7 +63,20 @@ public class InternalAccountController {
@PutMapping("/{id}/password") @PutMapping("/{id}/password")
public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) { @PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) {
verify(token); service.resetPassword(id, request); return ApiResponse.success(null); verify(token); service.resetPassword(id, request, request.forceChange()); return ApiResponse.success(null);
}
@PostMapping("/impersonation-tickets")
public ApiResponse<ImpersonationTicketResponse> impersonationTicket(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@Valid @RequestBody ImpersonationTicketRequest request) {
verify(token); return ApiResponse.success(service.createImpersonationTicket(request));
}
@PutMapping("/tenant/{tenantId}/archive")
public ApiResponse<Void> archiveTenant(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long tenantId) {
verify(token); service.archiveTenant(tenantId); return ApiResponse.success(null);
} }
@PutMapping("/{id}/scope") @PutMapping("/{id}/scope")
public ApiResponse<Void> scope(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, public ApiResponse<Void> scope(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@@ -14,6 +14,10 @@ public final class AccountManagementDtos {
public record ChangePasswordRequest(@NotBlank String oldPassword, public record ChangePasswordRequest(@NotBlank String oldPassword,
@NotBlank @Size(min = 8, max = 64) @NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { } @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { }
public record ForcedPasswordChangeRequest(
@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
String newPassword) { }
public record CreateAccountRequest(@NotNull Long accountId, public record CreateAccountRequest(@NotNull Long accountId,
@NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username, @NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
@NotBlank @Size(min = 8, max = 64) @NotBlank @Size(min = 8, max = 64)
@@ -32,7 +36,16 @@ public final class AccountManagementDtos {
public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { } public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { }
public record UpdateAccountStatusRequest(@NotBlank String status) { } public record UpdateAccountStatusRequest(@NotBlank String status) { }
public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64) public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { } @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword,
Boolean forceChangePassword) {
public boolean forceChange() { return Boolean.TRUE.equals(forceChangePassword); }
}
public record ResetPasswordOptions(boolean forceChangePassword) { }
public record ImpersonationTicketRequest(@NotNull Long targetAuthUserId, @NotNull Long tenantId,
@NotNull Long membershipId, @NotNull Long actorUserId,
@NotNull Long actorMembershipId, @NotBlank String reason) { }
public record ImpersonationTicketResponse(String code, String ticketId, long expiresIn) { }
public record ImpersonationExchangeRequest(@NotBlank String code) { }
public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId, public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId,
@NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { } @NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { }
public record AccountSummary(Long accountId, String username, String displayName, String status, public record AccountSummary(Long accountId, String username, String displayName, String status,
@@ -24,6 +24,7 @@ public class AuthAccount {
private Integer failedLoginCount; private Integer failedLoginCount;
private LocalDateTime lockedUntil; private LocalDateTime lockedUntil;
private Integer tokenVersion; private Integer tokenVersion;
private Boolean passwordChangeRequired;
private Long defaultTenantId; private Long defaultTenantId;
private Long operatorTenantId; private Long operatorTenantId;
private Long defaultMembershipId; private Long defaultMembershipId;
@@ -6,6 +6,7 @@ import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.mapper.AuthAccountMapper; import com.baiyue.auth.mapper.AuthAccountMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
/** /**
* 认证账号仓储,封装按用户名或主键查询以及账号保存操作,隔离服务层与 MyBatis-Plus 查询细节。 * 认证账号仓储,封装按用户名或主键查询以及账号保存操作,隔离服务层与 MyBatis-Plus 查询细节。
@@ -32,6 +33,12 @@ public class AuthAccountRepository {
return mapper.selectById(id); return mapper.selectById(id);
} }
public List<AuthAccount> findMerchantByTenant(Long tenantId) {
return mapper.selectList(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getDefaultTenantId, tenantId)
.eq(AuthAccount::getDefaultAppCode, "MERCHANT"));
}
public void save(AuthAccount account) { public void save(AuthAccount account) {
mapper.updateById(account); mapper.updateById(account);
} }
@@ -29,13 +29,30 @@ public class AccountManagementService {
@BusinessAudit(event = "PASSWORD_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改本人密码", captureAfter = false) @BusinessAudit(event = "PASSWORD_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改本人密码", captureAfter = false)
public void changePassword(Long accountId, ChangePasswordRequest request) { public void changePassword(Long accountId, ChangePasswordRequest request) {
AuthAccount account = required(accountId); AuthAccount account = required(accountId);
if (Boolean.TRUE.equals(account.getPasswordChangeRequired())) {
throw new BizException(ResultCode.CONFLICT, "请先使用强制改密接口设置新密码");
}
if (!passwordEncoder.matches(request.oldPassword(), account.getPasswordHash())) { if (!passwordEncoder.matches(request.oldPassword(), account.getPasswordHash())) {
throw new BizException(ResultCode.BAD_REQUEST, "原密码错误"); throw new BizException(ResultCode.BAD_REQUEST, "原密码错误");
} }
if (passwordEncoder.matches(request.newPassword(), account.getPasswordHash())) { if (passwordEncoder.matches(request.newPassword(), account.getPasswordHash())) {
throw new BizException(ResultCode.BAD_REQUEST, "新密码不能与原密码相同"); throw new BizException(ResultCode.BAD_REQUEST, "新密码不能与原密码相同");
} }
setPasswordAndRevoke(account, request.newPassword()); setPasswordAndRevoke(account, request.newPassword(), false);
}
@Transactional
@BusinessAudit(event = "PASSWORD_FORCED_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
description = "完成管理员重置后的强制改密", captureAfter = false)
public void forcedChangePassword(Long accountId, ForcedPasswordChangeRequest request) {
AuthAccount account = required(accountId);
if (!Boolean.TRUE.equals(account.getPasswordChangeRequired())) {
throw new BizException(ResultCode.CONFLICT, "当前账号不需要强制改密");
}
if (passwordEncoder.matches(request.newPassword(), account.getPasswordHash())) {
throw new BizException(ResultCode.BAD_REQUEST, "新密码不能与当前密码相同");
}
setPasswordAndRevoke(account, request.newPassword(), false);
} }
@Transactional @Transactional
@@ -93,8 +110,21 @@ public class AccountManagementService {
@Transactional @Transactional
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false) @BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
public void resetPassword(Long accountId, ResetPasswordRequest request) { public void resetPassword(Long accountId, ResetPasswordRequest request, boolean forceChangePassword) {
setPasswordAndRevoke(required(accountId), request.newPassword()); setPasswordAndRevoke(required(accountId), request.newPassword(), forceChangePassword);
}
@Transactional
@BusinessAudit(event = "CUSTOMER_ACCOUNT_ARCHIVE", resourceType = "TENANT", resourceId = "#tenantId",
description = "归档客户并撤销客户账号", captureBefore = false, captureAfter = false)
public void archiveTenant(Long tenantId) {
for (AuthAccount account : accountRepository.findMerchantByTenant(tenantId)) {
account.setAccountStatus("DISABLED");
account.setPasswordChangeRequired(false);
account.setTokenVersion((account.getTokenVersion() == null ? 0 : account.getTokenVersion()) + 1);
accountRepository.save(account);
tokenSessionStore.revokeAll(account.getId());
}
} }
@Transactional @Transactional
@@ -125,10 +155,11 @@ public class AccountManagementService {
public AccountSummary summary(Long accountId) { return summary(required(accountId)); } public AccountSummary summary(Long accountId) { return summary(required(accountId)); }
private void setPasswordAndRevoke(AuthAccount account, String password) { private void setPasswordAndRevoke(AuthAccount account, String password, boolean forceChangePassword) {
account.setPasswordHash(passwordEncoder.encode(password)); account.setPasswordHash(passwordEncoder.encode(password));
account.setFailedLoginCount(0); account.setFailedLoginCount(0);
account.setLockedUntil(null); account.setLockedUntil(null);
account.setPasswordChangeRequired(forceChangePassword);
bumpVersion(account); bumpVersion(account);
} }
@@ -27,6 +27,7 @@ import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Base64; import java.util.Base64;
import java.util.UUID; import java.util.UUID;
import java.time.Duration;
/** /**
* 统一认证应用服务,编排密码校验、IAM 权限快照加载、JWT 签发、刷新令牌轮换与注销事务。 * 统一认证应用服务,编排密码校验、IAM 权限快照加载、JWT 签发、刷新令牌轮换与注销事务。
@@ -117,6 +118,7 @@ public class AuthService {
.claim("roles", authorization.getRoles()) .claim("roles", authorization.getRoles())
.claim("permissions", authorization.getPermissions()) .claim("permissions", authorization.getPermissions())
.claim("token_version", account.getTokenVersion()) .claim("token_version", account.getTokenVersion())
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired()))
.build(); .build();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from( String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue(); JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue();
@@ -130,6 +132,58 @@ public class AuthService {
rawRefresh, properties.refreshTokenTtl().toSeconds()); rawRefresh, properties.refreshTokenTtl().toSeconds());
} }
@Transactional
@BusinessAudit(event = "CUSTOMER_IMPERSONATION_TICKET_CREATE", resourceType = "IMPERSONATION",
resourceId = "#request.tenantId", description = "创建客户代登录授权码", captureBefore = false, captureAfter = false)
public ImpersonationTicketResponse createImpersonationTicket(ImpersonationTicketRequest request) {
AuthAccount account = accountRepository.findById(request.targetAuthUserId());
if (account == null || !"ACTIVE".equals(account.getAccountStatus())
|| !"MERCHANT".equals(account.getDefaultAppCode())
|| !request.tenantId().equals(account.getDefaultTenantId())
|| !request.membershipId().equals(account.getDefaultMembershipId())) {
throw new BizException(ResultCode.CONFLICT, "目标客户管理员账号不可代登录");
}
byte[] bytes = new byte[48];
secureRandom.nextBytes(bytes);
String code = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
String ticketId = UUID.randomUUID().toString();
tokenSessionStore.storeImpersonationTicket(code,
new TokenSessionStore.ImpersonationTicket(ticketId, account.getId(), request.tenantId(),
request.membershipId(), request.actorUserId(), request.actorMembershipId(), request.reason()),
Duration.ofSeconds(60));
return new ImpersonationTicketResponse(code, ticketId, 60);
}
@Transactional
@BusinessAudit(event = "CUSTOMER_IMPERSONATION_EXCHANGE", resourceType = "IMPERSONATION",
resourceId = "#code", description = "兑换客户代登录授权码", captureBefore = false, captureAfter = false)
public ImpersonationTokenResponse exchangeImpersonationTicket(String code) {
TokenSessionStore.ImpersonationTicket ticket = tokenSessionStore.consumeImpersonationTicket(code);
if (ticket == null) throw new BizException(ResultCode.UNAUTHORIZED, "代登录授权码无效或已过期");
AuthAccount account = accountRepository.findById(ticket.targetAuthUserId());
if (account == null || !"ACTIVE".equals(account.getAccountStatus()))
throw new BizException(ResultCode.UNAUTHORIZED, "目标客户账号不可用");
AuthorizationSnapshot authorization = authorizationLoader.load(account);
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(Duration.ofMinutes(15));
String jwtId = UUID.randomUUID().toString();
JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer()).id(jwtId)
.issuedAt(issuedAt).expiresAt(expiresAt).subject(account.getId().toString())
.claim("user_id", account.getId()).claim("username", account.getUsername())
.claim("tenant_id", account.getDefaultTenantId()).claim("operator_tenant_id", account.getOperatorTenantId())
.claim("membership_id", account.getDefaultMembershipId()).claim("app_code", "MERCHANT")
.claim("client_type", authorization.getClientType()).claim("roles", authorization.getRoles())
.claim("permissions", authorization.getPermissions()).claim("token_version", account.getTokenVersion())
.claim("impersonated", true).claim("impersonation_id", ticket.ticketId())
.claim("actor_user_id", ticket.actorUserId()).claim("actor_membership_id", ticket.actorMembershipId())
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue();
tokenSessionStore.storeAccessOnly(jwtId, account.getId(), Duration.ofMinutes(15));
return new ImpersonationTokenResponse("Bearer", accessToken, 900);
}
public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn) { }
private static BizException invalidCredentials() { private static BizException invalidCredentials() {
return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误"); return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误");
} }
@@ -59,6 +59,38 @@ public class TokenSessionStore {
} }
} }
public void storeAccessOnly(String jwtId, Long userId, Duration ttl) {
try {
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
redisTemplate.opsForValue().set(accessKey, userId.toString(), ttl);
redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey);
redisTemplate.expire(userSessionsKey(userId), ttl);
} catch (Exception exception) {
throw unavailable(exception);
}
}
public void storeImpersonationTicket(String rawCode, ImpersonationTicket ticket, Duration ttl) {
try {
redisTemplate.opsForValue().set(keyPrefix + ":impersonation:" + sha256(rawCode),
objectMapper.writeValueAsString(ticket), ttl);
} catch (Exception exception) {
throw unavailable(exception);
}
}
public ImpersonationTicket consumeImpersonationTicket(String rawCode) {
try {
String value = redisTemplate.opsForValue().getAndDelete(keyPrefix + ":impersonation:" + sha256(rawCode));
if (value == null || value.isBlank()) return null;
return objectMapper.readValue(value, ImpersonationTicket.class);
} catch (DataAccessException exception) {
throw unavailable(exception);
} catch (Exception exception) {
throw new BizException(ResultCode.UNAUTHORIZED, "代登录授权码无效或已过期");
}
}
public RefreshSession consume(String rawRefreshToken) { public RefreshSession consume(String rawRefreshToken) {
try { try {
String value = redisTemplate.opsForValue().getAndDelete( String value = redisTemplate.opsForValue().getAndDelete(
@@ -136,4 +168,7 @@ public class TokenSessionStore {
} }
public record RefreshSession(Long userId, String accessJwtId) { } public record RefreshSession(Long userId, String accessJwtId) { }
public record ImpersonationTicket(String ticketId, Long targetAuthUserId, Long tenantId,
Long membershipId, Long actorUserId, Long actorMembershipId,
String reason) { }
} }
+1
View File
@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS auth_account (
failed_login_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数', failed_login_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数',
locked_until DATETIME(3) DEFAULT NULL COMMENT '临时锁定截止时间', locked_until DATETIME(3) DEFAULT NULL COMMENT '临时锁定截止时间',
token_version INT NOT NULL DEFAULT 1 COMMENT '令牌版本,递增后可使历史令牌失效', token_version INT NOT NULL DEFAULT 1 COMMENT '令牌版本,递增后可使历史令牌失效',
password_change_required TINYINT NOT NULL DEFAULT 0 COMMENT '管理员重置后是否强制下次登录改密',
default_tenant_id BIGINT NOT NULL COMMENT '默认业务租户ID', default_tenant_id BIGINT NOT NULL COMMENT '默认业务租户ID',
operator_tenant_id BIGINT NOT NULL COMMENT '操作主体租户ID,平台端与商户端身份隔离', operator_tenant_id BIGINT NOT NULL COMMENT '操作主体租户ID,平台端与商户端身份隔离',
default_membership_id BIGINT DEFAULT NULL COMMENT '默认租户成员关系ID', default_membership_id BIGINT DEFAULT NULL COMMENT '默认租户成员关系ID',
@@ -0,0 +1,11 @@
USE baiyue_auth;
SET @add_password_change_required = IF(
EXISTS(SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'auth_account'
AND column_name = 'password_change_required'),
'SELECT 1',
'ALTER TABLE auth_account ADD COLUMN password_change_required TINYINT NOT NULL DEFAULT 0 COMMENT ''管理员重置后是否强制下次登录改密'' AFTER token_version');
PREPARE phase1_customer_account_sql FROM @add_password_change_required;
EXECUTE phase1_customer_account_sql;
DEALLOCATE PREPARE phase1_customer_account_sql;
@@ -1,6 +1,7 @@
package com.baiyue.auth.service; package com.baiyue.auth.service;
import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest; import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest; import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest; import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest; import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest;
@@ -54,6 +55,25 @@ class AccountManagementServiceTest {
verify(sessions, never()).revokeAll(any()); verify(sessions, never()).revokeAll(any());
} }
@Test void forcedPasswordChangeRequiresResetFlagAndClearsIt() {
account.setPasswordChangeRequired(true);
service.forcedChangePassword(10L, new ForcedPasswordChangeRequest("NewPass2"));
assertTrue(encoder.matches("NewPass2", account.getPasswordHash()));
assertFalse(account.getPasswordChangeRequired());
verify(repository).save(account);
verify(sessions).revokeAll(10L);
}
@Test void normalPasswordChangeIsRejectedUntilForcedChangeIsCompleted() {
account.setPasswordChangeRequired(true);
assertThrows(BizException.class,
() -> service.changePassword(10L, new ChangePasswordRequest("OldPass1", "NewPass2")));
verify(repository, never()).save(any());
}
@Test void disableAccountRevokesAllSessions() { @Test void disableAccountRevokesAllSessions() {
service.updateStatus(10L, new UpdateAccountStatusRequest("DISABLED")); service.updateStatus(10L, new UpdateAccountStatusRequest("DISABLED"));
assertEquals("DISABLED", account.getAccountStatus()); assertEquals("DISABLED", account.getAccountStatus());
@@ -8,7 +8,13 @@ import java.util.Set;
*/ */
public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType, String username, String appCode, String clientType,
Set<String> permissions) implements Principal { Set<String> permissions, boolean impersonated, Long actorUserId,
Long actorMembershipId, boolean passwordChangeRequired) implements Principal {
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType, Set<String> permissions) {
this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType,
permissions, false, null, null, false);
}
public AuthenticatedUser { public AuthenticatedUser {
permissions = permissions == null ? Set.of() : Set.copyOf(permissions); permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
} }
@@ -18,4 +24,3 @@ public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantI
return username; return username;
} }
} }
@@ -34,7 +34,11 @@ public class JwtClaimsMapper {
username, username,
jwt.getClaimAsString("app_code"), jwt.getClaimAsString("app_code"),
jwt.getClaimAsString("client_type"), jwt.getClaimAsString("client_type"),
permissions); permissions,
Boolean.TRUE.equals(jwt.getClaim("impersonated")),
longClaim(jwt, "actor_user_id"),
longClaim(jwt, "actor_membership_id"),
Boolean.TRUE.equals(jwt.getClaim("password_change_required")));
List<SimpleGrantedAuthority> authorities = permissions.stream() List<SimpleGrantedAuthority> authorities = permissions.stream()
.map(SimpleGrantedAuthority::new).toList(); .map(SimpleGrantedAuthority::new).toList();
return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities); return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities);
@@ -74,4 +78,3 @@ public class JwtClaimsMapper {
return "unknown"; return "unknown";
} }
} }
@@ -49,4 +49,26 @@ class JwtClaimsMapperTest {
assertThat(authentication.getPrincipal().userId()).isEqualTo(99L); assertThat(authentication.getPrincipal().userId()).isEqualTo(99L);
assertThat(authentication.getAuthorities()).extracting("authority").contains("APP_PLATFORM"); assertThat(authentication.getAuthorities()).extracting("authority").contains("APP_PLATFORM");
} }
@Test
void mapsImpersonationAndForcedPasswordClaims() {
Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "none")
.subject("99")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(60))
.claim("tenant_id", 7L)
.claim("app_code", "MERCHANT")
.claim("impersonated", true)
.claim("actor_user_id", 42L)
.claim("actor_membership_id", 43L)
.claim("password_change_required", true)
.build();
AuthenticatedUser user = new JwtClaimsMapper().map(jwt).getPrincipal();
assertThat(user.impersonated()).isTrue();
assertThat(user.actorUserId()).isEqualTo(42L);
assertThat(user.actorMembershipId()).isEqualTo(43L);
assertThat(user.passwordChangeRequired()).isTrue();
}
} }
@@ -150,6 +150,7 @@ CREATE TABLE IF NOT EXISTS auth_account (
failed_login_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数', failed_login_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数',
locked_until DATETIME(3) DEFAULT NULL COMMENT '临时锁定截止时间', locked_until DATETIME(3) DEFAULT NULL COMMENT '临时锁定截止时间',
token_version INT NOT NULL DEFAULT 1 COMMENT '令牌版本,递增后可使历史令牌失效', token_version INT NOT NULL DEFAULT 1 COMMENT '令牌版本,递增后可使历史令牌失效',
password_change_required TINYINT NOT NULL DEFAULT 0 COMMENT '管理员重置后是否强制下次登录改密',
default_tenant_id BIGINT NOT NULL COMMENT '默认业务租户ID', default_tenant_id BIGINT NOT NULL COMMENT '默认业务租户ID',
operator_tenant_id BIGINT NOT NULL COMMENT '操作主体租户ID,平台端与商户端身份隔离', operator_tenant_id BIGINT NOT NULL COMMENT '操作主体租户ID,平台端与商户端身份隔离',
default_membership_id BIGINT DEFAULT NULL COMMENT '默认租户成员关系ID', default_membership_id BIGINT DEFAULT NULL COMMENT '默认租户成员关系ID',
@@ -163,6 +164,18 @@ CREATE TABLE IF NOT EXISTS auth_account (
KEY idx_auth_account_status (account_status, locked_until) KEY idx_auth_account_status (account_status, locked_until)
) ENGINE=InnoDB COMMENT='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态'; ) ENGINE=InnoDB COMMENT='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态';
-- 存量认证库补齐管理员重置密码后的强制改密标记(可重复执行)。
SET @auth_password_change_required_sql = IF(
EXISTS(SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'auth_account'
AND column_name = 'password_change_required'),
'SELECT 1',
"ALTER TABLE auth_account ADD COLUMN password_change_required TINYINT NOT NULL DEFAULT 0 COMMENT '管理员重置后是否强制下次登录改密' AFTER token_version"
);
PREPARE auth_password_change_required_stmt FROM @auth_password_change_required_sql;
EXECUTE auth_password_change_required_stmt;
DEALLOCATE PREPARE auth_password_change_required_stmt;
CREATE TABLE IF NOT EXISTS auth_refresh_token ( CREATE TABLE IF NOT EXISTS auth_refresh_token (
id BIGINT NOT NULL COMMENT '刷新令牌记录ID(雪花ID)', id BIGINT NOT NULL COMMENT '刷新令牌记录ID(雪花ID)',
token_hash CHAR(64) NOT NULL COMMENT '刷新令牌SHA-256摘要,不保存令牌明文', token_hash CHAR(64) NOT NULL COMMENT '刷新令牌SHA-256摘要,不保存令牌明文',
@@ -1383,6 +1396,9 @@ VALUES
(5100012, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:edit', '编辑企业客户', 20, 'ACTIVE', 1, 0), (5100012, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:edit', '编辑企业客户', 20, 'ACTIVE', 1, 0),
(5100013, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:status', '启停企业客户', 30, 'ACTIVE', 1, 0), (5100013, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:status', '启停企业客户', 30, 'ACTIVE', 1, 0),
(5100015, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:export', '导出企业客户', 40, 'ACTIVE', 1, 0), (5100015, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:export', '导出企业客户', 40, 'ACTIVE', 1, 0),
(5100016, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:reset-password', '重置客户管理员密码', 50, 'ACTIVE', 1, 0),
(5100017, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:delete', '归档企业客户', 60, 'ACTIVE', 1, 0),
(5100018, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:impersonate', '跳转客户登录', 70, 'ACTIVE', 1, 0),
(5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 1, 0), (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 1, 0),
(5101032, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:edit', '编辑权限资源', 20, 'ACTIVE', 1, 0), (5101032, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:edit', '编辑权限资源', 20, 'ACTIVE', 1, 0),
(5101033, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:status', '启停权限资源', 30, 'ACTIVE', 1, 0), (5101033, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:status', '启停权限资源', 30, 'ACTIVE', 1, 0),
@@ -22,6 +22,12 @@ public interface AuthAccountClient {
@PathVariable Long id, @RequestBody ProfileRequest request); @PathVariable Long id, @RequestBody ProfileRequest request);
@PutMapping("/{id}/password") ApiResponse<Void> reset(@RequestHeader("X-Internal-Service-Token") String token, @PutMapping("/{id}/password") ApiResponse<Void> reset(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id, @RequestBody PasswordRequest request); @PathVariable Long id, @RequestBody PasswordRequest request);
/** Idempotently disables and revokes all accounts belonging to a customer. */
@PutMapping("/tenant/{tenantId}/archive") ApiResponse<Void> archiveTenant(
@RequestHeader("X-Internal-Service-Token") String token, @PathVariable Long tenantId);
@PostMapping("/impersonation-tickets") ApiResponse<ImpersonationTicketResponse> createImpersonationTicket(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ImpersonationTicketRequest request);
@PutMapping("/{id}/scope") ApiResponse<Void> scope(@RequestHeader("X-Internal-Service-Token") String token, @PutMapping("/{id}/scope") ApiResponse<Void> scope(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id, @RequestBody ScopeRequest request); @PathVariable Long id, @RequestBody ScopeRequest request);
@DeleteMapping("/{id}/compensation") ApiResponse<Void> compensate(@RequestHeader("X-Internal-Service-Token") String token, @DeleteMapping("/{id}/compensation") ApiResponse<Void> compensate(@RequestHeader("X-Internal-Service-Token") String token,
@@ -46,6 +52,11 @@ public interface AuthAccountClient {
} }
record StatusRequest(String status) { } record StatusRequest(String status) { }
record ProfileRequest(String displayName) { } record ProfileRequest(String displayName) { }
record PasswordRequest(String newPassword) { } record PasswordRequest(String newPassword, boolean forceChangePassword) {
public PasswordRequest(String newPassword) { this(newPassword, false); }
}
record ScopeRequest(Long tenantId, Long membershipId, String appCode) { } record ScopeRequest(Long tenantId, Long membershipId, String appCode) { }
record ImpersonationTicketRequest(Long targetAuthUserId, Long tenantId, Long membershipId,
Long actorUserId, Long actorMembershipId, String reason) { }
record ImpersonationTicketResponse(String code, String ticketId, long expiresIn) { }
} }
@@ -49,6 +49,7 @@ public class MerchantTenantController {
public ApiResponse<PersonalProfileResponse> updatePersonalProfile( public ApiResponse<PersonalProfileResponse> updatePersonalProfile(
@Valid @RequestBody UpdatePersonalProfileRequest request, @Valid @RequestBody UpdatePersonalProfileRequest request,
@AuthenticationPrincipal AuthenticatedUser user) { @AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.rejectImpersonated(user);
long tenantId = TenantGuard.tenant(user, "MERCHANT"); long tenantId = TenantGuard.tenant(user, "MERCHANT");
return ApiResponse.success(personalProfileService.update(tenantId, user.userId(), user.username(), request)); return ApiResponse.success(personalProfileService.update(tenantId, user.userId(), user.username(), request));
} }
@@ -67,6 +68,7 @@ public class MerchantTenantController {
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Profile updated / 更新成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid profile / 资料不合法")}) @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Profile updated / 更新成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid profile / 资料不合法")})
public ApiResponse<TenantResponse> update(@Valid @RequestBody UpdateProfileRequest request, public ApiResponse<TenantResponse> update(@Valid @RequestBody UpdateProfileRequest request,
@AuthenticationPrincipal AuthenticatedUser user) { @AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.rejectImpersonated(user);
return ApiResponse.success(tenantService.update(TenantGuard.tenant(user, "MERCHANT"), request)); return ApiResponse.success(tenantService.update(TenantGuard.tenant(user, "MERCHANT"), request));
} }
@@ -99,6 +101,7 @@ public class MerchantTenantController {
@PreAuthorize("hasAuthority('merchant:member:create')") @PreAuthorize("hasAuthority('merchant:member:create')")
public ApiResponse<MemberResponse> addMember(@Valid @RequestBody CreateMemberRequest request, public ApiResponse<MemberResponse> addMember(@Valid @RequestBody CreateMemberRequest request,
@AuthenticationPrincipal AuthenticatedUser user) { @AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.rejectImpersonated(user);
return ApiResponse.success(memberService.create(TenantGuard.tenant(user, "MERCHANT"), request, return ApiResponse.success(memberService.create(TenantGuard.tenant(user, "MERCHANT"), request,
user.permissions())); user.permissions()));
} }
@@ -108,6 +111,7 @@ public class MerchantTenantController {
@Operation(summary = "编辑企业成员及角色") @Operation(summary = "编辑企业成员及角色")
public ApiResponse<MemberResponse> updateMember(@PathVariable Long membershipId, public ApiResponse<MemberResponse> updateMember(@PathVariable Long membershipId,
@Valid @RequestBody UpdateMemberRequest request, @AuthenticationPrincipal AuthenticatedUser user) { @Valid @RequestBody UpdateMemberRequest request, @AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.rejectImpersonated(user);
return ApiResponse.success(memberService.update(TenantGuard.tenant(user, "MERCHANT"), membershipId, request, return ApiResponse.success(memberService.update(TenantGuard.tenant(user, "MERCHANT"), membershipId, request,
user.permissions())); user.permissions()));
} }
@@ -117,6 +121,7 @@ public class MerchantTenantController {
@Operation(summary = "启用或停用企业成员") @Operation(summary = "启用或停用企业成员")
public ApiResponse<MemberResponse> changeMemberStatus(@PathVariable Long membershipId, public ApiResponse<MemberResponse> changeMemberStatus(@PathVariable Long membershipId,
@Valid @RequestBody ChangeMemberStatusRequest request, @AuthenticationPrincipal AuthenticatedUser user) { @Valid @RequestBody ChangeMemberStatusRequest request, @AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.rejectImpersonated(user);
return ApiResponse.success(memberService.changeStatus(TenantGuard.tenant(user, "MERCHANT"), return ApiResponse.success(memberService.changeStatus(TenantGuard.tenant(user, "MERCHANT"),
membershipId, request, user.membershipId())); membershipId, request, user.membershipId()));
} }
@@ -126,6 +131,7 @@ public class MerchantTenantController {
@Operation(summary = "管理员重置成员密码") @Operation(summary = "管理员重置成员密码")
public ApiResponse<Void> resetMemberPassword(@PathVariable Long membershipId, public ApiResponse<Void> resetMemberPassword(@PathVariable Long membershipId,
@Valid @RequestBody ResetMemberPasswordRequest request, @AuthenticationPrincipal AuthenticatedUser user) { @Valid @RequestBody ResetMemberPasswordRequest request, @AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.rejectImpersonated(user);
memberService.resetPassword(TenantGuard.tenant(user, "MERCHANT"), membershipId, request); memberService.resetPassword(TenantGuard.tenant(user, "MERCHANT"), membershipId, request);
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@@ -8,7 +8,10 @@ import com.baiyue.iam.dto.TenantDtos.ReviewRequest;
import com.baiyue.iam.dto.TenantDtos.TenantResponse; import com.baiyue.iam.dto.TenantDtos.TenantResponse;
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest; import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest; import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
import com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest;
import com.baiyue.iam.dto.TenantDtos.ApprovalResponse; import com.baiyue.iam.dto.TenantDtos.ApprovalResponse;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationResponse;
import com.baiyue.iam.entity.IamTenantReview; import com.baiyue.iam.entity.IamTenantReview;
import com.baiyue.iam.service.TenantGuard; import com.baiyue.iam.service.TenantGuard;
import com.baiyue.iam.service.TenantService; import com.baiyue.iam.service.TenantService;
@@ -128,6 +131,37 @@ public class PlatformTenantController {
return ApiResponse.success(service.changeMerchantStatus(tenantId, request)); return ApiResponse.success(service.changeMerchantStatus(tenantId, request));
} }
@PutMapping("/tenants/{tenantId:\\d+}/owner/password")
@PreAuthorize("hasAuthority('platform:customer:reset-password')")
@Operation(summary = "重置客户企业管理员密码")
public ApiResponse<Void> resetOwnerPassword(@PathVariable long tenantId,
@Valid @RequestBody ResetCustomerOwnerPasswordRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
service.resetOwnerPassword(tenantId, request);
return ApiResponse.success(null);
}
@PostMapping("/tenants/{tenantId:\\d+}/archive")
@PreAuthorize("hasAuthority('platform:customer:delete')")
@Operation(summary = "逻辑归档客户", description = "停用客户并撤销其全部账号,保留业务历史数据")
public ApiResponse<TenantResponse> archive(@PathVariable long tenantId,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.archiveMerchant(tenantId));
}
@PostMapping("/tenants/{tenantId:\\d+}/impersonation")
@PreAuthorize("hasAuthority('platform:customer:impersonate')")
@Operation(summary = "创建客户代登录授权码", description = "创建受审计、60 秒有效且只能兑换一次的客户企业管理员授权码")
public ApiResponse<CustomerImpersonationResponse> createImpersonation(
@PathVariable long tenantId,
@Valid @RequestBody CustomerImpersonationRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.createImpersonationTicket(tenantId, request, user));
}
@PostMapping("/tenants/{tenantId:\\d+}/submit-review") @PostMapping("/tenants/{tenantId:\\d+}/submit-review")
@PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')") @PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')")
@Operation(summary = "提交企业客户审核", description = "校验客户基础资料与必填资质后创建审核单;保存客户或上传文件不会自动进入审核。") @Operation(summary = "提交企业客户审核", description = "校验客户基础资料与必填资质后创建审核单;保存客户或上传文件不会自动进入审核。")
@@ -49,6 +49,7 @@ public class RbacController {
@Valid @RequestBody CreateRoleRequest request, @Valid @RequestBody CreateRoleRequest request,
@AuthenticationPrincipal AuthenticatedUser user) { @AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user); Scope scope = scope(app, user);
TenantGuard.rejectImpersonated(user);
return ApiResponse.success(service.createRole(scope.tenantId(), scope.appCode(), request)); return ApiResponse.success(service.createRole(scope.tenantId(), scope.appCode(), request));
} }
@@ -59,6 +60,7 @@ public class RbacController {
@Valid @RequestBody UpdateRoleRequest request, @Valid @RequestBody UpdateRoleRequest request,
@AuthenticationPrincipal AuthenticatedUser user) { @AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user); Scope scope = scope(app, user);
TenantGuard.rejectImpersonated(user);
return ApiResponse.success(service.updateRole(scope.tenantId(), scope.appCode(), roleId, request)); return ApiResponse.success(service.updateRole(scope.tenantId(), scope.appCode(), roleId, request));
} }
@@ -69,6 +71,7 @@ public class RbacController {
@Valid @RequestBody ChangeRoleStatusRequest request, @Valid @RequestBody ChangeRoleStatusRequest request,
@AuthenticationPrincipal AuthenticatedUser user) { @AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user); Scope scope = scope(app, user);
TenantGuard.rejectImpersonated(user);
return ApiResponse.success(service.changeStatus(scope.tenantId(), scope.appCode(), roleId, request)); return ApiResponse.success(service.changeStatus(scope.tenantId(), scope.appCode(), roleId, request));
} }
@@ -78,6 +81,7 @@ public class RbacController {
public ApiResponse<Void> deleteRole(@PathVariable String app, @PathVariable Long roleId, public ApiResponse<Void> deleteRole(@PathVariable String app, @PathVariable Long roleId,
@AuthenticationPrincipal AuthenticatedUser user) { @AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user); Scope scope = scope(app, user);
TenantGuard.rejectImpersonated(user);
service.deleteRole(scope.tenantId(), scope.appCode(), roleId); service.deleteRole(scope.tenantId(), scope.appCode(), roleId);
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@@ -99,6 +103,7 @@ public class RbacController {
@Valid @RequestBody AssignRolesRequest request, @Valid @RequestBody AssignRolesRequest request,
@AuthenticationPrincipal AuthenticatedUser user) { @AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user); Scope scope = scope(app, user);
TenantGuard.rejectImpersonated(user);
service.assignRoles(scope.tenantId(), scope.appCode(), request); service.assignRoles(scope.tenantId(), scope.appCode(), request);
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@@ -119,6 +124,7 @@ public class RbacController {
@Valid @RequestBody CreatePermissionRequest request, @Valid @RequestBody CreatePermissionRequest request,
@AuthenticationPrincipal AuthenticatedUser user) { @AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user); Scope scope = scope(app, user);
TenantGuard.rejectImpersonated(user);
return ApiResponse.success(service.createPermission(scope.tenantId(), scope.appCode(), request)); return ApiResponse.success(service.createPermission(scope.tenantId(), scope.appCode(), request));
} }
@@ -129,6 +135,7 @@ public class RbacController {
@Valid @RequestBody UpdatePermissionRequest request, @Valid @RequestBody UpdatePermissionRequest request,
@AuthenticationPrincipal AuthenticatedUser user) { @AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user); Scope scope = scope(app, user);
TenantGuard.rejectImpersonated(user);
return ApiResponse.success(service.updatePermission(scope.tenantId(), scope.appCode(), permissionId, request)); return ApiResponse.success(service.updatePermission(scope.tenantId(), scope.appCode(), permissionId, request));
} }
@@ -139,6 +146,7 @@ public class RbacController {
@Valid @RequestBody ChangePermissionStatusRequest request, @Valid @RequestBody ChangePermissionStatusRequest request,
@AuthenticationPrincipal AuthenticatedUser user) { @AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user); Scope scope = scope(app, user);
TenantGuard.rejectImpersonated(user);
return ApiResponse.success(service.changePermissionStatus(scope.tenantId(), scope.appCode(), permissionId, request)); return ApiResponse.success(service.changePermissionStatus(scope.tenantId(), scope.appCode(), permissionId, request));
} }
@@ -148,6 +156,7 @@ public class RbacController {
public ApiResponse<Void> deletePermission(@PathVariable String app, @PathVariable Long permissionId, public ApiResponse<Void> deletePermission(@PathVariable String app, @PathVariable Long permissionId,
@AuthenticationPrincipal AuthenticatedUser user) { @AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user); Scope scope = scope(app, user);
TenantGuard.rejectImpersonated(user);
service.deletePermission(scope.tenantId(), scope.appCode(), permissionId); service.deletePermission(scope.tenantId(), scope.appCode(), permissionId);
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@@ -159,6 +168,7 @@ public class RbacController {
@Valid @RequestBody GrantPermissionsRequest request, @Valid @RequestBody GrantPermissionsRequest request,
@AuthenticationPrincipal AuthenticatedUser user) { @AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user); Scope scope = scope(app, user);
TenantGuard.rejectImpersonated(user);
service.grantPermissions(scope.tenantId(), scope.appCode(), request); service.grantPermissions(scope.tenantId(), scope.appCode(), request);
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@@ -78,6 +78,24 @@ public final class TenantDtos {
@NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status
) { } ) { }
@Schema(name = "ResetCustomerOwnerPasswordRequest", description = "Reset the enterprise owner's login password")
public record ResetCustomerOwnerPasswordRequest(
@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
String newPassword,
Boolean forceChangePassword
) {
public boolean forceChange() { return forceChangePassword == null || forceChangePassword; }
}
@Schema(name = "CustomerImpersonationRequest", description = "Reason for audited customer owner impersonation")
public record CustomerImpersonationRequest(
@NotBlank @Size(min = 5, max = 500) String reason
) { }
@Schema(name = "CustomerImpersonationResponse", description = "One-time short-lived impersonation authorization code")
public record CustomerImpersonationResponse(String code, String ticketId, long expiresIn) { }
@Schema(name = "TenantReviewRequest", description = "Enterprise review comment / 企业审核意见") @Schema(name = "TenantReviewRequest", description = "Enterprise review comment / 企业审核意见")
public record ReviewRequest( public record ReviewRequest(
@Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过") String comment @Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过") String comment
@@ -13,6 +13,9 @@ public final class TenantGuard {
return 0L; return 0L;
} }
if(!"MERCHANT".equals(expectedApp)||user.tenantId()<=0) throw new BizException(ResultCode.FORBIDDEN,"Merchant tenant required / 缺少商户租户"); if(!"MERCHANT".equals(expectedApp)||user.tenantId()<=0) throw new BizException(ResultCode.FORBIDDEN,"Merchant tenant required / 缺少商户租户");
return user.tenantId(); return user.tenantId();
}
public static void rejectImpersonated(AuthenticatedUser user){
if(user!=null&&user.impersonated()) throw new BizException(ResultCode.FORBIDDEN,"代登录会话不能执行账号或权限安全操作");
} }
} }
@@ -6,12 +6,17 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamTenantQualification; import com.baiyue.iam.entity.IamTenantQualification;
import com.baiyue.iam.entity.IamTenantReview; import com.baiyue.iam.entity.IamTenantReview;
import com.baiyue.iam.repository.TenantRepository; import com.baiyue.iam.repository.TenantRepository;
import com.baiyue.iam.repository.ProfileResourceRepository; import com.baiyue.iam.repository.ProfileResourceRepository;
import com.baiyue.iam.client.AuthAccountClient; import com.baiyue.iam.client.AuthAccountClient;
import com.baiyue.iam.repository.MemberRepository;
import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest;
import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse; import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse;
import com.baiyue.iam.dto.TenantDtos.RegisterRequest; import com.baiyue.iam.dto.TenantDtos.RegisterRequest;
@@ -20,6 +25,8 @@ import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest;
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest; import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest; import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
import com.baiyue.iam.dto.TenantDtos.ApprovalResponse; import com.baiyue.iam.dto.TenantDtos.ApprovalResponse;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationResponse;
import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch; import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch;
import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportRow; import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportRow;
@@ -46,6 +53,8 @@ public class TenantService {
private ProfileResourceRepository profileRepository; private ProfileResourceRepository profileRepository;
@Resource @Resource
private AuthAccountClient authClient; private AuthAccountClient authClient;
@Resource
private MemberRepository memberRepository;
@Value("${baiyue.security.internal-service-token}") @Value("${baiyue.security.internal-service-token}")
private String internalToken; private String internalToken;
@Resource @Resource
@@ -53,6 +62,83 @@ public class TenantService {
@Resource @Resource
private CustomerDimensionRuleInitializationService dimensionRuleInitialization; private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@Transactional
@BusinessAudit(event = "PLATFORM_CUSTOMER_OWNER_PASSWORD_RESET", resourceType = "TENANT",
resourceId = "#tenantId", description = "平台重置客户企业管理员密码",
captureBefore = false, captureAfter = false)
public void resetOwnerPassword(long tenantId, ResetCustomerOwnerPasswordRequest request) {
requireMerchant(tenantId);
IamMembership owner = memberRepository.findOwnerByTenant(tenantId);
if (owner == null) throw new BizException(ResultCode.NOT_FOUND, "企业管理员不存在");
IamUser user = memberRepository.findUser(owner.getUserId());
if (user == null || user.getAuthUserId() == null) throw new BizException(ResultCode.NOT_FOUND, "企业管理员账号不存在");
try {
ApiResponse<Void> result = authClient.reset(internalToken, user.getAuthUserId(),
new AuthAccountClient.PasswordRequest(request.newPassword(), request.forceChange()));
if (result == null || !result.successful()) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用");
} catch (FeignException ex) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用");
}
}
@Transactional
@BusinessAudit(event = "PLATFORM_CUSTOMER_ARCHIVE", resourceType = "TENANT",
resourceId = "#tenantId", description = "平台逻辑归档客户",
captureBefore = false, captureAfter = false)
public TenantResponse archiveMerchant(long tenantId) {
IamTenant tenant = requireMerchantForUpdate(tenantId);
if ("DISABLED".equals(tenant.getStatus())) return response(tenant);
if (!Set.of("PROFILE_REQUIRED", "REJECTED").contains(tenant.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前客户状态不允许归档");
}
if (repository.findPendingReviewByTenant(tenantId) != null) {
throw new BizException(ResultCode.CONFLICT, "客户存在待审核记录,不能归档");
}
try {
ApiResponse<Void> result = authClient.archiveTenant(internalToken, tenantId);
if (result == null || !result.successful()) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用");
} catch (FeignException ex) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用");
}
tenant.setStatus("DISABLED");
repository.saveTenant(tenant);
return response(tenant);
}
@Transactional
@BusinessAudit(event = "PLATFORM_CUSTOMER_IMPERSONATION_TICKET_CREATE", resourceType = "TENANT",
resourceId = "#tenantId", description = "平台创建客户代登录授权码",
captureBefore = false, captureAfter = false)
public CustomerImpersonationResponse createImpersonationTicket(long tenantId,
CustomerImpersonationRequest request,
AuthenticatedUser actor) {
IamTenant tenant = requireMerchant(tenantId);
if (!"ACTIVE".equals(tenant.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "只有已启用客户可以代登录");
}
IamMembership owner = memberRepository.findOwnerByTenant(tenantId);
if (owner == null || !"ACTIVE".equals(owner.getStatus())) {
throw new BizException(ResultCode.NOT_FOUND, "企业管理员不存在或已停用");
}
IamUser ownerUser = memberRepository.findUser(owner.getUserId());
if (ownerUser == null || ownerUser.getAuthUserId() == null || !"ACTIVE".equals(ownerUser.getStatus())) {
throw new BizException(ResultCode.NOT_FOUND, "企业管理员账号不存在或已停用");
}
try {
ApiResponse<AuthAccountClient.ImpersonationTicketResponse> result = authClient.createImpersonationTicket(
internalToken,
new AuthAccountClient.ImpersonationTicketRequest(ownerUser.getAuthUserId(), tenantId, owner.getId(),
actor.userId(), actor.membershipId(), request.reason().trim()));
if (result == null || !result.successful() || result.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用");
}
AuthAccountClient.ImpersonationTicketResponse ticket = result.data();
return new CustomerImpersonationResponse(ticket.code(), ticket.ticketId(), ticket.expiresIn());
} catch (FeignException ex) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用");
}
}
@BusinessAudit( @BusinessAudit(
event = "TENANT_REGISTER", event = "TENANT_REGISTER",
@@ -36,6 +36,9 @@ VALUES
(5100012, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:edit', '编辑企业客户', 20, 'ACTIVE', 1, 0), (5100012, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:edit', '编辑企业客户', 20, 'ACTIVE', 1, 0),
(5100013, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:status', '启停企业客户', 30, 'ACTIVE', 1, 0), (5100013, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:status', '启停企业客户', 30, 'ACTIVE', 1, 0),
(5100015, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:export', '导出企业客户', 40, 'ACTIVE', 1, 0), (5100015, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:export', '导出企业客户', 40, 'ACTIVE', 1, 0),
(5100016, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:reset-password', '重置客户管理员密码', 50, 'ACTIVE', 1, 0),
(5100017, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:delete', '归档企业客户', 60, 'ACTIVE', 1, 0),
(5100018, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:impersonate', '跳转客户登录', 70, 'ACTIVE', 1, 0),
(5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 1, 0), (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 1, 0),
(5101032, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:edit', '编辑权限资源', 20, 'ACTIVE', 1, 0), (5101032, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:edit', '编辑权限资源', 20, 'ACTIVE', 1, 0),
(5101033, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:status', '启停权限资源', 30, 'ACTIVE', 1, 0), (5101033, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:status', '启停权限资源', 30, 'ACTIVE', 1, 0),
@@ -58,7 +61,8 @@ SELECT 4000001, id, 0
FROM iam_permission FROM iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM' WHERE tenant_id = 0 AND app_code = 'PLATFORM'
AND permission_code IN ( AND permission_code IN (
'platform:customer:edit', 'platform:customer:status', 'platform:user:grant-role', 'platform:customer:edit', 'platform:customer:status', 'platform:customer:reset-password',
'platform:customer:delete', 'platform:customer:impersonate', 'platform:user:grant-role',
'platform:permission:edit', 'platform:permission:status', 'platform:permission:delete', 'platform:permission:edit', 'platform:permission:status', 'platform:permission:delete',
'platform:express:list', 'platform:express:create', 'platform:express:edit', 'platform:express:status', 'platform:express:import', 'platform:express:list', 'platform:express:create', 'platform:express:edit', 'platform:express:status', 'platform:express:import',
'platform:counterparty:list', 'platform:counterparty:create', 'platform:counterparty:edit', 'platform:counterparty:status', 'platform:counterparty:list', 'platform:counterparty:create', 'platform:counterparty:edit', 'platform:counterparty:status',
@@ -7,6 +7,9 @@ INSERT INTO iam_permission
permission_name, sort_no, status, deleted) permission_name, sort_no, status, deleted)
VALUES VALUES
(5100011, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:create', '新增企业客户', 10, 'ACTIVE', 0), (5100011, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:create', '新增企业客户', 10, 'ACTIVE', 0),
(5100016, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:reset-password', '重置客户管理员密码', 50, 'ACTIVE', 0),
(5100017, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:delete', '归档企业客户', 60, 'ACTIVE', 0),
(5100018, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:impersonate', '跳转客户登录', 70, 'ACTIVE', 0),
(5101023, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:edit', '编辑角色', 30, 'ACTIVE', 0), (5101023, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:edit', '编辑角色', 30, 'ACTIVE', 0),
(5101024, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:status', '启停角色', 40, 'ACTIVE', 0), (5101024, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:status', '启停角色', 40, 'ACTIVE', 0),
(5101025, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:delete', '删除角色', 50, 'ACTIVE', 0), (5101025, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:delete', '删除角色', 50, 'ACTIVE', 0),
@@ -183,6 +183,63 @@ paths:
'403': {$ref: '#/components/responses/Forbidden'} '403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'} '404': {$ref: '#/components/responses/NotFound'}
'409': {$ref: '#/components/responses/Conflict'} '409': {$ref: '#/components/responses/Conflict'}
/api/platform/v1/iam/tenants/{tenantId}/owner/password:
parameters:
- {$ref: '#/components/parameters/TenantId'}
put:
tags: [Platform Tenant / 平台租户]
summary: 重置客户企业管理员密码
description: 仅重置指定客户 OWNER 的认证密码;密码不写入审计快照并撤销该账号现有会话。
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ResetCustomerOwnerPasswordRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'503': {$ref: '#/components/responses/ServiceUnavailable'}
/api/platform/v1/iam/tenants/{tenantId}/archive:
parameters:
- {$ref: '#/components/parameters/TenantId'}
post:
tags: [Platform Tenant / 平台租户]
summary: 逻辑归档客户
description: 仅停用客户及其认证账号,保留业务历史数据;重复归档幂等返回当前客户。
security: [{bearerAuth: []}]
responses:
'200': {$ref: '#/components/responses/TenantSuccess'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'409': {$ref: '#/components/responses/Conflict'}
'503': {$ref: '#/components/responses/ServiceUnavailable'}
/api/platform/v1/iam/tenants/{tenantId}/impersonation:
parameters:
- {$ref: '#/components/parameters/TenantId'}
post:
tags: [Platform Tenant / 平台租户]
summary: 创建客户代登录授权码
description: 创建 60 秒有效、只能兑换一次且不包含刷新令牌的客户 OWNER 代登录授权码。
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CustomerImpersonationRequest'}
responses:
'200':
description: 授权码创建成功
content:
application/json:
schema: {$ref: '#/components/schemas/CustomerImpersonationApiResponse'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'409': {$ref: '#/components/responses/Conflict'}
'503': {$ref: '#/components/responses/ServiceUnavailable'}
/api/merchant/v1/iam/authorization/permissions: /api/merchant/v1/iam/authorization/permissions:
get: get:
tags: [IAM Resources / IAM 资源] tags: [IAM Resources / IAM 资源]
@@ -770,6 +827,7 @@ components:
Forbidden: {description: Permission, customer scope or platform technical scope rejected / 权限、客户范围或平台技术作用域不匹配, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} Forbidden: {description: Permission, customer scope or platform technical scope rejected / 权限、客户范围或平台技术作用域不匹配, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
NotFound: {description: Resource not found / 资源不存在, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} NotFound: {description: Resource not found / 资源不存在, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
Conflict: {description: Resource conflict / 资源冲突, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} Conflict: {description: Resource conflict / 资源冲突, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
ServiceUnavailable: {description: Authentication service unavailable / 认证服务不可用, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
UnprocessableEntity: {description: 参数校验失败, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} UnprocessableEntity: {description: 参数校验失败, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
schemas: schemas:
ApiResponse: ApiResponse:
@@ -1022,6 +1080,29 @@ components:
required: [status] required: [status]
properties: properties:
status: {type: string, enum: [ACTIVE, DISABLED]} status: {type: string, enum: [ACTIVE, DISABLED]}
ResetCustomerOwnerPasswordRequest:
type: object
required: [newPassword]
properties:
newPassword: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true}
forceChangePassword: {type: boolean, default: true}
CustomerImpersonationRequest:
type: object
required: [reason]
properties:
reason: {type: string, minLength: 5, maxLength: 500}
CustomerImpersonationApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data:
type: object
required: [code, ticketId, expiresIn]
properties:
code: {type: string, writeOnly: true}
ticketId: {type: string, format: uuid}
expiresIn: {type: integer, example: 60}
ReviewRequest: ReviewRequest:
type: object type: object
properties: properties:
@@ -66,6 +66,25 @@ class PlatformTenantControllerContractTest {
returnType.getActualTypeArguments()[0]); returnType.getActualTypeArguments()[0]);
} }
@Test
void shouldExposeCustomerOwnerPasswordResetWithDedicatedPermission() throws Exception {
Method method = PlatformTenantController.class.getMethod("resetOwnerPassword", long.class,
com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest.class, AuthenticatedUser.class);
assertEquals("/tenants/{tenantId:\\d+}/owner/password", method.getAnnotation(org.springframework.web.bind.annotation.PutMapping.class).value()[0]);
assertEquals("hasAuthority('platform:customer:reset-password')", method.getAnnotation(PreAuthorize.class).value());
}
@Test
void shouldExposeArchiveAndImpersonationWithDedicatedPermissions() throws Exception {
Method archive = PlatformTenantController.class.getMethod("archive", long.class, AuthenticatedUser.class);
assertEquals("/tenants/{tenantId:\\d+}/archive", archive.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class).value()[0]);
assertEquals("hasAuthority('platform:customer:delete')", archive.getAnnotation(PreAuthorize.class).value());
Method impersonation = PlatformTenantController.class.getMethod("createImpersonation", long.class,
com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest.class, AuthenticatedUser.class);
assertEquals("/tenants/{tenantId:\\d+}/impersonation", impersonation.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class).value()[0]);
assertEquals("hasAuthority('platform:customer:impersonate')", impersonation.getAnnotation(PreAuthorize.class).value());
}
private <T extends Annotation> T annotation(Method method, int parameter, Class<T> type) { private <T extends Annotation> T annotation(Method method, int parameter, Class<T> type) {
return method.getParameters()[parameter].getAnnotation(type); return method.getParameters()[parameter].getAnnotation(type);
} }
@@ -8,6 +8,11 @@ import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest;
import com.baiyue.iam.dto.TenantDtos.RegisterRequest; import com.baiyue.iam.dto.TenantDtos.RegisterRequest;
import com.baiyue.iam.entity.IamTenantQualification; import com.baiyue.iam.entity.IamTenantQualification;
import com.baiyue.iam.entity.IamTenantReview; import com.baiyue.iam.entity.IamTenantReview;
import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.repository.TenantRepository; import com.baiyue.iam.repository.TenantRepository;
import com.baiyue.iam.repository.ProfileResourceRepository; import com.baiyue.iam.repository.ProfileResourceRepository;
import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.repository.MemberRepository;
@@ -137,6 +142,57 @@ class TenantServiceTest {
verify(authClient).availability("internal-token", "13800138000"); verify(authClient).availability("internal-token", "13800138000");
} }
@Test
void resetOwnerPasswordResolvesOwnerAuthAccountAndForwardsForceChangeFlag() {
when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ACTIVE"));
IamMembership owner = new IamMembership(); owner.setId(70L); owner.setTenantId(10001L);
owner.setUserId(71L); owner.setMemberType("OWNER"); owner.setStatus("ACTIVE");
IamUser user = new IamUser(); user.setId(71L); user.setAuthUserId(72L); user.setStatus("ACTIVE");
when(members.findOwnerByTenant(10001L)).thenReturn(owner);
when(members.findUser(71L)).thenReturn(user);
when(authClient.reset(eq("internal-token"), eq(72L), any(AuthAccountClient.PasswordRequest.class)))
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(null));
service.resetOwnerPassword(10001L, new ResetCustomerOwnerPasswordRequest("NewPass2", null));
verify(authClient).reset(eq("internal-token"), eq(72L),
argThat(request -> request.forceChangePassword() && "NewPass2".equals(request.newPassword())));
}
@Test
void archiveIsIdempotentForDisabledCustomerWithoutCallingAuth() {
IamTenant tenant = merchant(10001L, "DISABLED");
when(repository.findTenantForUpdate(10001L)).thenReturn(tenant);
assertEquals("DISABLED", service.archiveMerchant(10001L).status());
verify(authClient, never()).archiveTenant(any(), any());
verify(repository, never()).saveTenant(any());
}
@Test
void impersonationTicketUsesActiveOwnerAndCurrentPlatformActor() {
when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ACTIVE"));
IamMembership owner = new IamMembership(); owner.setId(70L); owner.setTenantId(10001L);
owner.setUserId(71L); owner.setMemberType("OWNER"); owner.setStatus("ACTIVE");
IamUser user = new IamUser(); user.setId(71L); user.setAuthUserId(72L); user.setStatus("ACTIVE");
when(members.findOwnerByTenant(10001L)).thenReturn(owner);
when(members.findUser(71L)).thenReturn(user);
when(authClient.createImpersonationTicket(eq("internal-token"), any(AuthAccountClient.ImpersonationTicketRequest.class)))
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(
new AuthAccountClient.ImpersonationTicketResponse("one-time-code", "ticket-1", 60)));
AuthenticatedUser actor = new AuthenticatedUser(9001L, 0L, 0L, 9002L,
"operator", "PLATFORM", "WEB", java.util.Set.of("platform:customer:impersonate"));
var result = service.createImpersonationTicket(10001L,
new CustomerImpersonationRequest("support investigation"), actor);
assertEquals("one-time-code", result.code());
verify(authClient).createImpersonationTicket(eq("internal-token"), argThat(request ->
request.targetAuthUserId().equals(72L) && request.tenantId().equals(10001L)
&& request.membershipId().equals(70L) && request.actorUserId().equals(9001L)
&& request.actorMembershipId().equals(9002L)));
}
@Test @Test
void platformCannotBypassReviewToActivatePendingTenant() { void platformCannotBypassReviewToActivatePendingTenant() {
when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "PENDING")); when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "PENDING"));