目录
概述
上一篇主要是总结了有关spring security 的账号主体的设计,这一篇主要会一步一步总结这个配置类是如何编写的。这个配置类在spring security中至关重要,许多的组件都汇集于此,搞定这配置类整个流程就走通了。
路由配置
这一类的安全框架大多都是通过过滤器来匹配你请求的路由来判断拦截合放行的,所以我们需要在配置类中进行路由的配置。首先我们创建一个配置类,
@Configuration
@EnableWebSecurity
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
然后继承这个WebSecurityCongiurerAdapter,继承它可以根据我们项目自身的需要去重写其中的方法。然后加上两个注解,一个是将这个类作为spring bean容器定义的源,同时可以用来调用同类中的其它bean方法来定义bean之间的依赖关系,所以后面我们会把其它的一些bean定义到这个类中。第二个注解是用来激活WebSecurity的配置,如同我们使用spring的缓存、定时任务组件时所需要的激活注解一样。
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers(HttpMethod.GET,
"/favicon.ico",
"/**/*.html",
"/**/*.css",
"/**/*.js",
"/swagger-ui/**",
"/webjars/**",
"/swagger-resources/**",
"/v3/**"
).permitAll()
.antMatchers(HttpMethod.POST,
"/user/login/**")
.permitAll();
http.authorizeRequests().anyRequest().authenticated();
}
然后重写这个configure方法,这里表示匹配下面的get请求,允许匿名访问这些资源,这里主要时开放了swagger3的一些静态资源。第二个匹配的时我们的登录入口,使用的时post请求,spring security默认的表单登录请求也是建议使用post请求。最后一句是其余所有的路由都需要认证后才能访问。
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
然后我们重写这个认证管理器,并且把它作为bean注册到spring 容器中。
@Bean
public SelfUserDetailService selfUserDetailService() {
return new SelfUserDetailService();
}
这里把上一篇中账号主体信息获取的实现了以bean的形式注册。
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
这里是选择我们的加密算法
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(selfUserDetailService())
.passwordEncoder(passwordEncoder());
}
然后重写这个configure方法,注意的是它接收的是一个有关认证的参数,和之前路由的configure方法是不同的。将前面提到的账号主体和加密方式这两个容器配置进来。然后我们需要暴露一个路由作为我们的登录入口。里面具体的登录逻辑,我提炼后如下
@Override
public void userLogin(LoginBO loginBO) {
SecurityContext currentUser = SecurityContextHolder.getContext();
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginBO.getUserName(),loginBO.getPassword());
final Authentication authenticate = authenticationManager.authenticate(authenticationToken);
currentUser.setAuthentication(authenticate);
}
这里其实和shiro的登录实现是比较类似的。第一行获取当前这个会话session中账号主体信息的上下文,相当于我得到一个记录账号的载体。第二行,我们登录采用的是账号密码的方式,所以需要用这个账号和密码封装成一个token令牌来继续后面的认证流程,第三行就是认证操作了,spring security为我们封装成了这一个方法,隐藏了内部的实现,这里使用final,我个人的理解是,在当前认证流程中,获取完认证信息后,不应该再被别的操作修改,保证了安全性。最后将认证信息写入到我们的上下文中,那么我们这个会话session中这个账号就被认为是已认证的了。
异常处理
我们知道框架本身会定义好一些异常,但是这些异常信息基本都是英文的我们需要转成中文,另外在前后端分离的项目中这些异常我们需要统一处理通过json的形式返回给前端。那么我们就需要在这里再创建几个类。spring security中异常主要是分为认证异常和访问拒绝异常。首先我们创建一个认证异常的处理类,并让它实现AuthenticationEntryPoint接口,然后重写commence方法
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
//处理异常
Object webJson;
if (e instanceof UsernameNotFoundException) {
//查询不到账号
webJson = WebResponse.fail(ServiceErrorEnum.UN_KNOWN_ACCOUNT.getErrCode(), ServiceErrorEnum.UN_KNOWN_ACCOUNT.getErrMsg());
} else if (e instanceof BadCredentialsException) {
//账号或密码错误
webJson = WebResponse.fail(ServiceErrorEnum.ERROR_CREDENTIALS.getErrCode(), ServiceErrorEnum.ERROR_CREDENTIALS.getErrMsg());
} else if (e instanceof AccountExpiredException) {
//账号过期
webJson = WebResponse.fail(ServiceErrorEnum.ACCOUNT_EXPIRE.getErrCode(), ServiceErrorEnum.ACCOUNT_EXPIRE.getErrMsg());
} else if (e instanceof ProviderNotFoundException) {
//认证方式不支持
webJson = WebResponse.fail(ServiceErrorEnum.PROVIDER_NOT_FOUND.getErrCode(), ServiceErrorEnum.PROVIDER_NOT_FOUND.getErrMsg());
} else if (e instanceof DisabledException) {
//账号被禁用
webJson = WebResponse.fail(ServiceErrorEnum.DISABLE_ERROR.getErrCode(), ServiceErrorEnum.DISABLE_ERROR.getErrMsg());
} else if (e instanceof LockedException) {
//账号被锁定
webJson = WebResponse.fail(ServiceErrorEnum.LOCK_ACCOUNT.getErrCode(), ServiceErrorEnum.LOCK_ACCOUNT.getErrMsg());
} else if (e instanceof CredentialsExpiredException) {
//凭证过期异常异常
webJson = WebResponse.fail(ServiceErrorEnum.CREDENTIALS_EXPIRE.getErrCode(), ServiceErrorEnum.CREDENTIALS_EXPIRE.getErrMsg());
}else if(e instanceof InsufficientAuthenticationException){
//需要认证
webJson = WebResponse.fail(ServiceErrorEnum.NEED_LOGIN.getErrCode(),ServiceErrorEnum.NEED_LOGIN.getErrMsg());
} else {
//认证服务异常
webJson = WebResponse.fail(ServiceErrorEnum.AUTH_SERVICE_ERROR.getErrCode(), ServiceErrorEnum.AUTH_SERVICE_ERROR.getErrMsg());
}
httpServletResponse.setStatus(HttpStatus.OK.value());
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setContentType("application/json");
Writer writer = httpServletResponse.getWriter();
String toJSONString = JSON.toJSONString(webJson);
writer.write(toJSONString);
writer.flush();
writer.close();
}
这个方法默认在监听到认证异常后会去查找我们代码中所配置的登录页,然后跳转到登录页。但是在前后端分离项目中我们后端只需要返回这个json格式的异常信息就可以了。这里将匹配spring security中的认证异常,结合我们的登录代码,我总结了下面几种
-
UsernameNotFoundException 顾名思义就是当通过账号名搜索时无法获取账号,就返回这个异常信息
-
BadCredentialsException 这个就是我们的我们的账号和密码不正确时会返回的信息
-
AccountExpiredException 账号过期,可以抛出这个异常
-
ProviderNotFoundException 认证方式不支持,比如当前时账号密码认证,但是传递的时jwt认证那判断后可以抛出这个异常
-
DisabledException 账号被禁用时可以抛出的这个异常
-
LockedException 账号被锁定的时候可以抛出这个异常
-
CredentialsExpiredException 凭证过期可以抛出的异常
-
InsufficientAuthenticationException 当前的认证信息不足时可以抛出这个异常
这里需要注意的是这个spring security自带的这个usernamenotfoundException一般是不会被抛出到最外层,我举个例子,在之前重写获取账号主体信息的方法中,对于通过账号名获取不到信息的,我们自主地抛出了这个框架自带的异常,通过断点可以发现
try {
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
} catch (UsernameNotFoundException var6) {
this.logger.debug("User '" + username + "' not found");
if (this.hideUserNotFoundExceptions) {
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
throw var6;
}
当这个我们抛出的账号不存在的异常被捕获后,系统通过这个hideUserNotFoundException参数(这个参数默认是true)将异常转为BadeCredentialsException凭证不正确抛出。这个也是处于安全考虑,避免别人通过不断的请求来推断你的账号名。
我们通过匹配抛出的原生异常类,然后封装成json串返回给前端由前端决定跳转地址。
另一种异常就是访问权限异常,这里需要实现AccessDeniedHandler接口
public class SelfAccessDecisionHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setStatus(HttpStatus.OK.value());
httpServletResponse.setContentType("application/json");
WebResponse<Object> fail = WebResponse.fail(ServiceErrorEnum.PERMISSION_REFUSE.getErrCode(), ServiceErrorEnum.PERMISSION_REFUSE.getErrMsg());
Writer writer = httpServletResponse.getWriter();
String toJSONString = JSONObject.toJSONString(fail);
writer.write(toJSONString);
writer.flush();
writer.close();
}
}
这里比较简单将异常封装好以json串的格式jiang返回给前端。
最后要做的是在之前配置类中,将这两个类注册到spring容器中
@Bean
public SelfAuthenticationHandler selfAuthenticationHandler() {
return new SelfAuthenticationHandler();
}
@Bean
public SelfAccessDecisionHandler selfAccessDecisionHandler() {
return new SelfAccessDecisionHandler();
}
将这两个异常处理器添加到exceptionHandling。那么spring security 就会把认证异常和访问拒绝异常抛出到这两个处理器中
http.exceptionHandling().authenticationEntryPoint(selfAuthenticationHandler())
.accessDeniedHandler(selfAccessDecisionHandler());
到此spring security 的异常处理就结束了。
退出登录
和登录一样重要的是退出登录,一般来说会重新跳转到登录页面,但是在前后端分离的项目中应该交给前端来处理。所以我们需要把退出登录的信息封装成json 数据然后返回
我们需要先创建一个类然后实现LogoutSuccessHandler接口,重写onLogoutSuccess方法。这个方法在退出登录成功时会调用。
public class SelfLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
httpServletResponse.setContentType("application/json");
httpServletResponse.setCharacterEncoding("UTF-8");
WebResponse<String> webResponse = WebResponse.success("退出登录成功");
PrintWriter writer = httpServletResponse.getWriter();
writer.write(JSON.toJSONString(webResponse));
writer.flush();
writer.close();
}
}
接着就需要在之前的configure类中精选配置了。我们梳理下要做的事,首先我们需要指定一个路由用作我们退出登录的路由,然后我们需要去执行退出登录时所需要处理的业务,比如清除会话,清除cookie,清除凭证等等。然后这些处理完了,意味着我们的退出登录业务时成功的,我们需要处理退出登录成功后的业务,是跳转还是直接返回一个信息提示给前端。
按照这三点我们在配置类中之前配置路由的地方再加上一句
http.logout(logout -> logout
.logoutUrl("/user/logout/**")
.logoutSuccessHandler(selfLogoutSuccessHandler())
.deleteCookies("selfCookie")
);
logoutUrl用于指定退出的入口,spring security框架再退出登录的时候会默认为我们处理一些事情,默认是由SecurityContextLogoutHandler来实现的,默认是会使会话失效,同时清除认证信息。
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
Assert.notNull(request, "HttpServletRequest required");
if (this.invalidateHttpSession) {
HttpSession session = request.getSession(false);
if (session != null) {
this.logger.debug("Invalidating session: " + session.getId());
session.invalidate();
}
}
if (this.clearAuthentication) {
SecurityContext context = SecurityContextHolder.getContext();
context.setAuthentication((Authentication)null);
}
SecurityContextHolder.clearContext();
}
所以这里没有选择再重写框架的logoutHandler,因为本身的这些功能已经基本够用了。
我这里是增加了一个清除指定cookies的功能,它可以根据名称来清除cookie的值。这里需要注意的一点是在登录流程中我们所写入的cookie的path需要这样写
selfCookie.setPath(request.getContextPath() + "/");
这样才能和框架清除cookie时的path保持一致,这样才能重写掉cookie的值。
最后将之前自定义的退出登录成功处理器注册到spring 容器中作为logoutSuccessHandler配置进去。
权限管理
和大多数的权限系统框架一样,spring security 也是通过注解来管理权限的。首先第一步在配置类上增加注解
@EnableGlobalMethodSecurity(prePostEnabled = true)
这个表示全局方法级别的安全校验开启,并且可以在切面前后发挥作用,指的是在进入方法前处理权限后执行完方法后处理权限。
@PreAuthorize(value = "hasRole('ROLE_ADMIN')")
@PreAuthorize(value = "hasAuthority('TEST_ONE')")
这两个就是在controller层中加在方法层面上的注解,一个是角色一个是权限。注意角色必须加上前缀ROLE_。
跨域配置
在前后端分离的一大痛点就是跨域问题,spring security有专门对于跨域的配置。
系统默认会读取corsConfigurationSource名称的bean,所以我们可以把跨域配置写在这里
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList(ORIGIN));
configuration.setAllowedMethods(Arrays.asList("GET","POST","PUT","DELETE","OPTION"));
configuration.setAllowedHeaders(Arrays.asList("Origin", "X-Requested-With", "Content-Type", "Accept","Authorization","If-Modified-Since"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
这里分别是自定义配置了跨域允许的源、方法、请求头、是否允许跨域携带凭证。并且当允许跨域携带凭证的时候,我们的origin不能设置为*。当然实际开发中origin 也不会有很多。至于在shiro中跨域带来的复杂请求,请求预校验。security本身已经封装了处理的方法不需要我们再额外写了。
http.cors(Customizer.withDefaults());
在配置类之前配置路由的方法中再加上这么一句,表示使用跨域资源共享,并且读取默认的名称为corsConfigurationSource的容器。
杂项配置
由于spring security本身的一个特点就是能够对抗跨域伪造请求,所以这个功能是默认开启的,它需要请求的时候默认携带一个csrfToken。这个功能我们可以暂时关闭
http.csrf().disable();
在配置类配置路由的方法中加上这句。
对于会话的时间来说,可以在yml配置文件中直接配置时间
server:
port: 9092
servlet:
context-path: /security
session:
timeout: 30m
到这里一个基本的可以进行前后端分离开发的spring security配置就完成了。