diff --git a/pom.xml b/pom.xml
index 867c06e..ed0c0c6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -71,6 +71,11 @@
zkh-data
${project.version}
+
+ org.springdoc
+ springdoc-openapi-common
+ 1.8.0
+
diff --git a/zkh-common/pom.xml b/zkh-common/pom.xml
index 95c4c7d..d45743d 100644
--- a/zkh-common/pom.xml
+++ b/zkh-common/pom.xml
@@ -30,6 +30,10 @@
org.springframework.data
spring-data-jpa
+
+ org.springdoc
+ springdoc-openapi-common
+
diff --git a/zkh-common/src/main/java/vip/jcfd/common/dto/LoginResponse.java b/zkh-common/src/main/java/vip/jcfd/common/dto/LoginResponse.java
new file mode 100644
index 0000000..f6eda39
--- /dev/null
+++ b/zkh-common/src/main/java/vip/jcfd/common/dto/LoginResponse.java
@@ -0,0 +1,25 @@
+package vip.jcfd.common.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+/**
+ * 登录响应DTO
+ */
+@Schema(description = "登录响应")
+public record LoginResponse(
+
+ @Schema(description = "访问令牌", example = "550e8400-e29b-41d4-a716-446655440000")
+ String accessToken,
+
+ @Schema(description = "刷新令牌", example = "550e8400-e29b-41d4-a716-446655440001")
+ String refreshToken,
+
+ @Schema(description = "令牌类型", example = "Bearer")
+ String tokenType,
+
+ @Schema(description = "访问令牌过期时间(秒)", example = "1800")
+ long expiresIn,
+
+ @Schema(description = "用户名", example = "admin")
+ String username
+) {}
diff --git a/zkh-common/src/main/java/vip/jcfd/common/dto/TokenRefreshRequest.java b/zkh-common/src/main/java/vip/jcfd/common/dto/TokenRefreshRequest.java
new file mode 100644
index 0000000..31fc11d
--- /dev/null
+++ b/zkh-common/src/main/java/vip/jcfd/common/dto/TokenRefreshRequest.java
@@ -0,0 +1,21 @@
+package vip.jcfd.common.dto;
+
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+
+/**
+ * Token刷新请求DTO
+ */
+@Schema(description = "Token刷新请求")
+public record TokenRefreshRequest(
+
+ @Parameter(description = "刷新令牌")
+ @NotBlank(message = "刷新令牌不能为空")
+ @Schema(description = "刷新令牌", example = "550e8400-e29b-41d4-a716-446655440000")
+ String refreshToken,
+
+ @Parameter(description = "设备标识")
+ @Schema(description = "设备标识", example = "web-desktop", required = false)
+ String deviceId
+) {}
diff --git a/zkh-common/src/main/java/vip/jcfd/common/dto/TokenRefreshResponse.java b/zkh-common/src/main/java/vip/jcfd/common/dto/TokenRefreshResponse.java
new file mode 100644
index 0000000..38f50e4
--- /dev/null
+++ b/zkh-common/src/main/java/vip/jcfd/common/dto/TokenRefreshResponse.java
@@ -0,0 +1,19 @@
+package vip.jcfd.common.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+/**
+ * Token刷新响应DTO
+ */
+@Schema(description = "Token刷新响应")
+public record TokenRefreshResponse(
+
+ @Schema(description = "新的访问令牌", example = "550e8400-e29b-41d4-a716-446655440000")
+ String accessToken,
+
+ @Schema(description = "新的刷新令牌", example = "550e8400-e29b-41d4-a716-446655440001")
+ String refreshToken,
+
+ @Schema(description = "令牌类型", example = "Bearer")
+ String tokenType
+) {}
diff --git a/zkh-web/pom.xml b/zkh-web/pom.xml
index 66e24e4..7789ccc 100644
--- a/zkh-web/pom.xml
+++ b/zkh-web/pom.xml
@@ -35,6 +35,12 @@
com.fasterxml.jackson.datatype
jackson-datatype-jsr310
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ 2.8.14
+ provided
+
diff --git a/zkh-web/src/main/java/vip/jcfd/web/auth/CustomDaoAuthenticationProvider.java b/zkh-web/src/main/java/vip/jcfd/web/auth/CustomDaoAuthenticationProvider.java
new file mode 100644
index 0000000..76ea409
--- /dev/null
+++ b/zkh-web/src/main/java/vip/jcfd/web/auth/CustomDaoAuthenticationProvider.java
@@ -0,0 +1,17 @@
+package vip.jcfd.web.auth;
+
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
+import org.springframework.security.core.userdetails.UserDetailsService;
+
+public class CustomDaoAuthenticationProvider extends DaoAuthenticationProvider {
+
+ public CustomDaoAuthenticationProvider(UserDetailsService userDetailsService) {
+ super(userDetailsService);
+ }
+
+ @Override
+ public boolean supports(Class> authentication) {
+ return UsernamePasswordAuthenticationToken.class.equals(authentication);
+ }
+}
diff --git a/zkh-web/src/main/java/vip/jcfd/web/auth/RefreshTokenAuthProvider.java b/zkh-web/src/main/java/vip/jcfd/web/auth/RefreshTokenAuthProvider.java
new file mode 100644
index 0000000..95b0c27
--- /dev/null
+++ b/zkh-web/src/main/java/vip/jcfd/web/auth/RefreshTokenAuthProvider.java
@@ -0,0 +1,24 @@
+package vip.jcfd.web.auth;
+
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+
+public class RefreshTokenAuthProvider extends DaoAuthenticationProvider {
+
+ public RefreshTokenAuthProvider(UserDetailsService userDetailsService) {
+ super(userDetailsService);
+ }
+
+ @Override
+ protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
+
+ }
+
+ @Override
+ public boolean supports(Class> authentication) {
+ return RefreshTokenAuthenticationToken.class.isAssignableFrom(authentication);
+ }
+}
diff --git a/zkh-web/src/main/java/vip/jcfd/web/auth/RefreshTokenAuthenticationToken.java b/zkh-web/src/main/java/vip/jcfd/web/auth/RefreshTokenAuthenticationToken.java
new file mode 100644
index 0000000..fd53e25
--- /dev/null
+++ b/zkh-web/src/main/java/vip/jcfd/web/auth/RefreshTokenAuthenticationToken.java
@@ -0,0 +1,16 @@
+package vip.jcfd.web.auth;
+
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.GrantedAuthority;
+
+import java.util.Collection;
+
+public class RefreshTokenAuthenticationToken extends UsernamePasswordAuthenticationToken {
+ public RefreshTokenAuthenticationToken(Object principal, Object credentials) {
+ super(principal, credentials);
+ }
+
+ public RefreshTokenAuthenticationToken(Object principal, Object credentials, Collection extends GrantedAuthority> authorities) {
+ super(principal, credentials, authorities);
+ }
+}
diff --git a/zkh-web/src/main/java/vip/jcfd/web/config/RedisConfig.java b/zkh-web/src/main/java/vip/jcfd/web/config/RedisConfig.java
index ed9a1f3..98d4b97 100644
--- a/zkh-web/src/main/java/vip/jcfd/web/config/RedisConfig.java
+++ b/zkh-web/src/main/java/vip/jcfd/web/config/RedisConfig.java
@@ -1,5 +1,6 @@
package vip.jcfd.web.config;
+import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
@@ -19,8 +20,13 @@ public class RedisConfig {
}
@Bean
- public TokenRedisStorage tokenRedisTemplate(RedisConnectionFactory factory, StringRedisTemplate stringRedisTemplate) {
- TokenRedisStorage tokenRedisStorage = new TokenRedisStorage(securityProps.getDuration(), stringRedisTemplate);
+ public TokenRedisStorage tokenRedisTemplate(RedisConnectionFactory factory, StringRedisTemplate stringRedisTemplate, ObjectMapper objectMapper) {
+ TokenRedisStorage tokenRedisStorage = new TokenRedisStorage(
+ securityProps.getAccessTokenDuration(),
+ securityProps.getRefreshTokenDuration(),
+ stringRedisTemplate,
+ objectMapper
+ );
tokenRedisStorage.setConnectionFactory(factory);
tokenRedisStorage.setValueSerializer(new JdkSerializationRedisSerializer());
tokenRedisStorage.setKeySerializer(new StringRedisSerializer());
diff --git a/zkh-web/src/main/java/vip/jcfd/web/config/SpringDocConfig.java b/zkh-web/src/main/java/vip/jcfd/web/config/SpringDocConfig.java
new file mode 100644
index 0000000..5c4026a
--- /dev/null
+++ b/zkh-web/src/main/java/vip/jcfd/web/config/SpringDocConfig.java
@@ -0,0 +1,72 @@
+package vip.jcfd.web.config;
+
+import io.swagger.v3.oas.models.Operation;
+import io.swagger.v3.oas.models.PathItem;
+import io.swagger.v3.oas.models.media.*;
+import io.swagger.v3.oas.models.parameters.RequestBody;
+import io.swagger.v3.oas.models.responses.ApiResponse;
+import io.swagger.v3.oas.models.responses.ApiResponses;
+import org.springdoc.core.customizers.OpenApiCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration("_springDocConfig")
+public class SpringDocConfig {
+
+ @Bean
+ public OpenApiCustomizer openApiCustomizer() {
+ return (openAPI) -> {
+ openAPI.path("/login", new PathItem()
+ .post(new Operation()
+ .summary("登录接口")
+ .description("用于用户登录,返回token")
+ .addTagsItem("认证管理")
+ .requestBody(new RequestBody()
+ .description("帐号密码")
+ .required(true)
+ .content(new Content().addMediaType("application/json", new MediaType().schema(new Schema<>()
+ .addProperty("username", new StringSchema().example("admin"))
+ .addProperty("password", new StringSchema().example("123456"))))))
+ .responses(new ApiResponses()
+ .addApiResponse("成功", new ApiResponse()
+ .content(new Content().addMediaType("application/json", new MediaType().schema(new Schema<>()
+ .addProperty("data", new JsonSchema()
+ .addProperty("accessToken", new StringSchema().example("550e8400-e29b-41d4-a716-446655440000"))
+ .addProperty("refreshToken", new StringSchema().example("550e8400-e29b-41d4-a716-446655440001"))
+ .addProperty("tokenType", new StringSchema().example("Bearer"))
+ .addProperty("expiresIn", new NumberSchema().example(1800))
+ .addProperty("username", new StringSchema().example("admin"))
+ )
+ .addProperty("success", new BooleanSchema().example(true))
+ .addProperty("code", new IntegerSchema().example(200))
+ .addProperty("message", new StringSchema().example("登录成功"))
+ ))))
+ .addApiResponse("失败", new ApiResponse()
+ .content(new Content().addMediaType("application/json", new MediaType().schema(new Schema<>()
+ .addProperty("data", new StringSchema().example(null))
+ .addProperty("success", new BooleanSchema().example(false))
+ .addProperty("code", new IntegerSchema().example(401))
+ .addProperty("message", new StringSchema().example("用户名或密码错误"))
+ ))))
+ )));
+ openAPI.path("/logout", new PathItem()
+ .post(new Operation()
+ .summary("登出接口")
+ .description("用于用户登出")
+ .addTagsItem("认证管理")
+ .responses(new ApiResponses()
+ .addApiResponse("成功", new ApiResponse()
+ .content(new Content().addMediaType("application/json", new MediaType().schema(new Schema<>()
+ .addProperty("data", new StringSchema().example(null))
+ .addProperty("success", new BooleanSchema().example(true))
+ .addProperty("code", new IntegerSchema().example(200))
+ .addProperty("message", new StringSchema().example("登出成功"))
+ )
+ )
+ )
+ )
+ )
+ ));
+ };
+ }
+}
diff --git a/zkh-web/src/main/java/vip/jcfd/web/config/WebSecurityConfig.java b/zkh-web/src/main/java/vip/jcfd/web/config/WebSecurityConfig.java
index 50cd570..e798166 100644
--- a/zkh-web/src/main/java/vip/jcfd/web/config/WebSecurityConfig.java
+++ b/zkh-web/src/main/java/vip/jcfd/web/config/WebSecurityConfig.java
@@ -19,6 +19,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
+import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -27,6 +29,7 @@ import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
@@ -37,6 +40,9 @@ import org.springframework.security.web.authentication.AuthenticationSuccessHand
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import vip.jcfd.common.core.R;
+import vip.jcfd.common.dto.LoginResponse;
+import vip.jcfd.web.auth.CustomDaoAuthenticationProvider;
+import vip.jcfd.web.auth.RefreshTokenAuthProvider;
import vip.jcfd.web.config.props.SecurityProps;
import vip.jcfd.web.filter.JsonUsernamePasswordAuthenticationFilter;
import vip.jcfd.web.filter.TokenFilter;
@@ -59,10 +65,18 @@ public class WebSecurityConfig {
private final ObjectMapper objectMapper;
private final TokenRedisStorage tokenRedisStorage;
- public WebSecurityConfig(SecurityProps securityProps, ObjectMapper objectMapper, TokenRedisStorage tokenRedisStorage) {
+ public WebSecurityConfig(SecurityProps securityProps,
+ ObjectMapper objectMapper,
+ TokenRedisStorage tokenRedisStorage,
+ AuthenticationManagerBuilder builder,
+ UserDetailsService userDetailsService) {
this.securityProps = securityProps;
this.objectMapper = objectMapper;
this.tokenRedisStorage = tokenRedisStorage;
+ builder.authenticationProvider(new RefreshTokenAuthProvider(userDetailsService));
+ DaoAuthenticationProvider authenticationProvider = new CustomDaoAuthenticationProvider(userDetailsService);
+ authenticationProvider.setPasswordEncoder(new BCryptPasswordEncoder());
+ builder.authenticationProvider(authenticationProvider);
}
@Scheduled(cron = "0 */30 * * * *")
@@ -104,8 +118,6 @@ public class WebSecurityConfig {
CustomAuthenticationEntryPoint authenticationEntryPoint = new CustomAuthenticationEntryPoint(objectMapper, tokenRedisStorage);
http.formLogin(config -> {
config.loginProcessingUrl("/login");
- config.failureHandler(authenticationEntryPoint);
- config.successHandler(authenticationEntryPoint);
});
http.csrf(AbstractHttpConfigurer::disable);
http.logout(config -> {
@@ -121,6 +133,7 @@ public class WebSecurityConfig {
http.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class);
JsonUsernamePasswordAuthenticationFilter filter = new JsonUsernamePasswordAuthenticationFilter(objectMapper, authenticationManager);
filter.setAuthenticationSuccessHandler(authenticationEntryPoint);
+ filter.setAuthenticationFailureHandler(authenticationEntryPoint);
http.addFilterAt(filter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@@ -147,12 +160,47 @@ public class WebSecurityConfig {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("用户「{}」登录成功", authentication.getName());
- String token = UUID.randomUUID().toString();
- tokenRedisStorage.put(token, authentication);
+
+ // 生成双重Token
+ String accessToken = UUID.randomUUID().toString();
+ String refreshToken = UUID.randomUUID().toString();
+
+ // 存储Access Token
+ tokenRedisStorage.putAccessToken(accessToken, authentication);
+
+ // 存储Refresh Token
+ String deviceId = extractDeviceId(request);
+ tokenRedisStorage.putRefreshToken(refreshToken, authentication.getName(), deviceId);
+
+ // 构造登录响应
+ LoginResponse loginResponse = new LoginResponse(
+ accessToken,
+ refreshToken,
+ "Bearer",
+ 1800, // 30分钟,秒数
+ authentication.getName()
+ );
+
response.setContentType("application/json;charset=UTF-8");
- R