This commit is contained in:
2026-08-08 09:17:42 +08:00
parent 4a6f976a91
commit 5951e6aa68
27 changed files with 598 additions and 13 deletions
@@ -8,6 +8,8 @@ import com.baiyue.auth.dto.MerchantRegisterRequest;
import com.baiyue.auth.dto.MerchantRegisterResponse;
import com.baiyue.auth.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) { }
}
+1
View File
@@ -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());