1

前言

这周写了一下后台登录,老师叫我参考一下教程后台,正好通过这次机会学习一下spring Security。

Spring Security

我们看一下官网对于spring security的介绍

Spring Security is a powerful and highly customizable authentication and access-control framework. It is the de-facto standard for securing Spring-based applications.
Spring Security is a framework that focuses on providing both authentication and authorization to Java applications. Like all Spring projects, the real power of Spring Security is found in how easily it can be extended to meet custom requirements

这段文字的大致意思是:
Spring Security是一个强大的、可高度定制化的身份验证和访问控制的框架,它基本上是保护基于Spring应用的安全标准。
Spring Security是一个专注于向Java应用程序提供身份验证和授权的框架。像所有的Spring项目一样,Spring Security的真正威力在于它可以很容易地被扩展以满足定制需求。

我们开发一个后台,一些资源想要供所有人访问,一些资源则只想让登录的人访问,这时候就需要用到我们的spring security。spring security将身份验证抽离于业务代码之外。

使用

首先在配置文件中引入spring security的依赖

<!-- Spring Security的核心依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

此时,spring security就已经起作用了,我们再向后台发送信息就会被拦截。
这是因为Spring Boot项目引入了Spring Security以后,自动装配了Spring Security的环境,Spring Security的默认配置是要求经过了HTTP Basic认证成功后才可以访问到URL对应的资源,且默认的用户名是user,密码则是一串UUID字符串,输出到了控制台日志里
image.png
这显然不是我们想要的认证规则。但是就想前面介绍的那样,spring security强大的地方就在与我们可以自定义认证规则。

我们现在来分析一下项目的spring security,你也可以参考官网给的demo
官网demo
项目的大致思路就用户第一次登录后台会给一个token,再次请求时就带着token,后台通过token与用户信息绑定,从而知道登录用户是谁。这里的token是有时效的,当token过期后,重新发送token给浏览器,浏览器缓存起来。带着这个思路让我们看一下代码实现。

@Configuration
@EnableWebSecurity
public class MvcSecurityConfig extends WebSecurityConfigurerAdapter {
  public static String xAuthTokenKey = "x-auth-token";

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
    // 设置授权配置
        .authorizeRequests()
        // 规定开放端口与需要认证端口
        .antMatchers("/teacher/login").authenticated()
        .antMatchers("/teacher/me").authenticated()
        .antMatchers("/teacher/logout").authenticated()
        .antMatchers("/teacher/**").permitAll()
        .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
        .anyRequest().authenticated()
        // 设置cors过滤器
        .and().cors()
        // 设置httpBasic认证
        .and().httpBasic()
        // 禁用csrf过滤器
        .and().csrf().disable()
        // 在 basic 认证过滤器前后加入自定义过滤器
        .addFilterBefore(this.headerAuthenticationFilter, BasicAuthenticationFilter.class)
        .addFilterAfter(this.addAuthHeaderFilter, BasicAuthenticationFilter.class);
  }
}  

我们自定义一个MvcSecurityConfig继承WebSecurityConfigurerAdapter来自定义我们的认证规则
再覆盖父类的configure方法,在此方法里自定义规则。
首先
我们需要规定哪些接口可以作为公共资源任意访问,哪些接口只能登录后才可以访问。通过antMatchers(url).authenticated()规定请求这个url需要认证,
通过antMatchers(url).permitAll()规定请求这个url不需要认证。
最后将其他url设置为需要认证anyRequest().authenticated()
然后增加cors过滤器,CORS (Cross-Origin Resource Sharing,跨域资源共享)CORS介绍
增加httpBasic认证,
并且禁用csrf过滤器,CSRF(Cross Site Request Forgery, 跨站域请求伪造)CSRF介绍
最后,在BasicAuthenticationFilter过滤器前后加入我们自定义的过滤器headerAuthenticationFilteraddAuthHeaderFilter(通过依赖注入)。

headerAuthenticationFilter

我们先说headerAuthenticationFilter,headerAuthenticationFilter主要设置token与验证token是否有效。

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    // 获取token,且token为已认证,则设置PreAuthenticatedAuthenticationToken,表明当前用户已认证
    String authToken = request.getHeader(MvcSecurityConfig.xAuthTokenKey);
    if (authToken == null) {
      authToken = UUID.randomUUID().toString();
      this.userService.bindAuthTokenLoginUsername(authToken, null, false);
    } else if (this.userService.isAuth(authToken)) {
      Optional<User> teacherOptional = this.userService.getUserByToken(authToken);
      if (teacherOptional.isPresent()) {
        // token有效,则设置登录信息
        PreAuthenticatedAuthenticationToken authentication = new PreAuthenticatedAuthenticationToken(
            new UserServiceImpl.UserDetail(teacherOptional.get(), new ArrayList<>()), null, new ArrayList<>());
        SecurityContextHolder.getContext().setAuthentication(authentication);
      }
    } else if (!this.userService.getUserByToken(authToken).isPresent()) {
      this.userService.bindAuthTokenLoginUsername(authToken, null, false);
    }

    response.setHeader(MvcSecurityConfig.xAuthTokenKey, authToken);

    filterChain.doFilter(new RequestWrapper(request, authToken), response);
  }

image.png

如果用户第一次登录,token为null,生成token并与user为null绑定,设置其未登录,然后将token设置在相应头里,转发。
如果用户非第一次登录,获取token并认证token是否有效,有效则设置登录信息,无效则与user为null绑定,设置其未登录。

AddAuthHeaderFilter

AddAuthHeaderFilter只有在用户名密码正确时才会触发,作用是将Basic认证过滤器认证的用户名与token绑定并设置其已登录。

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    // 如果用户是通过Basic认证过滤器认证的,则将认证的用户名与xAuthToken相绑定
    Authentication authResult = SecurityContextHolder.getContext().getAuthentication();
    if (authResult != null && authResult.isAuthenticated() && !(authResult instanceof PreAuthenticatedAuthenticationToken)) {
      String xAuthToken = request.getHeader(MvcSecurityConfig.xAuthTokenKey);
      if (xAuthToken == null) {
        throw new RuntimeException("未接收到xAuthToken,请在前置过滤器中加入有效的xAuthToken");
      }
      TeacherServiceImpl.UserDetail userDetail = (TeacherServiceImpl.UserDetail) authResult.getPrincipal();
      this.teacherService.bindAuthTokenLoginUsername(xAuthToken, userDetail.getTeacher(), true);
    }

    filterChain.doFilter(request, response);
  }

那我们输入的用户名密码在哪里验证呢。
首先我们在执行spring security中的过滤器时是按照顺序依次执行的,此被称为Spring security过滤器链
image.png
而我们上述配置的链路大概为... -> HeaderAuthenticationFilter -> BasicAuthenticationFilter -> AddAuthHeaderFilter ...
经过测试,所有的登录请求都会触发HeaderAuthenticationFilter,而只有用户名密码密码正确的登录请求才会触发AddAuthHeaderFilter。所以,唯一的解释就是BasicAuthenticationFilter进行了用户名密码验证。

我们观察BasicAuthenticationFilter源码

protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
  try {
    UsernamePasswordAuthenticationToken authRequest = this.authenticationConverter.convert(request);
    if (authRequest == null) {
      this.logger.trace("Did not process authentication request since failed to find username and password in Basic Authorization header");
      chain.doFilter(request, response);
      return;
    }
    
    ...
  }    

里面调用了authenticationConverter.convert(request)

  public UsernamePasswordAuthenticationToken convert(HttpServletRequest request) {
    String header = request.getHeader("Authorization");
    if (header == null) {
      return null;
    } else {
      header = header.trim();
      if (!StringUtils.startsWithIgnoreCase(header, "Basic")) {
        return null;
      } else if (header.equalsIgnoreCase("Basic")) {
        throw new BadCredentialsException("Empty basic authentication token");
      } else {
        byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8);
        byte[] decoded = this.decode(base64Token);
        String token = new String(decoded, this.getCredentialsCharset(request));
        int delim = token.indexOf(":");
        if (delim == -1) {
          throw new BadCredentialsException("Invalid basic authentication token");
        } else {
          UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(token.substring(0, delim), token.substring(delim + 1));
          result.setDetails(this.authenticationDetailsSource.buildDetails(request));
          return result;
        }
      }
    }
  }

看了这个方法就知道前台在登录时传输用户名密码的格式了。

    const authString = encodeURIComponent(this.teacher.username) + ':'
        + encodeURIComponent(this.teacher.password);
    const authToken = btoa(authString);
    let httpHeaders = new HttpHeaders();
    httpHeaders = httpHeaders.append('Authorization', 'Basic ' + authToken);

总结

token可以理解为学生的学生证,我们通过学生证的方式证明了我是我。具体可以看
你是谁

参考

Spring Security从入门到实践(一)小试牛刀


小强Zzz
1.2k 声望32 粉丝