spring security的处理流程
spring security采用一系列链式操作来完成认证和鉴权任务。它的总体流程在江南一点雨的博客中有写,这里就不在罗列了。这里仅对我们需要自定义的部分进行提取。
登录处理
Spring Security 默认使用form表单进行登录,在基本的无配置情况下他会在使用自带的登录页面,并且在控制台打印出一串密码以进行登录验证。而这显然不符合实际的情况。所以,Spring Security提供基本的配置类来进行设置。只要在配置类中继承WebSecurityConfigurerAdapter
就可以对Spring Security进行简单且高效的配置。
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable().cors()
.and()
.formLogin()
.loginPage("/login")
.usernameParameter("loginName")
.passwordParameter("passwd")
.successForwardUrl("/index")
.failureForwardUrl("/error");
}
}
登录过程实现
只要使用.loginPage()
就可以指定自己编写的登录界面了。但是需要注意的是默认情况下表单中的参数名必须得是username
和password
。如果参数名不一致的话,spring security是无法进行匹配的。当然你也可以使用.usernameParameter
和.passwordParameter
来指定前端传来的from表单内容。除此之外,我们还需要实现一个UserDetailService
接口来从数据库或内存中获取正确的User信息用以判断登录成功与否。
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private UserService userService;
@Autowired
public void setUserService(UserService userService) {
this.userService = userService;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser sysUser = userService.findByLoginName(username);
if (sysUser == null) {
throw new UsernameNotFoundException("用户名或密码不正确");
}
return new UserDetails("具体数据");
}
}
该接口只有一个loadUserByUsername
方法需要实现该方法需要返回一个UserDetail
类用以存放真实的用户信息,这个UserDetail
也是一个接口。
源码如下:
public interface UserDetails extends Serializable {
Collection extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
spring security也提供实现类User
,一般情况下拿来即用就行,也可以通过继承进行拓展。具体根据实际的业务逻辑来决定。最后在配置类中将写好的UserDetailService
载入AuthenticationManager
即可。这样就能将登录过程完全托管给spring security来处理了。
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}
登录成功和登录失败处理
但是很遗憾啊,公司里的项目是前后端分离式的,所以上面的配置并不能满足我们的需求。前后端分离项目的特点就是前后端之间只是用json数据进行传输,页面路由完全由前端控制。所以合理解决方案是,在登录成功后前端发送一个携带凭证的json数据,登录失败的话也返回对应的json数据。
根据上面的流程图可以看出,在登录验证之后,SpringSecurity 会根据验证结果,进入AuthenticationFailureHandler
或AuthenticationSuccessHandler
中,所以只要我们实现这两个接口,就可以对登录处理进行自定义了。
登录失败的话我们将失败原因封装成固定的json格式返回回去即可。
@Component
public class LoginFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
OutputStream out = response.getOutputStream();
String result;
ObjectMapper mapper = new ObjectMapper();
if (exception instanceof BadCredentialsException) {
result = mapper.writeValueAsString(BaseResponse.fail("用户名或者密码错误"));
} else if (exception instanceof DisabledException) {
result = mapper.writeValueAsString(BaseResponse.fail("该账户已禁用"));
} else {
result = mapper.writeValueAsString(BaseResponse.fail(exception.getMessage()));
}
out.write(result.getBytes(StandardCharsets.UTF_8));
out.flush();
out.close();
}
}
登录成功的逻辑也差不多,生成一个特定的凭证返回给前端使用就好,这里我们采用jwt(json web token)作为凭证。
@Component
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setContentType("application/json; charset=UTF-8");
ServletOutputStream out = response.getOutputStream();
out.write(getResponse(authentication).getBytes(StandardCharsets.UTF_8));
out.flush();
out.close();
}
private String getResponse(Authentication authentication) throws JsonProcessingException {
// 获取spring security user对象
User user = (User) authentication.getPrincipal();
// 用jwt工具类根据用户信息来生成token
String token = JwtUtil.sign(user);
// 将spring security user转化成vo传回去
LoginUserVo lv = new LoginVo(user,token);
BaseResponse result = BaseResponse.success(lv);
ObjectMapper mapper = new ObjectMapper();
return mapper.writeValueAsString(result);
}
}
最后别忘记将两个自定义的实现类编入spring security中去
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable().cors()
.and()
// 登录验证逻辑
.formLogin()
.loginPage("/login")
.loginProcessingUrl("/login")
.usernameParameter("loginName")
.passwordParameter("passwd")
.successHandler(loginSuccessHandler)
.failureHandler(loginFailureHandler);
登出处理
因为采用的jwt机制的无状态服务,而且jwt是无法手动注销的,所以其实登出操作只要在前端把token从缓存中删除就可以了。不过由于我们采用了无状态服务,所以还要配置将session给关闭
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
虽然session和jwt相互并不冲突,但是为了效率考量还是关闭session比较合理,不然服务端会积累太多session id的。
当然如果是用了redis等缓存机制,还要在登出成功后在缓存中也将token进行同步删除。这一点可以通过实现LogoutSuccessHandler
来实现,详见上述流程图。
认证验证
将整个登录登出模块配置完成之后,就需要解决如何验证已登录问题,而spring security自生自然使用session id 来进行验证的,但是之前我们已经将session给关闭了。所以就要用到我们自己派发的jwt了,只要一个请求的请求头中携带了我们的jwt,且jwt未过期,我们就认为他是已经登录。
而要实现这个需求,自然是又要自定义一个过滤器了。这次我们采用继承BasicauthenticationFilter
且重写doFilterInternal
的方式来实现。
public class JwtAuthenticationFilter extends BasicAuthenticationFilter {
public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String token = request.getHeader("Authorization");
if (JwtUtil.verify(token)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken("username", null,
"权限列表");
// 将token中的信息放到security context中用以后续验证
SecurityContextHolder.getContext().setAuthentication(authToken);
}
chain.doFilter(request, response);
}
}
这里的逻辑也相当简单,从前端的请求头中获取jwt判断是否有效,如果有效则在spring security context中设置相应的权限,否则就直接交给之后的过滤器来验证。唯一需要注意的就是这个UsernamePasswordAuthenticationToken
这是spring security中authentication 的一个具体实现。可以从中通过getPrncipal
来获取当前的用户信息,通过getCredentials
来获取当前用户的密码,通过getDetails
来获取请求的更多详细信息。然后因为我们是使用jwt作为凭证的,自然不可能在里面存放密码,所以就将密码设为null了。值得一提的是,最后一项权限列表是必填的不能为null。即使你的业务逻辑里没有权限认证,你也需要提供一个权限作为认证权限,不然即使已登录也是无法访问到任何controller的。
最后将这个过滤器在配置类中进行配置。
http.addFilter(getJwtAuthenticationFilter());
添加过滤器一共有四种方式addFilterAt
,addFilterBefore
,addFilterAfter
,addFilter
。基本上可以做到见名知义,而他生效原理么,就和spring security中对过滤的实现方式有关了。spring security会维护一个filter序列,并通过优先级来判断当前应该执行哪个过滤器,spring security对自己实现过滤器已经有了默认的优先级配置,所以前三个方法分别可以获取目标过滤器的优先级,优先级+1,优先级-1。如果两个过滤器的优先级相同的话会优先执行我们自定义的过滤器。详见原文总结的相当全面了。然后就是我们这里用到的addFilter
了,使用这个方法的话spring security会去判断这个过滤器是否已经注册到filter列表中,如果没有就会报没有指定优先级错
public HttpSecurity addFilter(Filter filter) {
Class extends Filter> filterClass = filter.getClass();
if (!comparator.isRegistered(filterClass)) {
throw new IllegalArgumentException(
"The Filter class "
+ filterClass.getName()
+ " does not have a registered order and cannot be added without a specified order. Consider using addFilterBefore or addFilterAfter instead.");
}
this.filters.add(filter);
return this;
}
public boolean isRegistered(Class extends Filter> filter) {
return getOrder(filter) != null;
}
很显然我们自定义的过滤器没有被注册,但是为啥没有报错呢,那肯定能想到是因为继承了父类的优先级,而事实也是如此:
private Integer getOrder(Class> clazz) {
while (clazz != null) {
Integer result = filterToOrder.get(clazz.getName());
if (result != null) {
return result;
}
clazz = clazz.getSuperclass();
}
return null;
}
认证和权限异常处理
在完成了登录验证之后,自然是要对未登录的请求进行拦截和向前端发送对应信息了。拦截这一部分spring security可以帮我们做,但是前后端分离状态的消息回送自然要我们来手动处理。参考上面的流程图可以得知,spring security的异常处理有两个入口,一是认证异常处理AuthenticationEntryPoint
,一是权限不足异常处理AccessDeniedHandler
。所以只要实现这个两个接口并在配置类中进行处理就行了。
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
OutputStream out = response.getOutputStream();
ObjectMapper mapper = new ObjectMapper();
response.setStatus(401);
String result = mapper.writeValueAsString(BaseResponse.fail("用户未认证"));
out.write(result.getBytes(StandardCharsets.UTF_8));
out.flush();
out.close();
}
}
@Component
public class JwtDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
OutputStream out = response.getOutputStream();
ObjectMapper mapper = new ObjectMapper();
response.setStatus(403);
String result;
if (accessDeniedException instanceof AuthorizationServiceException) {
result = mapper.writeValueAsString(BaseResponse.fail("无访问权限"));
} else {
result = mapper.writeValueAsString(BaseResponse.fail(accessDeniedException.getMessage()));
}
out.write(result.getBytes(StandardCharsets.UTF_8));
out.flush();
out.close();
}
}
关键词
springboot, spring security, 前后端分离, jwt, restful
纯小白,写的有点乱,如有问题还请指正orz,会不断完善内容的。
参考自江南一点雨大神的博客。原文指路