Files
baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/AuthService.java
T
2026-09-29 18:47:37 +08:00

235 lines
13 KiB
Java

package com.baiyue.auth.service;
import com.baiyue.auth.client.AuthorizationLoader;
import com.baiyue.auth.client.AuthorizationSnapshot;
import com.baiyue.auth.config.AuthSecurityProperties;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import com.baiyue.auth.dto.LoginRequest;
import com.baiyue.auth.dto.TokenResponse;
import com.baiyue.auth.dto.AccountManagementDtos.ImpersonationTicketRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ImpersonationTicketResponse;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.Resource;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.baiyue.auth.config.UnifiedJwtProperties;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.UUID;
import java.time.Duration;
import java.util.List;
/**
* 统一认证应用服务,编排密码校验、IAM 权限快照加载、小 JWT 签发、刷新令牌轮换与注销事务。
*/
@Service
public class AuthService {
@Resource
private AuthAccountRepository accountRepository;
@Resource
private PasswordEncoder passwordEncoder;
@Resource
private JwtEncoder jwtEncoder;
@Resource
private AuthSecurityProperties properties;
@Resource
private AuthorizationLoader authorizationLoader;
@Resource
private TokenSessionStore tokenSessionStore;
@Resource
private UnifiedJwtProperties signingProperties;
private final SecureRandom secureRandom = new SecureRandom();
@Transactional
@BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.loginId",
description = "User login / 用户登录", captureAfter = false)
public TokenResponse login(LoginRequest request) {
String loginId = request.loginId().trim();
AuthAccount account = loginId.matches("^1[3-9]\\d{9}$")
? accountRepository.findByPhone(loginId)
: accountRepository.findByUsername(loginId);
if (account == null) throw invalidCredentials();
LocalDateTime now = LocalDateTime.now();
if (!"ACTIVE".equals(account.getAccountStatus()))
throw new BizException(ResultCode.FORBIDDEN, "账号已禁用");
if (account.getLockedUntil() != null && account.getLockedUntil().isAfter(now))
throw new BizException(ResultCode.TOO_MANY_REQUESTS, "账号暂时锁定,请稍后重试");
if (!passwordEncoder.matches(request.password(), account.getPasswordHash())) {
recordFailure(account, now);
throw invalidCredentials();
}
account.setFailedLoginCount(0);
account.setLockedUntil(null);
account.setLastLoginAt(now);
accountRepository.save(account);
return issue(account);
}
@Transactional
@BusinessAudit(event = "AUTH_TOKEN_REFRESH", resourceType = "REFRESH_TOKEN",
description = "Refresh access token / 刷新访问令牌", captureBefore = false, captureAfter = false)
public TokenResponse refresh(String rawRefreshToken) {
TokenSessionStore.RefreshSession session = tokenSessionStore.consume(rawRefreshToken);
if (session == null) {
throw new BizException(ResultCode.UNAUTHORIZED, "刷新令牌无效或已过期");
}
AuthAccount account = accountRepository.findById(session.userId());
if (account == null || !"ACTIVE".equals(account.getAccountStatus()))
throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用");
if (session.fingerprint() == null) throw new BizException(ResultCode.UNAUTHORIZED, "刷新会话缺少授权状态");
return issue(account, session.fingerprint());
}
@Transactional
@BusinessAudit(event = "AUTH_LOGOUT", resourceType = "REFRESH_TOKEN",
description = "User logout / 用户退出", captureBefore = false, captureAfter = false)
public void logout(String rawRefreshToken) {
tokenSessionStore.revoke(rawRefreshToken);
}
private void recordFailure(AuthAccount account, LocalDateTime now) {
int failures = account.getFailedLoginCount() == null ? 1 : account.getFailedLoginCount() + 1;
account.setFailedLoginCount(failures);
if (failures >= properties.maxLoginFailures()) account.setLockedUntil(now.plus(properties.lockDuration()));
accountRepository.save(account);
}
private TokenResponse issue(AuthAccount account) { return issue(account, null); }
private TokenResponse issue(AuthAccount account, String expectedState) {
AuthorizationSnapshot authorization = authorizationLoader.load(account);
if (expectedState != null && !expectedState.equals(SessionStateService.fingerprint(account, authorization))) {
throw new BizException(ResultCode.UNAUTHORIZED, "授权状态已变更,请重新登录");
}
Instant issuedAt = Instant.now();
String jwtId = UUID.randomUUID().toString();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder()
.issuer(properties.issuer()).audience(List.of(properties.audience()))
.id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
.subject(account.getId().toString())
.claim("user_id", account.getId())
.claim("username", account.getUsername())
.claim("display_name", account.getDisplayName() == null ? "" : account.getDisplayName())
.claim("tenant_id", account.getDefaultTenantId())
.claim("operator_tenant_id", account.getOperatorTenantId())
.claim("membership_id", account.getDefaultMembershipId())
.claim("app_code", account.getDefaultAppCode())
.claim("client_type", authorization.getClientType())
.claim("token_version", account.getTokenVersion())
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired()))
.claim("userId", account.getId())
.claim("tenantId", account.getDefaultTenantId())
.claim("appCode", account.getDefaultAppCode())
.claim("permissionVersion", account.getTokenVersion());
if (authorization.getOverseasWarehouseId() != null) {
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
}
if (authorization.getDomesticWarehouseId() != null) {
claimsBuilder.claim("domestic_warehouse_id", authorization.getDomesticWarehouseId());
}
JwtClaimsSet claims = claimsBuilder.build();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
signingHeader(), claims)).getTokenValue();
byte[] random = new byte[48];
secureRandom.nextBytes(random);
String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(),
SessionStateService.fingerprint(account, authorization), rawRefresh,
properties.accessTokenTtl(), properties.refreshTokenTtl());
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
rawRefresh, properties.refreshTokenTtl().toSeconds(),
authorization.getRoles(), authorization.getPermissions());
}
@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 = "'IMPERSONATION_EXCHANGE'", 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())
.audience(List.of(properties.audience())).id(jwtId)
.issuedAt(issuedAt).expiresAt(expiresAt).subject(account.getId().toString())
.claim("user_id", account.getId()).claim("username", account.getUsername()).claim("display_name", account.getDisplayName() == null ? "" : account.getDisplayName())
.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("token_version", account.getTokenVersion())
.claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId())
.claim("appCode", "MERCHANT")
.claim("permissionVersion", 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(signingHeader(), claims)).getTokenValue();
tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(),
authorization.getPermissions(), SessionStateService.fingerprint(account, authorization), Duration.ofMinutes(15));
return new ImpersonationTokenResponse("Bearer", accessToken, 900,
authorization.getRoles(), authorization.getPermissions());
}
public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn,
java.util.Set<String> roles,
java.util.Set<String> authorities) {
public ImpersonationTokenResponse {
roles = roles == null ? java.util.Set.of() : java.util.Set.copyOf(roles);
authorities = authorities == null ? java.util.Set.of() : java.util.Set.copyOf(authorities);
}
}
private JwsHeader signingHeader() {
return JwsHeader.with(SignatureAlgorithm.RS256).keyId(signingProperties.currentKid()).build();
}
private static BizException invalidCredentials() {
return new BizException(ResultCode.UNAUTHORIZED, "登录标识或密码错误");
}
}