针对Web设计的安全系统

概述

Spring Security在WEB项目里的核心不是某一个登录接口,而是一条过滤器链。请求进入Controller之前,会先经过这条链,系统会在这里完成登录态识别、权限判断、异常处理、CSRF防护等工作。

理解这条链以后,很多问题就容易解释了。为什么JWT要写Filter,为什么没有登录会返回401,为什么权限不足会返回403,为什么有些接口要放行,答案都在过滤器链里。

请求进入系统

一个HTTP请求进入Spring Boot以后,大致会经过这些步骤:

浏览器或客户端
    |
    v
Servlet容器
    |
    v
Spring Security过滤器链
    |
    v
DispatcherServlet
    |
    v
Controller

Spring Security会尽量在请求到达Controller之前把安全问题处理掉。业务代码拿到请求时,应该已经知道当前用户是谁,以及当前用户是否可以访问这个接口。

SecurityFilterChain

Spring Security 6以后,常用写法是声明SecurityFilterChain。以前很多资料里的WebSecurityConfigurerAdapter已经不建议继续使用。

下面是一个适合纯API服务的基础配置:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/auth/**", "/actuator/health").permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling(exception -> exception
                        .authenticationEntryPoint(new RestAuthenticationEntryPoint())
                        .accessDeniedHandler(new RestAccessDeniedHandler())
                )
                .build();
    }
}

这段配置表达了几个意思:

  • 关闭CSRF,因为这是一个无状态API服务,不使用Cookie保存登录态。
  • Session策略设为STATELESS,不让服务端保存会话。
  • /auth/**和健康检查接口放行。
  • 其他接口都需要认证。
  • JWT过滤器放在用户名密码过滤器之前。
  • 认证失败和权限不足都返回统一JSON。

放行接口

安全配置里最容易写乱的是放行规则。我的习惯是只放行明确的公开入口,其他全部认证。

.authorizeHttpRequests(authorize -> authorize
        .requestMatchers(HttpMethod.POST, "/auth/login").permitAll()
        .requestMatchers(HttpMethod.POST, "/auth/wechat/login").permitAll()
        .requestMatchers(HttpMethod.GET, "/actuator/health").permitAll()
        .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
        .anyRequest().authenticated()
)

不要为了调试把/**直接放开,也不要让上传、导出、管理接口混进公开路径。公开接口越少,系统越容易控制。

登录接口

在Token系统里,登录接口的职责很清楚:验证用户凭证,签发自己的Token。

@RestController
@RequestMapping("/auth")
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final JwtTokenService jwtTokenService;

    public AuthController(AuthenticationManager authenticationManager,
                          JwtTokenService jwtTokenService) {
        this.authenticationManager = authenticationManager;
        this.jwtTokenService = jwtTokenService;
    }

    @PostMapping("/login")
    public TokenResponse login(@RequestBody LoginRequest request) {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.username(), request.password())
        );

        LoginUser user = (LoginUser) authentication.getPrincipal();
        String accessToken = jwtTokenService.createAccessToken(user);

        return new TokenResponse(accessToken, "Bearer", 3600);
    }
}

AuthenticationManager负责调度真正的认证逻辑。用户名密码是否正确、用户是否禁用、权限怎么加载,都可以放在UserDetailsService或自定义AuthenticationProvider里。

UserDetailsService

如果使用用户名密码登录,最常见的是实现UserDetailsService

@Service
public class DatabaseUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public DatabaseUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("用户不存在"));

        List<GrantedAuthority> authorities = user.getPermissions().stream()
                .map(SimpleGrantedAuthority::new)
                .toList();

        return new LoginUser(
                user.getId(),
                user.getUsername(),
                user.getPassword(),
                user.isEnabled(),
                authorities
        );
    }
}

LoginUser可以继承UserDetails,也可以自己实现接口。重点是把数据库里的用户转换成Spring Security能理解的对象。

统一异常返回

API服务不要返回默认HTML错误页。未登录返回401,权限不足返回403,并且最好统一成自己的JSON结构。

public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.getWriter().write("""
                {"code":"UNAUTHORIZED","message":"请先登录"}
                """);
    }
}
public class RestAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.getWriter().write("""
                {"code":"FORBIDDEN","message":"没有访问权限"}
                """);
    }
}

这两个类虽然简单,但很重要。前端拿到稳定的错误格式,才能统一跳登录页或提示无权限。

方法级权限

URL权限适合控制入口,方法级权限适合控制业务动作。

@Service
public class UserAdminService {

    @PreAuthorize("hasAuthority('user:read')")
    public UserDetail getUser(Long userId) {
        return userRepository.getDetail(userId);
    }

    @PreAuthorize("hasAuthority('user:delete')")
    public void deleteUser(Long userId) {
        userRepository.deleteById(userId);
    }
}

要让@PreAuthorize生效,需要在配置类上加@EnableMethodSecurity。URL层拦住未登录用户,方法层拦住没有具体业务权限的用户,这样比较稳。

CORS

前后端分离项目经常会遇到跨域。跨域不是Spring Security独有的问题,但它会出现在过滤器链里,所以建议在安全配置中一起处理。

@Bean
CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("https://www.zhouzhou.net"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
    config.setAllowCredentials(false);
    config.setMaxAge(3600L);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);
    return source;
}

生产环境不要直接写*放开所有来源。开发环境可以宽松一点,线上环境至少应该限制域名和请求头。

CSRF

CSRF主要针对浏览器自动携带Cookie的场景。如果系统使用Cookie和Session,尤其是服务端页面,要认真处理CSRF。

如果是纯API服务,登录态放在Authorization Header里,服务端不依赖Cookie识别用户,一般会关闭CSRF。

.csrf(AbstractHttpConfigurer::disable)

关闭之前要先确认项目确实不依赖Cookie认证。如果后台管理系统仍然使用Session登录,就不要随手关闭。

推荐结构

安全模块不要全部堆在一个配置类里。稍微拆一下,后面会清爽很多。

security
 ├── SecurityConfig.java
 ├── JwtAuthenticationFilter.java
 ├── JwtTokenService.java
 ├── RestAuthenticationEntryPoint.java
 ├── RestAccessDeniedHandler.java
 ├── LoginUser.java
 └── SecurityUtils.java

SecurityConfig只放过滤器链配置,JwtAuthenticationFilter只负责解析Token,JwtTokenService只负责签发和校验Token。职责分开以后,出现问题时也容易定位。

© 舟哥 all right reserved,powered by Gitbook文件修订时间: 2026-07-16 12:51:24
作者: 舟哥
链接:https://www.b919p4.com
来源: B919P4
本文原创发布于B919P4,©著作权归作者所有,转载请注明出处,谢谢合作!

undefined

results matching ""

    No results matching ""