commit dbf4f87e7b04c1a328c23748727b980f70407337 Author: zkh <1650697374@qq.com> Date: Thu Nov 20 18:33:49 2025 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a20fba1 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..2495d0d --- /dev/null +++ b/pom.xml @@ -0,0 +1,164 @@ + + + 4.0.0 + + vip.jcfd + zkh-framework + 1.0 + pom + ZKH Framework + A Java framework for ZKH applications + https://gitea.jcfd.vip/zkh/zkh-framework + + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + + + + + + zkh + 1650697374@qq.com + 横球集团 + https://www.jcfd.vip + + + + + scm:git:git://gitea.jcfd.vip/zkh/zkh-framework.git + scm:git:ssh://gitea.jcfd.vip:zkh/zkh-framework.git + https://gitea.jcfd.vip/zkh/zkh-framework + + + + zkh-common + zkh-web + zkh-data + + + + 21 + 21 + UTF-8 + 3.5.7 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + vip.jcfd + zkh-common + ${project.version} + + + vip.jcfd + zkh-web + ${project.version} + + + + + + + release + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.1.0 + + + sign-artifacts + verify + + sign + + + + + + ${env.GPG_PASSPHRASE} + + gpg + + true + + + --pinentry-mode + loopback + + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.9.0 + true + + central + + true + + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.0 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.5.0 + + + attach-javadocs + + jar + + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.1.0 + + + sign-artifacts + verify + + sign + + + + + + + + + diff --git a/zkh-common/pom.xml b/zkh-common/pom.xml new file mode 100644 index 0000000..48d9de6 --- /dev/null +++ b/zkh-common/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + vip.jcfd + zkh-framework + 1.0 + + + zkh-common + ZKH Common + Common utilities and base classes for ZKH framework + + + + jakarta.persistence + jakarta.persistence-api + + + com.fasterxml.jackson.core + jackson-annotations + + + org.springframework.data + spring-data-commons + + + org.springframework.data + spring-data-jpa + + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.4.1 + + + attach-javadocs + + jar + + + + + + + diff --git a/zkh-common/src/main/java/vip/jcfd/common/core/BaseEntity.java b/zkh-common/src/main/java/vip/jcfd/common/core/BaseEntity.java new file mode 100644 index 0000000..76b5f72 --- /dev/null +++ b/zkh-common/src/main/java/vip/jcfd/common/core/BaseEntity.java @@ -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; + } + + +} diff --git a/zkh-common/src/main/java/vip/jcfd/common/core/BizException.java b/zkh-common/src/main/java/vip/jcfd/common/core/BizException.java new file mode 100644 index 0000000..0fb7f55 --- /dev/null +++ b/zkh-common/src/main/java/vip/jcfd/common/core/BizException.java @@ -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); + } +} diff --git a/zkh-common/src/main/java/vip/jcfd/common/core/R.java b/zkh-common/src/main/java/vip/jcfd/common/core/R.java new file mode 100644 index 0000000..74b3b43 --- /dev/null +++ b/zkh-common/src/main/java/vip/jcfd/common/core/R.java @@ -0,0 +1,69 @@ +package vip.jcfd.common.core; + +public class R { + 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 R success(T data) { + return new R<>(200, "操作成功", true, data); + } + + public static R success(String message) { + return new R<>(200, message, true, null); + } + + public static R error(String message) { + return new R<>(400, message, false, null); + } + + public static R 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; + } +} diff --git a/zkh-data/pom.xml b/zkh-data/pom.xml new file mode 100644 index 0000000..c194a44 --- /dev/null +++ b/zkh-data/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + vip.jcfd + zkh-framework + 1.0 + + + zkh-data + ZKH Data + Data layer components for ZKH framework + + + 21 + 21 + UTF-8 + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.4.1 + + + attach-javadocs + + jar + + + + + + + diff --git a/zkh-web/pom.xml b/zkh-web/pom.xml new file mode 100644 index 0000000..ecc2c75 --- /dev/null +++ b/zkh-web/pom.xml @@ -0,0 +1,71 @@ + + + 4.0.0 + + + vip.jcfd + zkh-framework + 1.0 + + + zkh-web + ZKH Web + Web components for ZKH framework + + + + vip.jcfd + zkh-common + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.4.1 + + + attach-javadocs + + jar + + + + + + + diff --git a/zkh-web/src/main/java/vip/jcfd/web/config/GlobalExceptionHandler.java b/zkh-web/src/main/java/vip/jcfd/web/config/GlobalExceptionHandler.java new file mode 100644 index 0000000..a61251c --- /dev/null +++ b/zkh-web/src/main/java/vip/jcfd/web/config/GlobalExceptionHandler.java @@ -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 handleException(Exception e) { + log.error("服务异常", e); + return R.serverError("服务器繁忙,请稍候重试"); + } + + @ExceptionHandler(value = BizException.class) + public R handleBizException(BizException e) { + log.error("业务异常", e); + return R.error(e.getMessage()); + } + + @ExceptionHandler(value = NoResourceFoundException.class) + public R handleNotFoundException(NoResourceFoundException e) { + log.error("404异常", e); + return new R<>(404, "您访问的地址不存在", false, null); + } +} 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 new file mode 100644 index 0000000..ed9a1f3 --- /dev/null +++ b/zkh-web/src/main/java/vip/jcfd/web/config/RedisConfig.java @@ -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; + } +} 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 new file mode 100644 index 0000000..50cd570 --- /dev/null +++ b/zkh-web/src/main/java/vip/jcfd/web/config/WebSecurityConfig.java @@ -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 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 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 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 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 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 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); + } + } + } +} diff --git a/zkh-web/src/main/java/vip/jcfd/web/config/props/SecurityProps.java b/zkh-web/src/main/java/vip/jcfd/web/config/props/SecurityProps.java new file mode 100644 index 0000000..19df022 --- /dev/null +++ b/zkh-web/src/main/java/vip/jcfd/web/config/props/SecurityProps.java @@ -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; + } +} diff --git a/zkh-web/src/main/java/vip/jcfd/web/filter/JsonUsernamePasswordAuthenticationFilter.java b/zkh-web/src/main/java/vip/jcfd/web/filter/JsonUsernamePasswordAuthenticationFilter.java new file mode 100644 index 0000000..fa1effe --- /dev/null +++ b/zkh-web/src/main/java/vip/jcfd/web/filter/JsonUsernamePasswordAuthenticationFilter.java @@ -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("登录请求异常"); + } + } +} diff --git a/zkh-web/src/main/java/vip/jcfd/web/filter/TokenFilter.java b/zkh-web/src/main/java/vip/jcfd/web/filter/TokenFilter.java new file mode 100644 index 0000000..b13532b --- /dev/null +++ b/zkh-web/src/main/java/vip/jcfd/web/filter/TokenFilter.java @@ -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 ""; + } + + +} diff --git a/zkh-web/src/main/java/vip/jcfd/web/redis/TokenRedisStorage.java b/zkh-web/src/main/java/vip/jcfd/web/redis/TokenRedisStorage.java new file mode 100644 index 0000000..c86848c --- /dev/null +++ b/zkh-web/src/main/java/vip/jcfd/web/redis/TokenRedisStorage.java @@ -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 { + + 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 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 tokens) { + public void addToken(String token) { + tokens.add(token); + } + } + + public void clearExpiredTokens() { + logger.info("开始清理过期token"); + Set keys = keys(TOKEN_LIST_KEY_PREFIX + "*"); + if (CollectionUtils.isEmpty(keys)) { + logger.info("清理过期token完成"); + return; + } + List tokenStorages = new ArrayList<>(); + for (String key : keys) { + List 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完成"); + } +}