235 lines
13 KiB
Java
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, "登录标识或密码错误");
|
|
}
|
|
|
|
}
|