Spring Security学习笔记-Spring专区论坛-技术-SpringForAll社区

Spring Security学习笔记

shiro、spring security 这两个安全框架一直有听说,但一直没有使用过。从事过的几家公司,也都是自建安全框架。近期公司在搞数据权限设计,正好趁此机会学习下 spring security。

对安全框架的认识

  1. 登录验证

通过用户名、密码或者手机号、验证码等方式,让系统知道你是谁的一个步骤,并且认证通过后会发给你一个凭证(token、sessionId 等),接下来都是通过这个凭证来访问。

  1. 权限校验

你拿着凭证,只能访问管理员赋予你权限访问的地址。这里即包括在前端页面(web、app 等)可以看到的菜单、按钮等页面信息,也包括每次动作请求的后端接口。如果没有权限对于前端页面来说你就看不到相关信息,对于后端接口来说你请求会返回无权限的标识,例如 http 的 403 状态。

  1. 如何实现
  • a. 实现一个登录接口,校验通过后,生成凭证,并将用户的信息和权限缓存起来。
  • b. 实现一个 filter,对每个请求判断是否有凭证,如果没有凭证则返回 401 信息,如果有凭证则拿出用户信息和权限进行校验,如果凭证错误,则返回 401 信息,如果权限不足则返回 403,有权限则允许继续处理。

spring security

名词解释

  • Authentication
    认证,即用于确认用户身份的过程,一般来说就是校验用户名和密码。
  • Authorization
    授权,即确定用户是否有权访问资源或执行特定操作的过程。授权通常是访问控制的第二步,因为它确保只有经过授权的用户才能访问系统中的资源。
  • Protection Against Exploits
    防御攻击漏洞,主要是针对 CSRF(跨站点请求伪造)、Security HTTP Response Headers(安全响应头)
  • Principal
    在计算机中一般代指一个用户、一个实体。在 Authentication 过程中,一般就是指用户的用户名或者其他什么唯一标识这个用户的信息。
  • Credentials
    指身份验证信息,一般就是密码、数字证书、生物识别信息等。

security 的几个核心过滤器

SecurityFilterChain

  • 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 都是继承了这个过滤器。其流程如下:

AbstractAuthenticationProcessingFilter有几个核心的关注点:

  • AuthenticationManager,处理认证请求的接口
    • ProviderManager,AuthenticationManager 的具体实现,通过一系列的 AuthenticationProvider 来做认证
      • AuthenticationProvider,提供具体的某一种认证流程的接口
        • AbstractUserDetailsAuthenticationProvider,AuthenticationProvider 的抽象实现,指定了通过用户名密码来认证
          • DaoAuthenticationProvider,继承了 AbstractUserDetailsAuthenticationProvider,通过 Dao 类即 UserDetailService 来获取用户信息
            • PasswordEncoder,对密码的加密处理
  • AuthenticationFailureHandler,认证失败后的处理
  • AuthenticationSuccessHandler,认证成功后的处理
  • SecurityContext,用户认证信息上下文(用户名、密码、准许的权限)
  • SecurityContextHolder,用来关联当前线程和用户认证信息上下文,默认策略是通过 ThreadLocal 来实现的。

无 session 基于 token+redis 来做登陆授权校验的纯后端案例

案例基于 Spring boot 2.6.8

  1. 在 pom 中引入
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
    <version>2.6.8</version>
</dependency>
  1. 在工程中创建一个包,专门用来放 Spring security 的相关配置
  2. 一些核心类的代码

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

请登录后发表评论