针对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。职责分开以后,出现问题时也容易定位。
作者: 舟哥
链接:https://www.b919p4.com
来源: B919P4
本文原创发布于B919P4,©著作权归作者所有,转载请注明出处,谢谢合作!