暂存提交

This commit is contained in:
2026-07-30 18:01:36 +08:00
commit 2f4e159356
368 changed files with 20370 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId><version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath></parent>
<artifactId>auth-service</artifactId>
<dependencies>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-oauth2-jose</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency>
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-loadbalancer</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -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) { }
}
+48
View File
@@ -0,0 +1,48 @@
server:
port: ${AUTH_PORT:9100}
spring:
application:
name: auth-service
config:
import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: ${REDIS_DATABASE:0}
timeout: ${REDIS_TIMEOUT:3s}
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public}
config.namespace: ${NACOS_NAMESPACE:public}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${AUTH_DB_USERNAME:root}
password: ${AUTH_DB_PASSWORD:123456}
baiyue.security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
issuer: ${JWT_ISSUER:auth-service}
access-token-ttl: ${ACCESS_TOKEN_TTL:30m}
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
max-login-failures: ${MAX_LOGIN_FAILURES:5}
lock-duration: ${LOGIN_LOCK_DURATION:15m}
baiyue.clients:
iam:
url: ${IAM_SERVICE_URL:}
management.endpoints.web.exposure.include: health,info,prometheus
+44
View File
@@ -0,0 +1,44 @@
SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS baiyue_auth
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_auth;
CREATE TABLE IF NOT EXISTS auth_account (
id BIGINT NOT NULL COMMENT '统一登录账号ID(雪花ID)',
username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名',
password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码',
display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称',
account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE、DISABLED、LOCKED',
failed_login_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数',
locked_until DATETIME(3) DEFAULT NULL COMMENT '临时锁定截止时间',
token_version INT NOT NULL DEFAULT 1 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',
default_app_code VARCHAR(20) NOT NULL COMMENT '默认应用端:MERCHANT或PLATFORM',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
last_login_at DATETIME(3) DEFAULT NULL COMMENT '最近一次成功登录时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_auth_account_username (username),
KEY idx_auth_account_status (account_status, locked_until)
) ENGINE=InnoDB COMMENT='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态';
CREATE TABLE IF NOT EXISTS auth_refresh_token (
id BIGINT NOT NULL COMMENT '刷新令牌记录ID(雪花ID)',
token_hash CHAR(64) NOT NULL COMMENT '刷新令牌SHA-256摘要,不保存令牌明文',
user_id BIGINT NOT NULL COMMENT '认证账号ID',
tenant_id BIGINT NOT NULL COMMENT '令牌当前业务租户ID',
operator_tenant_id BIGINT NOT NULL COMMENT '令牌操作主体租户ID',
app_code VARCHAR(20) NOT NULL COMMENT '令牌所属应用端',
expires_at DATETIME(3) NOT NULL COMMENT '到期时间',
revoked_at DATETIME(3) DEFAULT NULL COMMENT '吊销时间,非空表示不可继续使用',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '签发时间',
PRIMARY KEY (id),
UNIQUE KEY uk_auth_refresh_hash (token_hash),
KEY idx_auth_refresh_user (user_id, revoked_at, expires_at),
KEY idx_auth_refresh_expire (expires_at, revoked_at)
) ENGINE=InnoDB COMMENT='刷新令牌表:保存令牌摘要、租户上下文、有效期和轮换/吊销状态';
-- 初始管理员密码必须由部署脚本生成 BCrypt 哈希后显式写入,仓库内禁止提供默认密码。
@@ -0,0 +1,26 @@
-- Phase-one local administrator accounts.
-- Development-only initial password: ChangeMe123!
-- The BCrypt hash is intentionally stored instead of plaintext.
USE baiyue_auth;
SET NAMES utf8mb4;
INSERT INTO auth_account (
id, username, password_hash, display_name, account_status,
failed_login_count, token_version, default_tenant_id,
operator_tenant_id, default_membership_id, default_app_code, deleted
) VALUES
(1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
'平台管理员', 'ACTIVE', 0, 1, 0, 0, 3000001, 'PLATFORM', 0),
(1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
'一期演示企业管理员', 'ACTIVE', 0, 1, 10001, 10001, 3000002, 'MERCHANT', 0)
ON DUPLICATE KEY UPDATE
password_hash = VALUES(password_hash),
display_name = VALUES(display_name),
account_status = 'ACTIVE',
failed_login_count = 0,
locked_until = NULL,
default_tenant_id = VALUES(default_tenant_id),
operator_tenant_id = VALUES(operator_tenant_id),
default_membership_id = VALUES(default_membership_id),
default_app_code = VALUES(default_app_code),
deleted = 0;
@@ -0,0 +1,52 @@
package com.baiyue.auth.service;
import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AccountManagementServiceTest {
private final AuthAccountRepository repository = mock(AuthAccountRepository.class);
private final TokenSessionStore sessions = mock(TokenSessionStore.class);
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(4);
private final AccountManagementService service = new AccountManagementService();
private AuthAccount account;
@BeforeEach void setUp() {
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "passwordEncoder", encoder);
ReflectionTestUtils.setField(service, "tokenSessionStore", sessions);
account = new AuthAccount(); account.setId(10L); account.setPasswordHash(encoder.encode("OldPass1"));
account.setTokenVersion(2); account.setAccountStatus("ACTIVE");
when(repository.findById(10L)).thenReturn(account);
}
@Test void 修改密码校验旧密码并撤销全部会话() {
service.changePassword(10L, new ChangePasswordRequest("OldPass1", "NewPass2"));
assertTrue(encoder.matches("NewPass2", account.getPasswordHash()));
assertEquals(3, account.getTokenVersion());
verify(repository).save(account);
verify(sessions).revokeAll(10L);
}
@Test void 原密码错误不得写入() {
assertThrows(BizException.class,
() -> service.changePassword(10L, new ChangePasswordRequest("WrongPass1", "NewPass2")));
verify(repository, never()).save(any());
verify(sessions, never()).revokeAll(any());
}
@Test void 停用账号撤销全部会话() {
service.updateStatus(10L, new UpdateAccountStatusRequest("DISABLED"));
assertEquals("DISABLED", account.getAccountStatus());
verify(sessions).revokeAll(10L);
}
}
@@ -0,0 +1,80 @@
package com.baiyue.auth.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.data.redis.core.SetOperations;
import java.time.Duration;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.eq;
class TokenSessionStoreTest {
@Test
void storesAccessAndHashedRefreshWithIndependentTtls() {
StringRedisTemplate redis = mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> values = mock(ValueOperations.class);
when(redis.opsForValue()).thenReturn(values);
TokenSessionStore store = new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth:");
store.store("jti-1", 42L, "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7));
verify(values).set("baiyue:auth:access:jti-1", "42", Duration.ofMinutes(30));
verify(values).set(org.mockito.ArgumentMatchers.startsWith("baiyue:auth:refresh:"),
org.mockito.ArgumentMatchers.contains("\"userId\":42"), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
}
@Test
void atomicallyConsumesRefreshAndRevokesItsAccessSession() throws Exception {
StringRedisTemplate redis = mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> values = mock(ValueOperations.class);
when(redis.opsForValue()).thenReturn(values);
String json = new ObjectMapper().writeValueAsString(
new TokenSessionStore.RefreshSession(42L, "jti-1"));
when(values.getAndDelete(anyString())).thenReturn(json);
TokenSessionStore store = new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth");
TokenSessionStore.RefreshSession session = store.consume("raw-refresh");
assertThat(session.userId()).isEqualTo(42L);
assertThat(session.accessJwtId()).isEqualTo("jti-1");
verify(values).getAndDelete(org.mockito.ArgumentMatchers.startsWith("baiyue:auth:refresh:"));
verify(redis).delete("baiyue:auth:access:jti-1");
}
@Test
void treatsAlreadyConsumedRefreshAsInvalid() {
StringRedisTemplate redis = mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> values = mock(ValueOperations.class);
when(redis.opsForValue()).thenReturn(values);
when(values.getAndDelete(anyString())).thenReturn(null);
assertThat(new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth")
.consume("raw-refresh")).isNull();
}
@Test
void revokesAllIndexedSessionsForUser() {
StringRedisTemplate redis = mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
SetOperations<String, String> sets = mock(SetOperations.class);
when(redis.opsForSet()).thenReturn(sets);
when(sets.members("baiyue:auth:user-sessions:42"))
.thenReturn(Set.of("baiyue:auth:access:a", "baiyue:auth:refresh:b"));
new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth").revokeAll(42L);
verify(redis).delete(eq(Set.of("baiyue:auth:access:a", "baiyue:auth:refresh:b")));
verify(redis).delete("baiyue:auth:user-sessions:42");
}
}