diff --git a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java index 601ecb42b..437a8fda7 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java @@ -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 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 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 exchangeImpersonation( + @Valid @RequestBody AccountManagementDtos.ImpersonationExchangeRequest request) { + return ApiResponse.success(authService.exchangeImpersonationTicket(request.code())); + } } diff --git a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java index 55d31cb86..9ee82fe11 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java @@ -63,7 +63,20 @@ public class InternalAccountController { @PutMapping("/{id}/password") public ApiResponse 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 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 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 scope(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, diff --git a/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java b/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java index 18cd61743..c36d5d9b0 100644 --- a/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java +++ b/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java @@ -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, diff --git a/auth/src/main/java/com/baiyue/auth/entity/AuthAccount.java b/auth/src/main/java/com/baiyue/auth/entity/AuthAccount.java index d2b6a4a67..a26a18d33 100644 --- a/auth/src/main/java/com/baiyue/auth/entity/AuthAccount.java +++ b/auth/src/main/java/com/baiyue/auth/entity/AuthAccount.java @@ -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; diff --git a/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java b/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java index a969f6f31..f4102abb5 100644 --- a/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java +++ b/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java @@ -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 findMerchantByTenant(Long tenantId) { + return mapper.selectList(new LambdaQueryWrapper() + .eq(AuthAccount::getDefaultTenantId, tenantId) + .eq(AuthAccount::getDefaultAppCode, "MERCHANT")); + } + public void save(AuthAccount account) { mapper.updateById(account); } diff --git a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java index 9305f96a7..66ff20523 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java @@ -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); } diff --git a/auth/src/main/java/com/baiyue/auth/service/AuthService.java b/auth/src/main/java/com/baiyue/auth/service/AuthService.java index 9fb486383..528f726a4 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -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, "用户名或密码错误"); } diff --git a/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java b/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java index 254839ee1..4ef5cdb2f 100644 --- a/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java +++ b/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java @@ -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) { } } diff --git a/auth/src/main/resources/db/auth.sql b/auth/src/main/resources/db/auth.sql index 594484e66..408de4f3f 100644 --- a/auth/src/main/resources/db/auth.sql +++ b/auth/src/main/resources/db/auth.sql @@ -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', diff --git a/auth/src/main/resources/db/phase1-customer-account-operations.sql b/auth/src/main/resources/db/phase1-customer-account-operations.sql new file mode 100644 index 000000000..2abf45552 --- /dev/null +++ b/auth/src/main/resources/db/phase1-customer-account-operations.sql @@ -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; diff --git a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java index 3cd148e1e..a32fe1a99 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java @@ -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()); diff --git a/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java b/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java index 27d1c0e0e..1f4927303 100644 --- a/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java +++ b/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java @@ -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 permissions) implements Principal { + Set 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 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; } } - diff --git a/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java b/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java index 19d798d17..e8de88771 100644 --- a/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java +++ b/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java @@ -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 authorities = permissions.stream() .map(SimpleGrantedAuthority::new).toList(); return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities); @@ -74,4 +78,3 @@ public class JwtClaimsMapper { return "unknown"; } } - diff --git a/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java b/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java index 0f47397ab..025fdffdb 100644 --- a/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java @@ -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(); + } } diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 5310fb0ce..c6e6edf47 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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), diff --git a/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java b/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java index a204d907e..a350af9dc 100644 --- a/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java +++ b/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java @@ -22,6 +22,12 @@ public interface AuthAccountClient { @PathVariable Long id, @RequestBody ProfileRequest request); @PutMapping("/{id}/password") ApiResponse 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 archiveTenant( + @RequestHeader("X-Internal-Service-Token") String token, @PathVariable Long tenantId); + @PostMapping("/impersonation-tickets") ApiResponse createImpersonationTicket( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody ImpersonationTicketRequest request); @PutMapping("/{id}/scope") ApiResponse scope(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable Long id, @RequestBody ScopeRequest request); @DeleteMapping("/{id}/compensation") ApiResponse 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) { } } diff --git a/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java index e41b8daa4..97a950ed2 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java @@ -49,6 +49,7 @@ public class MerchantTenantController { public ApiResponse 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 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 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 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 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 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); } diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index b3c1c996d..ba15e8235 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -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 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 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 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 = "校验客户基础资料与必填资质后创建审核单;保存客户或上传文件不会自动进入审核。") diff --git a/iam/src/main/java/com/baiyue/iam/controller/RbacController.java b/iam/src/main/java/com/baiyue/iam/controller/RbacController.java index c2056cee6..b23f2d870 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/RbacController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/RbacController.java @@ -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 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 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); } diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index cac507ae6..939cb4520 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -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 diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantGuard.java b/iam/src/main/java/com/baiyue/iam/service/TenantGuard.java index 11f846750..48bddcd4b 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantGuard.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantGuard.java @@ -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,"代登录会话不能执行账号或权限安全操作"); } } diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index 22329a090..cffb9e340 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -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 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 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 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", diff --git a/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql b/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql index 93e4551e0..b98cb4fe1 100644 --- a/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql +++ b/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql @@ -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', diff --git a/iam/src/main/resources/db/rbac-role-management-v2.sql b/iam/src/main/resources/db/rbac-role-management-v2.sql index dbdacdd99..5228b9b71 100644 --- a/iam/src/main/resources/db/rbac-role-management-v2.sql +++ b/iam/src/main/resources/db/rbac-role-management-v2.sql @@ -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), diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 9d93691e1..5ddcecb9a 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -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: diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java index b5e407a1f..b9a1fe415 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java @@ -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 annotation(Method method, int parameter, Class type) { return method.getParameters()[parameter].getAnnotation(type); } diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index ed6814832..051ab26ec 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -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"));