shiro、spring security 这两个安全框架一直有听说,但一直没有使用过。从事过的几家公司,也都是自建安全框架。近期公司在搞数据权限设计,正好趁此机会学习下 spring security。
对安全框架的认识
- 登录验证
通过用户名、密码或者手机号、验证码等方式,让系统知道你是谁的一个步骤,并且认证通过后会发给你一个凭证(token、sessionId 等),接下来都是通过这个凭证来访问。
- 权限校验
你拿着凭证,只能访问管理员赋予你权限访问的地址。这里即包括在前端页面(web、app 等)可以看到的菜单、按钮等页面信息,也包括每次动作请求的后端接口。如果没有权限对于前端页面来说你就看不到相关信息,对于后端接口来说你请求会返回无权限的标识,例如 http 的 403 状态。
- 如何实现
- a. 实现一个登录接口,校验通过后,生成凭证,并将用户的信息和权限缓存起来。
- b. 实现一个 filter,对每个请求判断是否有凭证,如果没有凭证则返回 401 信息,如果有凭证则拿出用户信息和权限进行校验,如果凭证错误,则返回 401 信息,如果权限不足则返回 403,有权限则允许继续处理。
spring security
名词解释
- Authentication
认证,即用于确认用户身份的过程,一般来说就是校验用户名和密码。
- Authorization
授权,即确定用户是否有权访问资源或执行特定操作的过程。授权通常是访问控制的第二步,因为它确保只有经过授权的用户才能访问系统中的资源。
- Protection Against Exploits
防御攻击漏洞,主要是针对 CSRF(跨站点请求伪造)、Security HTTP Response Headers(安全响应头)
- Principal
在计算机中一般代指一个用户、一个实体。在 Authentication 过程中,一般就是指用户的用户名或者其他什么唯一标识这个用户的信息。
- Credentials
指身份验证信息,一般就是密码、数字证书、生物识别信息等。
security 的几个核心过滤器

-
DelegatingFilterProxy
过滤器代理,其可以将请求委托给一个 Spring 管理的 Filter Bean。通过配置targetBeanName
来指定具体的一个实现 Filter 接口的 Bean。这里的意义在于可以延迟加载这个 Bean,且这个 Bean 由 Spring 上下文管理。
-
FilterChainProxy
security 提供的过滤器链代理,其可以将请求委托给一组过滤器链来处理,这些过滤器链可以根据请求的 URL 路径进行匹配,并按照一定的顺序依次处理请求。在配置 FilterChainProxy 时,可以使用多个 SecurityFilterChain 实例来定义不同的过滤器链,并将其组合成一个完整的过滤器链。每个 SecurityFilterChain 实例都包含了一个或多个过滤器,这些过滤器可以处理特定的 URL 路径。
-
SecurityFilterChain
security 提供的过滤器链,它定义了一组过滤器,这些过滤器可以处理特定的 URL 路径。
-
Security Filters
列出一些使用到的的 Security Filter,如下:
- CorsFilter,处理跨域请求
- CsrfFilter,处理 Csrf
- LogoutFilter,登出
- UsernamePasswordAuthenticationFilter,用户名密码登入,这里还有各种登入过滤器(OAuth2.0,SAML2.0,CAS 等)
- AnonymousAuthenticationFilter,未登录用户给一个匿名用户身份
- ExceptionTranslationFilter,用于处理认证异常和访问被拒异常
- FilterSecurityInterceptor,用于访问权限判定
Authentication
AbstractAuthenticationProcessingFilter 是认证过程最重要和最基础的过滤器,UsernamePasswordAuthenticationFilter 都是继承了这个过滤器。其流程如下:
有几个核心的关注点:
- AuthenticationManager,处理认证请求的接口
- ProviderManager,AuthenticationManager 的具体实现,通过一系列的 AuthenticationProvider 来做认证
- AuthenticationProvider,提供具体的某一种认证流程的接口
- AbstractUserDetailsAuthenticationProvider,AuthenticationProvider 的抽象实现,指定了通过用户名密码来认证
- DaoAuthenticationProvider,继承了 AbstractUserDetailsAuthenticationProvider,通过 Dao 类即 UserDetailService 来获取用户信息
- AuthenticationFailureHandler,认证失败后的处理
- AuthenticationSuccessHandler,认证成功后的处理
- SecurityContext,用户认证信息上下文(用户名、密码、准许的权限)
- SecurityContextHolder,用来关联当前线程和用户认证信息上下文,默认策略是通过 ThreadLocal 来实现的。
无 session 基于 token+redis 来做登陆授权校验的纯后端案例
案例基于 Spring boot 2.6.8
- 在 pom 中引入
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.6.8</version>
</dependency>
- 在工程中创建一个包,专门用来放 Spring security 的相关配置
- 一些核心类的代码
WebSecurityConfig.java,security 的配置类
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
public static final String LOGIN_URI = "/login";
public static final String LOGOUT_URI = "/logout";
public static final String ATTR_USERNAME = "username";
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private SysUserService sysUserService;
@Autowired
private CustomPwdEncoder customMd5PasswordEncoder;
@Autowired
private StringRedisTemplate redisTemplate;
@Value("${maxLoginAttempts:3}")
private Integer maxLoginAttempts;
@Value("${tokenTimeoutSeconds:600}")
private Integer tokenTimeoutSeconds;
@Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf,开启cors跨域
.csrf().disable()
.cors()
//禁用session
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
//拦截规则
.and()
.authorizeRequests()
.antMatchers(LOGIN_URI).permitAll()
.anyRequest().authenticated()
//登出配置
.and()
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher(LOGOUT_URI, "POST"))
.addLogoutHandler(new CustomLogoutHandler(redisTemplate))
// 异常处理
.and()
.exceptionHandling()
.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.accessDeniedHandler(new CustomAccessDeniedHandler())
//过滤器
.and()
.addFilterBefore(new TokenAuthenticationFilter(redisTemplate, tokenTimeoutSeconds), UsernamePasswordAuthenticationFilter.class)
.addFilter(new TokenLoginFilter(authenticationManager(), redisTemplate, sysUserService, maxLoginAttempts, tokenTimeoutSeconds));
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 指定UserDetailService和加密器
auth
.eraseCredentials(true)//登录成功后清除上下文中的密码
.userDetailsService(userDetailsService)
.passwordEncoder(customMd5PasswordEncoder);
}
/**
* 配置哪些请求不拦截
* 排除swagger相关请求
*
* @param web
* @throws Exception
*/
@Override
public void configure(WebSecurity web) throws Exception {
web
.ignoring()
.antMatchers("/favicon.ico", "/swagger-resources/**", "/webjars/**", "/v3/**", "/swagger-ui/**", "/swagger-ui**", "/doc.html", "/error");
}
}
TokenLoginFilter.java,登录过滤器
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {
private StringRedisTemplate redisTemplate;
private Integer maxLoginAttempts;
private Integer tokenTimeoutSeconds;
private SysUserService sysUserService;
public TokenLoginFilter(AuthenticationManager authenticationManager, StringRedisTemplate redisTemplate,
SysUserService sysUserService, Integer maxLoginAttempts, Integer tokenTimeoutSeconds) {
this.setAuthenticationManager(authenticationManager);
this.setPostOnly(false);
//指定登录接口的地址和http method
this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher(WebSecurityConfig.LOGIN_URI, "POST"));
this.redisTemplate = redisTemplate;
this.sysUserService = sysUserService;
this.maxLoginAttempts = maxLoginAttempts;
this.tokenTimeoutSeconds = tokenTimeoutSeconds;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res)
throws AuthenticationException {
try {
LoginVO loginVo = new ObjectMapper().readValue(req.getInputStream(), LoginVO.class);
req.setAttribute(WebSecurityConfig.ATTR_USERNAME, loginVo.getUsername());
Authentication authenticationToken = new UsernamePasswordAuthenticationToken(loginVo.getUsername(), loginVo.getPassword());
return this.getAuthenticationManager().authenticate(authenticationToken);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
/**
* 认证成功后的处理流程
*/
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication auth) throws IOException, ServletException {
//获取认证信息
CustomUser customUser = (CustomUser) auth.getPrincipal();
//缓存认证信息到redis
String token = UUIDUtils.getUUID();
redisTemplate.opsForValue().set(token, JSONObject.toJSONString(customUser), tokenTimeoutSeconds, TimeUnit.SECONDS);
//解锁用户
sysUserService.releaseLock(customUser.getId());
//返回
ResponseUtils.out(response, R.ok(token));
}
/**
* 认证失败后的处理流程
*/
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException e) throws IOException, ServletException {
//针对用户名密码错误的异常,记录错误次数,达到上限则锁住用户
if (e instanceof BadCredentialsException) {
String userName = (String) request.getAttribute(WebSecurityConfig.ATTR_USERNAME);
if (StringUtils.isNotBlank(userName)) {
SysUser sysUser = sysUserService.getByUsername(userName);
if (sysUser != null) {
sysUser.setErrorNum(Optional.ofNullable(sysUser.getErrorNum()).orElse(0) + 1);
if (sysUser.getErrorNum() >= maxLoginAttempts) {
sysUser.setLocked(1);
}
sysUserService.updateLock(sysUser);
}
}
}
ResponseUtils.out(response, R.fail().addError("401", e.getMessage()));
}
}
TokenAuthenticationFilter.java,token 认证过滤器
public class TokenAuthenticationFilter extends OncePerRequestFilter {
public static String CONTEXT_TOKEN = "m-token";
private StringRedisTemplate redisTemplate;
private Integer tokenTimeoutSeconds;
public TokenAuthenticationFilter(StringRedisTemplate redisTemplate, Integer tokenTimeoutSeconds) {
this.redisTemplate = redisTemplate;
this.tokenTimeoutSeconds = tokenTimeoutSeconds;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
logger.info("uri:" + request.getRequestURI());
//如果是登录接口,直接放行
if (WebSecurityConfig.LOGIN_URI.equals(request.getRequestURI())) {
chain.doFilter(request, response);
return;
}
UsernamePasswordAuthenticationToken authentication = getAuthentication(request);
if (authentication != null) {
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
// token从header里取
String token = request.getHeader(CONTEXT_TOKEN);
if (StringUtils.isNotBlank(token)) {
String authoritiesString = redisTemplate.opsForValue().get(token);
if (StringUtils.isNotBlank(authoritiesString)) {
try {
CustomUser customUser = JSONObject.parseObject(authoritiesString, CustomUser.class);
if (customUser != null) {
redisTemplate.expire(token, tokenTimeoutSeconds, TimeUnit.SECONDS);
return new UsernamePasswordAuthenticationToken(customUser.getUsername(), null, customUser.getAuthorities());
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
}
}
}
return null;
}
}
还要一些自定义的异常处理、登出、密码加密、用户信息获取等代码逻辑较为简单,可以直接参考对应接口进行实现即可。
总结
查阅 spring security 文档和源码的过程中,发现相对于自己来实现一个安全框架,其提供了更加完善的流程。每个流程节点都提供了市面上常用的默认实现,以及预留了扩展槽位,且能做到不侵入业务代码。而且内置了很多安全防御机制(如在密码校验的时候,有专门防御计时攻击等),可见其对安全的重视。总结就是果然牛~
图片来源:https://docs.spring.io/spring-security/reference/5.7/servlet/getting-started.html