This commit is contained in:
zkh
2025-11-20 18:33:49 +08:00
commit dbf4f87e7b
15 changed files with 1050 additions and 0 deletions

40
.gitignore vendored Normal file
View File

@ -0,0 +1,40 @@
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
.kotlin
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store
.idea

164
pom.xml Normal file
View File

@ -0,0 +1,164 @@
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>vip.jcfd</groupId>
<artifactId>zkh-framework</artifactId>
<version>1.0</version>
<packaging>pom</packaging>
<name>ZKH Framework</name>
<description>A Java framework for ZKH applications</description>
<url>https://gitea.jcfd.vip/zkh/zkh-framework</url>
<licenses>
<license>
<name>The Apache Software License, Version 2.0</name>
<url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
</license>
</licenses>
<developers>
<developer>
<name>zkh</name>
<email>1650697374@qq.com</email>
<organization>横球集团</organization>
<organizationUrl>https://www.jcfd.vip</organizationUrl>
</developer>
</developers>
<scm>
<connection>scm:git:git://gitea.jcfd.vip/zkh/zkh-framework.git</connection>
<developerConnection>scm:git:ssh://gitea.jcfd.vip:zkh/zkh-framework.git</developerConnection>
<url>https://gitea.jcfd.vip/zkh/zkh-framework</url>
</scm>
<modules>
<module>zkh-common</module>
<module>zkh-web</module>
<module>zkh-data</module>
</modules>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-boot.version>3.5.7</spring-boot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>vip.jcfd</groupId>
<artifactId>zkh-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>vip.jcfd</groupId>
<artifactId>zkh-web</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<profiles>
<profile>
<id>release</id>
<build>
<plugins>
<!-- Maven GPG Plugin - 用于签名构件 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-gpg-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<id>sign-artifacts</id>
<phase>verify</phase>
<goals>
<goal>sign</goal>
</goals>
</execution>
</executions>
<configuration>
<!-- 使用环境变量中的gpg密钥密码 -->
<passphrase>${env.GPG_PASSPHRASE}</passphrase>
<!-- 指定GPG可执行文件路径 -->
<executable>gpg</executable>
<!-- 使用默认密钥 -->
<useAgent>true</useAgent>
<!-- 设置gpg主目录 -->
<gpgArguments>
<arg>--pinentry-mode</arg>
<arg>loopback</arg>
</gpgArguments>
</configuration>
</plugin>
<!-- Central Publishing Plugin - 发布到Maven Central -->
<plugin>
<groupId>org.sonatype.central</groupId>
<artifactId>central-publishing-maven-plugin</artifactId>
<version>0.9.0</version>
<extensions>true</extensions>
<configuration>
<publishingServerId>central</publishingServerId>
<!-- 确保所有文件都被签名 -->
<autoPublish>true</autoPublish>
</configuration>
</plugin>
<!-- 在发布时激活source和javadoc插件 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<version>3.3.0</version>
<executions>
<execution>
<id>attach-sources</id>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>attach-javadocs</id>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- GPG签名 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-gpg-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<id>sign-artifacts</id>
<phase>verify</phase>
<goals>
<goal>sign</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

65
zkh-common/pom.xml Normal file
View File

@ -0,0 +1,65 @@
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>vip.jcfd</groupId>
<artifactId>zkh-framework</artifactId>
<version>1.0</version>
</parent>
<artifactId>zkh-common</artifactId>
<name>ZKH Common</name>
<description>Common utilities and base classes for ZKH framework</description>
<dependencies>
<dependency>
<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-commons</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-jpa</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<version>3.2.1</version>
<executions>
<execution>
<id>attach-sources</id>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<version>3.4.1</version>
<executions>
<execution>
<id>attach-javadocs</id>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,79 @@
package vip.jcfd.common.core;
import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.persistence.*;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Column
@CreatedDate
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@Column
@LastModifiedDate
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
@Column
@CreatedBy
private String createBy;
@Column
@LastModifiedBy
private String updateBy;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public LocalDateTime getCreateTime() {
return createTime;
}
public void setCreateTime(LocalDateTime createTime) {
this.createTime = createTime;
}
public LocalDateTime getUpdateTime() {
return updateTime;
}
public void setUpdateTime(LocalDateTime updateTime) {
this.updateTime = updateTime;
}
public String getCreateBy() {
return createBy;
}
public void setCreateBy(String createBy) {
this.createBy = createBy;
}
public String getUpdateBy() {
return updateBy;
}
public void setUpdateBy(String updateBy) {
this.updateBy = updateBy;
}
}

View File

@ -0,0 +1,23 @@
package vip.jcfd.common.core;
public class BizException extends RuntimeException {
public BizException() {
}
public BizException(String message) {
super(message);
}
public BizException(String message, Throwable cause) {
super(message, cause);
}
public BizException(Throwable cause) {
super(cause);
}
public BizException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
}

View File

@ -0,0 +1,69 @@
package vip.jcfd.common.core;
public class R<T> {
private int code;
private String message;
private boolean success;
private T data;
public R() {
this.code = 200;
this.message = "操作成功";
this.success = true;
}
public R(int code, String message, boolean success, T data) {
this.code = code;
this.message = message;
this.success = success;
this.data = data;
}
public static <T> R<T> success(T data) {
return new R<>(200, "操作成功", true, data);
}
public static <T> R<T> success(String message) {
return new R<>(200, message, true, null);
}
public static <T> R<T> error(String message) {
return new R<>(400, message, false, null);
}
public static <T> R<T> serverError(String message) {
return new R<>(500, message, false, null);
}
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public boolean isSuccess() {
return success;
}
public void setSuccess(boolean success) {
this.success = success;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
}

52
zkh-data/pom.xml Normal file
View File

@ -0,0 +1,52 @@
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>vip.jcfd</groupId>
<artifactId>zkh-framework</artifactId>
<version>1.0</version>
</parent>
<artifactId>zkh-data</artifactId>
<name>ZKH Data</name>
<description>Data layer components for ZKH framework</description>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<version>3.2.1</version>
<executions>
<execution>
<id>attach-sources</id>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<version>3.4.1</version>
<executions>
<execution>
<id>attach-javadocs</id>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

71
zkh-web/pom.xml Normal file
View File

@ -0,0 +1,71 @@
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>vip.jcfd</groupId>
<artifactId>zkh-framework</artifactId>
<version>1.0</version>
</parent>
<artifactId>zkh-web</artifactId>
<name>ZKH Web</name>
<description>Web components for ZKH framework</description>
<dependencies>
<dependency>
<groupId>vip.jcfd</groupId>
<artifactId>zkh-common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</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-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<version>3.2.1</version>
<executions>
<execution>
<id>attach-sources</id>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<version>3.4.1</version>
<executions>
<execution>
<id>attach-javadocs</id>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,33 @@
package vip.jcfd.web.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import vip.jcfd.common.core.BizException;
import vip.jcfd.common.core.R;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(value = Exception.class)
public R<String> handleException(Exception e) {
log.error("服务异常", e);
return R.serverError("服务器繁忙,请稍候重试");
}
@ExceptionHandler(value = BizException.class)
public R<String> handleBizException(BizException e) {
log.error("业务异常", e);
return R.error(e.getMessage());
}
@ExceptionHandler(value = NoResourceFoundException.class)
public R<String> handleNotFoundException(NoResourceFoundException e) {
log.error("404异常", e);
return new R<>(404, "您访问的地址不存在", false, null);
}
}

View File

@ -0,0 +1,29 @@
package vip.jcfd.web.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import vip.jcfd.web.config.props.SecurityProps;
import vip.jcfd.web.redis.TokenRedisStorage;
@Configuration
public class RedisConfig {
private final SecurityProps securityProps;
public RedisConfig(SecurityProps securityProps) {
this.securityProps = securityProps;
}
@Bean
public TokenRedisStorage tokenRedisTemplate(RedisConnectionFactory factory, StringRedisTemplate stringRedisTemplate) {
TokenRedisStorage tokenRedisStorage = new TokenRedisStorage(securityProps.getDuration(), stringRedisTemplate);
tokenRedisStorage.setConnectionFactory(factory);
tokenRedisStorage.setValueSerializer(new JdkSerializationRedisSerializer());
tokenRedisStorage.setKeySerializer(new StringRedisSerializer());
return tokenRedisStorage;
}
}

View File

@ -0,0 +1,202 @@
package vip.jcfd.web.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.http.HttpHeaders;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
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.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import vip.jcfd.common.core.R;
import vip.jcfd.web.config.props.SecurityProps;
import vip.jcfd.web.filter.JsonUsernamePasswordAuthenticationFilter;
import vip.jcfd.web.filter.TokenFilter;
import vip.jcfd.web.redis.TokenRedisStorage;
import java.io.IOException;
import java.util.Optional;
import java.util.UUID;
@Configuration
@EnableWebSecurity
@ConfigurationPropertiesScan(basePackageClasses = {SecurityProps.class})
@EnableJpaAuditing
@EnableAsync
@EnableScheduling
public class WebSecurityConfig {
private static final Logger log = LoggerFactory.getLogger(WebSecurityConfig.class);
private final SecurityProps securityProps;
private final ObjectMapper objectMapper;
private final TokenRedisStorage tokenRedisStorage;
public WebSecurityConfig(SecurityProps securityProps, ObjectMapper objectMapper, TokenRedisStorage tokenRedisStorage) {
this.securityProps = securityProps;
this.objectMapper = objectMapper;
this.tokenRedisStorage = tokenRedisStorage;
}
@Scheduled(cron = "0 */30 * * * *")
@Async
public void scheduleClearExpiredTokens() {
tokenRedisStorage.clearExpiredTokens();
}
@Bean
public AuditorAware<String> auditorAware() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext())
.map(SecurityContext::getAuthentication)
.map(Authentication::getName)
.or(() -> Optional.of("system"));
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@ConditionalOnMissingBean
public TokenFilter tokenFilter() {
return new TokenFilter(tokenRedisStorage);
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain security(HttpSecurity http, TokenFilter tokenFilter, AuthenticationManager authenticationManager) throws Exception {
http.authorizeHttpRequests(config -> {
config.requestMatchers(securityProps.getIgnoreUrls()).permitAll();
config.anyRequest().authenticated();
});
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 -> {
config.addLogoutHandler(new CustomLogoutSuccessHandler(objectMapper, tokenRedisStorage));
});
http.rememberMe(AbstractHttpConfigurer::disable);
http.sessionManagement(AbstractHttpConfigurer::disable);
http.exceptionHandling(config -> {
config.authenticationEntryPoint(authenticationEntryPoint);
config.accessDeniedHandler(new CustomAccessDeniedHandler(objectMapper));
});
http.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class);
JsonUsernamePasswordAuthenticationFilter filter = new JsonUsernamePasswordAuthenticationFilter(objectMapper, authenticationManager);
filter.setAuthenticationSuccessHandler(authenticationEntryPoint);
http.addFilterAt(filter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
private record CustomAuthenticationEntryPoint(
ObjectMapper objectMapper,
TokenRedisStorage tokenRedisStorage) implements AuthenticationEntryPoint, AuthenticationFailureHandler, AuthenticationSuccessHandler {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
log.warn("认证失败", authException);
R<Object> data = new R<>(HttpServletResponse.SC_UNAUTHORIZED, "未登录", false, null);
response.setContentType("application/json;charset=UTF-8");
objectMapper.writeValue(response.getWriter(), data);
}
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
log.warn("登录失败", exception);
R<Object> data = new R<>(HttpServletResponse.SC_UNAUTHORIZED, "用户名或密码错误", false, null);
response.setContentType("application/json;charset=UTF-8");
objectMapper.writeValue(response.getWriter(), data);
}
@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);
response.setContentType("application/json;charset=UTF-8");
R<Object> data = new R<>(HttpServletResponse.SC_OK, "登录成功", true, token);
objectMapper.writeValue(response.getWriter(), data);
}
}
private record CustomAccessDeniedHandler(ObjectMapper objectMapper) implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
log.warn("访问被拒绝", accessDeniedException);
if (authentication.isAuthenticated()) {
log.warn("用户「{}」访问「{}」被拒绝,因为:{}", authentication.getPrincipal(), request.getRequestURI(), accessDeniedException.getMessage());
} else {
log.warn("匿名用户访问「{}」被拒绝,因为:{}", request.getRequestURI(), accessDeniedException.getMessage());
}
R<Object> data = new R<>(HttpServletResponse.SC_FORBIDDEN, "无权限", false, null);
response.setContentType("application/json;charset=UTF-8");
objectMapper.writeValue(response.getWriter(), data);
}
}
private record CustomLogoutSuccessHandler(ObjectMapper objectMapper,
TokenRedisStorage tokenRedisStorage) implements LogoutHandler {
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
authentication = tokenRedisStorage.get(token);
tokenRedisStorage.remove(token);
}
if (authentication != null) {
log.info("用户「{}」退出成功", authentication.getName());
String all = request.getParameter("all");
if ("true".equals(all)) {
tokenRedisStorage.removeByUserName(authentication.getName());
}
}
R<Object> data = new R<>(HttpServletResponse.SC_OK, "退出成功", true, null);
response.setContentType("application/json;charset=UTF-8");
try {
objectMapper.writeValue(response.getWriter(), data);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
}

View File

@ -0,0 +1,29 @@
package vip.jcfd.web.config.props;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
@ConfigurationProperties(prefix = "zkh.security")
public class SecurityProps {
private String[] ignoreUrls;
private Duration duration;
public String[] getIgnoreUrls() {
return ignoreUrls;
}
public void setIgnoreUrls(String[] ignoreUrls) {
this.ignoreUrls = ignoreUrls;
}
public Duration getDuration() {
return duration;
}
public void setDuration(Duration duration) {
this.duration = duration;
}
}

View File

@ -0,0 +1,44 @@
package vip.jcfd.web.filter;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.io.IOException;
public class JsonUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final ObjectMapper objectMapper;
public JsonUsernamePasswordAuthenticationFilter(ObjectMapper objectMapper, AuthenticationManager authenticationManager) {
super(authenticationManager);
this.objectMapper = objectMapper;
}
private record LoginDTO(String username, String password) {
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("登录请求只支持POST");
}
try {
ServletInputStream inputStream = request.getInputStream();
LoginDTO loginDTO = objectMapper.readValue(inputStream, LoginDTO.class);
UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.unauthenticated(loginDTO.username, loginDTO.password);
setDetails(request, token);
return getAuthenticationManager().authenticate(token);
} catch (IOException e) {
logger.error("读取请求体失败", e);
throw new AuthenticationServiceException("登录请求异常");
}
}
}

View File

@ -0,0 +1,53 @@
package vip.jcfd.web.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.lang.NonNull;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import vip.jcfd.web.redis.TokenRedisStorage;
import java.io.IOException;
public class TokenFilter extends OncePerRequestFilter {
private final TokenRedisStorage tokenRedisStorage;
public TokenFilter(TokenRedisStorage tokenRedisStorage) {
this.tokenRedisStorage = tokenRedisStorage;
}
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {
// 获取token
String token = getToken(request);
if (!token.isBlank() && validateToken(token)) {
Authentication authentication = extractAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
boolean validateToken(String token) {
return tokenRedisStorage.exists(token);
}
Authentication extractAuthentication(String token) {
return tokenRedisStorage.get(token);
}
@NonNull
String getToken(HttpServletRequest request) {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header != null && header.startsWith("Bearer ")) {
return header.substring(7);
}
return "";
}
}

View File

@ -0,0 +1,97 @@
package vip.jcfd.web.redis;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.core.Authentication;
import org.springframework.util.CollectionUtils;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class TokenRedisStorage extends RedisTemplate<String, Authentication> {
private final static Logger logger = LoggerFactory.getLogger(TokenRedisStorage.class);
private final Duration expire;
private final StringRedisTemplate stringRedisTemplate;
private static final String TOKEN_KEY_PREFIX = "TOKEN:";
private static final String TOKEN_LIST_KEY_PREFIX = TOKEN_KEY_PREFIX + "LIST:";
public TokenRedisStorage(Duration expire, StringRedisTemplate stringRedisTemplate) {
this.expire = expire;
this.stringRedisTemplate = stringRedisTemplate;
}
public Authentication get(String token) {
return opsForValue().get(TOKEN_KEY_PREFIX + token);
}
public void put(String token, Authentication authentication) {
opsForValue().set(TOKEN_KEY_PREFIX + token, authentication, expire);
stringRedisTemplate.opsForList().leftPush(TOKEN_LIST_KEY_PREFIX + authentication.getName(), token);
}
public void remove(String token) {
Authentication authentication = get(token);
if (authentication != null) {
stringRedisTemplate.opsForList().remove(TOKEN_LIST_KEY_PREFIX + authentication.getName(), 0, token);
expire(TOKEN_KEY_PREFIX + token, Duration.ZERO);
}
}
public boolean exists(String token) {
return opsForValue().get(TOKEN_KEY_PREFIX + token) != null;
}
public void removeByUserName(String username) {
List<String> range = stringRedisTemplate.opsForList().range(TOKEN_LIST_KEY_PREFIX + username, 0, -1);
if (CollectionUtils.isEmpty(range)) {
return;
}
for (String s : range) {
expire(TOKEN_KEY_PREFIX + s, Duration.ZERO);
}
expire(TOKEN_LIST_KEY_PREFIX + username, Duration.ZERO);
}
private record TokenStorage(String key, Set<String> tokens) {
public void addToken(String token) {
tokens.add(token);
}
}
public void clearExpiredTokens() {
logger.info("开始清理过期token");
Set<String> keys = keys(TOKEN_LIST_KEY_PREFIX + "*");
if (CollectionUtils.isEmpty(keys)) {
logger.info("清理过期token完成");
return;
}
List<TokenStorage> tokenStorages = new ArrayList<>();
for (String key : keys) {
List<String> range = stringRedisTemplate.opsForList().range(key, 0, -1);
if (CollectionUtils.isEmpty(range)) {
continue;
}
TokenStorage tokenStorage = new TokenStorage(key, new HashSet<>());
tokenStorages.add(tokenStorage);
for (String token : range) {
if (!exists(token)) {
tokenStorage.addToken(token);
}
}
}
logger.info("收集过期token完成共{}个token", tokenStorages.stream().map(TokenStorage::tokens).mapToInt(Set::size).sum());
for (TokenStorage tokenStorage : tokenStorages) {
for (String token : tokenStorage.tokens) {
stringRedisTemplate.opsForList().remove(tokenStorage.key, 0, token);
}
}
logger.info("清理过期token完成");
}
}