有循环依赖

The dependencies of some of the beans in the application context form a cycle:

┌─────┐
|  jwtAuthenticationFilter defined in file [D:\work\java-space\deploy-ease-platform\backend\target\classes\com\qqchen\deploy\backend\common\security\filter\JwtAuthenticationFilter.class]
↑     ↓
|  securityConfig defined in file [D:\work\java-space\deploy-ease-platform\backend\target\classes\com\qqchen\deploy\backend\common\security\config\SecurityConfig.class]
└─────┘
This commit is contained in:
dengqichen 2024-11-27 17:35:43 +08:00
parent 68d42ce6f0
commit 06aaead053
23 changed files with 432 additions and 18 deletions

View File

@ -24,6 +24,8 @@
<lombok.version>1.18.30</lombok.version>
<spring-security.version>6.2.0</spring-security.version>
<jjwt.version>0.12.3</jjwt.version>
<springdoc.version>2.2.0</springdoc.version>
<hutool.version>5.8.23</hutool.version>
</properties>
<dependencyManagement>
@ -140,6 +142,18 @@
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- Swagger -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- Hutool -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
</dependencies>
<build>

View File

@ -4,12 +4,15 @@ import com.qqchen.deploy.backend.common.controller.BaseController;
import com.qqchen.deploy.backend.common.api.Response;
import com.qqchen.deploy.backend.common.enums.ResponseCode;
import com.qqchen.deploy.backend.converter.UserConverter;
import com.qqchen.deploy.backend.dto.request.LoginRequest;
import com.qqchen.deploy.backend.dto.response.LoginResponse;
import com.qqchen.deploy.backend.entity.User;
import com.qqchen.deploy.backend.dto.query.UserQuery;
import com.qqchen.deploy.backend.dto.request.UserRequest;
import com.qqchen.deploy.backend.dto.response.UserResponse;
import com.qqchen.deploy.backend.dto.request.UserRegisterRequest;
import com.qqchen.deploy.backend.service.IUserService;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.data.domain.Page;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
@ -29,13 +32,18 @@ public class UserApiController extends BaseController<User, Long, UserQuery, Use
this.userService = userService;
}
@Operation(summary = "用户登录")
@PostMapping("/login")
public Response<LoginResponse> login(@Validated @RequestBody LoginRequest request) {
return Response.success(userService.login(request));
}
@PostMapping("/register")
public Response<UserResponse> register(@Validated @RequestBody UserRegisterRequest request) {
// 基础的注册逻辑
if (!request.getPassword().equals(request.getConfirmPassword())) {
return Response.error(ResponseCode.USER_NOT_FOUND);
}
User user = converter.toEntity(request);
User savedUser = userService.register(user);
return Response.success(converter.toResponse(savedUser));

View File

@ -0,0 +1,17 @@
package com.qqchen.deploy.backend.common.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface TenantFilter {
/**
* 是否启用租户过滤
*/
boolean value() default true;
}

View File

@ -0,0 +1,33 @@
package com.qqchen.deploy.backend.common.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Configuration
public class JacksonConfig {
private static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
@Bean
public ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
JavaTimeModule javaTimeModule = new JavaTimeModule();
javaTimeModule.addSerializer(LocalDateTime.class,
new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DATE_TIME_PATTERN)));
javaTimeModule.addDeserializer(LocalDateTime.class,
new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DATE_TIME_PATTERN)));
objectMapper.registerModule(javaTimeModule);
return objectMapper;
}
}

View File

@ -0,0 +1,22 @@
package com.qqchen.deploy.backend.common.context;
public class TenantContext {
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
CURRENT_TENANT.set(tenantId);
}
public static String getTenantId() {
return CURRENT_TENANT.get();
}
public static void clear() {
CURRENT_TENANT.remove();
}
public static boolean isSystemTenant() {
String tenantId = getTenantId();
return tenantId == null || "0".equals(tenantId);
}
}

View File

@ -13,9 +13,10 @@ public enum ResponseCode {
CONFLICT(409, "response.conflict"),
// 业务错误码
USER_NOT_FOUND(1001, "user.not.found"),
USERNAME_EXISTS(1002, "user.username.exists"),
EMAIL_EXISTS(1003, "user.email.exists");
TENANT_NOT_FOUND(1001, "tenant.not.found"),
USER_NOT_FOUND(2001, "user.not.found"),
USERNAME_EXISTS(2002, "user.username.exists"),
EMAIL_EXISTS(2003, "user.email.exists");
private final int code;

View File

@ -6,7 +6,9 @@ import java.time.LocalDateTime;
@Getter
public abstract class DomainEvent {
private final String eventId;
private final LocalDateTime occurredOn;
protected DomainEvent() {

View File

@ -0,0 +1,22 @@
package com.qqchen.deploy.backend.common.exception;
import com.qqchen.deploy.backend.common.enums.ResponseCode;
import com.qqchen.deploy.backend.common.utils.MessageUtils;
import lombok.Getter;
@Getter
public class BusinessException extends RuntimeException {
private final ResponseCode errorCode;
public BusinessException(ResponseCode errorCode) {
super(MessageUtils.getMessage(errorCode.getMessageKey()));
this.errorCode = errorCode;
}
public BusinessException(ResponseCode errorCode, Throwable cause) {
super(MessageUtils.getMessage(errorCode.getMessageKey()), cause);
this.errorCode = errorCode;
}
}

View File

@ -0,0 +1,32 @@
package com.qqchen.deploy.backend.common.exception;
import com.qqchen.deploy.backend.common.api.Response;
import com.qqchen.deploy.backend.common.enums.ResponseCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
// @ExceptionHandler(AuthenticationException.class)
// public ApiResult<String> handleAuthenticationException(AuthenticationException e) {
// log.error("认证异常", e);
// if (e instanceof BadCredentialsException) {
// return ApiResult.error(401, "用户名或密码错误");
// }
// return ApiResult.error(401, "认证失败:" + e.getMessage());
// }
@ExceptionHandler(BusinessException.class)
public Response<?> handleApiException(BusinessException e) {
return Response.error(e.getErrorCode());
}
@ExceptionHandler(Exception.class)
public Response<?> handleException(Exception e) {
log.error("系统错误", e);
return Response.error(ResponseCode.ERROR);
}
}

View File

@ -0,0 +1,34 @@
package com.qqchen.deploy.backend.common.filter;
import com.qqchen.deploy.backend.common.context.TenantContext;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
@Order(1)
public class TenantFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
try {
// 从请求头中获取租户ID
String tenantId = req.getHeader("X-Tenant-ID");
if (tenantId != null) {
TenantContext.setTenantId(tenantId);
}
chain.doFilter(request, response);
} finally {
// 清理租户上下文
TenantContext.clear();
}
}
}

View File

@ -0,0 +1,54 @@
package com.qqchen.deploy.backend.common.interceptor;
import com.qqchen.deploy.backend.common.context.TenantContext;
import com.qqchen.deploy.backend.common.enums.ResponseCode;
import com.qqchen.deploy.backend.common.exception.BusinessException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.Arrays;
import java.util.List;
@Component
public class TenantInterceptor implements HandlerInterceptor {
private final AntPathMatcher pathMatcher = new AntPathMatcher();
// 不需要进行租户隔离的路径
private final List<String> excludePaths = Arrays.asList(
"/api/v1/users/login",
"/api/system/tenants/list",
"/swagger-ui/**",
"/v3/api-docs/**"
);
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String path = request.getRequestURI();
// 如果是白名单路径不进行租户校验
if (isExcludePath(path)) {
TenantContext.clear();
return true;
}
// 获取租户ID
String tenantId = request.getHeader("X-Tenant-ID");
if (tenantId == null || tenantId.trim().isEmpty()) {
throw new BusinessException(ResponseCode.TENANT_NOT_FOUND);
}
TenantContext.setTenantId(tenantId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
TenantContext.clear();
}
private boolean isExcludePath(String path) {
return excludePaths.stream()
.anyMatch(pattern -> pathMatcher.match(pattern, path));
}
}

View File

@ -1,5 +1,8 @@
package com.qqchen.deploy.backend.common.security.config;
import com.qqchen.deploy.backend.common.security.filter.JwtAuthenticationFilter;
import com.qqchen.deploy.backend.common.security.handler.CustomAuthenticationEntryPoint;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
@ -14,6 +17,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@ -22,8 +26,13 @@ import java.util.Arrays;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CustomAuthenticationEntryPoint authenticationEntryPoint;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
@ -31,10 +40,14 @@ public class SecurityConfig {
.csrf(csrf -> csrf.disable()) // 禁用CSRF
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 禁用session
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(authenticationEntryPoint)
)
.formLogin(form -> form.disable()) // 禁用form登录
.httpBasic(basic -> basic.disable()) // 禁用basic认证
.logout(logout -> logout.disable()) // 禁用logout
.anonymous(anonymous -> {}) // 允许匿名访问
.anonymous(anonymous -> {
}) // 允许匿名访问
.authorizeHttpRequests(auth -> auth
// 公开接口
// .requestMatchers("/api/v1/users/register").permitAll()
@ -47,7 +60,8 @@ public class SecurityConfig {
.anyRequest().permitAll()
// 生产环境建议改为需要认证
//.anyRequest().authenticated()
);
).addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}

View File

@ -16,6 +16,8 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
@Slf4j
@Component
@ -23,13 +25,25 @@ import java.io.IOException;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenUtil jwtTokenUtil;
private final UserDetailsService userDetailsService;
// 添加白名单路径
private static final List<String> WHITELIST = Arrays.asList(
"/api/v1/users/login",
"/api/v1/users/register",
"/swagger-ui/**",
"/v3/api-docs/**"
);
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
return WHITELIST.stream().anyMatch(path::startsWith);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
try {
String authHeader = request.getHeader("Authorization");
String token = null;
@ -39,10 +53,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
token = authHeader.substring(7);
username = jwtTokenUtil.getUsernameFromToken(token);
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtTokenUtil.validateToken(token, userDetails)) {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
@ -50,10 +62,10 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
chain.doFilter(request, response);
} catch (Exception e) {
log.error("Cannot set user authentication", e);
chain.doFilter(request, response);
}
chain.doFilter(request, response);
}
}

View File

@ -0,0 +1,39 @@
package com.qqchen.deploy.backend.common.security.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.qqchen.deploy.backend.common.api.Response;
import com.qqchen.deploy.backend.common.enums.ResponseCode;
import com.qqchen.deploy.backend.common.utils.MessageUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
Response<?> result;
if (authException instanceof BadCredentialsException) {
result = Response.error(ResponseCode.UNAUTHORIZED, MessageUtils.getMessage(ResponseCode.SUCCESS.getMessageKey()));
} else if (authException instanceof InternalAuthenticationServiceException) {
result = Response.error(ResponseCode.USER_NOT_FOUND, MessageUtils.getMessage(ResponseCode.USER_NOT_FOUND.getMessageKey()));
} else {
result = Response.error(ResponseCode.UNAUTHORIZED, MessageUtils.getMessage(ResponseCode.SUCCESS.getMessageKey()));
}
response.getWriter().write(objectMapper.writeValueAsString(result));
}
}

View File

@ -0,0 +1,14 @@
package com.qqchen.deploy.backend.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class LoginRequest {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
}

View File

@ -0,0 +1,12 @@
package com.qqchen.deploy.backend.dto.response;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class LoginResponse extends UserResponse {
private String token;
}

View File

@ -1,6 +1,8 @@
package com.qqchen.deploy.backend.service;
import com.qqchen.deploy.backend.common.service.IBaseService;
import com.qqchen.deploy.backend.dto.request.LoginRequest;
import com.qqchen.deploy.backend.dto.response.LoginResponse;
import com.qqchen.deploy.backend.entity.User;
public interface IUserService extends IBaseService<User, Long> {
@ -8,4 +10,6 @@ public interface IUserService extends IBaseService<User, Long> {
User findByUsername(String username);
boolean checkUsernameExists(String username);
boolean checkEmailExists(String email);
LoginResponse login(LoginRequest request);
}

View File

@ -0,0 +1,36 @@
package com.qqchen.deploy.backend.service.impl;
import com.qqchen.deploy.backend.entity.User;
import com.qqchen.deploy.backend.repository.IUserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collections;
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final IUserRepository userRepository; // 直接使用Repository
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 直接查询数据库不要通过Controller或其他Service
return userRepository.findByUsernameAndDeletedFalse(username)
.map(this::createUserDetails)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
}
private UserDetails createUserDetails(User user) {
return org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
.authorities(Collections.emptyList()) // 或者从数据库加载权限
.build();
}
}

View File

@ -1,19 +1,35 @@
package com.qqchen.deploy.backend.service.impl;
import com.qqchen.deploy.backend.common.enums.ResponseCode;
import com.qqchen.deploy.backend.common.exception.BusinessException;
import com.qqchen.deploy.backend.common.security.util.JwtTokenUtil;
import com.qqchen.deploy.backend.common.service.impl.BaseServiceImpl;
import com.qqchen.deploy.backend.dto.request.LoginRequest;
import com.qqchen.deploy.backend.dto.response.LoginResponse;
import com.qqchen.deploy.backend.entity.User;
import com.qqchen.deploy.backend.repository.IUserRepository;
import com.qqchen.deploy.backend.service.IUserService;
import jakarta.transaction.Transactional;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
@Service
@Transactional
@Slf4j
public class UserServiceImpl extends BaseServiceImpl<User, Long> implements IUserService {
private final IUserRepository userRepository;
@Resource
private AuthenticationManager authenticationManager;
@Resource
private JwtTokenUtil jwtTokenUtil;
@Autowired
public UserServiceImpl(IUserRepository userRepository) {
super(userRepository);
@ -47,4 +63,24 @@ public class UserServiceImpl extends BaseServiceImpl<User, Long> implements IUse
return userRepository.existsByEmail(email);
}
@Override
public LoginResponse login(LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
);
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
String token = jwtTokenUtil.generateToken(userDetails);
User user = userRepository.findByUsernameAndDeletedFalse(userDetails.getUsername()).orElseThrow(() -> new BusinessException(ResponseCode.USER_NOT_FOUND));
LoginResponse response = new LoginResponse();
response.setId(user.getId());
response.setUsername(user.getUsername());
response.setNickname(user.getNickname());
response.setEmail(user.getEmail());
response.setPhone(user.getPhone());
response.setToken(token);
log.info("用户 {} ({}) 登录成功", user.getUsername(), user.getNickname());
return response;
}
}

View File

@ -19,7 +19,7 @@ spring:
jdbc:
time_zone: Asia/Shanghai
mvc:
log-request-details: true # \u6253\u5370\u8BF7\u6C42\u8BE6\u60C5
log-request-details: true
messages:
basename: messages
encoding: UTF-8
@ -35,8 +35,8 @@ logging:
level:
org.springframework.web: DEBUG
org.springframework.context.i18n: DEBUG
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping: TRACE # \u6253\u5370\u6240\u6709\u6CE8\u518C\u7684\u63A5\u53E3\u8DEF\u5F84
org.hibernate.type.descriptor.sql.BasicBinder: TRACE # \u663E\u793ASQL\u53C2\u6570
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping: TRACE
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
org.hibernate.type.descriptor.sql: TRACE
com.qqchen.deploy.backend.common.utils.EntityPathResolver: DEBUG
jwt:

View File

@ -7,6 +7,9 @@ response.forbidden=\u7981\u6B62\u8BBF\u95EE
response.not.found=\u8D44\u6E90\u672A\u627E\u5230
response.conflict=\u8D44\u6E90\u51B2\u7A81
tenant.not.found=\u79DF\u6237\u4E0D\u5B58\u5728
user.not.found=\u7528\u6237\u4E0D\u5B58\u5728
user.username.exists=\u7528\u6237\u540D\u5DF2\u5B58\u5728
user.email.exists=\u90AE\u7BB1\u5DF2\u5B58\u5728

View File

@ -7,6 +7,9 @@ response.forbidden=Forbidden
response.not.found=Resource Not Found
response.conflict=Resource Conflict
tenant.not.found=Tenant not found
user.not.found=User not found
user.username.exists=Username already exists
user.email.exists=Email already exists

View File

@ -7,6 +7,8 @@ response.forbidden=\u7981\u6B62\u8BBF\u95EE
response.not.found=\u8D44\u6E90\u672A\u627E\u5230
response.conflict=\u8D44\u6E90\u51B2\u7A81
tenant.not.found=\u79DF\u6237\u4E0D\u5B58\u5728
user.not.found=\u7528\u6237\u4E0D\u5B58\u5728
user.username.exists=\u7528\u6237\u540D\u5DF2\u5B58\u5728
user.email.exists=\u90AE\u7BB1\u5DF2\u5B58\u5728