优化
This commit is contained in:
@@ -8,6 +8,8 @@ import com.baiyue.auth.dto.MerchantRegisterRequest;
|
||||
import com.baiyue.auth.dto.MerchantRegisterResponse;
|
||||
import com.baiyue.auth.service.MerchantRegistrationService;
|
||||
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.responses.ApiResponses;
|
||||
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 com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
|
||||
import com.baiyue.auth.service.AccountManagementService;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
@@ -40,10 +43,22 @@ public class AuthController {
|
||||
@Operation(summary = "修改当前用户密码")
|
||||
public ApiResponse<Void> changePassword(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ChangePasswordRequest request) {
|
||||
if (user.impersonated()) throw new BizException(ResultCode.FORBIDDEN, "代登录会话不能修改密码");
|
||||
accountManagementService.changePassword(user.userId(), request);
|
||||
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
|
||||
@PostMapping("/register")
|
||||
@Operation(summary = "Merchant registration / 客户注册",
|
||||
@@ -82,4 +97,11 @@ public class AuthController {
|
||||
authService.logout(request.refreshToken());
|
||||
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")
|
||||
public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@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")
|
||||
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,
|
||||
@NotBlank @Size(min = 8, max = 64)
|
||||
@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,
|
||||
@NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
|
||||
@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 UpdateAccountStatusRequest(@NotBlank String status) { }
|
||||
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,
|
||||
@NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { }
|
||||
public record AccountSummary(Long accountId, String username, String displayName, String status,
|
||||
|
||||
@@ -24,6 +24,7 @@ public class AuthAccount {
|
||||
private Integer failedLoginCount;
|
||||
private LocalDateTime lockedUntil;
|
||||
private Integer tokenVersion;
|
||||
private Boolean passwordChangeRequired;
|
||||
private Long defaultTenantId;
|
||||
private Long operatorTenantId;
|
||||
private Long defaultMembershipId;
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.mapper.AuthAccountMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 认证账号仓储,封装按用户名或主键查询以及账号保存操作,隔离服务层与 MyBatis-Plus 查询细节。
|
||||
@@ -32,6 +33,12 @@ public class AuthAccountRepository {
|
||||
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) {
|
||||
mapper.updateById(account);
|
||||
}
|
||||
|
||||
@@ -29,13 +29,30 @@ public class AccountManagementService {
|
||||
@BusinessAudit(event = "PASSWORD_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改本人密码", captureAfter = false)
|
||||
public void changePassword(Long accountId, ChangePasswordRequest request) {
|
||||
AuthAccount account = required(accountId);
|
||||
if (Boolean.TRUE.equals(account.getPasswordChangeRequired())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "请先使用强制改密接口设置新密码");
|
||||
}
|
||||
if (!passwordEncoder.matches(request.oldPassword(), account.getPasswordHash())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "原密码错误");
|
||||
}
|
||||
if (passwordEncoder.matches(request.newPassword(), account.getPasswordHash())) {
|
||||
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
|
||||
@@ -93,8 +110,21 @@ public class AccountManagementService {
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
|
||||
public void resetPassword(Long accountId, ResetPasswordRequest request) {
|
||||
setPasswordAndRevoke(required(accountId), request.newPassword());
|
||||
public void resetPassword(Long accountId, ResetPasswordRequest request, boolean forceChangePassword) {
|
||||
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
|
||||
@@ -125,10 +155,11 @@ public class AccountManagementService {
|
||||
|
||||
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.setFailedLoginCount(0);
|
||||
account.setLockedUntil(null);
|
||||
account.setPasswordChangeRequired(forceChangePassword);
|
||||
bumpVersion(account);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.UUID;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 统一认证应用服务,编排密码校验、IAM 权限快照加载、JWT 签发、刷新令牌轮换与注销事务。
|
||||
@@ -117,6 +118,7 @@ public class AuthService {
|
||||
.claim("roles", authorization.getRoles())
|
||||
.claim("permissions", authorization.getPermissions())
|
||||
.claim("token_version", account.getTokenVersion())
|
||||
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired()))
|
||||
.build();
|
||||
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
|
||||
JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue();
|
||||
@@ -130,6 +132,58 @@ public class AuthService {
|
||||
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() {
|
||||
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) {
|
||||
try {
|
||||
String value = redisTemplate.opsForValue().getAndDelete(
|
||||
@@ -136,4 +168,7 @@ public class TokenSessionStore {
|
||||
}
|
||||
|
||||
public record RefreshSession(Long userId, String accessJwtId) { }
|
||||
public record ImpersonationTicket(String ticketId, Long targetAuthUserId, Long tenantId,
|
||||
Long membershipId, Long actorUserId, Long actorMembershipId,
|
||||
String reason) { }
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS auth_account (
|
||||
failed_login_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数',
|
||||
locked_until DATETIME(3) DEFAULT NULL 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',
|
||||
operator_tenant_id BIGINT NOT 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;
|
||||
|
||||
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.UpdateAccountProfileRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest;
|
||||
@@ -54,6 +55,25 @@ class AccountManagementServiceTest {
|
||||
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() {
|
||||
service.updateStatus(10L, new UpdateAccountStatusRequest("DISABLED"));
|
||||
assertEquals("DISABLED", account.getAccountStatus());
|
||||
|
||||
@@ -8,7 +8,13 @@ import java.util.Set;
|
||||
*/
|
||||
public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||
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 {
|
||||
permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
|
||||
}
|
||||
@@ -18,4 +24,3 @@ public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantI
|
||||
return username;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,11 @@ public class JwtClaimsMapper {
|
||||
username,
|
||||
jwt.getClaimAsString("app_code"),
|
||||
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()
|
||||
.map(SimpleGrantedAuthority::new).toList();
|
||||
return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities);
|
||||
@@ -74,4 +78,3 @@ public class JwtClaimsMapper {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,4 +49,26 @@ class JwtClaimsMapperTest {
|
||||
assertThat(authentication.getPrincipal().userId()).isEqualTo(99L);
|
||||
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 '连续登录失败次数',
|
||||
locked_until DATETIME(3) DEFAULT NULL 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',
|
||||
operator_tenant_id BIGINT NOT 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)
|
||||
) 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 (
|
||||
id BIGINT NOT NULL COMMENT '刷新令牌记录ID(雪花ID)',
|
||||
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),
|
||||
(5100013, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:status', '启停企业客户', 30, '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),
|
||||
(5101032, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:edit', '编辑权限资源', 20, '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);
|
||||
@PutMapping("/{id}/password") ApiResponse<Void> reset(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@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,
|
||||
@PathVariable Long id, @RequestBody ScopeRequest request);
|
||||
@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 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 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(
|
||||
@Valid @RequestBody UpdatePersonalProfileRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.rejectImpersonated(user);
|
||||
long tenantId = TenantGuard.tenant(user, "MERCHANT");
|
||||
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 / 资料不合法")})
|
||||
public ApiResponse<TenantResponse> update(@Valid @RequestBody UpdateProfileRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.rejectImpersonated(user);
|
||||
return ApiResponse.success(tenantService.update(TenantGuard.tenant(user, "MERCHANT"), request));
|
||||
}
|
||||
|
||||
@@ -99,6 +101,7 @@ public class MerchantTenantController {
|
||||
@PreAuthorize("hasAuthority('merchant:member:create')")
|
||||
public ApiResponse<MemberResponse> addMember(@Valid @RequestBody CreateMemberRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.rejectImpersonated(user);
|
||||
return ApiResponse.success(memberService.create(TenantGuard.tenant(user, "MERCHANT"), request,
|
||||
user.permissions()));
|
||||
}
|
||||
@@ -108,6 +111,7 @@ public class MerchantTenantController {
|
||||
@Operation(summary = "编辑企业成员及角色")
|
||||
public ApiResponse<MemberResponse> updateMember(@PathVariable Long membershipId,
|
||||
@Valid @RequestBody UpdateMemberRequest request, @AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.rejectImpersonated(user);
|
||||
return ApiResponse.success(memberService.update(TenantGuard.tenant(user, "MERCHANT"), membershipId, request,
|
||||
user.permissions()));
|
||||
}
|
||||
@@ -117,6 +121,7 @@ public class MerchantTenantController {
|
||||
@Operation(summary = "启用或停用企业成员")
|
||||
public ApiResponse<MemberResponse> changeMemberStatus(@PathVariable Long membershipId,
|
||||
@Valid @RequestBody ChangeMemberStatusRequest request, @AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.rejectImpersonated(user);
|
||||
return ApiResponse.success(memberService.changeStatus(TenantGuard.tenant(user, "MERCHANT"),
|
||||
membershipId, request, user.membershipId()));
|
||||
}
|
||||
@@ -126,6 +131,7 @@ public class MerchantTenantController {
|
||||
@Operation(summary = "管理员重置成员密码")
|
||||
public ApiResponse<Void> resetMemberPassword(@PathVariable Long membershipId,
|
||||
@Valid @RequestBody ResetMemberPasswordRequest request, @AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.rejectImpersonated(user);
|
||||
memberService.resetPassword(TenantGuard.tenant(user, "MERCHANT"), membershipId, request);
|
||||
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.UpdateTenantRequest;
|
||||
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.CustomerImpersonationRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationResponse;
|
||||
import com.baiyue.iam.entity.IamTenantReview;
|
||||
import com.baiyue.iam.service.TenantGuard;
|
||||
import com.baiyue.iam.service.TenantService;
|
||||
@@ -128,6 +131,37 @@ public class PlatformTenantController {
|
||||
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")
|
||||
@PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')")
|
||||
@Operation(summary = "提交企业客户审核", description = "校验客户基础资料与必填资质后创建审核单;保存客户或上传文件不会自动进入审核。")
|
||||
|
||||
@@ -49,6 +49,7 @@ public class RbacController {
|
||||
@Valid @RequestBody CreateRoleRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
Scope scope = scope(app, user);
|
||||
TenantGuard.rejectImpersonated(user);
|
||||
return ApiResponse.success(service.createRole(scope.tenantId(), scope.appCode(), request));
|
||||
}
|
||||
|
||||
@@ -59,6 +60,7 @@ public class RbacController {
|
||||
@Valid @RequestBody UpdateRoleRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
Scope scope = scope(app, user);
|
||||
TenantGuard.rejectImpersonated(user);
|
||||
return ApiResponse.success(service.updateRole(scope.tenantId(), scope.appCode(), roleId, request));
|
||||
}
|
||||
|
||||
@@ -69,6 +71,7 @@ public class RbacController {
|
||||
@Valid @RequestBody ChangeRoleStatusRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
Scope scope = scope(app, user);
|
||||
TenantGuard.rejectImpersonated(user);
|
||||
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,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
Scope scope = scope(app, user);
|
||||
TenantGuard.rejectImpersonated(user);
|
||||
service.deleteRole(scope.tenantId(), scope.appCode(), roleId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
@@ -99,6 +103,7 @@ public class RbacController {
|
||||
@Valid @RequestBody AssignRolesRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
Scope scope = scope(app, user);
|
||||
TenantGuard.rejectImpersonated(user);
|
||||
service.assignRoles(scope.tenantId(), scope.appCode(), request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
@@ -119,6 +124,7 @@ public class RbacController {
|
||||
@Valid @RequestBody CreatePermissionRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
Scope scope = scope(app, user);
|
||||
TenantGuard.rejectImpersonated(user);
|
||||
return ApiResponse.success(service.createPermission(scope.tenantId(), scope.appCode(), request));
|
||||
}
|
||||
|
||||
@@ -129,6 +135,7 @@ public class RbacController {
|
||||
@Valid @RequestBody UpdatePermissionRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
Scope scope = scope(app, user);
|
||||
TenantGuard.rejectImpersonated(user);
|
||||
return ApiResponse.success(service.updatePermission(scope.tenantId(), scope.appCode(), permissionId, request));
|
||||
}
|
||||
|
||||
@@ -139,6 +146,7 @@ public class RbacController {
|
||||
@Valid @RequestBody ChangePermissionStatusRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
Scope scope = scope(app, user);
|
||||
TenantGuard.rejectImpersonated(user);
|
||||
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,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
Scope scope = scope(app, user);
|
||||
TenantGuard.rejectImpersonated(user);
|
||||
service.deletePermission(scope.tenantId(), scope.appCode(), permissionId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
@@ -159,6 +168,7 @@ public class RbacController {
|
||||
@Valid @RequestBody GrantPermissionsRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
Scope scope = scope(app, user);
|
||||
TenantGuard.rejectImpersonated(user);
|
||||
service.grantPermissions(scope.tenantId(), scope.appCode(), request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,24 @@ public final class TenantDtos {
|
||||
@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 / 企业审核意见")
|
||||
public record ReviewRequest(
|
||||
@Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过") String comment
|
||||
|
||||
@@ -13,6 +13,9 @@ public final class TenantGuard {
|
||||
return 0L;
|
||||
}
|
||||
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.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import com.baiyue.iam.entity.IamTenantQualification;
|
||||
import com.baiyue.iam.entity.IamTenantReview;
|
||||
import com.baiyue.iam.repository.TenantRepository;
|
||||
import com.baiyue.iam.repository.ProfileResourceRepository;
|
||||
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.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.ChangeTenantStatusRequest;
|
||||
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.CustomerExportRow;
|
||||
|
||||
@@ -46,6 +53,8 @@ public class TenantService {
|
||||
private ProfileResourceRepository profileRepository;
|
||||
@Resource
|
||||
private AuthAccountClient authClient;
|
||||
@Resource
|
||||
private MemberRepository memberRepository;
|
||||
@Value("${baiyue.security.internal-service-token}")
|
||||
private String internalToken;
|
||||
@Resource
|
||||
@@ -53,6 +62,83 @@ public class TenantService {
|
||||
@Resource
|
||||
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(
|
||||
event = "TENANT_REGISTER",
|
||||
|
||||
@@ -36,6 +36,9 @@ VALUES
|
||||
(5100012, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:edit', '编辑企业客户', 20, '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),
|
||||
(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),
|
||||
(5101032, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:edit', '编辑权限资源', 20, '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
|
||||
WHERE tenant_id = 0 AND app_code = 'PLATFORM'
|
||||
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: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',
|
||||
|
||||
@@ -7,6 +7,9 @@ INSERT INTO iam_permission
|
||||
permission_name, sort_no, status, deleted)
|
||||
VALUES
|
||||
(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),
|
||||
(5101024, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:status', '启停角色', 40, 'ACTIVE', 0),
|
||||
(5101025, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:delete', '删除角色', 50, 'ACTIVE', 0),
|
||||
|
||||
@@ -183,6 +183,63 @@ paths:
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
'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:
|
||||
get:
|
||||
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'}}}}
|
||||
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'}}}}
|
||||
ServiceUnavailable: {description: Authentication service unavailable / 认证服务不可用, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
|
||||
UnprocessableEntity: {description: 参数校验失败, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
|
||||
schemas:
|
||||
ApiResponse:
|
||||
@@ -1022,6 +1080,29 @@ components:
|
||||
required: [status]
|
||||
properties:
|
||||
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:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -66,6 +66,25 @@ class PlatformTenantControllerContractTest {
|
||||
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) {
|
||||
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.entity.IamTenantQualification;
|
||||
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.ProfileResourceRepository;
|
||||
import com.baiyue.iam.repository.MemberRepository;
|
||||
@@ -137,6 +142,57 @@ class TenantServiceTest {
|
||||
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
|
||||
void platformCannotBypassReviewToActivatePendingTenant() {
|
||||
when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "PENDING"));
|
||||
|
||||
Reference in New Issue
Block a user