暂存提交
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.auth;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
|
||||
/**
|
||||
* 认证服务启动入口,负责装配统一登录、令牌签发与会话管理相关组件。
|
||||
*/
|
||||
@MapperScan("com.baiyue.auth.mapper")
|
||||
@EnableFeignClients(basePackages = "com.baiyue.auth.client")
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
public class BaiyueAuthApplication {
|
||||
public static void main(String[] args) { SpringApplication.run(BaiyueAuthApplication.class, args); }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.auth.client;
|
||||
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import feign.FeignException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 授权快照加载器,调用 IAM 服务并校验返回身份上下文,为认证令牌组装权限声明。
|
||||
*/
|
||||
@Component
|
||||
public class AuthorizationLoader {
|
||||
private final IamAuthorizationClient client;
|
||||
private final String internalToken;
|
||||
|
||||
public AuthorizationLoader(IamAuthorizationClient client,
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken) {
|
||||
if (internalToken == null || internalToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) {
|
||||
throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
|
||||
}
|
||||
this.client = client;
|
||||
this.internalToken = internalToken;
|
||||
}
|
||||
|
||||
public AuthorizationSnapshot load(AuthAccount account) {
|
||||
try {
|
||||
var response = client.load(internalToken, new AuthorizationSnapshotRequest(
|
||||
account.getId(), account.getDefaultTenantId(), account.getDefaultMembershipId(),
|
||||
account.getDefaultAppCode()));
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "账号没有有效的 IAM 身份或权限");
|
||||
}
|
||||
return response.data();
|
||||
} catch (FeignException exception) {
|
||||
if (exception.status() == 400 || exception.status() == 403 || exception.status() == 404) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "账号没有有效的 IAM 身份或权限");
|
||||
}
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 权限服务暂时不可用");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.auth.client;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* IAM 授权快照响应模型,承载用户身份、租户、角色、按钮/接口权限及数据范围。
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AuthorizationSnapshot {
|
||||
private Long userId;
|
||||
private Long tenantId;
|
||||
private Long membershipId;
|
||||
private String appCode;
|
||||
private String clientType;
|
||||
private Set<String> roles;
|
||||
private Set<String> permissions;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.auth.client;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* IAM 授权快照查询请求契约,描述待解析的认证账号、租户及应用权限域。
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AuthorizationSnapshotRequest {
|
||||
private Long userId;
|
||||
private Long tenantId;
|
||||
private Long membershipId;
|
||||
private String appCode;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.auth.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
/**
|
||||
* 认证服务访问 IAM 内部授权接口的声明式客户端,用于加载用户在指定应用和租户下的权限快照。
|
||||
*/
|
||||
@FeignClient(name = "iam-service", contextId = "iamAuthorizationClient",
|
||||
url = "${baiyue.clients.iam.url:}", path = "/internal/iam")
|
||||
public interface IamAuthorizationClient {
|
||||
@PostMapping("/authorization-snapshot")
|
||||
ApiResponse<AuthorizationSnapshot> load(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@RequestBody AuthorizationSnapshotRequest request);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.auth.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
@FeignClient(name = "iam-service", contextId = "iamOnboardingClient",
|
||||
url = "${baiyue.clients.iam.url:}", path = "/internal/iam")
|
||||
public interface IamOnboardingClient {
|
||||
@PostMapping("/merchant-registrations")
|
||||
ApiResponse<OnboardingResponse> register(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@RequestBody OnboardingRequest request);
|
||||
|
||||
record OnboardingRequest(Long authUserId, String displayName, String companyName,
|
||||
String legalPerson, String legalPhone, String contactEmail,
|
||||
String countryCode, String address) { }
|
||||
|
||||
record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus) { }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.auth.config;
|
||||
|
||||
import com.nimbusds.jose.jwk.source.ImmutableSecret;
|
||||
import com.baiyue.common.security.JwtAuthenticationFilter;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 认证服务安全配置,集中声明密码编码器、JWT 编码器以及认证接口的安全过滤链。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(AuthSecurityProperties.class)
|
||||
public class AuthSecurityConfig {
|
||||
@Bean
|
||||
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
|
||||
|
||||
@Bean
|
||||
JwtEncoder jwtEncoder(AuthSecurityProperties properties) {
|
||||
byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8);
|
||||
if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节");
|
||||
return new NimbusJwtEncoder(new ImmutableSecret<>(secret));
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain authSecurityFilterChain(HttpSecurity http,
|
||||
JwtAuthenticationFilter jwtFilter,
|
||||
AuthenticationEntryPoint entryPoint,
|
||||
AccessDeniedHandler deniedHandler) throws Exception {
|
||||
return http.csrf(csrf -> csrf.disable())
|
||||
.httpBasic(httpBasic -> httpBasic.disable())
|
||||
.formLogin(form -> form.disable())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.exceptionHandling(errors -> errors
|
||||
.authenticationEntryPoint(entryPoint).accessDeniedHandler(deniedHandler))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/refresh", "/api/auth/logout",
|
||||
"/actuator/health", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
|
||||
.requestMatchers("/internal/auth/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.auth.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "baiyue.security")
|
||||
public record AuthSecurityProperties(String jwtSecret, String issuer,
|
||||
Duration accessTokenTtl, Duration refreshTokenTtl,
|
||||
int maxLoginFailures, Duration lockDuration) {
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.baiyue.auth.controller;
|
||||
|
||||
import com.baiyue.auth.service.AuthService;
|
||||
import com.baiyue.auth.dto.LoginRequest;
|
||||
import com.baiyue.auth.dto.RefreshRequest;
|
||||
import com.baiyue.auth.dto.TokenResponse;
|
||||
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 io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
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.service.AccountManagementService;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
/**
|
||||
* 统一认证接口,向平台端与商户端提供登录、刷新令牌和退出登录能力。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@Tag(name = "Authentication / 统一认证", description = "Unified login, token refresh and logout. / 统一登录、令牌刷新与退出。")
|
||||
public class AuthController {
|
||||
@Resource
|
||||
private AuthService authService;
|
||||
@Resource
|
||||
private MerchantRegistrationService registrationService;
|
||||
@Resource
|
||||
private AccountManagementService accountManagementService;
|
||||
|
||||
@PutMapping("/password")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@Operation(summary = "修改当前用户密码")
|
||||
public ApiResponse<Void> changePassword(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ChangePasswordRequest request) {
|
||||
accountManagementService.changePassword(user.userId(), request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@SecurityRequirements
|
||||
@PostMapping("/register")
|
||||
@Operation(summary = "Merchant registration / 客户注册",
|
||||
description = "Create a login account and an enterprise profile awaiting one business-license upload.")
|
||||
public ApiResponse<MerchantRegisterResponse> register(@Valid @RequestBody MerchantRegisterRequest request) {
|
||||
return ApiResponse.success(registrationService.register(request));
|
||||
}
|
||||
|
||||
@SecurityRequirements
|
||||
@Operation(summary = "Login / 登录", description = "Validate credentials and issue access and refresh tokens. / 校验账号密码并签发访问令牌和刷新令牌。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Login succeeded / 登录成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数错误"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Invalid username or password / 用户名或密码错误"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "429", description = "Account temporarily locked / 账号暂时锁定")
|
||||
})
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<TokenResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||
return ApiResponse.success(authService.login(request));
|
||||
}
|
||||
|
||||
@SecurityRequirements
|
||||
@Operation(summary = "Refresh token / 刷新令牌", description = "Rotate a valid refresh token and issue a new token pair. / 轮换有效刷新令牌并签发新的令牌对。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Token refreshed / 刷新成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Refresh token invalid or expired / 刷新令牌无效或已过期")
|
||||
})
|
||||
@PostMapping("/refresh")
|
||||
public ApiResponse<TokenResponse> refresh(@Valid @RequestBody RefreshRequest request) {
|
||||
return ApiResponse.success(authService.refresh(request.refreshToken()));
|
||||
}
|
||||
|
||||
@Operation(summary = "Logout / 退出登录", description = "Revoke the supplied refresh token. / 吊销指定刷新令牌。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Logged out / 退出成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required / 需要登录")
|
||||
})
|
||||
@PostMapping("/logout")
|
||||
public ApiResponse<Void> logout(@Valid @RequestBody RefreshRequest request) {
|
||||
authService.logout(request.refreshToken());
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.baiyue.auth.controller;
|
||||
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.*;
|
||||
import com.baiyue.auth.service.AccountManagementService;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/** 仅供可信内部服务调用的账号管理接口。 */
|
||||
@RestController
|
||||
@RequestMapping("/internal/auth/accounts")
|
||||
public class InternalAccountController {
|
||||
private final AccountManagementService service;
|
||||
private final String configuredToken;
|
||||
private byte[] tokenBytes;
|
||||
|
||||
public InternalAccountController(AccountManagementService service,
|
||||
@Value("${baiyue.security.internal-service-token}") String configuredToken) {
|
||||
this.service = service;
|
||||
this.configuredToken = configuredToken;
|
||||
}
|
||||
|
||||
@PostConstruct void init() { tokenBytes = configuredToken.getBytes(StandardCharsets.UTF_8); }
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<AccountSummary> create(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@Valid @RequestBody CreateAccountRequest request) {
|
||||
verify(token); return ApiResponse.success(service.create(request));
|
||||
}
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<AccountSummary> summary(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@PathVariable Long id) {
|
||||
verify(token); return ApiResponse.success(service.summary(id));
|
||||
}
|
||||
@PutMapping("/{id}/status")
|
||||
public ApiResponse<Void> status(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@PathVariable Long id, @Valid @RequestBody UpdateAccountStatusRequest request) {
|
||||
verify(token); service.updateStatus(id, request); return ApiResponse.success(null);
|
||||
}
|
||||
@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);
|
||||
}
|
||||
@DeleteMapping("/{id}/compensation")
|
||||
public ApiResponse<Void> compensate(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@PathVariable Long id) {
|
||||
verify(token); service.compensateDelete(id); return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
private void verify(String supplied) {
|
||||
byte[] value = supplied == null ? new byte[0] : supplied.getBytes(StandardCharsets.UTF_8);
|
||||
if (!MessageDigest.isEqual(tokenBytes, value)) throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
/** 账号安全及内部账号管理契约。 */
|
||||
public final class AccountManagementDtos {
|
||||
private AccountManagementDtos() { }
|
||||
|
||||
public record ChangePasswordRequest(@NotBlank String oldPassword,
|
||||
@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)
|
||||
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
|
||||
@NotBlank String displayName, @NotNull Long tenantId,
|
||||
@NotNull Long membershipId) { }
|
||||
public record UpdateAccountStatusRequest(@NotBlank String status) { }
|
||||
public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64)
|
||||
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { }
|
||||
public record AccountSummary(Long accountId, String username, String displayName, String status) { }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 登录请求契约,接收账号、密码和目标应用域等统一登录参数。
|
||||
*/
|
||||
@Schema(name = "LoginRequest", description = "Unified login credentials / 统一登录凭据")
|
||||
public record LoginRequest(
|
||||
@Schema(description = "Login username / 登录用户名", example = "merchant_admin") @NotBlank String username,
|
||||
@Schema(description = "Login password / 登录密码", example = "ChangeMe123!", format = "password") @NotBlank String password
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
@Schema(name = "MerchantRegisterRequest", description = "Phase-one merchant account registration")
|
||||
public record MerchantRegisterRequest(
|
||||
@NotBlank @Pattern(regexp = "[A-Za-z0-9_]{4,64}") String username,
|
||||
@NotBlank @Size(min = 8, max = 72) String password,
|
||||
@NotBlank @Size(max = 64) String displayName,
|
||||
@NotBlank @Size(max = 128) String companyName,
|
||||
@NotBlank @Size(max = 64) String legalPerson,
|
||||
@NotBlank @Size(max = 32) String legalPhone,
|
||||
@Email @Size(max = 128) String contactEmail,
|
||||
@Size(min = 2, max = 2) String countryCode,
|
||||
@Size(max = 255) String address
|
||||
) { }
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.auth.dto;
|
||||
|
||||
public record MerchantRegisterResponse(
|
||||
Long accountId,
|
||||
Long tenantId,
|
||||
Long membershipId,
|
||||
String tenantStatus,
|
||||
String nextStep
|
||||
) { }
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.baiyue.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 刷新或注销请求契约,承载客户端提交的刷新令牌。
|
||||
*/
|
||||
@Schema(name = "RefreshRequest", description = "Refresh token request / 刷新令牌请求")
|
||||
public record RefreshRequest(
|
||||
@Schema(description = "Refresh token returned by login or refresh / 登录或刷新接口返回的刷新令牌", example = "eyJ...refresh-token", format = "password") @NotBlank String refreshToken
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
/**
|
||||
* 认证令牌响应契约,返回访问令牌、刷新令牌及其有效期等会话信息。
|
||||
*/
|
||||
@Schema(name = "TokenResponse", description = "Access and refresh token pair / 访问令牌与刷新令牌")
|
||||
public record TokenResponse(
|
||||
@Schema(description = "Token type / 令牌类型", example = "Bearer") String tokenType,
|
||||
@Schema(description = "JWT access token / JWT 访问令牌", format = "password") String accessToken,
|
||||
@Schema(description = "Access token lifetime in seconds / 访问令牌有效秒数", example = "1800") long expiresIn,
|
||||
@Schema(description = "Refresh token / 刷新令牌", format = "password") String refreshToken,
|
||||
@Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.baiyue.auth.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 认证账号持久化实体,对应表 {@code auth_account};保存统一登录账号、密码摘要、账号类型、租户归属和启停状态,是身份凭证的数据源。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("auth_account")
|
||||
public class AuthAccount {
|
||||
@TableId private Long id;
|
||||
private String username;
|
||||
private String passwordHash;
|
||||
private String displayName;
|
||||
private String accountStatus;
|
||||
private Integer failedLoginCount;
|
||||
private LocalDateTime lockedUntil;
|
||||
private Integer tokenVersion;
|
||||
private Long defaultTenantId;
|
||||
private Long operatorTenantId;
|
||||
private Long defaultMembershipId;
|
||||
private String defaultAppCode;
|
||||
@TableLogic private Integer deleted;
|
||||
private LocalDateTime lastLoginAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.auth.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 刷新令牌持久化实体,对应表 {@code auth_refresh_token};保存刷新令牌摘要、访问令牌标识、有效期和撤销状态,用于令牌轮换、追踪与失效控制。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("auth_refresh_token")
|
||||
public class AuthRefreshToken {
|
||||
@TableId private Long id;
|
||||
private String tokenHash;
|
||||
private Long userId;
|
||||
private Long tenantId;
|
||||
private Long operatorTenantId;
|
||||
private String appCode;
|
||||
private LocalDateTime expiresAt;
|
||||
private LocalDateTime revokedAt;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.auth.mapper;
|
||||
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
/**
|
||||
* 认证账号 MyBatis-Plus 数据访问接口,负责 {@code auth_account} 表的基础持久化操作。
|
||||
*/
|
||||
public interface AuthAccountMapper extends BaseMapper<AuthAccount> { }
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.auth.mapper;
|
||||
|
||||
import com.baiyue.auth.entity.AuthRefreshToken;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
/**
|
||||
* 刷新令牌 MyBatis-Plus 数据访问接口,负责 {@code auth_refresh_token} 表的基础持久化操作。
|
||||
*/
|
||||
public interface AuthRefreshTokenMapper extends BaseMapper<AuthRefreshToken> { }
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.baiyue.auth.repository;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 认证账号仓储,封装按用户名或主键查询以及账号保存操作,隔离服务层与 MyBatis-Plus 查询细节。
|
||||
*/
|
||||
@Repository
|
||||
public class AuthAccountRepository {
|
||||
private final AuthAccountMapper mapper;
|
||||
|
||||
public AuthAccountRepository(AuthAccountMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public AuthAccount findByUsername(String username) {
|
||||
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>()
|
||||
.eq(AuthAccount::getUsername, username)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
public boolean existsByUsername(String username) {
|
||||
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
|
||||
.eq(AuthAccount::getUsername, username)) > 0;
|
||||
}
|
||||
|
||||
public void insert(AuthAccount account) { mapper.insert(account); }
|
||||
|
||||
public AuthAccount findById(Long id) {
|
||||
return mapper.selectById(id);
|
||||
}
|
||||
|
||||
public void save(AuthAccount account) {
|
||||
mapper.updateById(account);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.auth.repository;
|
||||
|
||||
import com.baiyue.auth.entity.AuthRefreshToken;
|
||||
import com.baiyue.auth.mapper.AuthRefreshTokenMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* 刷新令牌仓储,封装令牌摘要查询、加锁读取、创建和状态更新操作。
|
||||
*/
|
||||
@Repository
|
||||
public class AuthRefreshTokenRepository {
|
||||
private final AuthRefreshTokenMapper mapper;
|
||||
|
||||
public AuthRefreshTokenRepository(AuthRefreshTokenMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public AuthRefreshToken findByHash(String tokenHash, boolean forUpdate) {
|
||||
String limit = forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1";
|
||||
return mapper.selectOne(new LambdaQueryWrapper<AuthRefreshToken>()
|
||||
.eq(AuthRefreshToken::getTokenHash, tokenHash)
|
||||
.last(limit));
|
||||
}
|
||||
|
||||
public void insert(AuthRefreshToken token) {
|
||||
mapper.insert(token);
|
||||
}
|
||||
|
||||
public void save(AuthRefreshToken token) {
|
||||
mapper.updateById(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.*;
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
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.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
|
||||
/** 账号生命周期管理,所有密码只以 BCrypt 摘要写入数据库。 */
|
||||
@Service
|
||||
public class AccountManagementService {
|
||||
@Resource private AuthAccountRepository repository;
|
||||
@Resource private PasswordEncoder passwordEncoder;
|
||||
@Resource private TokenSessionStore tokenSessionStore;
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PASSWORD_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改本人密码", captureAfter = false)
|
||||
public void changePassword(Long accountId, ChangePasswordRequest request) {
|
||||
AuthAccount account = required(accountId);
|
||||
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());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "ACCOUNT_CREATE", resourceType = "ACCOUNT",
|
||||
resourceId = "#request.accountId", description = "创建认证账号")
|
||||
public AccountSummary create(CreateAccountRequest request) {
|
||||
if (repository.existsByUsername(request.username()) || repository.findById(request.accountId()) != null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "登录账号已存在");
|
||||
}
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(request.accountId());
|
||||
account.setUsername(request.username());
|
||||
account.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||
account.setDisplayName(request.displayName());
|
||||
account.setAccountStatus("ACTIVE");
|
||||
account.setFailedLoginCount(0);
|
||||
account.setTokenVersion(1);
|
||||
account.setDefaultTenantId(request.tenantId());
|
||||
account.setOperatorTenantId(request.tenantId());
|
||||
account.setDefaultMembershipId(request.membershipId());
|
||||
account.setDefaultAppCode("MERCHANT");
|
||||
account.setDeleted(0);
|
||||
repository.insert(account);
|
||||
return summary(account);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "ACCOUNT_STATUS_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改账号状态", captureAfter = false)
|
||||
public void updateStatus(Long accountId, UpdateAccountStatusRequest request) {
|
||||
if (!"ACTIVE".equals(request.status()) && !"DISABLED".equals(request.status())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "账号状态只允许 ACTIVE 或 DISABLED");
|
||||
}
|
||||
AuthAccount account = required(accountId);
|
||||
account.setAccountStatus(request.status());
|
||||
bumpVersion(account);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
|
||||
public void resetPassword(Long accountId, ResetPasswordRequest request) {
|
||||
setPasswordAndRevoke(required(accountId), request.newPassword());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "ACCOUNT_COMPENSATE_DELETE", resourceType = "ACCOUNT",
|
||||
resourceId = "#accountId", description = "补偿删除认证账号", captureAfter = false)
|
||||
public void compensateDelete(Long accountId) {
|
||||
AuthAccount account = repository.findById(accountId);
|
||||
if (account != null) {
|
||||
account.setAccountStatus("DISABLED");
|
||||
account.setDeleted(1);
|
||||
bumpVersion(account);
|
||||
}
|
||||
}
|
||||
|
||||
public AccountSummary summary(Long accountId) { return summary(required(accountId)); }
|
||||
|
||||
private void setPasswordAndRevoke(AuthAccount account, String password) {
|
||||
account.setPasswordHash(passwordEncoder.encode(password));
|
||||
account.setFailedLoginCount(0);
|
||||
account.setLockedUntil(null);
|
||||
bumpVersion(account);
|
||||
}
|
||||
|
||||
private void bumpVersion(AuthAccount account) {
|
||||
account.setTokenVersion((account.getTokenVersion() == null ? 0 : account.getTokenVersion()) + 1);
|
||||
repository.save(account);
|
||||
tokenSessionStore.revokeAll(account.getId());
|
||||
}
|
||||
|
||||
private AuthAccount required(Long id) {
|
||||
AuthAccount account = repository.findById(id);
|
||||
if (account == null) throw new BizException(ResultCode.NOT_FOUND, "账号不存在");
|
||||
return account;
|
||||
}
|
||||
|
||||
private AccountSummary summary(AuthAccount a) {
|
||||
return new AccountSummary(a.getId(), a.getUsername(), a.getDisplayName(), a.getAccountStatus());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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.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.MacAlgorithm;
|
||||
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 java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 统一认证应用服务,编排密码校验、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;
|
||||
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.username",
|
||||
description = "User login / 用户登录", captureAfter = false)
|
||||
public TokenResponse login(LoginRequest request) {
|
||||
AuthAccount account = accountRepository.findByUsername(request.username());
|
||||
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, "账号不可用");
|
||||
return issue(account);
|
||||
}
|
||||
|
||||
@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) {
|
||||
AuthorizationSnapshot authorization = authorizationLoader.load(account);
|
||||
Instant issuedAt = Instant.now();
|
||||
String jwtId = UUID.randomUUID().toString();
|
||||
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
||||
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", account.getDefaultAppCode())
|
||||
.claim("client_type", authorization.getClientType())
|
||||
.claim("roles", authorization.getRoles())
|
||||
.claim("permissions", authorization.getPermissions())
|
||||
.claim("token_version", account.getTokenVersion())
|
||||
.build();
|
||||
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
|
||||
JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue();
|
||||
|
||||
byte[] random = new byte[48];
|
||||
secureRandom.nextBytes(random);
|
||||
String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
|
||||
tokenSessionStore.store(jwtId, account.getId(), rawRefresh,
|
||||
properties.accessTokenTtl(), properties.refreshTokenTtl());
|
||||
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
|
||||
rawRefresh, properties.refreshTokenTtl().toSeconds());
|
||||
}
|
||||
|
||||
private static BizException invalidCredentials() {
|
||||
return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.auth.client.IamOnboardingClient;
|
||||
import com.baiyue.auth.dto.MerchantRegisterRequest;
|
||||
import com.baiyue.auth.dto.MerchantRegisterResponse;
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class MerchantRegistrationService {
|
||||
@Resource private AuthAccountRepository accountRepository;
|
||||
@Resource private PasswordEncoder passwordEncoder;
|
||||
@Resource private IamOnboardingClient onboardingClient;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "MERCHANT_REGISTER", resourceType = "ACCOUNT",
|
||||
resourceId = "#request.username", description = "商户注册", captureAfter = false)
|
||||
public MerchantRegisterResponse register(MerchantRegisterRequest request) {
|
||||
if (accountRepository.existsByUsername(request.username())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Username already exists");
|
||||
}
|
||||
long accountId = IdWorker.getId();
|
||||
IamOnboardingClient.OnboardingResponse onboarding;
|
||||
try {
|
||||
var response = onboardingClient.register(internalToken,
|
||||
new IamOnboardingClient.OnboardingRequest(accountId, request.displayName(),
|
||||
request.companyName(), request.legalPerson(), request.legalPhone(),
|
||||
request.contactEmail(), request.countryCode(), request.address()));
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed");
|
||||
}
|
||||
onboarding = response.data();
|
||||
} catch (FeignException exception) {
|
||||
if (exception.status() == 409) throw new BizException(ResultCode.CONFLICT, "Enterprise already exists");
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration service is unavailable");
|
||||
}
|
||||
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(accountId);
|
||||
account.setUsername(request.username());
|
||||
account.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||
account.setDisplayName(request.displayName());
|
||||
account.setAccountStatus("ACTIVE");
|
||||
account.setFailedLoginCount(0);
|
||||
account.setTokenVersion(1);
|
||||
account.setDefaultTenantId(onboarding.tenantId());
|
||||
account.setOperatorTenantId(onboarding.tenantId());
|
||||
account.setDefaultMembershipId(onboarding.membershipId());
|
||||
account.setDefaultAppCode("MERCHANT");
|
||||
account.setDeleted(0);
|
||||
accountRepository.insert(account);
|
||||
return new MerchantRegisterResponse(accountId, onboarding.tenantId(), onboarding.membershipId(),
|
||||
onboarding.tenantStatus(), "LOGIN_AND_UPLOAD_BUSINESS_LICENSE");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.RedisTokenKeys;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.SetOperations;
|
||||
import org.springframework.data.redis.core.Cursor;
|
||||
import org.springframework.data.redis.core.ScanOptions;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Set;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 认证会话存储服务,使用 Redis 保存和一次性消费刷新会话,支持刷新令牌轮换及主动撤销。
|
||||
*/
|
||||
@Component
|
||||
public class TokenSessionStore {
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final String keyPrefix;
|
||||
|
||||
public TokenSessionStore(StringRedisTemplate redisTemplate, ObjectMapper objectMapper,
|
||||
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
this.keyPrefix = RedisTokenKeys.normalize(keyPrefix);
|
||||
}
|
||||
|
||||
public void store(String jwtId, Long userId, String rawRefreshToken,
|
||||
Duration accessTtl, Duration refreshTtl) {
|
||||
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
|
||||
String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken));
|
||||
String userKey = userSessionsKey(userId);
|
||||
try {
|
||||
redisTemplate.opsForValue().set(accessKey, userId.toString(), accessTtl);
|
||||
String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId));
|
||||
redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl);
|
||||
SetOperations<String, String> sets = redisTemplate.opsForSet();
|
||||
if (sets != null) sets.add(userKey, accessKey, refreshKey);
|
||||
redisTemplate.expire(userKey, refreshTtl);
|
||||
} catch (Exception exception) {
|
||||
try { redisTemplate.delete(Set.of(accessKey, refreshKey, userKey)); } catch (DataAccessException ignored) { }
|
||||
throw unavailable(exception);
|
||||
}
|
||||
}
|
||||
|
||||
public RefreshSession consume(String rawRefreshToken) {
|
||||
try {
|
||||
String value = redisTemplate.opsForValue().getAndDelete(
|
||||
RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken)));
|
||||
if (value == null || value.isBlank()) return null;
|
||||
RefreshSession session = objectMapper.readValue(value, RefreshSession.class);
|
||||
redisTemplate.delete(RedisTokenKeys.access(keyPrefix, session.accessJwtId()));
|
||||
return session;
|
||||
} catch (DataAccessException exception) {
|
||||
throw unavailable(exception);
|
||||
} catch (Exception exception) {
|
||||
throw new BizException(ResultCode.UNAUTHORIZED, "刷新令牌会话无效");
|
||||
}
|
||||
}
|
||||
|
||||
public void revoke(String rawRefreshToken) {
|
||||
consume(rawRefreshToken);
|
||||
}
|
||||
|
||||
/** 撤销该账号当前已登记的全部访问令牌和刷新令牌。 */
|
||||
public void revokeAll(Long userId) {
|
||||
String userKey = userSessionsKey(userId);
|
||||
try {
|
||||
Set<String> keys = redisTemplate.opsForSet().members(userKey);
|
||||
if (keys != null && !keys.isEmpty()) redisTemplate.delete(keys);
|
||||
// 兼容功能上线前尚未写入用户会话索引的存量令牌,SCAN 不阻塞 Redis 主线程。
|
||||
List<String> legacyKeys = new ArrayList<>();
|
||||
collectLegacyKeys(keyPrefix + ":access:*", userId, legacyKeys, false);
|
||||
collectLegacyKeys(keyPrefix + ":refresh:*", userId, legacyKeys, true);
|
||||
if (!legacyKeys.isEmpty()) redisTemplate.delete(legacyKeys);
|
||||
redisTemplate.delete(userKey);
|
||||
} catch (DataAccessException exception) {
|
||||
throw unavailable(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void collectLegacyKeys(String pattern, Long userId, List<String> result, boolean refresh) {
|
||||
Cursor<String> cursor = redisTemplate.scan(ScanOptions.scanOptions().match(pattern).count(500).build());
|
||||
if (cursor == null) return;
|
||||
try (cursor) {
|
||||
while (cursor.hasNext()) {
|
||||
String key = cursor.next();
|
||||
String value = redisTemplate.opsForValue().get(key);
|
||||
if ((!refresh && userId.toString().equals(value)) || (refresh && belongsTo(value, userId))) {
|
||||
result.add(key);
|
||||
}
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
throw unavailable(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean belongsTo(String value, Long userId) {
|
||||
if (value == null || value.isBlank()) return false;
|
||||
try { return userId.equals(objectMapper.readValue(value, RefreshSession.class).userId()); }
|
||||
catch (Exception ignored) { return false; }
|
||||
}
|
||||
|
||||
private String userSessionsKey(Long userId) {
|
||||
return keyPrefix + ":user-sessions:" + userId;
|
||||
}
|
||||
|
||||
private static BizException unavailable(Exception cause) {
|
||||
return new BizException(ResultCode.SERVICE_UNAVAILABLE,
|
||||
"Redis 令牌会话服务暂时不可用: " + cause.getClass().getSimpleName());
|
||||
}
|
||||
|
||||
private static String sha256(String value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
public record RefreshSession(Long userId, String accessJwtId) { }
|
||||
}
|
||||
Reference in New Issue
Block a user