Spring-Security
配置代码
package magnus.configuration;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.ws.rs.core.MediaType;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.Customizer;
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.config.annotation.web.configurers.LogoutConfigurer;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Configuration
@EnableWebSecurity
public class MagnusSecurityConfiguration {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity,
ObjectMapper objectMapper) throws Exception {
// 定义安全请求拦截规则
httpSecurity.authorizeHttpRequests(request -> request.anyRequest().authenticated());
// 给SpringSecurity注入 /login登录页面及用户密码表单处理的登录请求
httpSecurity.formLogin(Customizer.withDefaults());
// 登出请求注册
httpSecurity.logout(LogoutConfigurer::permitAll);
// httpSecurity.addFilterBefore(magnusUsernamePasswordFilter(objectMapper),
// UsernamePasswordAuthenticationFilter.class);
// 关闭csrf
httpSecurity.csrf(AbstractHttpConfigurer::disable);
return httpSecurity.build();
}
@Bean
public UsernamePasswordAuthenticationFilter magnusUsernamePasswordFilter(ObjectMapper objectMapper) {
// 配置自定义的UsernamePasswordAuthenticationFilter
MagnusUsernamePasswordFilter magnusUsernamePasswordFilter = new MagnusUsernamePasswordFilter();
magnusUsernamePasswordFilter.setAuthenticationManager(providerManager());
magnusUsernamePasswordFilter.setObjectMapper(objectMapper);
magnusUsernamePasswordFilter.initialize();
return magnusUsernamePasswordFilter;
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails build = User.withUsername("username").password("{noop}password").roles("user").build();
return new InMemoryUserDetailsManager(build);
}
@Bean
public AuthenticationManager providerManager() {
List<AuthenticationProvider> providers = new ArrayList<>();
// 自定义Provider,此处可以定义多数据源。
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService());
providers.add(provider);
return new ProviderManager(providers);
}
}
原理
配置spring-security
包,需要使用@EnableWebSecurity
注解。
@EnableWebSecurity
注解通过@Import
注解注入了四个类: WebSecurityConfiguration
,SpringWebMvcImportSelector
,OAuth2ImportSelector
,HttpSecurityConfiguration
;
WebSecurityConfiguration
主要是注入FilterChainProxy
对象,这个对象是SpringSecurity调用链的入口代理。
HttpSecurityConfiguration
主要是注入不同的SecurityFilterChain
对象。这些对象包含不同的过滤规则,但是最后都被注册到FilterChainProxy
内作为内部的调用链流程进行请求过滤。
SpringSecurity
中最重要的两个概念,一个是FilterChainProxy
,一个是SecurityFilterChain
。FilterChainProxy
作为SpringSecurity安全控制的总入口,而SecurityFilterChain
则定义了安全校验的各种规则。
SpringWeb
框架会使用GenericFilterBean
来标识一个Bean为过滤器,在初始化Web容器过程中,会扫描这些bean,并将他们统一注册到SpringFilterChain
中,最后把这个SpringFilterChain
注册为Tomcat的Filter。
授权流程
SpringSecurity 授权流程主要涉及UsernamePasswordAuthenticationFilter
类,通过HttpSecurity.formLogin
来自动注入这个过滤器到FilterChainProxy
中。这个类会校验是否需要生成默认登录页面,并自动生成/login GET
开头的登录html网页和/login POST
的登录处理器,所以这里依赖spring-mvc
环境。UsernamePasswordAuthenticationFilter
类调用doFilter()
方法判断当前请求是否是POST
,如果符合条件,会调用attemptAuthentication
方法来进行权限校验。权限校验过程中会生成UsernamePasswordAuthenticationToken
对象,并且以这个对象为授权处理类。UsernamePasswordAuthenticationFilter
主要逻辑是从请求Request
中获取用户登录的参数,然后将具体的校验逻辑委派给AuthenticationManager
来验证,即调用this.getAuthenticationManager().authenticate(authRequest)
。
AuthenticationManager
的一般实现类是ProviderManager
。ProviderManager
类会把校验委派给注册的所有provider
。只要有一个provider
实现类能成功校验,就直接返回校验成功。ProviderManager
中一般会注册一个JDBC
相关的Provider
,即DaoAuthenticationProvider
。DaoAuthenticationProvider
里面会注册UserDetailsService.loadUserByUsername()
来去数据库中查询用户相关数据。
ProviderManager
验证用户身份正确后,UsernamePasswordAuthenticationFilter
会继续执行父类的doFilter
方法。调用this.successfulAuthentication(request, response, chain, authenticationResult)
方法来进行验证通过的后续处理。将Authentication
验证结果放到SecurityContextHolder
对象中(可以在后续的处理中通过SecurityContextHolder.getContext
来获取)。同时会调用this.securityContextRepository.saveContext(context, request, response)
来定制化的保存Authentication
。其中,默认的SecurityContextRepository
实现有HttpSessionSecurityContextRepository
和RequestAttributeSecurityContextRepository
。HttpSessionSecurityContextRepository
的作用是,判断当前请求是否有Session字段,如果有Session,则清空当前的Session,并创建一个新的Session,放到Response的报文头中。RequestAttributeSecurityContextRepository
的作用是,在SPRING_CONTEXT中加入字段SPRING_SECURITY_CONTEXT
,可以在Spring定义的html中通过此字段访问这个变量。在所有的SecurityContextRepository
调用结束后,会执行AbstractAuthenticationProcessingFilter
中定义的成功回调this.successHandler.onAuthenticationSuccess(request, response, authResult)
。此回调方法可以通过setAuthenticationSuccessHandler
方法实现自定义。
至此,SpringSecurity基于用于身份、密码的校验流程已经完成。
权限验证
SpringSecurity经过上述的授权流程后,会在HTTP请求的返回报文中附上SessionID,即Cookies:JSESSIONID=74E183225F3A22A4FEF14A32E7717584; Path=/; HttpOnly;
。客户端下一次请求资源的时候,就会带上这个JSESSIONID作为自己的身份验证标识。以请求路径为/
为例,客户端授权后发起/
的路径请求默认页面。
SpringSecurity在构造SecurityFilterChain
的时候,会默认加上HttpSecurity.securityContext()
方法。此方法给SecurityFilterChain
对象注册了SecurityContextHolderFilter
对象。这个对象就是默认权限验证的关键类。
SpringSecurity在构造SecurityFilterChain
的时候,会要求指定要过滤的链接,即HttpSecurity.authorizeHttpRequest()
构造器,此构造器会给SecurityFilterChain
对象注册AuthorizationFilter
。
请求进来之后,经过FilterChainProxy
的doFilter
方法,会通过代理,遍历执行FilterChainProxy
中注册的所有filters
链。当请求被SecurityContextHolderFilter
过滤时,这个过滤器会给securityContextHolderStrategy
设置一个deferredContext
回调方法。这个方法内部实现是校验当前请求是否有Session,如果有Session则判断这个Session是不是Security使用的Session。这里不会立即执行,而是存放到securityContextHolderStrategy
中供后续调用。请求过滤器链继续执行,最后会来到AuthorizationFilter
。AuthorizationFilter
会调用this::getAuthentication
方法来获取当前验证的结果。而this::getAuthentication
方法就是通过调用this.securityContextHolderStrategy.getContext().getAuthentication();
方法来实现,也即上述在SecurityContextHolderFilter
流程中注册的对象。这里调用了延迟函数deferredContext
的内部实现:HttpSessionSecurityContextRepository.readSecurityContextFromSession()
。读取了Session后,将Session转换为Authentication对象,来继续进行权限过滤校验。如果通过,则直接放行,否则会抛出Access Denied
异常。抛出的Access Denied
异常将被捕获并提交给ExceptionHandling
来处理。默认的ExceptionHandling
处理是使用Http403AuthenticationEntrypoint
,即返回403报错。
至此,权限校验流程结束。
扩展
当前分布式环境下,已经不再使用Session
作为客户端信息保存的场景。SpringSecurity引入JWT(JSON Web Token)来保存登录状态是更好的选择。
扩展SpringSecurity默认的实现,有两个需要扩展的点:权限验证,授权。
首先是扩展授权,也就是我们需要自定义实现类似于UsernamePasswordAuthenticationFilter
的登录验证。授权的目标是,监听/login POST
请求,读取ReqBody
中用户上送的身份验证相关数据,和数据库中存储的用户信息做对比。如果验证无问题,则生成JWT的TOKEN码值,并存放到HttpServletResponse
的自定义Header
中。
package magnus.configuration;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.ws.rs.HttpMethod;
import jakarta.ws.rs.core.MediaType;
import lombok.Setter;
import magnus.utils.jwt.JwtAuthentication;
import magnus.utils.jwt.JwtPayload;
import magnus.utils.jwt.JwtTokenService;
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.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
/**
* Jwt授权,判断当前路径是否是/login POST,如果满足条件则给予授权服务
*/
@Setter
public class MagnusJwtAuthorizationFilter extends AbstractAuthenticationProcessingFilter {
private String tokenHeader;
private ObjectMapper objectMapper;
private JwtTokenService jwtTokenService;
private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login",
HttpMethod.POST);
private static final String USERNAME_KEY = "username";
private static final String PASSWORD_KEY = "password";
protected MagnusJwtAuthorizationFilter(AuthenticationManager authenticationManager) {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
}
protected MagnusJwtAuthorizationFilter(RequestMatcher requiresAuthenticationRequestMatcher) {
super(requiresAuthenticationRequestMatcher);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
// 取 登录请求的数据
if (!request.getMethod().equals(HttpMethod.POST)) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
ServletInputStream inputStream = request.getInputStream();
byte[] b = new byte[1024];
int read;
StringBuilder stringBuilder = new StringBuilder();
while ((read = inputStream.read(b)) > 0) {
stringBuilder.append(new String(b, 0, read));
}
String reqBody = stringBuilder.toString();
Map map = objectMapper.readValue(reqBody, Map.class);
//
String username = (String) map.get(USERNAME_KEY);
String password = (String) map.get(PASSWORD_KEY);
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
username, password);
this.getAuthenticationManager().authenticate(usernamePasswordAuthenticationToken);
return usernamePasswordAuthenticationToken;
}
public void initialize() {
// todo 设置success handler 和 failure handler
super.setAuthenticationSuccessHandler(((request, response, authentication) -> {
JwtPayload build = JwtPayload.builder().expireTime(Instant.now().plusSeconds(300).toEpochMilli())
.userid((String) authentication.getPrincipal())
.allocationTime(Instant.now().toEpochMilli()).build();
String token = jwtTokenService.generateToken(build);
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = (UsernamePasswordAuthenticationToken) authentication;
usernamePasswordAuthenticationToken.setDetails(build);
response.setContentType(MediaType.APPLICATION_JSON);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
Map<String, Object> map = new HashMap<>();
map.put("result", "登录成功");
map.put("authentication", authentication);
response.getWriter().write(objectMapper.writeValueAsString(map));
response.setHeader(tokenHeader, token);
}));
super.setAuthenticationFailureHandler(((request, response, exception) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
}));
}
}
需要将上面的MagnusJwtAuthorizationFilter
注入到SecurityFilterChain
中,如下所示:
package magnus.configuration;
import com.fasterxml.jackson.databind.ObjectMapper;
import magnus.utils.jwt.JwtTokenService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
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.config.annotation.web.configurers.LogoutConfigurer;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.context.SecurityContextHolderFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Configuration
@EnableWebSecurity
@ConfigurationProperties(prefix = "magnus.jwt")
public class MagnusSecurityConfiguration {
String tokenHeader;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, ObjectMapper objectMapper,
JwtTokenService jwtTokenService) throws Exception {
httpSecurity.authorizeHttpRequests(
request -> request.requestMatchers("/restful/**").permitAll().anyRequest().authenticated());
httpSecurity.logout(LogoutConfigurer::permitAll);
httpSecurity.addFilterAfter(magnusJwtAuthenticationFilter(jwtTokenService, objectMapper),
SecurityContextHolderFilter.class);
httpSecurity.csrf(AbstractHttpConfigurer::disable);
return httpSecurity.build();
}
public MagnusJwtAuthorizationFilter magnusJwtAuthorizationFilter(JwtTokenService jwtTokenService,
ObjectMapper objectMapper) {
MagnusJwtAuthorizationFilter magnusJwtAuthorizationFilter = new MagnusJwtAuthorizationFilter(providerManager());
magnusJwtAuthorizationFilter.setObjectMapper(objectMapper);
magnusJwtAuthorizationFilter.setTokenHeader(tokenHeader);
magnusJwtAuthorizationFilter.setJwtTokenService(jwtTokenService);
magnusJwtAuthorizationFilter.initialize();
return magnusJwtAuthorizationFilter;
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails build = User.withUsername("username").password("{noop}password").roles("user").build();
return new InMemoryUserDetailsManager(build);
}
@Bean
@Qualifier("providerManager")
public AuthenticationManager providerManager() {
List<AuthenticationProvider> providers = new ArrayList<>();
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService());
MagnusJwtUserInfoProvider magnusJwtUserInfoProvider = new MagnusJwtUserInfoProvider();
providers.add(provider);
providers.add(magnusJwtUserInfoProvider);
return new ProviderManager(providers);
}
public void setTokenHeader(String tokenHeader) {
this.tokenHeader = tokenHeader;
}
}
需要注意的一点是,这个类不可以注册到Spring容器中,因为这个Bean是默认实现了GenericFilterBean
,会被SpringWeb
捕获到作为SpringWeb
的过滤器,这样就会被执行两遍了!
更多扩展点包括,可以自定义从数据库中读取用户参数(上面代码是内存中配置的用户),只要修改UserDetailsService
。可以自定义请求头的key值,只需配置文件中修改magnus.jwt.tokenHeader
等。同时也可以配置多数据源校验,就是在ProviderManager
中注册多个DaoAuthenticationProvider
。
授权完成,当用户再次请求某个服务器资源时,服务器就需要校验客户是否有足够的权限,也就是客户请求携带的token是否符合服务器的要求。
package magnus.configuration;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.ws.rs.core.MediaType;
import lombok.NonNull;
import lombok.Setter;
import magnus.utils.jwt.JwtPayload;
import magnus.utils.jwt.JwtTokenService;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
@Setter
public class MagnusJwtAuthenticationFilter extends OncePerRequestFilter {
private String tokenHeader;
private ObjectMapper objectMapper;
private JwtTokenService jwtTokenService;
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws IOException, ServletException {
if (requireAuthentication(request)) {
// 如果是POST请求,则获取对应的token报文头
String header = request.getHeader(tokenHeader);
if (header != null) {
try {
// 验证成功
JwtPayload jwtPayload = jwtTokenService.verifyToken(header);
// 直接放行
UsernamePasswordAuthenticationToken authenticated = UsernamePasswordAuthenticationToken.authenticated(
jwtPayload.getUserid(), jwtPayload.getPassword(), jwtPayload.getAuthorities());
// todo 查询出用户相关信息,放到Authentication里面
SecurityContextHolder.getContext().setAuthentication(authenticated);
} catch (Exception e) {
// 校验失败,交给后续的步骤来校验
response.setHeader(tokenHeader, null);
}
}
}
filterChain.doFilter(request, response);
}
public boolean requireAuthentication(HttpServletRequest request) {
if (request.getHeader(tokenHeader) != null) {
return true;
}
return false;
}
private void errorHandler(HttpServletRequest request, HttpServletResponse response) throws IOException {
// 报错直接返回登录异常
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
Map<String, Object> map = new HashMap<>();
map.put("result", "登录失败");
response.getWriter().write(objectMapper.writeValueAsString(map));
}
}
同样是定义一个过滤器,每次读取请求的报文头中是否包含自定义的tokenHeader
字段。如果包含,则校验包含的token是否有误。校验通过,将Authentication结果存到SecurityContextHolder
中,并继续后续的过滤规则。