Spring Security详解

原创
2020/01/05 02:59
阅读数 1.4W

要使用Spring Security,首先当然是得要加上依赖

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-security</artifactId>
</dependency>

这个时候我们不在配置文件中做任何配置,随便写一个Controller

@RestController
public class TestController {
    @GetMapping("/hello")
    public String request() {
        return "hello";
    }
}

启动项目,我们会发现有这么一段日志

2020-01-05 01:57:16.482  INFO 3932 --- [           main] .s.s.UserDetailsServiceAutoConfiguration :

Using generated security password: 1f0b4e14-1d4c-4dc8-ac32-6d84524a57dc

此时表示Security生效,默认对项目进行了保护,我们访问该Controller中的接口,会见到如下登录界面

这里面的用户名和密码是什么呢?此时我们需要输入用户名:user,密码则为之前日志中的"1f0b4e14-1d4c-4dc8-ac32-6d84524a57dc",输入之后,我们可以看到此时可以正常访问该接口

在老版本的Springboot中(比如说Springboot 1.x版本中),可以通过如下方式来关闭Spring Security的生效,但是现在Springboot 2中已经不再支持

security:
  basic:
    enabled: false

当然像这种什么都不配置的情况下,其实是使用的表单认证,现在我们可以把认证方式改成HttpBasic认证(关于HTTP的几种认证方式可以参考HTTP协议整理 中的HTTP的常见认证方式)。

此时我们需要在项目中加入这样一个配置类

/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置的适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
public class SecrityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许Basic登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.httpBasic() //允许Basic登录
                .and()
                .authorizeRequests() //对请求进行授权
                .anyRequest()  //任何请求
                .authenticated();   //都需要身份认证
    }
}

此时重启项目,在访问/hello,界面如下

输入用户名,密码(方法与之前相同),则可以正常访问该接口。当然在这里,我们也可以改回允许表单登录。

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.formLogin() //允许表单登录
            .and()
            .authorizeRequests() //对请求进行授权
            .anyRequest()  //任何请求
            .authenticated();   //都需要身份认证
}

这样又变回跟之前默认不配置一样了。

SpringSecutiry基本原理

由上图我们可以看到,Spring Security其实就是一个过滤器链,它里面有很多很多的过滤器,就图上的第一个过滤器UsernamePasswordAuthenticationFilter是用来做表单认证过滤的;如果我们没有配置表单认证,而是Basic认证,则第二个过滤器BasicAuthenticationFilter会发挥作用。最后一个FilterSecurityInterceptor则是用来最后一个过滤器,它的作用是用来根据前面的过滤器是否生效以及生效的结果来判断你的请求是否可以访问REST接口。如果无法通过FilterSecurityInterceptor的判断的情况下,会抛出异常。而ExceptionTranslationFIlter会捕获抛出的异常来进行相应的处理。

过滤器的使用

现在我们自己来写一个过滤器,看看过滤器是如何使用的,现在我们要看一下接口的调用时间(该Filter接口为javax.servlet.Filter)

@Slf4j
@Component
public class TimeFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("time filter init");
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        log.info("time filter start");
        long start = System.currentTimeMillis();
        filterChain.doFilter(servletRequest,servletResponse);
        log.info("time filter: " + (System.currentTimeMillis() - start));
        log.info("time filter finish");
    }

    @Override
    public void destroy() {
        log.info("time filter destroy");
    }
}

启动项目,我们可以看到这样一段输出

2020-01-05 09:02:17.646  INFO 526 --- [           main] c.g.secritydemo.config.TimeFilter        : time filter init

说明过滤器已经开始工作。

当我们调用Controller方法之后,可以看到如下日志

2020-01-05 09:02:56.910  INFO 526 --- [nio-8080-exec-8] c.g.secritydemo.config.TimeFilter        : time filter start
2020-01-05 09:02:56.920  INFO 526 --- [nio-8080-exec-8] c.g.secritydemo.config.TimeFilter        : time filter: 10
2020-01-05 09:02:56.920  INFO 526 --- [nio-8080-exec-8] c.g.secritydemo.config.TimeFilter        : time filter finish

在Spring MVC中,我们是把过滤器配置到web.xml中,但是在Spring boot中是没有web.xml的,如果我们写的过滤器或者第三方过滤器没有使用依赖注入,即这里不使用@Component注解,该如何使得该过滤器正常使用的。

@Configuration
public class WebConfig {
    @Bean
    public FilterRegistrationBean timeFilter() {
        //初始化一个过滤器注册器
        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
        TimeFilter timeFilter = new TimeFilter();
        //将自定义的过滤器或者第三方过滤器注册到过滤器链中
        registrationBean.setFilter(timeFilter);

        List<String> urls = new ArrayList<>();
        urls.add("/*");
        //该过滤器对所有的url起作用,但你也可以配置专门的url进行过滤
        registrationBean.setUrlPatterns(urls);
        return registrationBean;
    }
}

经过以上的设置,我们就可以将自定义过滤器或者第三方过滤器加入到过滤器链中了。

现在我们回到SpringSecutiry的过滤器中,先来看一下FilterSecurityInterceptor

public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements
      Filter {

   private static final String FILTER_APPLIED = "__spring_security_filterSecurityInterceptor_filterApplied";

   private FilterInvocationSecurityMetadataSource securityMetadataSource;
   private boolean observeOncePerRequest = true;

   public void init(FilterConfig arg0) throws ServletException {
   }

   public void destroy() {
   }

   public void doFilter(ServletRequest request, ServletResponse response,
         FilterChain chain) throws IOException, ServletException {
      FilterInvocation fi = new FilterInvocation(request, response, chain);
      invoke(fi);
   }

   public FilterInvocationSecurityMetadataSource getSecurityMetadataSource() {
      return this.securityMetadataSource;
   }

   public SecurityMetadataSource obtainSecurityMetadataSource() {
      return this.securityMetadataSource;
   }

   public void setSecurityMetadataSource(FilterInvocationSecurityMetadataSource newSource) {
      this.securityMetadataSource = newSource;
   }

   public Class<?> getSecureObjectClass() {
      return FilterInvocation.class;
   }

   public void invoke(FilterInvocation fi) throws IOException, ServletException {
      if ((fi.getRequest() != null)
            && (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
            && observeOncePerRequest) {
         //非首次请求正常处理
         fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
      }
      else {
         //当首次请求的时候,request的FILTER_APPLIED属性是null的
         if (fi.getRequest() != null && observeOncePerRequest) {
            //给request设置FILTER_APPLIED属性
            fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
         }
         //在调用RestAPI前对认证授权进行检查
         InterceptorStatusToken token = super.beforeInvocation(fi);

         try {
            //调真正的RestAPI服务
            fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
         }
         finally {
            super.finallyInvocation(token);
         }

         super.afterInvocation(token, null);
      }
   }

   public boolean isObserveOncePerRequest() {
      return observeOncePerRequest;
   }

   public void setObserveOncePerRequest(boolean observeOncePerRequest) {
      this.observeOncePerRequest = observeOncePerRequest;
   }
}

InterceptorStatusToken token = super.beforeInvocation(fi); //在调用RestAPI前对认证授权进行检查

由该段代码可以看到,要想完成RestAPI的请求就会对之前的认证进行检查,如果通过检查,之后的访问就会正常访问,不再检查。

异常捕获ExceptionTranslationFIlter

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
      throws IOException, ServletException {
   HttpServletRequest request = (HttpServletRequest) req;
   HttpServletResponse response = (HttpServletResponse) res;

   try {
      chain.doFilter(request, response);

      logger.debug("Chain processed normally");
   }
   catch (IOException ex) {
      throw ex;
   }
   catch (Exception ex) {
      //捕获到异常后进行异常处理
      Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex);
      RuntimeException ase = (AuthenticationException) throwableAnalyzer
            .getFirstThrowableOfType(AuthenticationException.class, causeChain);

      if (ase == null) {
         ase = (AccessDeniedException) throwableAnalyzer.getFirstThrowableOfType(
               AccessDeniedException.class, causeChain);
      }

      if (ase != null) {
         if (response.isCommitted()) {
            throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", ex);
         }
         handleSpringSecurityException(request, response, chain, ase);
      }
      else {
         // Rethrow ServletExceptions and RuntimeExceptions as-is
         if (ex instanceof ServletException) {
            throw (ServletException) ex;
         }
         else if (ex instanceof RuntimeException) {
            throw (RuntimeException) ex;
         }

         // Wrap other Exceptions. This shouldn't actually happen
         // as we've already covered all the possibilities for doFilter
         throw new RuntimeException(ex);
      }
   }
}

表单登录UsernamePasswordAuthenticationFilter,我们来看一下它的继承图

由图可以看到,它是继承了实现Filter接口的父类的子类。doFilter方法在其父类AbstractAuthenticationProcessingFilter中

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
      throws IOException, ServletException {

   HttpServletRequest request = (HttpServletRequest) req;
   HttpServletResponse response = (HttpServletResponse) res;
   //当要求的认证方式不是表单认证时,过滤器相后传递,直接返回
   if (!requiresAuthentication(request, response)) {
      chain.doFilter(request, response);

      return;
   }

   if (logger.isDebugEnabled()) {
      logger.debug("Request is to process authentication");
   }

   Authentication authResult;

   try {
      //开始进行认证请求处理,attemptAuthentication为一个抽象方法,会在UsernamePasswordAuthenticationFilter中实现
      authResult = attemptAuthentication(request, response);
      if (authResult == null) {
         // return immediately as subclass has indicated that it hasn't completed
         // authentication
         return;
      }
      sessionStrategy.onAuthentication(authResult, request, response);
   }
   catch (InternalAuthenticationServiceException failed) {
      logger.error(
            "An internal error occurred while trying to authenticate the user.",
            failed);
      unsuccessfulAuthentication(request, response, failed);

      return;
   }
   catch (AuthenticationException failed) {
      // Authentication failed
      unsuccessfulAuthentication(request, response, failed);

      return;
   }

   // Authentication success
   if (continueChainBeforeSuccessfulAuthentication) {
      chain.doFilter(request, response);
   }

   successfulAuthentication(request, response, chain, authResult);
}
protected boolean requiresAuthentication(HttpServletRequest request,
      HttpServletResponse response) {
   return requiresAuthenticationRequestMatcher.matches(request);
}

而它自己只会处理"/login", "POST"这样一个请求

public UsernamePasswordAuthenticationFilter() {
   super(new AntPathRequestMatcher("/login", "POST"));
}
protected AbstractAuthenticationProcessingFilter(
      RequestMatcher requiresAuthenticationRequestMatcher) {
   Assert.notNull(requiresAuthenticationRequestMatcher,
         "requiresAuthenticationRequestMatcher cannot be null");
   this.requiresAuthenticationRequestMatcher = requiresAuthenticationRequestMatcher;
}

在收到这样一个请求后,会拿到用户名,密码进行一个登录

public Authentication attemptAuthentication(HttpServletRequest request,
      HttpServletResponse response) throws AuthenticationException {
   if (postOnly && !request.getMethod().equals("POST")) {
      throw new AuthenticationServiceException(
            "Authentication method not supported: " + request.getMethod());
   }

   String username = obtainUsername(request);
   String password = obtainPassword(request);

   if (username == null) {
      username = "";
   }

   if (password == null) {
      password = "";
   }

   username = username.trim();

   UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
         username, password);

   // Allow subclasses to set the "details" property
   setDetails(request, authRequest);

   return this.getAuthenticationManager().authenticate(authRequest);
}

自定义用户认证逻辑

  • 处理用户信息获取逻辑

自定义处理用户信息获取的是通过UserDetailsService这个接口来实现的,该接口定义如下

public interface UserDetailsService {
   UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

现在我们来做一个自定义的实现,我们先在SecrityConfig配置类中添加一个加密器,因为新版本的SpringSecrity是不允许明文密码的(老版本Springboot 1.x的允许明文密码),所以我们要对密码进行一个加密。

/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
public class SecrityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许Basic登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin() //允许表单登录
                .and()
                .authorizeRequests() //对请求进行授权
                .anyRequest()  //任何请求
                .authenticated();   //都需要身份认证
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
@Service
@Slf4j
public class MyUserDetailsService implements UserDetailsService {
    @Autowired
    private PasswordEncoder passwordEncoder;
    /**
     * 根据用户名查找用户信息,该用户信息可以从数据库中取出,
     * 然后拼装成UserDetails对象
     * @param username
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("登录用户名:" + username);
        //该User类是SpringSecurity自带实现UserDetails接口的一个用户类
        //使用加密工具对密码进行加密
        //其第三个属性为用户权限,后续说明
        return new User(username,passwordEncoder.encode("123456")
                , AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

其中UserDetails为一个接口,如果我们自己在数据库中取数,登录用户类需要实现该接口

//该接口封装了SpringSecurity登录所需要的所有信息
public interface UserDetails extends Serializable {
   //权限信息
   Collection<? extends GrantedAuthority> getAuthorities();
   //获取密码
   String getPassword();
   //获取用户名
   String getUsername();
   //账户是否过期(true未过期,false过期)
   boolean isAccountNonExpired();
   //账户是否锁定
   boolean isAccountNonLocked();
   //密码是否过期
   boolean isCredentialsNonExpired();
   //账户是否可用
   boolean isEnabled();
}

除了前三个接口方法,后面四个可根据你的项目都实际情况酌情实现和设定,它们不是必须的,在不需要使用的情况下可以直接设定为true.一般我们认为锁定的用户可以被恢复,而不可用用户不能被恢复。

此时重启项目,访问Controller接口,一样会出现输入用户名,密码的界面,但此处与之前不同的地方为用户名可以是任意的,而并非user了,密码则也不是启动日志中出现的一长串字符串,而且现在启动日志中也不会出现这个字符串了,密码就是我们设置的123456

  • 用户校验逻辑

现在我们来改写MyUserDetailsService,使返回的用户被锁定

@Service
@Slf4j
public class MyUserDetailsService implements UserDetailsService {
    @Autowired
    private PasswordEncoder passwordEncoder;
    /**
     * 根据用户名查找用户信息,该用户信息可以从数据库中取出,
     * 然后拼装成UserDetails对象
     * @param username
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("登录用户名:" + username);
        //该User类是SpringSecurity自带实现UserDetails接口的一个用户类
        //使用加密工具对密码进行加密
        //第三个参数为是否可用,第四个参数为账户是否过期,第五个参数为密码是否过期,第六个参数为账户是否被锁定
        //其第七个属性为用户权限
        return new User(username,passwordEncoder.encode("123456")
                ,true,true,true,false
                , AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

现在我们将三参构造器变成一个七参构造器,重新启动项目。访问Controller接口,输入用户名(任意),密码(123456)后会出现如下提示

这里需要说明的是BCryptPasswordEncoder对同一个密码每次加密后的密文都是不一样的,比如我们对加密后对密文进行打印

@Service
@Slf4j
public class MyUserDetailsService implements UserDetailsService {
    @Autowired
    private PasswordEncoder passwordEncoder;
    /**
     * 根据用户名查找用户信息,该用户信息可以从数据库中取出,
     * 然后拼装成UserDetails对象
     * @param username
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("登录用户名:" + username);
        String password = passwordEncoder.encode("123456");
        log.info("密码:" + password);
        //该User类是SpringSecurity自带实现UserDetails接口的一个用户类
        //使用加密工具对密码进行加密
        //第三个参数为是否可用,第四个参数为账户是否过期,第五个参数为密码是否过期,第六个参数为账户是否被锁定
        //其第七个属性为用户权限
        return new User(username,password
                ,true,true,true,true
                , AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

经过接口访问后,可以看到这样一段日志

2020-01-05 20:38:53.898  INFO 851 --- [nio-8080-exec-5] c.g.s.service.MyUserDetailsService       : 密码:$2a$10$Wtrf9TkrHHooNo6Fv1vRqOjJmxayOMBatJoSfelHjBWajj3JOdwly

然后我们换一个浏览器访问该接口

2020-01-05 20:43:22.520  INFO 851 --- [nio-8080-exec-9] c.g.s.service.MyUserDetailsService       : 密码:$2a$10$eGB0bYvQiGiZtr79IFlms.16Q8FSoQxMKSxJK00uQM9PfeKy5xnHu

同样都是123456,加密出来却不一样。这主要要从BCryptPasswordEncoder加密和密码比对的两个方法来看

private final int strength; //密码长度
private final SecureRandom random; //随机种子

有关SecureRandom的说明可以参考使用Random来生成随机数的危险性

public String encode(CharSequence rawPassword) {
   String salt;
   if (strength > 0) {
      if (random != null) {
         salt = BCrypt.gensalt(strength, random);
      }
      else {
         salt = BCrypt.gensalt(strength);
      }
   }
   else {
      //生成一个随机加盐的前缀,而使用SecureRandom来生成随机盐是较为安全的
      salt = BCrypt.gensalt();
   }
   //根据随机盐与密码进行一次SHA256的运算并在之前拼装随机盐得到最终密码
   //因为每次加密,随机盐是不同的,不然不叫随机了,所以加密出来的密文也不相同
   return BCrypt.hashpw(rawPassword.toString(), salt);
}

public boolean matches(CharSequence rawPassword, String encodedPassword) {
   if (encodedPassword == null || encodedPassword.length() == 0) {
      logger.warn("Empty encoded password");
      return false;
   }

   if (!BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
      logger.warn("Encoded password does not look like BCrypt");
      return false;
   }
   //密码比对的时候,先从密文中拿取随机盐,而不是重新生成新的随机盐
   //再通过该随机盐与要比对的密码进行一次Sha256的运算,再在前面拼装上该随机盐与密文进行比较
   return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
}

这里面的重点在于密文没有掌握在攻击者手里,是安全的,也就是攻击者无法得知随机盐是什么,而SecureRandom产生伪随机的条件非常苛刻,一般是一些计算机内部的事件。但是这是一种慢加密方式,对于要登录吞吐量较高的时候无法满足需求,具体可以参考Springboot 2-OAuth 2修改登录加密方式 ,但要说明的是MD5已经不安全了,可以被短时间内(小时记,也不是几秒内吧)暴力破解,个中取舍由开发者决定。

自定义登录界面

现在我们要用自己写的html文件来代替默认的登录界面,在资源文件夹(Resources)下新建一个Resources文件夹。在该文件夹下新建一个signIn.html的文件。html代码如下

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
    <h2>标准登录页面</h2>
    <h3>表单登录</h3>
    <form action="/authentication/form" method="post">
        <table>
            <tr>
                <td>用户名</td>
                <td><input type="text" name="username"></td>
            </tr>
            <tr>
                <td>密码</td>
                <td><input type="password" name="password"></td>
            </tr>
            <tr>
                <td colspan="2"><button type="submit">登录</button> </td>
            </tr>
        </table>
    </form>
</body>
</html>

修改SecrityConfig如下

/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
public class SecrityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许Basic登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin() //允许表单登录
                .loginPage("/signIn.html") //设置表单登录页
                //使用/authentication/form的url来处理表单登录请求
                .loginProcessingUrl("/authentication/form")
                .and()
                .authorizeRequests() //对请求进行授权
                //对signIn.html页面放行
                .antMatchers("/signIn.html").permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

重新启动项目,访问/hello接口,被转向到我们自己建立的html登录页面

随便输入用户名,密码123456后,/hello接口访问成功。

处理不同类型的请求

现在我们将登录流程改成上图所示。

加配置项(该配置项前两个可以任意设置,即gj.secrity),该设置为用户为html访问无权限时跳转的配置登录页/demo-signIn.html,当然我们还有一个主登录页/signIn.html

gj:
  secrity:
    browser:
      loginPage: /demo-signIn.html

该demo-signIn.html文件内容如下

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录页</title>
</head>
<body>
    <h2>demo登录页</h2>
</body>
</html>

设置两个属性类来获取配置登录页的属性

@Data
public class BrowserProperties {
    //当配置登录页取不到值的时候,使用主登录页
    private String loginPage = "/signIn.html";
}
@ConfigurationProperties(prefix = "gj.secrity")
@Data
public class SecrityProperties {
    private BrowserProperties browser = new BrowserProperties();
}
/**
 * 使SecrityProperties配置类生效
 */
@Configuration
@EnableConfigurationProperties(SecrityProperties.class)
public class SecrityCoreConfig {
}

设置一个认证Controller的返回类型

/**
 * 认证Controller返回的结果类型
 */
@Data
@AllArgsConstructor
public class SimpleResponse {
    private Object content;
}

添加一个认证Controller来判断是html的请求还是Restful接口请求

@Slf4j
@RestController
public class AuthenticationController {
    //请求缓存
    private RequestCache requestCache = new HttpSessionRequestCache();
    //跳转工具
    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
    @Autowired
    private SecrityProperties secrityProperties;

    /**
     * 当需要身份认证时跳转到这里
     * @param request
     * @param response
     * @return
     */
    @RequestMapping("/authencation/require")
    @ResponseStatus(code = HttpStatus.UNAUTHORIZED)
    public SimpleResponse requireAuthentication(HttpServletRequest request,
                                                HttpServletResponse response) throws IOException {
        //获取引发跳转的请求
        SavedRequest savedRequest = requestCache.getRequest(request, response);
        if (savedRequest != null) {
            String targetUrl = savedRequest.getRedirectUrl();
            log.info("引发跳转的请求是:" + targetUrl);
            if (StringUtils.endsWithIgnoreCase(targetUrl,".html")) {
                //如果是html请求跳转过来的则跳转到配置登录页,如果没有配置登录页则跳转到标准登录页
                redirectStrategy.sendRedirect(request,response,secrityProperties.getBrowser().getLoginPage());
            }
        }

        return new SimpleResponse("访问的服务需要身份认证,请引导用户到登录页");
    }
}

修改SecrityConfig,来达到跟/authencation/require路径一致

/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
public class SecrityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private SecrityProperties secrityProperties;
    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许Basic登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin() //允许表单登录
                .loginPage("/authencation/require") //设置登录处理请求路径
                //使用/authentication/form的url来处理表单登录请求
                .loginProcessingUrl("/authentication/form")
                .and()
                .authorizeRequests() //对请求进行授权
                //对/authencation/require以及配置页请求放行
                .antMatchers("/authencation/require",
                        secrityProperties.getBrowser().getLoginPage())
                .permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

重启项目,当我们访问/hello接口时,登录Controller不会进行登录跳转,而是会返回一个状态401的错误提示

但如果我们访问的是例如/index.html时,登录Controller会将其进行跳转到配置登录页

现在我们将配置中的内容注释掉,重启项目

#gj:
#  secrity:
#    browser:
#      loginPage: /demo-signIn.html

此时再访问/index.html时,则会跳转到主登录页

自定义登录成功处理

要实现登录成功处理,我们只需要实现AuthenticationSuccessHandler接口,该接口的定义如下

public interface AuthenticationSuccessHandler {

   /**
    * 登录成功后被调用
    */
   void onAuthenticationSuccess(HttpServletRequest request,
         HttpServletResponse response, Authentication authentication)
         throws IOException, ServletException;

}

我们使用一个实现类来实现该接口,登录成功后返回authentication的json字符串

@Slf4j
@Component("loginAuthenticationSuccessHandler")
public class LoginAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        log.info("登录成功");
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(JSONObject.toJSONString(authentication));
    }
}

修改SecrityConfig

/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
public class SecrityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private SecrityProperties secrityProperties;
    @Autowired
    private AuthenticationSuccessHandler loginAuthenticationSuccessHandler;
    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许Basic登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin() //允许表单登录
                .loginPage("/authencation/require")
                //使用/authentication/form的url来处理表单登录请求
                .loginProcessingUrl("/authentication/form")
                //添加自定义登录成功处理器
                .successHandler(loginAuthenticationSuccessHandler)
                .and()
                .authorizeRequests() //对请求进行授权
                //对/authencation/require以及配置页请求放行
                .antMatchers("/authencation/require",
                        secrityProperties.getBrowser().getLoginPage())
                .permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

重启项目,当我们用rebot,123456登录后,返回结果如下

这里面包含了所有的用户认证信息,Authentication为一个接口,定义如下

public interface Authentication extends Principal, Serializable {
   //获取登录用户权限
   Collection<? extends GrantedAuthority> getAuthorities();
   //获取密码
   Object getCredentials();
   //获取登录详情(包含认证请求的IP以及SessionID)
   Object getDetails();
   //UserDetailsService接口中的内容
   Object getPrincipal();
   //是否已登录
   boolean isAuthenticated();
   //设置是否登录验证成功
   void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

根据该接口的含义,我们可以来解读返回的内容

  • "authenticated":true 登录成功
  • "authorities":[{"authority":"admin"}] 权限为admin
  • "remoteAddress":"127.0.0.1" 请求IP为127.0.0.1
  • "sessionId":"72784404B030C3CBAF52B7FE133D30FB" sessionID
  • "name":"rebot" 登录名为rebot
  • "accountNonExpired":true 账户未过期
  • "accountNonLocked":true 账户未锁定
  • "credentialsNonExpired":true 密码未过期
  • "enabled":true 账户可用

自定义登录失败处理

要实现登录失败处理,我们只需要实现AuthenticationFailureHandler接口,该接口的定义如下

public interface AuthenticationFailureHandler {

   /**
    * 登录失败后被调用
    */
   void onAuthenticationFailure(HttpServletRequest request,
         HttpServletResponse response, AuthenticationException exception)
         throws IOException, ServletException;
}

这其中AuthenticationException为登录失败的一个异常,它是一个抽象类,具体的子类有很多

这里每一个子类都代表一种登录错误的情况

现在我们来写一个AuthenticationFailureHandler接口的实现类,将登录异常给发送到前端

@Slf4j
@Component("loginAuthenticationFailureHandler")
public class LoginAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        log.info("登录失败");
        //修改默认登录状态200为500
        response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(JSONObject.toJSONString(exception));
    }
}

修改SecrityConfig

/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
public class SecrityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private SecrityProperties secrityProperties;
    @Autowired
    private AuthenticationSuccessHandler loginAuthenticationSuccessHandler;
    @Autowired
    private AuthenticationFailureHandler loginAuthenticationFailureHandler;
    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许Basic登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin() //允许表单登录
                .loginPage("/authencation/require")
                //使用/authentication/form的url来处理表单登录请求
                .loginProcessingUrl("/authentication/form")
                //添加自定义登录成功处理器
                .successHandler(loginAuthenticationSuccessHandler)
                //添加自定义登录失败处理器
                .failureHandler(loginAuthenticationFailureHandler)
                .and()
                .authorizeRequests() //对请求进行授权
                //对/authencation/require以及配置页请求放行
                .antMatchers("/authencation/require",
                        secrityProperties.getBrowser().getLoginPage())
                .permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

重新启动项目,进行登录,并输入一个错误的密码后,结果如下所示

现在无论登录成功还是失败,返回的都是JSON,现在我们来将其修改成根据配置来决定是返回JSON还是重定向。

先添加一个登录成功的重定向页面index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>欢迎</title>
</head>
<body>
    <h2>欢迎登录成功</h2>
</body>
</html>

增加配置

gj:
  secrity:
    browser:
      loginType: REDIRECT

增加一个枚举类型

public enum LoginType {
    REDIRECT,
    JSON;
}

修改BrowserProperties

@Data
public class BrowserProperties {
    //当配置登录页取不到值的时候,使用主登录页
    private String loginPage = "/signIn.html";
    //当登录后的处理类型(跳转还是Json),默认为Json
    private LoginType loginType = LoginType.JSON;
}

现在我们需要给LoginAuthenticationSuccessHandler,LoginAuthenticationFailureHandler增加判断是返回JSon还是跳转的逻辑

@Slf4j
@Component("loginAuthenticationSuccessHandler")
public class LoginAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
    @Autowired
    private SecrityProperties secrityProperties;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        log.info("登录成功");
        //如果配置的登录方式为Json
        if (LoginType.JSON.equals(secrityProperties.getBrowser().getLoginType())) {
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(JSONObject.toJSONString(authentication));
        }else {
            //如果登录方式不为Json,则跳转到登录前访问的.html页面
            super.onAuthenticationSuccess(request,response,authentication);
        }
    }
}

这里SavedRequestAwareAuthenticationSuccessHandler是一个专门处理登录成功的包装器,我们可以来看一下它的继承图

由图中可以看到,他是实现了AuthenticationFailureHandler接口的SimpleUrlAuthenticationSuccessHandler实现类的子类。

@Slf4j
@Component("loginAuthenticationFailureHandler")
public class LoginAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
    @Autowired
    private SecrityProperties secrityProperties;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        log.info("登录失败");
        //如果配置的登录方式为Json
        if (LoginType.JSON.equals(secrityProperties.getBrowser().getLoginType())) {
            //修改默认登录状态200为500
            response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(JSONObject.toJSONString(exception));
        }else {
            //如果登录方式不为Json,则跳转到登录页判断的Controller接口
            super.onAuthenticationFailure(request,response,exception);
        }
    }
}

我们来看一下SimpleUrlAuthenticationSuccessHandler的继承图

由图可知,它就是实现了AuthenticationFailureHandler接口的实现类。

重新启动项目,这里需要说明的是如果不做配置,则结果跟之前返回JSon的情况一样,现在是做了配置的

如果登录成功,则跳转到index.html

如果登录失败,则跳转到/authencation/require的请求结果中

表单登录认证原理

UsernamePasswordAuthenticationFilter我们之前已经说了,是SpringSecurity过滤器链中处理表单登录的过滤器,当拿到用户名和密码的时候会执行这样一条代码

UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
      username, password);

我们来看一下UsernamePasswordAuthenticationToken的继承结构图

由图中可以看到,它就是Authentication接口的一个实现类,表示用户认证信息,在它的构造器中就是将用户名和密码给放入自身属性中

public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
   //设置权限,此时未认证,权限未知
   super(null);
   //传入用户名
   this.principal = principal;
   //传入密码
   this.credentials = credentials;
   //设置登录状态为未登录
   setAuthenticated(false);
}

然后是

setDetails(request, authRequest);
protected void setDetails(HttpServletRequest request,
      UsernamePasswordAuthenticationToken authRequest) {
   authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}

意思为将请求的信息(Session,机器IP等)放入到生成的UsernamePasswordAuthenticationToken实例authRequest中。然后是

return this.getAuthenticationManager().authenticate(authRequest);

这就到了图中的第二步AuthenticationManager了。AuthenticationManager为一个接口,本身不包含认证逻辑,是用来管理第三步的AuthenticationProvider

public interface AuthenticationManager {
   /**
    * 根据一个未认证成功的Authentication对象,试图去认证成功得到一个认证成功的Authentication对象
    */
   Authentication authenticate(Authentication authentication)
         throws AuthenticationException;
}

而它的实现类为ProviderManager,它的接口实现方法

public Authentication authenticate(Authentication authentication)
      throws AuthenticationException {
   //获取传入的Authentication的实现类型Class,不同的认证方式,传入的Authentication的实现类型不同
   Class<? extends Authentication> toTest = authentication.getClass();
   AuthenticationException lastException = null;
   AuthenticationException parentException = null;
   Authentication result = null;
   Authentication parentResult = null;
   boolean debug = logger.isDebugEnabled();
   //遍历所有支持的登录方式,如密码登录,第三方登录(如微信登录),去尝试哪一种登录方式是可用的
   for (AuthenticationProvider provider : getProviders()) {
      //判断传入的Authentication是否支持,根据传入的Authentication来挑出一个AuthenticationProvider对象来处理
      if (!provider.supports(toTest)) {
         continue;
      }

      if (debug) {
         logger.debug("Authentication attempt using "
               + provider.getClass().getName());
      }

      try {
         //根据我们自己注入的UserDetailsService来获取用户详情
         result = provider.authenticate(authentication);

         if (result != null) {
            copyDetails(authentication, result);
            break;
         }
      }
      catch (AccountStatusException e) {
         prepareException(e, authentication);
         // SEC-546: Avoid polling additional providers if auth failure is due to
         // invalid account status
         throw e;
      }
      catch (InternalAuthenticationServiceException e) {
         prepareException(e, authentication);
         throw e;
      }
      catch (AuthenticationException e) {
         lastException = e;
      }
   }

   if (result == null && parent != null) {
      // Allow the parent to try.
      try {
         result = parentResult = parent.authenticate(authentication);
      }
      catch (ProviderNotFoundException e) {
         // ignore as we will throw below if no other exception occurred prior to
         // calling parent and the parent
         // may throw ProviderNotFound even though a provider in the child already
         // handled the request
      }
      catch (AuthenticationException e) {
         lastException = parentException = e;
      }
   }

   if (result != null) {
      if (eraseCredentialsAfterAuthentication
            && (result instanceof CredentialsContainer)) {
         // Authentication is complete. Remove credentials and other secret data
         // from authentication
         ((CredentialsContainer) result).eraseCredentials();
      }

      // If the parent AuthenticationManager was attempted and successful than it will publish an AuthenticationSuccessEvent
      // This check prevents a duplicate AuthenticationSuccessEvent if the parent AuthenticationManager already published it
      if (parentResult == null) {
         eventPublisher.publishAuthenticationSuccess(result);
      }
      return result;
   }

   // Parent was null, or didn't authenticate (or throw an exception).

   if (lastException == null) {
      lastException = new ProviderNotFoundException(messages.getMessage(
            "ProviderManager.providerNotFound",
            new Object[] { toTest.getName() },
            "No AuthenticationProvider found for {0}"));
   }

   // If the parent AuthenticationManager was attempted and failed than it will publish an AbstractAuthenticationFailureEvent
   // This check prevents a duplicate AbstractAuthenticationFailureEvent if the parent AuthenticationManager already published it
   if (parentException == null) {
      prepareException(lastException, authentication);
   }

   throw lastException;
}

这里AuthenticationProvider为一个接口

public interface AuthenticationProvider {
   /**
    * 完成校验
    */
   Authentication authenticate(Authentication authentication)
         throws AuthenticationException;

   /**
    * 校验authentication的类型Class是否支持
    */
   boolean supports(Class<?> authentication);
}

它的实现类为DaoAuthenticationProvider,我们来看一下它的继承图

不过接口方法是在其父类AbstractUserDetailsAuthenticationProvider中实现的,这是一个抽象类

public Authentication authenticate(Authentication authentication)
      throws AuthenticationException {
   Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
         () -> messages.getMessage(
               "AbstractUserDetailsAuthenticationProvider.onlySupports",
               "Only UsernamePasswordAuthenticationToken is supported"));

   // Determine username
   String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
         : authentication.getName();

   boolean cacheWasUsed = true;
   UserDetails user = this.userCache.getUserFromCache(username);

   if (user == null) {
      cacheWasUsed = false;

      try {
         //获取用户详情,retrieveUser为一个抽象方法,具体是在DaoAuthenticationProvider中实现的
         user = retrieveUser(username,
               (UsernamePasswordAuthenticationToken) authentication);
      }
      catch (UsernameNotFoundException notFound) {
         logger.debug("User '" + username + "' not found");

         if (hideUserNotFoundExceptions) {
            throw new BadCredentialsException(messages.getMessage(
                  "AbstractUserDetailsAuthenticationProvider.badCredentials",
                  "Bad credentials"));
         }
         else {
            throw notFound;
         }
      }

      Assert.notNull(user,
            "retrieveUser returned null - a violation of the interface contract");
   }

   try {
      //根据用户信息检查账户是否过期,是否锁定,是否不可用
      preAuthenticationChecks.check(user);
      //附加校验,此处为一个抽象方法,在DaoAuthenticationProvider中实现,校验密码是否正确
      additionalAuthenticationChecks(user,
            (UsernamePasswordAuthenticationToken) authentication);
   }
   catch (AuthenticationException exception) {
      if (cacheWasUsed) {
         // There was a problem, so try again after checking
         // we're using latest data (i.e. not from the cache)
         cacheWasUsed = false;
         user = retrieveUser(username,
               (UsernamePasswordAuthenticationToken) authentication);
         preAuthenticationChecks.check(user);
         additionalAuthenticationChecks(user,
               (UsernamePasswordAuthenticationToken) authentication);
      }
      else {
         throw exception;
      }
   }
   //根据用户信息,检查密码是否过期
   postAuthenticationChecks.check(user);

   if (!cacheWasUsed) {
      this.userCache.putUserInCache(user);
   }

   Object principalToReturn = user;

   if (forcePrincipalAsString) {
      principalToReturn = user.getUsername();
   }
   //创建一个成功的认证
   return createSuccessAuthentication(principalToReturn, authentication, user);
}
protected abstract UserDetails retrieveUser(String username,
      UsernamePasswordAuthenticationToken authentication)
      throws AuthenticationException;
protected abstract void additionalAuthenticationChecks(UserDetails userDetails,
      UsernamePasswordAuthenticationToken authentication)
      throws AuthenticationException;
protected Authentication createSuccessAuthentication(Object principal,
      Authentication authentication, UserDetails user) {
   //此处同样创建一个UsernamePasswordAuthenticationToken,跟之前不同的是,它使用了一个三参构造器
   //除了用户名,密码外,还将权限放入其属性中
   UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(
         principal, authentication.getCredentials(),
         authoritiesMapper.mapAuthorities(user.getAuthorities()));
   //将认证的登录信息(IP,Session等)放入新的UsernamePasswordAuthenticationToken的实例result中
   result.setDetails(authentication.getDetails());

   return result;
}
public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
      Collection<? extends GrantedAuthority> authorities) {
   //传入用户权限
   super(authorities);
   //传入用户名
   this.principal = principal;
   //传入密码
   this.credentials = credentials;
   //设置登录状态为已登录
   super.setAuthenticated(true); // must use super, as we override
}
public void setDetails(Object details) {
   this.details = details;
}

DaoAuthenticationProvider中的实现

protected final UserDetails retrieveUser(String username,
      UsernamePasswordAuthenticationToken authentication)
      throws AuthenticationException {
   prepareTimingAttackProtection();
   try {
      //通过我们自己实现的UserDetailsService来获取用户详情
      UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
      if (loadedUser == null) {
         throw new InternalAuthenticationServiceException(
               "UserDetailsService returned null, which is an interface contract violation");
      }
      return loadedUser;
   }
   catch (UsernameNotFoundException ex) {
      mitigateAgainstTimingAttack(authentication);
      throw ex;
   }
   catch (InternalAuthenticationServiceException ex) {
      throw ex;
   }
   catch (Exception ex) {
      throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
   }
}
protected void additionalAuthenticationChecks(UserDetails userDetails,
      UsernamePasswordAuthenticationToken authentication)
      throws AuthenticationException {
   if (authentication.getCredentials() == null) {
      logger.debug("Authentication failed: no credentials provided");

      throw new BadCredentialsException(messages.getMessage(
            "AbstractUserDetailsAuthenticationProvider.badCredentials",
            "Bad credentials"));
   }
   //获取传递的密码
   String presentedPassword = authentication.getCredentials().toString();
   //检查密码是否匹配,不匹配则抛出异常
   if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
      logger.debug("Authentication failed: password does not match stored value");

      throw new BadCredentialsException(messages.getMessage(
            "AbstractUserDetailsAuthenticationProvider.badCredentials",
            "Bad credentials"));
   }
}

预检查账户信息

public void check(UserDetails user) {
   if (!user.isAccountNonLocked()) {
      logger.debug("User account is locked");

      throw new LockedException(messages.getMessage(
            "AbstractUserDetailsAuthenticationProvider.locked",
            "User account is locked"));
   }

   if (!user.isEnabled()) {
      logger.debug("User account is disabled");

      throw new DisabledException(messages.getMessage(
            "AbstractUserDetailsAuthenticationProvider.disabled",
            "User is disabled"));
   }

   if (!user.isAccountNonExpired()) {
      logger.debug("User account is expired");

      throw new AccountExpiredException(messages.getMessage(
            "AbstractUserDetailsAuthenticationProvider.expired",
            "User account has expired"));
   }
}

后检查账户信息

private class DefaultPostAuthenticationChecks implements UserDetailsChecker {
   public void check(UserDetails user) {
      if (!user.isCredentialsNonExpired()) {
         logger.debug("User account credentials have expired");

         throw new CredentialsExpiredException(messages.getMessage(
               "AbstractUserDetailsAuthenticationProvider.credentialsExpired",
               "User credentials have expired"));
      }
   }
}

经过以上步骤,得到了一个认证成功的Authentication以后,会在UsernamePasswordAuthenticationFilter的父类AbstractAuthenticationProcessingFilter的doFilter中调用以下方法

successfulAuthentication(request, response, chain, authResult);
protected void successfulAuthentication(HttpServletRequest request,
      HttpServletResponse response, FilterChain chain, Authentication authResult)
      throws IOException, ServletException {

   if (logger.isDebugEnabled()) {
      logger.debug("Authentication success. Updating SecurityContextHolder to contain: "
            + authResult);
   }

   SecurityContextHolder.getContext().setAuthentication(authResult);

   rememberMeServices.loginSuccess(request, response, authResult);

   // Fire event
   if (this.eventPublisher != null) {
      eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
            authResult, this.getClass()));
   }
   //认证成功后调用认证成功处理器进行成功后处理
   successHandler.onAuthenticationSuccess(request, response, authResult);
}

我们可以看一下successHandler的定义

private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();

很明显这是我们之前说的登录成功处理器。

而在doFIlter中,任何一个环节出现了异常,都会进行捕获

catch (InternalAuthenticationServiceException failed) {
   logger.error(
         "An internal error occurred while trying to authenticate the user.",
         failed);
   unsuccessfulAuthentication(request, response, failed);

   return;
}
catch (AuthenticationException failed) {
   // Authentication failed
   unsuccessfulAuthentication(request, response, failed);

   return;
}

而这里面的unsuccessfulAuthentication(request, response, failed)

protected void unsuccessfulAuthentication(HttpServletRequest request,
      HttpServletResponse response, AuthenticationException failed)
      throws IOException, ServletException {
   SecurityContextHolder.clearContext();

   if (logger.isDebugEnabled()) {
      logger.debug("Authentication request failed: " + failed.toString(), failed);
      logger.debug("Updated SecurityContextHolder to contain null Authentication");
      logger.debug("Delegating to authentication failure handler " + failureHandler);
   }

   rememberMeServices.loginFail(request, response);
   //认证失败后调用认证失败处理器进行后续处理
   failureHandler.onAuthenticationFailure(request, response, failed);
}
private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();

这是我们之前说的登录失败处理器

在多个请求之间共享认证结果

我们还是来看这段代码

protected void successfulAuthentication(HttpServletRequest request,
      HttpServletResponse response, FilterChain chain, Authentication authResult)
      throws IOException, ServletException {

   if (logger.isDebugEnabled()) {
      logger.debug("Authentication success. Updating SecurityContextHolder to contain: "
            + authResult);
   }
   //将认证成功的认证结果放入容器中
   SecurityContextHolder.getContext().setAuthentication(authResult);

   rememberMeServices.loginSuccess(request, response, authResult);

   // Fire event
   if (this.eventPublisher != null) {
      eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
            authResult, this.getClass()));
   }
   //认证成功后调用认证成功处理器进行成功后处理
   successHandler.onAuthenticationSuccess(request, response, authResult);
}

这里有一个SecurityContext的接口

public interface SecurityContext extends Serializable {
   /**
    * 获取认证对象
    */
   Authentication getAuthentication();

   /**
    * 存储认证对象
    */
   void setAuthentication(Authentication authentication);
}

它的实现类为SecurityContextImpl,这个类比较简单,就是封装了一个认证对象属性

private Authentication authentication;

并重写了equals和hashCode方法,接口方法即是给authentication赋值和取值

@Override
public Authentication getAuthentication() {
   return authentication;
}
@Override
public void setAuthentication(Authentication authentication) {
   this.authentication = authentication;
}

我们再来看一下SecurityContextHolder,由名称看出,它就是一个放置SecurityContext的容器。从它的getContext()方法可以看出,它的核心属性为

private static SecurityContextHolderStrategy strategy;
public static SecurityContext getContext() {
   return strategy.getContext();
}

这里SecurityContextHolderStrategy是一个接口

public interface SecurityContextHolderStrategy {

   /**
    * 清除SecurityContext
    */
   void clearContext();

   /**
    * 获取SecurityContext
    */
   SecurityContext getContext();

   /**
    * 设置SecurityContext
    */
   void setContext(SecurityContext context);

   /**
    * 创建空的SecurityContext
    */
   SecurityContext createEmptyContext();
}

而它到实现类为ThreadLocalSecurityContextHolderStrategy,从名字就能看出来,它是一个ThreadLocal的封装

private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<>();

到这里我们就能明白了,通过认证的认证信息是被封装到SecurityContext里,然后再放入ThreadLocal中供后续相同线程取出拿到认证信息。

然后就到了SecurityContextPersistenceFilter,它也是SpringSecutiry的过滤器链上的一个过滤器,我们来看一下它在过滤器链中的位置

由图可知,它是所有过滤器链的第一个过滤器,也是结果返回的最后一个过滤器。当请求进来的时候,检查Session中是否有SecurityContext(认证信息的封装),如果有SecurityContext,就把SecurityContext取出,放到线程中;如果没有就过渡到后续过滤器。当返回结果时候,到该过滤器的时候,就检查线程中是否有SecurityContext,如果有就取出放到Session中。这样不同的请求就可以在同一个Session中拿到相同的认证信息。我们来看一下它的doFilter方法。

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
      throws IOException, ServletException {
   HttpServletRequest request = (HttpServletRequest) req;
   HttpServletResponse response = (HttpServletResponse) res;

   if (request.getAttribute(FILTER_APPLIED) != null) {
      // ensure that filter is only applied once per request
      chain.doFilter(request, response);
      return;
   }

   final boolean debug = logger.isDebugEnabled();

   request.setAttribute(FILTER_APPLIED, Boolean.TRUE);

   if (forceEagerSessionCreation) {
      HttpSession session = request.getSession();

      if (debug && session.isNew()) {
         logger.debug("Eagerly created session: " + session.getId());
      }
   }

   HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request,
         response);
   SecurityContext contextBeforeChainExecution = repo.loadContext(holder);

   try {
      SecurityContextHolder.setContext(contextBeforeChainExecution);

      chain.doFilter(holder.getRequest(), holder.getResponse());

   }
   finally {
      SecurityContext contextAfterChainExecution = SecurityContextHolder
            .getContext();
      // Crucial removal of SecurityContextHolder contents - do this before anything
      // else.
      SecurityContextHolder.clearContext();
      repo.saveContext(contextAfterChainExecution, holder.getRequest(),
            holder.getResponse());
      request.removeAttribute(FILTER_APPLIED);

      if (debug) {
         logger.debug("SecurityContextHolder now cleared, as request processing completed");
      }
   }
}

在Controller中直接获取认证信息,用户信息

@RestController
public class UserController {
    /**
     * 直接获取用户信息
     * @param user
     * @return
     */
    @GetMapping("/me")
    public Object getCurrentUser(@AuthenticationPrincipal UserDetails user) {
        return user;
    }

    /**
     * 直接获取认证信息
     * @return
     */
    @GetMapping("/current/me")
    public Object getCurrentUser() {
        return SecurityContextHolder.getContext().getAuthentication();
    }

    /**
     * 直接获取认证信息简化版
     * @param authentication
     * @return
     */
    @GetMapping("/current/me/simple")
    public Object getCurrentUser(Authentication authentication) {
        return authentication;
    }
}

访问以上接口

第二个和第三个是一样的效果,第三个是被Spring封装过取出的,实际上也是从第二个里面的SecurityContextHolder.getContext().getAuthentication()取出的。

图形验证码

增加Spring扩展的社交依赖,该依赖可以进行一系列的社交APP的第三方登录

<dependency>
   <groupId>org.springframework.social</groupId>
   <artifactId>spring-social-web</artifactId>
   <version>1.1.6.RELEASE</version>
</dependency>

但是这里可能刷不出该依赖,只会出现spring-social-core,需要在mavan在强行下载

图形验证码实体类

/**
 * 图形验证码
 */
@Data
@AllArgsConstructor
public class ImageCode {
    private BufferedImage image;
    private String code;
    //过期时间
    private LocalDateTime expireTime;

    public ImageCode(BufferedImage image,String code,int expireIn) {
        this.image = image;
        this.code = code;
        this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
    }

    /**
     * 验证码是否过期
     * @return
     */
    public boolean isExpired() {
        return LocalDateTime.now().isAfter(this.expireTime);
    }
}

生成图形验证码的Controller

@RestController
public class ValidateCodeController {
    private static final String SESSION_KEY = "SESSION_KEY_IMAGE_CODE";
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    @GetMapping("/code/image")
    public void createCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
        //生成一个图形验证码的对象
        ImageCode imageCode = createImageCode(request);
        //将图形验证码对象放入到request请求的SESSION_KEY_IMAGE_CODE属性中
        sessionStrategy.setAttribute(new ServletWebRequest(request),SESSION_KEY,imageCode);
        //将图形验证码的图形字节码以JPEG格式放入到响应的字节流中
        ImageIO.write(imageCode.getImage(),"JPEG",response.getOutputStream());
    }

    private ImageCode createImageCode(HttpServletRequest request) {
        int width = 67;
        int height = 23;
        BufferedImage image = new BufferedImage(width,height,BufferedImage.TYPE_INT_RGB);

        Graphics g = image.getGraphics();
        Random random = new Random();
        g.setColor(getRandColor(200,250));
        g.fillRect(0,0,width,height);
        g.setFont(new Font("Time New Roman",Font.ITALIC,20));
        g.setColor(getRandColor(160,200));
        for (int i = 0;i < 155;i++) {
            int x = random.nextInt(width);
            int y = random.nextInt(height);
            int xl = random.nextInt(12);
            int yl = random.nextInt(12);
            g.drawLine(x,y,xl,yl);
        }
        String sRand = "";
        for (int i = 0;i < 4;i++) {
            String rand = String.valueOf(random.nextInt(10));
            sRand += rand;
            g.setColor(new Color(20 + random.nextInt(110),
                    20 + random.nextInt(110),
                    20 + random.nextInt(110)));
            g.drawString(rand,13 * i + 6,16);
        }
        g.dispose();
        return new ImageCode(image,sRand,60);
    }

    /**
     * 生成随机背景条纹
     * @param fc
     * @param bc
     * @return
     */
    private Color getRandColor(int fc,int bc) {
        Random random = new Random();
        if (fc > 255) {
            fc = 255;
        }
        if (bc > 255) {
            bc = 255;
        }
        int r = fc + random.nextInt(bc - fc);
        int g = fc + random.nextInt(bc -fc);
        int b = fc + random.nextInt(bc - fc);
        return new Color(r,g,b);
    }
}

修改SecrityConfig,以允许对/code/image进行放行

/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
public class SecrityConfig extends WebSecurityConfigurerAdapter {
    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许Basic登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin() //基于表单认证
                .loginPage("/signIn.html")
                .loginProcessingUrl("/authentication/form")
                .and()
                .authorizeRequests() //对请求进行授权
                //对/signIn.html,/code/image请求放行
                .antMatchers("/signIn.html","/code/image")
                .permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

修改signIn.html,增加图形验证码的图片

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
    <h2>标准登录页面</h2>
    <h3>表单登录</h3>
    <form action="/authentication/form" method="post">
        <table>
            <tr>
                <td>用户名</td>
                <td><input type="text" name="username"></td>
            </tr>
            <tr>
                <td>密码</td>
                <td><input type="password" name="password"></td>
            </tr>
            <tr>
                <td>图形验证码</td>
                <td>
                    <input type="text" name="imageCode">
                    <img src="/code/image">
                </td>
            </tr>
            <tr>
                <td colspan="2"><button type="submit">登录</button> </td>
            </tr>
        </table>
    </form>
</body>
</html>

启动项目,访问signIn.html,如下

图形验证码的校验

要进行图形验证码校验,可以加入我们自己写的过滤器,并插入到SpringSecrity的过滤器链中,放在UsernamePasswordAuthencationFilter之前。

先放入认证成功,失败的处理器

@Slf4j
@Component("validateAuthenticationSuccessHandler")
public class ValidateAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler{
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
        log.info("登录成功");
        super.onAuthenticationSuccess(request, response, authentication);
    }
}
@Slf4j
@Component("loginAuthenticationFailureHandler")
public class LoginAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        log.info("登录失败");
        super.onAuthenticationFailure(request, response, exception);
    }
}

自定义图形验证码过滤器

/**
 * OncePerRequestFilter保证我们自己写的过滤器只被调用一次
 */
@Slf4j
public class ValidateCodeFilter extends OncePerRequestFilter {
    @Getter
    @Setter
    private AuthenticationFailureHandler loginAuthenticationFailureHandler;
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //只在认证请求时才进行处理,否则直接让渡到后面的过滤器
        if (StringUtils.equals("/authentication/form",request.getRequestURI())
                && StringUtils.equalsIgnoreCase(request.getMethod(),"post")) {
            try {
                validate(new ServletWebRequest(request));
            }catch (ValidateCodeException e) {
                log.error(e.getMessage());
                loginAuthenticationFailureHandler.onAuthenticationFailure(request,response,e);
                return;
            }
        }
        filterChain.doFilter(request,response);
    }

    private void validate(ServletWebRequest request) throws ServletRequestBindingException {
        //从sessionStrategy中取出之前放入的图形验证码对象
        ImageCode codeInSession = (ImageCode) sessionStrategy.getAttribute(request, ValidateCodeController.SESSION_KEY);
        //拿取登录页中用户输入的验证码
        String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(),"imageCode");
        if (StringUtils.isBlank(codeInRequest)) {
            throw new ValidateCodeException("验证码的值不能为空");
        }
        if (codeInSession == null) {
            throw new ValidateCodeException("验证码不存在");
        }
        if (codeInSession.isExpired()) {
            //验证码过期无效了,从Session中移除
            sessionStrategy.removeAttribute(request,ValidateCodeController.SESSION_KEY);
            throw new ValidateCodeException("验证码已过期");
        }
        if (!StringUtils.equals(codeInSession.getCode(),codeInRequest)) {
            throw new ValidateCodeException("验证码不匹配");
        }
        //验证成功,当前验证码不再使用,从Session中移除
        sessionStrategy.removeAttribute(request,ValidateCodeController.SESSION_KEY);
    }
}

修改SecrityConfig,将自定义图形验证码过滤器放入到SpringSecurity过滤器链中

/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
public class SecrityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private AuthenticationSuccessHandler validateAuthenticationSuccessHandler;
    @Autowired
    private AuthenticationFailureHandler loginAuthenticationFailureHander;
    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许表单登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
        validateCodeFilter.setLoginAuthenticationFailureHandler(loginAuthenticationFailureHander);
        //将自定义图形验证码过滤器放入到SpringSecurity过滤器链,并放在UsernamePasswordAuthenticationFilter前
        http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
                .formLogin() //基于表单认证
                .loginPage("/signIn.html")
                //使用/authentication/form来处理登录请求
                .loginProcessingUrl("/authentication/form")
                //登录成功后处理
                .successHandler(validateAuthenticationSuccessHandler)
                //登录失败后处理
                .failureHandler(loginAuthenticationFailureHander)
                .and()
                .authorizeRequests() //对请求进行授权
                //对/signIn.html,/code/image请求放行
                .antMatchers("/signIn.html","/code/image")
                .permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

启动项目,输入正确的用户名,密码,图形验证码后,就会进入欢迎页面。否则就会一直停留在登录页。

重构图形验证码

  • 验证码的基本参数可配置

增加一个图形验证码的属性类

@Data
public class ImageCodeProperties {
    private int width = 67;
    private int height = 23;
    private int length = 4;
    private int expireIn = 60;
}

通用验证码属性类,其中包含了图形验证码属性

@Data
public class ValidateCodeProperties {
    private ImageCodeProperties image = new ImageCodeProperties();
}

将通用验证码属性配置到SecrityProperties中

@ConfigurationProperties(prefix = "gj.secrity")
@Data
public class SecrityProperties {
    private ValidateCodeProperties code = new ValidateCodeProperties();
}

依照前面的设置,让SecrityProperties生效

/**
 * 使SecrityProperties配置类生效
 */
@Configuration
@EnableConfigurationProperties(SecrityProperties.class)
public class SecrityCoreConfig {
}

增加配置项

gj:
  secrity:
    code:
      image:
        width: 100
        height: 23
        length: 6
        expireIn: 60

修改ValidateCodeController,使其可以使用配置项

@RestController
public class ValidateCodeController {
    public static final String SESSION_KEY = "SESSION_KEY_IMAGE_CODE";
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
    @Autowired
    private SecrityProperties secrityProperties;

    @GetMapping("/code/image")
    public void createCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
        //生成一个图形验证码的对象
        ImageCode imageCode = generate(new ServletWebRequest(request));
        //将图形验证码对象放入到request请求的SESSION_KEY_IMAGE_CODE属性中
        sessionStrategy.setAttribute(new ServletWebRequest(request),SESSION_KEY,imageCode);
        //将图形验证码的图形字节码以JPEG格式放入到响应的字节流中
        ImageIO.write(imageCode.getImage(),"JPEG",response.getOutputStream());
    }

    private ImageCode generate(ServletWebRequest request) {
        //从请求中获取width属性,若请求中没有,则从配置中获取
        int width = ServletRequestUtils.getIntParameter(request.getRequest(),
                "width",secrityProperties.getCode().getImage().getWidth());
        int height = ServletRequestUtils.getIntParameter(request.getRequest(),
                "height",secrityProperties.getCode().getImage().getHeight());
        BufferedImage image = new BufferedImage(width,height,BufferedImage.TYPE_INT_RGB);

        Graphics g = image.getGraphics();
        Random random = new Random();
        g.setColor(getRandColor(200,250));
        g.fillRect(0,0,width,height);
        g.setFont(new Font("Time New Roman",Font.ITALIC,20));
        g.setColor(getRandColor(160,200));
        for (int i = 0;i < 155;i++) {
            int x = random.nextInt(width);
            int y = random.nextInt(height);
            int xl = random.nextInt(12);
            int yl = random.nextInt(12);
            g.drawLine(x,y,xl,yl);
        }
        String sRand = "";
        for (int i = 0;i < secrityProperties.getCode().getImage().getLength();i++) {
            String rand = String.valueOf(random.nextInt(10));
            sRand += rand;
            g.setColor(new Color(20 + random.nextInt(110),
                    20 + random.nextInt(110),
                    20 + random.nextInt(110)));
            g.drawString(rand,13 * i + 6,16);
        }
        g.dispose();
        return new ImageCode(image,sRand,secrityProperties.getCode().getImage().getExpireIn());
    }

    /**
     * 生成随机背景条纹
     * @param fc
     * @param bc
     * @return
     */
    private Color getRandColor(int fc,int bc) {
        Random random = new Random();
        if (fc > 255) {
            fc = 255;
        }
        if (bc > 255) {
            bc = 255;
        }
        int r = fc + random.nextInt(bc - fc);
        int g = fc + random.nextInt(bc -fc);
        int b = fc + random.nextInt(bc - fc);
        return new Color(r,g,b);
    }
}

int width = ServletRequestUtils.getIntParameter(request.getRequest(), "width",secrityProperties.getCode().getImage().getWidth());可以看到它是先从请求中获取的,所以修改signIn.html,设置一个width参数

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
    <h2>标准登录页面</h2>
    <h3>表单登录</h3>
    <form action="/authentication/form" method="post">
        <table>
            <tr>
                <td>用户名</td>
                <td><input type="text" name="username"></td>
            </tr>
            <tr>
                <td>密码</td>
                <td><input type="password" name="password"></td>
            </tr>
            <tr>
                <td>图形验证码</td>
                <td>
                    <input type="text" name="imageCode">
                    <img src="/code/image?width=200">
                </td>
            </tr>
            <tr>
                <td colspan="2"><button type="submit">登录</button> </td>
            </tr>
        </table>
    </form>
</body>
</html>

启动项目,访问signIn.html,我们可以看到图形验证的宽度是200,而不是配置的100,而数字长度为配置的6位。

  • 验证码拦截的接口可配置

由于我们在ValidateCodeFilter中只对登录uri(/authentication/form)做了校验,但有可能在很多其他的页面上也需要做图形验证码的校验

if (StringUtils.equals("/authentication/form",request.getRequestURI())
        && StringUtils.equalsIgnoreCase(request.getMethod(),"post"))

现在我们需要对所有可配置的路径做校验。现假设我们要对/user,/user/*做校验,图形验证码属性增加url

@Data
public class ImageCodeProperties {
    private int width = 67;
    private int height = 23;
    private int length = 4;
    private int expireIn = 60;
    private String url;
}
gj:
  secrity:
    code:
      image:
        width: 100
        height: 23
        length: 6
        expireIn: 60
        url: /me,/current/**

修改ValidateCodeFilter,使其可对配置路径url: /me,/current/**进行校验。

/**
 * OncePerRequestFilter保证我们自己写的过滤器只被调用一次
 * InitializingBean在其他参数都组装完毕以后,去初始化url的值
 */
@Slf4j
public class ValidateCodeFilter extends OncePerRequestFilter implements InitializingBean {
    @Getter
    @Setter
    private AuthenticationFailureHandler loginAuthenticationFailureHandler;
    @Getter
    @Setter
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
    private Set<String> urls = new HashSet<>();
    @Getter
    @Setter
    private SecrityProperties secrityProperties;
    //可以进行带*通配符的url匹配
    private AntPathMatcher pathMatcher = new AntPathMatcher();

    /**
     * 初始化urls
     * @throws ServletException
     */
    @Override
    public void afterPropertiesSet() throws ServletException {
        super.afterPropertiesSet();
        String[] configUrls = StringUtils.splitByWholeSeparatorPreserveAllTokens(secrityProperties.getCode()
                                .getImage().getUrl(),",");
        Stream.of(configUrls).forEach(urls::add);
        urls.add("/authentication/form");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        boolean action = false;
        action = urls.stream().filter(url -> pathMatcher.match(url, request.getRequestURI()))
                .anyMatch(StringUtils::isNotEmpty);
        //只在匹配上的url才继续执行,否则直接让渡到后面的过滤器
        if (action) {
            try {
                validate(new ServletWebRequest(request));
            }catch (ValidateCodeException e) {
                log.error(e.getMessage());
                loginAuthenticationFailureHandler.onAuthenticationFailure(request,response,e);
                return;
            }
        }
        filterChain.doFilter(request,response);
    }

    private void validate(ServletWebRequest request) throws ServletRequestBindingException {
        //从sessionStrategy中取出之前放入的图形验证码对象
        ImageCode codeInSession = (ImageCode) sessionStrategy.getAttribute(request, ValidateCodeController.SESSION_KEY);
        //拿取登录页中用户输入的验证码
        String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(),"imageCode");
        if (StringUtils.isBlank(codeInRequest)) {
            throw new ValidateCodeException("验证码的值不能为空");
        }
        if (codeInSession == null) {
            throw new ValidateCodeException("验证码不存在");
        }
        if (codeInSession.isExpired()) {
            //验证码过期无效了,从Session中移除
            sessionStrategy.removeAttribute(request,ValidateCodeController.SESSION_KEY);
            throw new ValidateCodeException("验证码已过期");
        }
        if (!StringUtils.equals(codeInSession.getCode(),codeInRequest)) {
            throw new ValidateCodeException("验证码不匹配");
        }
        //验证成功,当前验证码不再使用,从Session中移除
        sessionStrategy.removeAttribute(request,ValidateCodeController.SESSION_KEY);
    }
}

以上我们用到的StringUtils为org.apache.commons.lang3.StringUtils,添加以下依赖即可

<dependency>
   <groupId>org.apache.commons</groupId>
   <artifactId>commons-lang3</artifactId>
</dependency>

修改SecrityConfig

/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
public class SecrityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private AuthenticationSuccessHandler validateAuthenticationSuccessHandler;
    @Autowired
    private AuthenticationFailureHandler loginAuthenticationFailureHander;
    @Autowired
    private SecrityProperties secrityProperties;
    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许表单登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
        validateCodeFilter.setLoginAuthenticationFailureHandler(loginAuthenticationFailureHander);
        validateCodeFilter.setSecrityProperties(secrityProperties);
        validateCodeFilter.afterPropertiesSet();
        //将自定义图形验证码过滤器放入到SpringSecurity过滤器链,并放在UsernamePasswordAuthenticationFilter前
        http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
                .formLogin() //基于表单认证
                .loginPage("/signIn.html")
                //使用/authentication/form来处理登录请求
                .loginProcessingUrl("/authentication/form")
                //登录成功后处理
                .successHandler(validateAuthenticationSuccessHandler)
                //登录失败后处理
                .failureHandler(loginAuthenticationFailureHander)
                .and()
                .authorizeRequests() //对请求进行授权
                //对/signIn.html,/code/image请求放行
                .antMatchers("/signIn.html","/code/image")
                .permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

启动项目,在我们登录成功后去访问/me,/current/me,/current/me/simple都会被过滤器拦截处理,并在后台打印如下日志

2020-01-11 11:18:57.058 ERROR 713 --- [io-8080-exec-10] c.g.s.config.ValidateCodeFilter          : 验证码的值不能为空
2020-01-11 11:18:57.058  INFO 713 --- [io-8080-exec-10] .g.s.c.LoginAuthenticationFailureHandler : 登录失败

  • 验证码的生成逻辑可配置

要做到验证码的生成逻辑可配置,我们需要自己写一个接口

/**
 * 验证码生成逻辑
 */
public interface ValidateCodeGenerator {
    ImageCode generate(ServletWebRequest request);
}

实现类

@Data
@AllArgsConstructor
public class ImageCodeGenerator implements ValidateCodeGenerator {
    private SecrityProperties secrityProperties;

    @Override
    public ImageCode generate(ServletWebRequest request) {
        //从请求中获取width属性,若请求中没有,则从配置中获取
        int width = ServletRequestUtils.getIntParameter(request.getRequest(),
                "width",secrityProperties.getCode().getImage().getWidth());
        int height = ServletRequestUtils.getIntParameter(request.getRequest(),
                "height",secrityProperties.getCode().getImage().getHeight());
        BufferedImage image = new BufferedImage(width,height,BufferedImage.TYPE_INT_RGB);

        Graphics g = image.getGraphics();
        Random random = new Random();
        g.setColor(getRandColor(200,250));
        g.fillRect(0,0,width,height);
        g.setFont(new Font("Time New Roman",Font.ITALIC,20));
        g.setColor(getRandColor(160,200));
        for (int i = 0;i < 155;i++) {
            int x = random.nextInt(width);
            int y = random.nextInt(height);
            int xl = random.nextInt(12);
            int yl = random.nextInt(12);
            g.drawLine(x,y,xl,yl);
        }
        String sRand = "";
        for (int i = 0;i < secrityProperties.getCode().getImage().getLength();i++) {
            String rand = String.valueOf(random.nextInt(10));
            sRand += rand;
            g.setColor(new Color(20 + random.nextInt(110),
                    20 + random.nextInt(110),
                    20 + random.nextInt(110)));
            g.drawString(rand,13 * i + 6,16);
        }
        g.dispose();
        return new ImageCode(image,sRand,secrityProperties.getCode().getImage().getExpireIn());
    }

    /**
     * 生成随机背景条纹
     * @param fc
     * @param bc
     * @return
     */
    private Color getRandColor(int fc,int bc) {
        Random random = new Random();
        if (fc > 255) {
            fc = 255;
        }
        if (bc > 255) {
            bc = 255;
        }
        int r = fc + random.nextInt(bc - fc);
        int g = fc + random.nextInt(bc -fc);
        int b = fc + random.nextInt(bc - fc);
        return new Color(r,g,b);
    }
}

增加配置类

@Configuration
public class ValidateCodeBeanConfig {
    @Autowired
    private SecrityProperties secrityProperties;

    /**
     * @ConditionalOnMissingBean 在Spring容器中如果可以找到
     * imageCodeGenerator,则不会进行加载
     * @return
     */
    @Bean
    @ConditionalOnMissingBean(name = "imageCodeGenerator")
    public ValidateCodeGenerator imageCodeGenerator() {
        return new ImageCodeGenerator(secrityProperties);
    }
}

修改ValidateCodeController

@RestController
public class ValidateCodeController {
    public static final String SESSION_KEY = "SESSION_KEY_IMAGE_CODE";
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
    @Autowired
    private ValidateCodeGenerator imageCodeGenerator;

    @GetMapping("/code/image")
    public void createCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
        //生成一个图形验证码的对象
        ImageCode imageCode = imageCodeGenerator.generate(new ServletWebRequest(request));
        //将图形验证码对象放入到request请求的SESSION_KEY_IMAGE_CODE属性中
        sessionStrategy.setAttribute(new ServletWebRequest(request),SESSION_KEY,imageCode);
        //将图形验证码的图形字节码以JPEG格式放入到响应的字节流中
        ImageIO.write(imageCode.getImage(),"JPEG",response.getOutputStream());
    }
}

启动项目,跟之前的效果没区别。

现在我们要覆盖ImageCodeGenerator,重新写一个实现类来被加载

@Slf4j
@Component("imageCodeGenerator")
public class DemoImageCodeGenerator implements ValidateCodeGenerator {
    @Override
    public ImageCode generate(ServletWebRequest request) {
        log.info("高级图形验证码实现");
        return null;
    }
}

重启项目,访问登录页面,这里当然不会出现图形验证码

并在后台显示

说明后写的实现类DemoImageCodeGenerator的确覆盖了之前的ImageCodeGenerator。

记住我功能

系统登录后,系统会记住用户一段时间,在这段时间内,用户不用反复登录,就使用系统。

基本原理

当第一次登录进行认证请求的时候,会发给UsernamePasswordAuthenticationFilter,认证成功后,会调一个RemeberMeService的服务,在服务中有一个TokenRepository,该服务会生成一个token,将token写入到浏览器的Cookie中,同时使用TokenRespository把生成的token写入数据库中,并且会把认证成功的用户名同时写进数据库。过了一天后,用户又来访问系统,就不需要登录了,可以直接访问某一个受保护的服务,该请求会经过过滤器链中叫做RememberMeAuthenticationFilter的过滤器,该过滤器到作用就是读取Cookie中的token,交给RemeberMeService,再通过TokenRepository到数据库中去查是否有该token的记录,如果有记录,就会把对应的用户名从数据库中取出。最后通过用户名去调UserDetailsService去获取用户的信息。然后把用户信息放到SecurityContext中。

RememberMeAuthenticationFilter过滤器位于所有绿色过滤器链的倒数第二个,当前面所有的认证过滤器都无法认证的时候,RememberMeAuthecticationFilter会尝试进行认证。

要添加记住我功能,现在signIn.html中添加一个checkbox

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
    <h2>标准登录页面</h2>
    <h3>表单登录</h3>
    <form action="/authentication/form" method="post">
        <table>
            <tr>
                <td>用户名</td>
                <td><input type="text" name="username"></td>
            </tr>
            <tr>
                <td>密码</td>
                <td><input type="password" name="password"></td>
            </tr>
            <tr>
                <td>图形验证码</td>
                <td>
                    <input type="text" name="imageCode">
                    <img src="/code/image?width=200">
                </td>
            </tr>
            <tr>
                <td colspan="2"><input name="remember-me" type="checkbox" value="true" />记住我</td>
            </tr>
            <tr>
                <td colspan="2"><button type="submit">登录</button> </td>
            </tr>
        </table>
    </form>
</body>
</html>

这里面需要注意的是<input name="remember-me" type="checkbox" value="true" />的名字只能被命名为remember-me

在BrowserProperties中增加一个过期的秒数

@Data
public class BrowserProperties {
    //当配置登录页取不到值的时候,使用主登录页
    private String loginPage = "/signIn.html";
    //当登录后的处理类型(跳转还是Json),默认为Json
    private LoginType loginType = LoginType.JSON;
    //记住我的时间(单位秒),这里配的是1小时,但一般会配1周或2周
    private int rememberMeSeconds = 3600;
}

当然我们也可以在配置项里面配置1周

gj:
  secrity:
    browser:
      rememberMeSeconds: 604800
    code:
      image:
        width: 100
        height: 23
        length: 6
        expireIn: 60
        url: /me,/current/**

由于我们配置TokenRepository需要数据库的连接,现加入对数据库连接的依赖,此处使用的为mysql 8的数据库

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
   <groupId>mysql</groupId>
   <artifactId>mysql-connector-java</artifactId>
   <version>8.0.11</version>
</dependency>
<dependency>
   <groupId>com.alibaba</groupId>
   <artifactId>druid</artifactId>
   <version>1.0.29</version>
</dependency>

增加配置项

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://xxx.xxx.xxx.xxx:3306/cloud_oauth?useSSL=FALSE&serverTimezone=GMT%2B8
    username: root
    password: root
    type: com.alibaba.druid.pool.DruidDataSource
    filters: stat
    maxActive: 20
    initialSize: 1
    maxWait: 60000
    minIdle: 1
    timeBetweenEvictionRunsMillis: 60000
    minEvictableIdleTimeMillis: 300000
    validationQuery: select 'x'
    testWhileIdle: true
    testOnBorrow: false
    testOnReturn: false
    poolPreparedStatements: true
    maxOpenPreparedStatements: 20

由于此处配置的是阿里的druid DataSource,所以需要增加一个druid特有的配置类,使得配置中具体的属性得以生效,否则spring.datasource是默认不生效druid设置的。

@Configuration
public class DruidConfig {
    @ConfigurationProperties(prefix = "spring.datasource")
    @Bean
    @Primary
    public DataSource dataSource() {
        return new DruidDataSource();
    }
}

如果不做该设置,则配置项中自filters: stat开始,后面对配置均不会生效,因为这些配置项为DruidDataSource特有的,DataSource只是一个接口,DruidDataSource为实现类,这些项目在DataSource接口中是没有的。我们可以来看一下DruidDataSource的继承图

修改SecrityConfig来添加RememberMe的各项配置

/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
@Slf4j
public class SecrityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private AuthenticationSuccessHandler validateAuthenticationSuccessHandler;
    @Autowired
    private AuthenticationFailureHandler loginAuthenticationFailureHander;
    @Autowired
    private SecrityProperties secrityProperties;
    @Autowired
    private DataSource dataSource;
    @Autowired
    private UserDetailsService myUserDetailsService;
    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许表单登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
        validateCodeFilter.setLoginAuthenticationFailureHandler(loginAuthenticationFailureHander);
        validateCodeFilter.setSecrityProperties(secrityProperties);
        validateCodeFilter.afterPropertiesSet();
        //将自定义图形验证码过滤器放入到SpringSecurity过滤器链,并放在UsernamePasswordAuthenticationFilter前
        http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
                .formLogin() //基于表单认证
                .loginPage("/signIn.html")
                //使用/authentication/form来处理登录请求
                .loginProcessingUrl("/authentication/form")
                //登录成功后处理
                .successHandler(validateAuthenticationSuccessHandler)
                //登录失败后处理
                .failureHandler(loginAuthenticationFailureHander)
                .and()
                .rememberMe() //配置记住我功能
                //添加TokenRepository
                .tokenRepository(persistentTokenRepository())
                //添加过期时间
                .tokenValiditySeconds(secrityProperties.getBrowser().getRememberMeSeconds())
                //添加用户信息
                .userDetailsService(myUserDetailsService)
                .and()
                .authorizeRequests() //对请求进行授权
                //对/signIn.html,/code/image请求放行
                .antMatchers("/signIn.html","/code/image")
                .permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 配置RememberMeService中的TokenRepository
     * @return
     */
    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);
        //启动的时候自动创建表
//        tokenRepository.setCreateTableOnStartup(true);
        return tokenRepository;
    }
}

这里需要注意的是tokenRepository.setCreateTableOnStartup(true);在第一次启动的时候会在数据库中增加一个的表,以后再启动需要注释掉该代码,否则会报错。

该表中包含对字段如下

启动项目,当我们登录成功后,会在数据库表中插入用户名和token

现在我们重新启动项目,按照惯例,重启项目,所有的登录认证都会被清除,需要重新登录。

访问/hello,我们会发现,此时并没有跳到登录认证页面,而是直接返回了结果,并且我们可以在Cookie中看到访问带上了RememberMeService传给浏览器的token

记住我原理解析

现在我们又回到AbstractAuthenticationProcessingFilter的successfulAuthentication()方法中

protected void successfulAuthentication(HttpServletRequest request,
      HttpServletResponse response, FilterChain chain, Authentication authResult)
      throws IOException, ServletException {

   if (logger.isDebugEnabled()) {
      logger.debug("Authentication success. Updating SecurityContextHolder to contain: "
            + authResult);
   }
   //将认证成功的认证结果放入容器中
   SecurityContextHolder.getContext().setAuthentication(authResult);

   rememberMeServices.loginSuccess(request, response, authResult);

   // Fire event
   if (this.eventPublisher != null) {
      eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
            authResult, this.getClass()));
   }
   //认证成功后调用认证成功处理器进行成功后处理
   successHandler.onAuthenticationSuccess(request, response, authResult);
}

这里我们可以看到这样一段代码

rememberMeServices.loginSuccess(request, response, authResult);

这里RememberMeServices也是一个接口

public interface RememberMeServices {
   /**
    * 无论SecurityContextHolder容器中是否包含认证信息,都可以通过该方法通过浏览器携带的有效Cookie进行登录
    */
   Authentication autoLogin(HttpServletRequest request, HttpServletResponse response);

   /**
    * 携带无效的Cookie,登录失败
    */
   void loginFail(HttpServletRequest request, HttpServletResponse response);

   /**
    * 登录成功后返回一个token给浏览器
    */
   void loginSuccess(HttpServletRequest request, HttpServletResponse response,
         Authentication successfulAuthentication);
}

它这里的实现类为PersistentTokenBasedRememberMeServices,我们来看一下它的继承图

loginSuccess()方法在其父类AbstractRememberMeServices中,这是一个抽象类

@Override
public final void loginSuccess(HttpServletRequest request,
      HttpServletResponse response, Authentication successfulAuthentication) {

   if (!rememberMeRequested(request, parameter)) {
      logger.debug("Remember-me login not requested.");
      return;
   }
   //该方法是一个抽象方法,在PersistentTokenBasedRememberMeServices中实现
   onLoginSuccess(request, response, successfulAuthentication);
}
protected abstract void onLoginSuccess(HttpServletRequest request,
      HttpServletResponse response, Authentication successfulAuthentication);

我们来看一下onLoginSuccess()方法在PersistentTokenBasedRememberMeServices中的实现

protected void onLoginSuccess(HttpServletRequest request,
      HttpServletResponse response, Authentication successfulAuthentication) {
   String username = successfulAuthentication.getName();

   logger.debug("Creating new persistent login for user " + username);

   PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(
         username, generateSeriesData(), generateTokenData(), new Date());
   try {
      //使用tokenRepository来创建一个新的token,然后将其存入数据库
      tokenRepository.createNewToken(persistentToken);
      //将生成出来的token写入浏览器的Cookie中
      addCookie(persistentToken, request, response);
   }
   catch (Exception e) {
      logger.error("Failed to save persistent token ", e);
   }
}

经过以上步骤,我们登录的过程就完成了。现在来看一下,我们之后在记住我功能下无需登录就可以访问受限的资源。在此过程下的访问会先进入RememberMeAuthenticationFilter的过滤器中。

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
      throws IOException, ServletException {
   HttpServletRequest request = (HttpServletRequest) req;
   HttpServletResponse response = (HttpServletResponse) res;
   //如果SecurityContext容器中没有登录认证信息
   if (SecurityContextHolder.getContext().getAuthentication() == null) {
      //通过rememberMeServices调用自动登录,通过request中的Cookie来进行登录
      Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
            response);
      //如果自动登录认证成功
      if (rememberMeAuth != null) {
         // Attempt authenticaton via AuthenticationManager
         try {
            //调用DaoAuthenticationProvider来检查认证信息为可用的认证信息
            rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);

            //将认证信息放入SecurityContext容器中
            SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);

            onSuccessfulAuthentication(request, response, rememberMeAuth);

            if (logger.isDebugEnabled()) {
               logger.debug("SecurityContextHolder populated with remember-me token: '"
                     + SecurityContextHolder.getContext().getAuthentication()
                     + "'");
            }

            // Fire event
            if (this.eventPublisher != null) {
               eventPublisher
                     .publishEvent(new InteractiveAuthenticationSuccessEvent(
                           SecurityContextHolder.getContext()
                                 .getAuthentication(), this.getClass()));
            }

            if (successHandler != null) {
               successHandler.onAuthenticationSuccess(request, response,
                     rememberMeAuth);

               return;
            }

         }
         catch (AuthenticationException authenticationException) {
            if (logger.isDebugEnabled()) {
               logger.debug(
                     "SecurityContextHolder not populated with remember-me token, as "
                           + "AuthenticationManager rejected Authentication returned by RememberMeServices: '"
                           + rememberMeAuth
                           + "'; invalidating remember-me token",
                     authenticationException);
            }

            rememberMeServices.loginFail(request, response);

            onUnsuccessfulAuthentication(request, response,
                  authenticationException);
         }
      }

      chain.doFilter(request, response);
   }
   else {
      if (logger.isDebugEnabled()) {
         logger.debug("SecurityContextHolder not populated with remember-me token, as it already contained: '"
               + SecurityContextHolder.getContext().getAuthentication() + "'");
      }

      chain.doFilter(request, response);
   }
}

在AbstractRememberMeServices中,我们来看一下autoLogin()方法

@Override
public final Authentication autoLogin(HttpServletRequest request,
      HttpServletResponse response) {
   String rememberMeCookie = extractRememberMeCookie(request);

   if (rememberMeCookie == null) {
      return null;
   }

   logger.debug("Remember-me cookie detected");

   if (rememberMeCookie.length() == 0) {
      logger.debug("Cookie was empty");
      cancelCookie(request, response);
      return null;
   }

   UserDetails user = null;

   try {
      String[] cookieTokens = decodeCookie(rememberMeCookie);
      //该方法是一个抽象方法,在PersistentTokenBasedRememberMeServices中实现
      user = processAutoLoginCookie(cookieTokens, request, response);
      userDetailsChecker.check(user);

      logger.debug("Remember-me cookie accepted");

      return createSuccessfulAuthentication(request, user);
   }
   catch (CookieTheftException cte) {
      cancelCookie(request, response);
      throw cte;
   }
   catch (UsernameNotFoundException noUser) {
      logger.debug("Remember-me login was valid but corresponding user not found.",
            noUser);
   }
   catch (InvalidCookieException invalidCookie) {
      logger.debug("Invalid remember-me cookie: " + invalidCookie.getMessage());
   }
   catch (AccountStatusException statusInvalid) {
      logger.debug("Invalid UserDetails: " + statusInvalid.getMessage());
   }
   catch (RememberMeAuthenticationException e) {
      logger.debug(e.getMessage());
   }

   cancelCookie(request, response);
   return null;
}
protected abstract UserDetails processAutoLoginCookie(String[] cookieTokens,
      HttpServletRequest request, HttpServletResponse response)
      throws RememberMeAuthenticationException, UsernameNotFoundException;

现在来看一下processAutoLoginCookie()方法在PersistentTokenBasedRememberMeServices中的实现

protected UserDetails processAutoLoginCookie(String[] cookieTokens,
      HttpServletRequest request, HttpServletResponse response) {

   if (cookieTokens.length != 2) {
      throw new InvalidCookieException("Cookie token did not contain " + 2
            + " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
   }
   //在请求中获取Cookie和Series
   final String presentedSeries = cookieTokens[0];
   final String presentedToken = cookieTokens[1];
   //从数据库中通过Series值去拿取数据库中的token以及用户名
   PersistentRememberMeToken token = tokenRepository
         .getTokenForSeries(presentedSeries);

   if (token == null) {
      // No series match, so we can't authenticate using this cookie
      throw new RememberMeAuthenticationException(
            "No persistent token found for series id: " + presentedSeries);
   }

   //比对request中的token跟数据库中的token
   if (!presentedToken.equals(token.getTokenValue())) {
      // Token doesn't match series value. Delete all logins for this user and throw
      // an exception to warn them.
      tokenRepository.removeUserTokens(token.getUsername());

      throw new CookieTheftException(
            messages.getMessage(
                  "PersistentTokenBasedRememberMeServices.cookieStolen",
                  "Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
   }
   //检查token是否过期
   if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System
         .currentTimeMillis()) {
      throw new RememberMeAuthenticationException("Remember-me login has expired");
   }

   // Token also matches, so login is valid. Update the token value, keeping the
   // *same* series number.
   if (logger.isDebugEnabled()) {
      logger.debug("Refreshing persistent login token for user '"
            + token.getUsername() + "', series '" + token.getSeries() + "'");
   }
   //前面对检查通过,生成一个新的token,包含用户名,过期时间
   PersistentRememberMeToken newToken = new PersistentRememberMeToken(
         token.getUsername(), token.getSeries(), generateTokenData(), new Date());

   try {
      //在数据库中更新为新的token
      tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(),
            newToken.getDate());
      addCookie(newToken, request, response);
   }
   catch (Exception e) {
      logger.error("Failed to update token: ", e);
      throw new RememberMeAuthenticationException(
            "Autologin failed due to data access problem");
   }
   //通过token的用户名获取用户信息
   return getUserDetailsService().loadUserByUsername(token.getUsername());
}

实现短信验证码登录

要实现短信验证码,就需要有一个短信验证码的Rest接口,根据之前到图形验证码,我们来做一定的修改,因为现在有两种验证码(图形验证码和短信验证码),我们先增加一个验证码的父抽象类

/**
 * 验证码
 */
@Data
@AllArgsConstructor
public abstract class ValidateCode {
    protected String code;
    //过期时间
    protected LocalDateTime expireTime;

    public ValidateCode(String code, int expireIn) {
        this.code = code;
        this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
    }

    /**
     * 验证码是否过期
     * @return
     */
    public boolean isExpired() {
        return LocalDateTime.now().isAfter(this.expireTime);
    }
}

修改图形验证码的实体类,使之继承与该抽象类

/**
 * 图形验证码
 */
@Data
public class ImageCode extends ValidateCode {
    private BufferedImage image;

    public ImageCode(BufferedImage image,String code,int expireIn) {
        super(code,expireIn);
        this.image = image;
    }

    public ImageCode(BufferedImage image, String code, LocalDateTime expireTime) {
        super(code,expireTime);
        this.image = image;
    }
}

增加我们的短信验证码实体类

/**
 * 短信验证码
 */
public class SmsCode extends ValidateCode {
    public SmsCode(String code, LocalDateTime expireTime) {
        super(code, expireTime);
    }
    public SmsCode(String code,int expireIn) {
        super(code,expireIn);
    }
}

由于发短信需要短信提供商,我们先略过短信提供商,自己先写一个发短信的接口

/**
 * 发送短信
 */
public interface SmsSender {
    void send(String mobile,String code);
}

写一个实现类简单实现该接口

@Slf4j
public class DefaultSmsSender implements SmsSender {
    @Override
    public void send(String mobile, String code) {
        log.info("向手机{}发送短信验证码{}",mobile,code);
    }
}

修改验证码生成逻辑接口,使之接口方法返回的是一个抽象父类

/**
 * 验证码生成逻辑
 */
public interface ValidateCodeGenerator {
    ValidateCode generate(ServletWebRequest request);
}

同样图形验证码的生成逻辑实现类也做同样的修改

@Data
@AllArgsConstructor
public class ImageCodeGenerator implements ValidateCodeGenerator {
    private SecrityProperties secrityProperties;

    @Override
    public ValidateCode generate(ServletWebRequest request) {
        //从请求中获取width属性,若请求中没有,则从配置中获取
        int width = ServletRequestUtils.getIntParameter(request.getRequest(),
                "width",secrityProperties.getCode().getImage().getWidth());
        int height = ServletRequestUtils.getIntParameter(request.getRequest(),
                "height",secrityProperties.getCode().getImage().getHeight());
        BufferedImage image = new BufferedImage(width,height,BufferedImage.TYPE_INT_RGB);

        Graphics g = image.getGraphics();
        Random random = new Random();
        g.setColor(getRandColor(200,250));
        g.fillRect(0,0,width,height);
        g.setFont(new Font("Time New Roman",Font.ITALIC,20));
        g.setColor(getRandColor(160,200));
        for (int i = 0;i < 155;i++) {
            int x = random.nextInt(width);
            int y = random.nextInt(height);
            int xl = random.nextInt(12);
            int yl = random.nextInt(12);
            g.drawLine(x,y,xl,yl);
        }
        String sRand = "";
        for (int i = 0;i < secrityProperties.getCode().getImage().getLength();i++) {
            String rand = String.valueOf(random.nextInt(10));
            sRand += rand;
            g.setColor(new Color(20 + random.nextInt(110),
                    20 + random.nextInt(110),
                    20 + random.nextInt(110)));
            g.drawString(rand,13 * i + 6,16);
        }
        g.dispose();
        return new ImageCode(image,sRand,secrityProperties.getCode().getImage().getExpireIn());
    }

    /**
     * 生成随机背景条纹
     * @param fc
     * @param bc
     * @return
     */
    private Color getRandColor(int fc,int bc) {
        Random random = new Random();
        if (fc > 255) {
            fc = 255;
        }
        if (bc > 255) {
            bc = 255;
        }
        int r = fc + random.nextInt(bc - fc);
        int g = fc + random.nextInt(bc -fc);
        int b = fc + random.nextInt(bc - fc);
        return new Color(r,g,b);
    }
}

配置类,因为现在要实现短信验证码的配置,需要一个抽象配置类

@Data
public abstract class AbstractValidateCodeProperties {
    protected int length = 6;
    protected int expireIn = 60;
    protected String url;
}

修改图形验证码配置类ImageCodeProperties,使之继承抽象类AbstractValidateCodeProperties

@Data
public class ImageCodeProperties extends AbstractValidateCodeProperties {
    public ImageCodeProperties() {
        setLength(4);
    }
    private int width = 67;
    private int height = 23;
}

增加短信验证码配置类,因为短信验证码的配置项跟抽象父类完全相同,所以只需要继承就可以了。

public class SmsCodeProperties extends AbstractValidateCodeProperties {
}

将短信验证码的属性放入到ValidateCodeProperties中

@Data
public class ValidateCodeProperties {
    private ImageCodeProperties image = new ImageCodeProperties();
    private SmsCodeProperties sms = new SmsCodeProperties();
}

增加短信验证码的生成逻辑实现类

@Data
@Component("smsCodeGenerator")
public class SmsCodeGenerator implements ValidateCodeGenerator {
    @Autowired
    private SecrityProperties secrityProperties;

    @Override
    public ValidateCode generate(ServletWebRequest request) {
        String code = RandomStringUtils.randomNumeric(secrityProperties.getCode().getSms().getLength());
        return new SmsCode(code, secrityProperties.getCode().getSms().getExpireIn());
    }
}

修改ValidateCodeBeanConfig

@Configuration
public class ValidateCodeBeanConfig {
    @Autowired
    private SecrityProperties secrityProperties;

    /**
     * @ConditionalOnMissingBean 在Spring容器中如果可以找到
     * imageCodeGenerator,则不会进行加载
     * @return
     */
    @Bean
    @ConditionalOnMissingBean(name = "imageCodeGenerator")
    public ValidateCodeGenerator imageCodeGenerator() {
        return new ImageCodeGenerator(secrityProperties);
    }

    /**
     * 这样写是可以随时增加一个短信提供商写的smsSender的Bean来
     * 随时覆盖DefaultSmsSender
     * @return
     */
    @Bean
    @ConditionalOnMissingBean(name = "smsSender")
    public SmsSender smsSender() {
        return new DefaultSmsSender();
    }
}

这里值得注意的是,我们可以随时增加一个SmsSender的实现类来覆盖掉默认实现,因为这里的默认实现并没有发送短信,只是在后台打印日志而已。

修改ValidateCodeController,增加短信验证码的API接口。

@RestController
public class ValidateCodeController {
    public static final String SESSION_IMAGE_KEY = "SESSION_KEY_IMAGE_CODE";
    public static final String SESSION_SMS_KEY = "SESSION_KEY_SMS_CODE";
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
    @Autowired
    private ValidateCodeGenerator imageCodeGenerator;
    @Autowired
    private ValidateCodeGenerator smsCodeGenerator;
    @Autowired
    private SmsSender smsSender;

    @GetMapping("/code/image")
    public void createCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
        //生成一个图形验证码的对象
        ImageCode imageCode = (ImageCode)imageCodeGenerator.generate(new ServletWebRequest(request));
        //将图形验证码对象放入到request请求的SESSION_KEY_IMAGE_CODE属性中
        sessionStrategy.setAttribute(new ServletWebRequest(request),SESSION_IMAGE_KEY,imageCode);
        //将图形验证码的图形字节码以JPEG格式放入到响应的字节流中
        ImageIO.write(imageCode.getImage(),"JPEG",response.getOutputStream());
    }

    @GetMapping("/code/sms")
    public void createSmsCode(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletRequestBindingException {
        //生成一个短信验证码的对象
        SmsCode smsCode = (SmsCode)smsCodeGenerator.generate(new ServletWebRequest(request));
        //将短信验证码对象放入到request请求的SESSION_KEY_SMS_CODE属性中
        sessionStrategy.setAttribute(new ServletWebRequest(request),SESSION_SMS_KEY,smsCode);
        //在请求中获取必填参数mobile
        String mobile = ServletRequestUtils.getRequiredStringParameter(request, "mobile");
        //将短信验证码发送到手机
        smsSender.send(mobile,smsCode.getCode());
    }
}

因为这里SESSION_KEY做了拆分,变成SESSION_IMAGE_KEY和SESSION_SMS_KEY,所以我们需要修改之前的ValidateCodeFilter

/**
 * OncePerRequestFilter保证我们自己写的过滤器只被调用一次
 * InitializingBean在其他参数都组装完毕以后,去初始化url的值
 */
@Slf4j
public class ValidateCodeFilter extends OncePerRequestFilter implements InitializingBean {
    @Getter
    @Setter
    private AuthenticationFailureHandler loginAuthenticationFailureHandler;
    @Getter
    @Setter
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
    private Set<String> urls = new HashSet<>();
    @Getter
    @Setter
    private SecrityProperties secrityProperties;
    //可以进行带*通配符的url匹配
    private AntPathMatcher pathMatcher = new AntPathMatcher();

    /**
     * 初始化urls
     * @throws ServletException
     */
    @Override
    public void afterPropertiesSet() throws ServletException {
        super.afterPropertiesSet();
        String[] configUrls = StringUtils.splitByWholeSeparatorPreserveAllTokens(secrityProperties.getCode()
                                .getImage().getUrl(),",");
        Stream.of(configUrls).forEach(urls::add);
        urls.add("/authentication/form");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        boolean action = false;
        action = urls.stream().filter(url -> pathMatcher.match(url, request.getRequestURI()))
                .anyMatch(StringUtils::isNotEmpty);
        //只在匹配上的url才继续执行,否则直接让渡到后面的过滤器
        if (action) {
            try {
                validate(new ServletWebRequest(request));
            }catch (ValidateCodeException e) {
                log.error(e.getMessage());
                loginAuthenticationFailureHandler.onAuthenticationFailure(request,response,e);
                return;
            }
        }
        filterChain.doFilter(request,response);
    }

    private void validate(ServletWebRequest request) throws ServletRequestBindingException {
        //从sessionStrategy中取出之前放入的图形验证码对象
        ImageCode codeInSession = (ImageCode) sessionStrategy.getAttribute(request, ValidateCodeController.SESSION_IMAGE_KEY);
        //拿取登录页中用户输入的验证码
        String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(),"imageCode");
        if (StringUtils.isBlank(codeInRequest)) {
            throw new ValidateCodeException("验证码的值不能为空");
        }
        if (codeInSession == null) {
            throw new ValidateCodeException("验证码不存在");
        }
        if (codeInSession.isExpired()) {
            //验证码过期无效了,从Session中移除
            sessionStrategy.removeAttribute(request,ValidateCodeController.SESSION_IMAGE_KEY);
            throw new ValidateCodeException("验证码已过期");
        }
        if (!StringUtils.equals(codeInSession.getCode(),codeInRequest)) {
            throw new ValidateCodeException("验证码不匹配");
        }
        //验证成功,当前验证码不再使用,从Session中移除
        sessionStrategy.removeAttribute(request,ValidateCodeController.SESSION_IMAGE_KEY);
    }
}

在登录页中添加短信验证码登录

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
    <h2>标准登录页面</h2>
    <h3>表单登录</h3>
    <form action="/authentication/form" method="post">
        <table>
            <tr>
                <td>用户名</td>
                <td><input type="text" name="username"></td>
            </tr>
            <tr>
                <td>密码</td>
                <td><input type="password" name="password"></td>
            </tr>
            <tr>
                <td>图形验证码</td>
                <td>
                    <input type="text" name="imageCode">
                    <img src="/code/image?width=200">
                </td>
            </tr>
            <tr>
                <td colspan="2"><input name="remember-me" type="checkbox" value="true" />记住我</td>
            </tr>
            <tr>
                <td colspan="2"><button type="submit">登录</button> </td>
            </tr>
        </table>
    </form>
    <h3>短信登录</h3>
    <form action="/authentication/mobile" method="post">
        <table>
            <tr>
                <td>手机号</td>
                <td><input type="text" name="mobile" value="13600000000"></td>
            </tr>
            <tr>
                <td>短信验证码</td>
                <td>
                    <input type="text" name="smsCode">
                    <a href="/code/sms?mobile=13600000000">发送验证码</a>
                </td>
            </tr>
            <tr>
                <td colspan="2"><button type="submit">登录</button> </td>
            </tr>
        </table>
    </form>
</body>
</html>

此时登录界面如下

使用模版方法模式重构ValidateCodeController

我们会先定义一个ValidateCodeProcessor的接口,该接口会有一个抽象的实现类AbstractValidateCodeProcessor,整个的生成Code,存储到Session,发送的流程会放到该抽象类中,具体的发送会分别放到子类中。

/**
 * 校验码处理器,封装不同校验码的处理逻辑
 */
public interface ValidateCodeProcessor {
    //验证码放入session时的前缀
    String SESSION_KEY_PREFIX = "SESSION_KEY_FOR_CODE_";

    /**
     * 创建校验码
     * @param request
     * @throws Exception
     */
    void create(ServletWebRequest request) throws Exception;
}

抽象类

public abstract class AbstractValidateCodeProcessor<T> implements ValidateCodeProcessor {
    //操作Session的工具类
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
    //收集系统中所有的ValidateCodeGenerator接口的实现
    //Spring在启动中会查找所有的ValidateCodeGenerator的实现类放入Map中
    //该方式称为依赖搜索
    @Autowired
    private Map<String,ValidateCodeGenerator> validateCodeGenerators;

    @Override
    public void create(ServletWebRequest request) throws Exception {
        T validateCode = generate(request);
        save(request,validateCode);
        send(request,validateCode);
    }

    /**
     * 生成校验码
     * @param request
     * @return
     */
    @SuppressWarnings("unchecked")
    private T generate(ServletWebRequest request) {
        String type = getProcessorType(request);
        ValidateCodeGenerator validateCodeGenerator = validateCodeGenerators.get(type + "CodeGenerator");
        return (T) validateCodeGenerator.generate(request);
    }

    /**
     * 保存校验码
     * @param request
     * @param validateCode
     */
    private void save(ServletWebRequest request,T validateCode) {
        sessionStrategy.setAttribute(request,SESSION_KEY_PREFIX + getProcessorType(request).toUpperCase(),
                validateCode);
    }

    /**
     * 发送校验码,由子类实现
     * @param request
     * @param validateCode
     * @throws Exception
     */
    protected abstract void send(ServletWebRequest request,T validateCode) throws Exception;

    /**
     * 根据请求的url获取校验码的类型
     * @param request
     * @return
     */
    private String getProcessorType(ServletWebRequest request) {
        return StringUtils.substringAfter(request.getRequest().getRequestURI(),"/code/");
    }
}

图片验证码子类

/**
 * 图片验证码处理器
 */
@Component("imageCodeProcessor")
public class ImageCodeProcessor extends AbstractValidateCodeProcessor<ImageCode> {
    /**
     * 发送图形验证码,将其写到响应中
     * @param request
     * @param validateCode
     * @throws Exception
     */
    @Override
    protected void send(ServletWebRequest request, ImageCode validateCode) throws Exception {
        ImageIO.write(validateCode.getImage(),"JPEG",request.getResponse().getOutputStream());
    }
}

短信验证码子类

/**
 * 短信验证码处理器
 */
@Component("smsCodeProcessor")
public class SmsCodeProcessor extends AbstractValidateCodeProcessor<SmsCode> {
    //短信验证码发送器
    @Autowired
    private SmsSender smsSender;

    /**
     * 发送短信验证码到手机上
     * @param request
     * @param validateCode
     * @throws Exception
     */
    @Override
    protected void send(ServletWebRequest request, SmsCode validateCode) throws Exception {
        String mobile = ServletRequestUtils.getRequiredStringParameter(request.getRequest(),"mobile");
        smsSender.send(mobile,validateCode.getCode());
    }
}

修改ValidateCodeController,用一个接口来获取两种验证码,其实这里跟我之前的用工厂方法模式来下不同订单 有异曲同工之妙。

@RestController
public class ValidateCodeController {
    @Autowired
    private Map<String,ValidateCodeProcessor> validateCodeProcessors;

    /**
     * 创建验证码,根据验证码不同,调用不同的ValidateCodeProcessor接口实现
     * @param request
     * @param response
     * @param type
     * @throws Exception
     */
    @GetMapping("/code/{type}")
    public void createCode(HttpServletRequest request, HttpServletResponse response, @PathVariable String type) throws Exception {
        validateCodeProcessors.get(type + "CodeProcessor")
                .create(new ServletWebRequest(request,response));
    }
}

再次调整之前的ValidateCodeFilter

/**
 * OncePerRequestFilter保证我们自己写的过滤器只被调用一次
 * InitializingBean在其他参数都组装完毕以后,去初始化url的值
 */
@Slf4j
public class ValidateCodeFilter extends OncePerRequestFilter implements InitializingBean {
    @Getter
    @Setter
    private AuthenticationFailureHandler loginAuthenticationFailureHandler;
    @Getter
    @Setter
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
    private Set<String> urls = new HashSet<>();
    @Getter
    @Setter
    private SecrityProperties secrityProperties;
    //可以进行带*通配符的url匹配
    private AntPathMatcher pathMatcher = new AntPathMatcher();

    /**
     * 初始化urls
     * @throws ServletException
     */
    @Override
    public void afterPropertiesSet() throws ServletException {
        super.afterPropertiesSet();
        String[] configUrls = StringUtils.splitByWholeSeparatorPreserveAllTokens(secrityProperties.getCode()
                                .getImage().getUrl(),",");
        Stream.of(configUrls).forEach(urls::add);
        urls.add("/authentication/form");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        boolean action = false;
        action = urls.stream().filter(url -> pathMatcher.match(url, request.getRequestURI()))
                .anyMatch(StringUtils::isNotEmpty);
        //只在匹配上的url才继续执行,否则直接让渡到后面的过滤器
        if (action) {
            try {
                validate(new ServletWebRequest(request));
            }catch (ValidateCodeException e) {
                log.error(e.getMessage());
                loginAuthenticationFailureHandler.onAuthenticationFailure(request,response,e);
                return;
            }
        }
        filterChain.doFilter(request,response);
    }

    private void validate(ServletWebRequest request) throws ServletRequestBindingException {
        //从sessionStrategy中取出之前放入的图形验证码对象
        ImageCode codeInSession = (ImageCode) sessionStrategy.getAttribute(request, ValidateCodeProcessor.SESSION_KEY_PREFIX + "IMAGE");
        //拿取登录页中用户输入的验证码
        String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(),"imageCode");
        if (StringUtils.isBlank(codeInRequest)) {
            throw new ValidateCodeException("验证码的值不能为空");
        }
        if (codeInSession == null) {
            throw new ValidateCodeException("验证码不存在");
        }
        if (codeInSession.isExpired()) {
            //验证码过期无效了,从Session中移除
            sessionStrategy.removeAttribute(request,ValidateCodeProcessor.SESSION_KEY_PREFIX + "IMAGE");
            throw new ValidateCodeException("验证码已过期");
        }
        if (!StringUtils.equals(codeInSession.getCode(),codeInRequest)) {
            throw new ValidateCodeException("验证码不匹配");
        }
        //验证成功,当前验证码不再使用,从Session中移除
        sessionStrategy.removeAttribute(request,ValidateCodeProcessor.SESSION_KEY_PREFIX + "IMAGE");
    }
}

修改SecrityConfig,使之放行/code/*

/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
@Slf4j
public class SecrityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private AuthenticationSuccessHandler validateAuthenticationSuccessHandler;
    @Autowired
    private AuthenticationFailureHandler loginAuthenticationFailureHander;
    @Autowired
    private SecrityProperties secrityProperties;
    @Autowired
    private DataSource dataSource;
    @Autowired
    private UserDetailsService myUserDetailsService;
    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许表单登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
        validateCodeFilter.setLoginAuthenticationFailureHandler(loginAuthenticationFailureHander);
        validateCodeFilter.setSecrityProperties(secrityProperties);
        validateCodeFilter.afterPropertiesSet();
        //将自定义图形验证码过滤器放入到SpringSecurity过滤器链,并放在UsernamePasswordAuthenticationFilter前
        http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
                .formLogin() //基于表单认证
                .loginPage("/signIn.html")
                //使用/authentication/form来处理登录请求
                .loginProcessingUrl("/authentication/form")
                //登录成功后处理
                .successHandler(validateAuthenticationSuccessHandler)
                //登录失败后处理
                .failureHandler(loginAuthenticationFailureHander)
                .and()
                .rememberMe() //配置记住我功能
                //添加TokenRepository
                .tokenRepository(persistentTokenRepository())
                //添加过期时间
                .tokenValiditySeconds(secrityProperties.getBrowser().getRememberMeSeconds())
                //添加用户信息
                .userDetailsService(myUserDetailsService)
                .and()
                .authorizeRequests() //对请求进行授权
                //对/signIn.html,/code/*请求放行
                .antMatchers("/signIn.html","/code/*")
                .permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 配置RememberMeService中的TokenRepository
     * @return
     */
    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);
        //启动的时候自动创建表
//        tokenRepository.setCreateTableOnStartup(true);
        return tokenRepository;
    }
}

短信验证码的认证

类似于表单认证,我们需要仿照表单认证流程,自己开发一套短信认证的流程。不过需要说明的是,我们的短信验证码的校验并不在这个流程中,而是会像图形验证码的校验一样,会另写一个校验的过滤器,加在这一套流程之前。

我们先来模仿UsernamePasswordAuthenticationToken,写一个SmsCodeAuthenticationToken,由于短信登录不需要密码,所以我们这里只有一个principal表示手机号。

public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    private final Object principal;

    public SmsCodeAuthenticationToken(Object principal) {
        super(null);
        this.principal = principal;
        setAuthenticated(false);
    }

    public SmsCodeAuthenticationToken(Object principal,Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        super.setAuthenticated(true);
    }

    @Override
    public boolean implies(Subject subject) {
        return false;
    }

    @Override
    public Object getCredentials() {
        return null;
    }

    @Override
    public Object getPrincipal() {
        return this.principal;
    }

    @Override
    public void setAuthenticated(boolean authenticated) {
        if (authenticated) {
            throw new IllegalArgumentException(
                    "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        }
        super.setAuthenticated(false);
    }

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
    }
}

模仿UsernamePasswordAuthenticationFilter,写一个SmsCodeAuthenticationFilter

public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    public static final String SPRING_SECURITY_FROM_KEY = "mobile";
    @Getter
    private String mobileParameter = SPRING_SECURITY_FROM_KEY;

    //是否只处理POST请求
    @Setter
    private boolean postOnly = true;

    public SmsCodeAuthenticationFilter() {
        super(new AntPathRequestMatcher("/authentication/mobile","POST"));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        if (postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + request.getMethod());
        }
        String mobile = obtainMobile(request);
        if (mobile == null) {
            mobile = "";
        }
        mobile = mobile.trim();
        SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);
        setDetails(request,authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    /**
     * 获取手机号
     * @param request
     * @return
     */
    protected String obtainMobile(HttpServletRequest request) {
        return request.getParameter(mobileParameter);
    }

    protected void setDetails(HttpServletRequest request,
                              SmsCodeAuthenticationToken authRequest) {
        authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
    }

    public void setMobileParameter(String mobileParameter) {
        Assert.hasText(mobileParameter, "Mobile parameter must not be empty or null");
        this.mobileParameter = mobileParameter;
    }
}

按照之前的逻辑,增加SmsCodeAuthenticationProvider,该Provider需要实现AuthenticationProvider接口

@Data
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
    private UserDetailsService userDetailsService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;
        UserDetails user = userDetailsService.loadUserByUsername((String) authentication.getPrincipal());

        if (user == null) {
            throw new InternalAuthenticationServiceException("无法获取用户信息");
        }
        SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(user,user.getAuthorities());
        authenticationResult.setDetails(authenticationToken.getDetails());
        return authenticationResult;
    }

    /**
     * 判断传入的authentication对象是否为SmsCodeAuthenticationToken类型
     * @param authentication
     * @return
     */
    @Override
    public boolean supports(Class<?> authentication) {
        return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

短信验证码校验过滤器只需要稍微修改一下图形验证码的ValidateCodeFilter即可

@Slf4j
public class SmsValidateCodeFilter extends OncePerRequestFilter implements InitializingBean {
    @Getter
    @Setter
    private AuthenticationFailureHandler loginAuthenticationFailureHandler;
    @Getter
    @Setter
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
    private Set<String> urls = new HashSet<>();
    @Getter
    @Setter
    private SecrityProperties secrityProperties;
    //可以进行带*通配符的url匹配
    private AntPathMatcher pathMatcher = new AntPathMatcher();

    /**
     * 初始化urls
     * @throws ServletException
     */
    @Override
    public void afterPropertiesSet() throws ServletException {
        super.afterPropertiesSet();
        String[] configUrls = StringUtils.splitByWholeSeparatorPreserveAllTokens(secrityProperties.getCode()
                .getSms().getUrl(),",");
        Stream.of(configUrls).forEach(urls::add);
        urls.add("/authentication/mobile");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        boolean action = false;
        action = urls.stream().filter(url -> pathMatcher.match(url, request.getRequestURI()))
                .anyMatch(StringUtils::isNotEmpty);
        //只在匹配上的url才继续执行,否则直接让渡到后面的过滤器
        if (action) {
            try {
                validate(new ServletWebRequest(request));
            }catch (ValidateCodeException e) {
                log.error(e.getMessage());
                loginAuthenticationFailureHandler.onAuthenticationFailure(request,response,e);
                return;
            }
        }
        filterChain.doFilter(request,response);
    }

    private void validate(ServletWebRequest request) throws ServletRequestBindingException {
        //从sessionStrategy中取出之前放入的短信验证码对象
        SmsCode codeInSession = (SmsCode) sessionStrategy.getAttribute(request, ValidateCodeProcessor.SESSION_KEY_PREFIX + "SMS");
        //拿取登录页中用户输入的验证码
        String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(),"smsCode");
        if (StringUtils.isBlank(codeInRequest)) {
            throw new ValidateCodeException("验证码的值不能为空");
        }
        if (codeInSession == null) {
            throw new ValidateCodeException("验证码不存在");
        }
        if (codeInSession.isExpired()) {
            //验证码过期无效了,从Session中移除
            sessionStrategy.removeAttribute(request,ValidateCodeProcessor.SESSION_KEY_PREFIX + "SMS");
            throw new ValidateCodeException("验证码已过期");
        }
        if (!StringUtils.equals(codeInSession.getCode(),codeInRequest)) {
            throw new ValidateCodeException("验证码不匹配");
        }
        //验证成功,当前验证码不再使用,从Session中移除
        sessionStrategy.removeAttribute(request,ValidateCodeProcessor.SESSION_KEY_PREFIX + "SMS");
    }
}

增加短信验证码的配置项url

gj:
  secrity:
    browser:
      rememberMeSeconds: 604800
    code:
      image:
        width: 100
        height: 23
        length: 6
        expireIn: 60
        url: /me,/current/**
      sms:
        url: /me,/current/**

增加短信验证码的安全配置类

@Component
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain,HttpSecurity> {
    @Autowired
    private AuthenticationSuccessHandler validateAuthenticationSuccessHandler;
    @Autowired
    private AuthenticationFailureHandler loginAuthenticationFailureHander;
    @Autowired
    private UserDetailsService myUserDetailsService;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
        smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(validateAuthenticationSuccessHandler);
        smsCodeAuthenticationFilter.setAuthenticationFailureHandler(loginAuthenticationFailureHander);

        SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
        smsCodeAuthenticationProvider.setUserDetailsService(myUserDetailsService);

        http.authenticationProvider(smsCodeAuthenticationProvider)
                .addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

修改SecrityConfig,将短信验证码的配置类添加到过滤器链中

/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
@Slf4j
public class SecrityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private AuthenticationSuccessHandler validateAuthenticationSuccessHandler;
    @Autowired
    private AuthenticationFailureHandler loginAuthenticationFailureHander;
    @Autowired
    private SecrityProperties secrityProperties;
    @Autowired
    private DataSource dataSource;
    @Autowired
    private UserDetailsService myUserDetailsService;
    @Autowired
    private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;
    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许表单登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
        validateCodeFilter.setLoginAuthenticationFailureHandler(loginAuthenticationFailureHander);
        validateCodeFilter.setSecrityProperties(secrityProperties);
        validateCodeFilter.afterPropertiesSet();

        SmsValidateCodeFilter smsValidateCodeFilter = new SmsValidateCodeFilter();
        smsValidateCodeFilter.setLoginAuthenticationFailureHandler(loginAuthenticationFailureHander);
        smsValidateCodeFilter.setSecrityProperties(secrityProperties);
        smsValidateCodeFilter.afterPropertiesSet();
        //将自定义短信验证码以及图形验证码过滤器放入到SpringSecurity过滤器链,并放在UsernamePasswordAuthenticationFilter前
        http.addFilterBefore(smsValidateCodeFilter,UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
                .formLogin() //基于表单认证
                .loginPage("/signIn.html")
                //使用/authentication/form来处理登录请求
                .loginProcessingUrl("/authentication/form")
                //登录成功后处理
                .successHandler(validateAuthenticationSuccessHandler)
                //登录失败后处理
                .failureHandler(loginAuthenticationFailureHander)
                .and()
                .rememberMe() //配置记住我功能
                //添加TokenRepository
                .tokenRepository(persistentTokenRepository())
                //添加过期时间
                .tokenValiditySeconds(secrityProperties.getBrowser().getRememberMeSeconds())
                //添加用户信息
                .userDetailsService(myUserDetailsService)
                .and()
                .authorizeRequests() //对请求进行授权
                //对/signIn.html,/code/*请求放行
                .antMatchers("/signIn.html","/code/*","/authentication/mobile")
                .permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable() //关闭跨站请求伪造防护
                //将短信验证码的认证过滤器加入过滤器链
                .apply(smsCodeAuthenticationSecurityConfig);
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 配置RememberMeService中的TokenRepository
     * @return
     */
    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);
        //启动的时候自动创建表
//        tokenRepository.setCreateTableOnStartup(true);
        return tokenRepository;
    }
}

经过以上的配置,已经可以使用短信验证码进行登录了。

重构重复代码

目标如下

重构两种验证码过滤器为一种

增加一个常量接口

public interface SecurityConstants {
    /**
     * 默认的用户名,密码登录请求url
     */
    public static final String DEFAULT_LOGIN_PROCESSING_URL_FORM = "/authentication/form";
    /**
     * 默认的手机验证码登录请求url
     */
    public static final String DEFAULT_LOGIN_PROCESSING_URL_MOBILE = "/authentication/mobile";
    /**
     * 验证图片验证码时,http请求默认的携带图片验证码信息的参数的名称
     */
    public static final String DEFAULT_PARAMTER_NAME_CODE_IMAGE = "imageCode";
    /**
     * 验证短信验证码时,http请求默认的携带短信验证码信息的参数的名称
     */
    public static final String DEFAULT_PARAMTER_NAME_CODE_SMS = "smsCode";
}

一个验证码类型的枚举

/**
 * 验证码类型
 */
public enum ValidateCodeType {
    IMAGE{
        @Override
        public String getParamNameOnValidate() {
            return SecurityConstants.DEFAULT_PARAMTER_NAME_CODE_IMAGE;
        }
    },SMS{
        @Override
        public String getParamNameOnValidate() {
            return SecurityConstants.DEFAULT_PARAMTER_NAME_CODE_SMS;
        }
    };

    /**
     * 校验时从请求中获取的参数的名字
     * @return
     */
    public abstract String getParamNameOnValidate();
}

修改ValidateCodeProcessor接口,增加一个校验验证码的方法

/**
 * 校验码处理器,封装不同校验码的处理逻辑
 */
public interface ValidateCodeProcessor {
    //验证码放入session时的前缀
    String SESSION_KEY_PREFIX = "SESSION_KEY_FOR_CODE_";

    /**
     * 创建验证码
     * @param request
     * @throws Exception
     */
    void create(ServletWebRequest request) throws Exception;

    /**
     * 校验验证码
     * @param request
     * @throws Exception
     */
    void validate(ServletWebRequest request);
}

修改抽象类AbstractValidateCodeProcessor,完成对该方法的实现

public abstract class AbstractValidateCodeProcessor<T extends ValidateCode> implements ValidateCodeProcessor {
    //操作Session的工具类
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
    //收集系统中所有的ValidateCodeGenerator接口的实现
    //Spring在启动中会查找所有的ValidateCodeGenerator的实现类放入Map中
    //该方式称为依赖搜索
    @Autowired
    private Map<String,ValidateCodeGenerator> validateCodeGenerators;

    @Override
    public void create(ServletWebRequest request) throws Exception {
        T validateCode = generate(request);
        save(request,validateCode);
        send(request,validateCode);
    }

    /**
     * 构建验证码放入Session时的key
     * @param request
     * @return
     */
    private String getSessionKey(ServletWebRequest request) {
        return SESSION_KEY_PREFIX + getValidateCodeType(request).toString().toUpperCase();
    }

    /**
     * 根据请求的url获取校验码的类型
     * @param request
     * @return
     */
    private ValidateCodeType getValidateCodeType(ServletWebRequest request) {
        String type = StringUtils.substringBefore(getClass().getSimpleName(), "ValidateCodeProcessor");
        return ValidateCodeType.valueOf(type.toUpperCase());
    }

    @Override
    @SuppressWarnings("unchecked")
    public void validate(ServletWebRequest request) {
        ValidateCodeType processorType = getValidateCodeType(request);
        String sessionKey = getSessionKey(request);
        //获取Session中的验证码对象
        T codeInSession = (T) sessionStrategy.getAttribute(request, sessionKey);
        String codeInRequest;
        try {
            //获取用户在登录界面输入的验证码
            codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(),
                    processorType.getParamNameOnValidate());
        }catch (ServletRequestBindingException e) {
            throw new ValidateCodeException("获取验证码的值失败");
        }
        if (StringUtils.isBlank(codeInRequest)) {
            throw new ValidateCodeException(processorType + "验证码的值不能为空");
        }
        if (codeInSession == null) {
            throw new ValidateCodeException(processorType + "验证码不存在");
        }
        if (codeInSession.isExpired()) {
            //验证码过期,从Session中移除
            sessionStrategy.removeAttribute(request,sessionKey);
            throw new ValidateCodeException(processorType + "验证码已过期");
        }
        if (!StringUtils.equals(codeInSession.getCode(),codeInRequest)) {
            throw new ValidateCodeException(processorType + "验证码不匹配");
        }
        //验证成功,验证码不再使用,从Session中移除
        sessionStrategy.removeAttribute(request,sessionKey);
    }

    /**
     * 生成校验码
     * @param request
     * @return
     */
    @SuppressWarnings("unchecked")
    private T generate(ServletWebRequest request) {
        String type = getProcessorType(request);
        ValidateCodeGenerator validateCodeGenerator = validateCodeGenerators.get(type + "CodeGenerator");
        return (T) validateCodeGenerator.generate(request);
    }

    /**
     * 保存校验码
     * @param request
     * @param validateCode
     */
    private void save(ServletWebRequest request,T validateCode) {
        sessionStrategy.setAttribute(request,SESSION_KEY_PREFIX + getProcessorType(request).toUpperCase(),
                validateCode);
    }

    /**
     * 发送校验码,由子类实现
     * @param request
     * @param validateCode
     * @throws Exception
     */
    protected abstract void send(ServletWebRequest request,T validateCode) throws Exception;

    /**
     * 根据请求的url获取校验码的类型
     * @param request
     * @return
     */
    private String getProcessorType(ServletWebRequest request) {
        return StringUtils.substringAfter(request.getRequest().getRequestURI(),"/code/");
    }
}

修改两个子类的名称和Bean的名称

/**
 * 图片验证码处理器
 */
@Component("imageValidateCodeProcessor")
public class ImageValidateCodeProcessor extends AbstractValidateCodeProcessor<ImageCode> {
    /**
     * 发送图形验证码,将其写到响应中
     * @param request
     * @param validateCode
     * @throws Exception
     */
    @Override
    protected void send(ServletWebRequest request, ImageCode validateCode) throws Exception {
        ImageIO.write(validateCode.getImage(),"JPEG",request.getResponse().getOutputStream());
    }
}
/**
 * 短信验证码处理器
 */
@Component("smsValidateCodeProcessor")
public class SmsValidateCodeProcessor extends AbstractValidateCodeProcessor<SmsCode> {
    //短信验证码发送器
    @Autowired
    private SmsSender smsSender;

    /**
     * 发送短信验证码到手机上
     * @param request
     * @param validateCode
     * @throws Exception
     */
    @Override
    protected void send(ServletWebRequest request, SmsCode validateCode) throws Exception {
        String mobile = ServletRequestUtils.getRequiredStringParameter(request.getRequest(),"mobile");
        smsSender.send(mobile,validateCode.getCode());
    }
}

增加一个验证码处理器容器,用来从容器中获取验证码处理器

/**
 * 验证码处理器容器,存储和获取两种验证码处理器
 */
@Component
public class ValidateCodeProcessorHolder {
    @Autowired
    private Map<String,ValidateCodeProcessor> validateCodeProcessors;

    public ValidateCodeProcessor findValidateCodeProcessor(ValidateCodeType type) {
        return findValidateCodeProcessor(type.toString().toLowerCase());
    }

    public ValidateCodeProcessor findValidateCodeProcessor(String type) {
        String name = type.toLowerCase() + ValidateCodeProcessor.class.getSimpleName();
        ValidateCodeProcessor processor = validateCodeProcessors.get(name);
        if (processor == null) {
            throw new ValidateCodeException("验证码处理器" + name + "不存在");
        }
        return processor;
    }
}

修改ValidateCodeController

@RestController
public class ValidateCodeController {
    @Autowired
    private Map<String,ValidateCodeProcessor> validateCodeProcessors;

    /**
     * 创建验证码,根据验证码不同,调用不同的ValidateCodeProcessor接口实现
     * @param request
     * @param response
     * @param type
     * @throws Exception
     */
    @GetMapping("/code/{type}")
    public void createCode(HttpServletRequest request, HttpServletResponse response, @PathVariable String type) throws Exception {
        validateCodeProcessors.get(type + "ValidateCodeProcessor")
                .create(new ServletWebRequest(request,response));
    }
}

修改ValidateCodeFilter,使之可以对两种验证码都产生过滤作用,并删除SmsValidateCodeFilter

/**
 * OncePerRequestFilter保证我们自己写的过滤器只被调用一次
 * InitializingBean在其他参数都组装完毕以后,去初始化url的值
 */
@Slf4j
@Component
public class ValidateCodeFilter extends OncePerRequestFilter implements InitializingBean {
    @Autowired
    private AuthenticationFailureHandler loginAuthenticationFailureHandler;
    //存放所有需要校验验证码的url
    private Map<String,ValidateCodeType> urlMap = new HashMap<>();
    @Autowired
    private SecrityProperties secrityProperties;
    @Autowired
    private ValidateCodeProcessorHolder validateCodeProcessorHolder;
    //可以进行带*通配符的url匹配
    private AntPathMatcher pathMatcher = new AntPathMatcher();

    /**
     * 初始化要拦截的url配置信息
     * @throws ServletException
     */
    @Override
    public void afterPropertiesSet() throws ServletException {
        super.afterPropertiesSet();
        urlMap.put(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_FORM,ValidateCodeType.IMAGE);
        addUrlToMap(secrityProperties.getCode().getImage().getUrl(),ValidateCodeType.IMAGE);

        urlMap.put(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_MOBILE,ValidateCodeType.SMS);
        addUrlToMap(secrityProperties.getCode().getSms().getUrl(),ValidateCodeType.SMS);
    }

    /**
     * 将系统中配置的需要校验校验码的URL根据校验码的类型放入map
     * @param urlString
     * @param type
     */
    protected void addUrlToMap(String urlString,ValidateCodeType type) {
        if (StringUtils.isNotBlank(urlString)) {
            String[] urls = StringUtils.splitByWholeSeparatorPreserveAllTokens(urlString, ",");
            Stream.of(urls).forEach(url -> urlMap.put(url,type));
        }
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        ValidateCodeType type = getValidateCodeType(request);
        if (type != null) {
            log.info("校验请求(" + request.getRequestURI() + ")中的验证码,验证码" + type);
            try {
                validateCodeProcessorHolder.findValidateCodeProcessor(type)
                        .validate(new ServletWebRequest(request,response));
            }catch (ValidateCodeException e) {
                loginAuthenticationFailureHandler.onAuthenticationFailure(request,response,e);
                return;
            }
        }
        filterChain.doFilter(request,response);
    }

    /**
     *  获取校验码的类型,如果当前请求不需要校验,则返回null
     * @param request
     * @return
     */
    private ValidateCodeType getValidateCodeType(HttpServletRequest request) {
        ValidateCodeType result = null;
        if (!StringUtils.equalsIgnoreCase(request.getMethod(),"get")) {
            result = urlMap.keySet().stream().filter(url -> pathMatcher.match(url, request.getRequestURI()))
                    .map(urlMap::get).findFirst().orElseGet(null);
        }
        return result;
    }

}

修改SecrityConfig,将重构后唯一的验证码过滤器加入过滤器链中

/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
@Slf4j
public class SecrityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private AuthenticationSuccessHandler validateAuthenticationSuccessHandler;
    @Autowired
    private AuthenticationFailureHandler loginAuthenticationFailureHander;
    @Autowired
    private SecrityProperties secrityProperties;
    @Autowired
    private DataSource dataSource;
    @Autowired
    private UserDetailsService myUserDetailsService;
    @Autowired
    private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;
    @Autowired
    private ValidateCodeFilter validateCodeFilter;
    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许表单登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //将验证码过滤器放入到SpringSecurity过滤器链,并放在UsernamePasswordAuthenticationFilter前
        http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
                .formLogin() //基于表单认证
                .loginPage("/signIn.html")
                //使用/authentication/form来处理登录请求
                .loginProcessingUrl("/authentication/form")
                //登录成功后处理
                .successHandler(validateAuthenticationSuccessHandler)
                //登录失败后处理
                .failureHandler(loginAuthenticationFailureHander)
                .and()
                .rememberMe() //配置记住我功能
                //添加TokenRepository
                .tokenRepository(persistentTokenRepository())
                //添加过期时间
                .tokenValiditySeconds(secrityProperties.getBrowser().getRememberMeSeconds())
                //添加用户信息
                .userDetailsService(myUserDetailsService)
                .and()
                .authorizeRequests() //对请求进行授权
                //对/signIn.html,/code/*请求放行
                .antMatchers("/signIn.html","/code/*","/authentication/mobile","/oauth/**")
                .permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable() //关闭跨站请求伪造防护
                //将短信验证码的认证过滤器加入过滤器链
                .apply(smsCodeAuthenticationSecurityConfig);
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 配置RememberMeService中的TokenRepository
     * @return
     */
    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);
        //启动的时候自动创建表
//        tokenRepository.setCreateTableOnStartup(true);
        return tokenRepository;
    }
}

最后重启项目,登录效果跟之前一样。

分离各个过滤器做单独配置

  • 分离表单过滤器
public class AbstratactChannelSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    protected AuthenticationSuccessHandler validateAuthenticationSuccessHandler;
    @Autowired
    protected AuthenticationFailureHandler loginAuthenticationFailureHandler;

    protected void applyPasswordAuthenticationConfig(HttpSecurity http) throws Exception {
        http.formLogin()
                .loginPage(SecurityConstants.DEFAULT_UNAUTHENTICATION_URL)
                .loginProcessingUrl(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_FORM)
                .successHandler(validateAuthenticationSuccessHandler)
                .failureHandler(loginAuthenticationFailureHandler);
    }
}
  • 分离验证码过滤器
@Component
public class ValidateCodeSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain,HttpSecurity> {
    @Autowired
    private Filter validateCodeFilter;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.addFilterBefore(validateCodeFilter, AbstractPreAuthenticatedProcessingFilter.class);
    }
}

最后修改SecrityConfig如下

/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
@Slf4j
public class SecrityConfig extends AbstratactChannelSecurityConfig {
    @Autowired
    private SecrityProperties secrityProperties;
    @Autowired
    private DataSource dataSource;
    @Autowired
    private UserDetailsService myUserDetailsService;
    @Autowired
    private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;
    @Autowired
    private ValidateCodeSecurityConfig validateCodeSecurityConfig;
    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许表单登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //进行表单认证
        applyPasswordAuthenticationConfig(http);
        //将验证码过滤器放入到SpringSecurity过滤器链,并放在UsernamePasswordAuthenticationFilter前
        http.apply(validateCodeSecurityConfig)
                .and()
                //将短信验证码认证过滤器放入到SpringSecurity过滤器链
                .apply(smsCodeAuthenticationSecurityConfig)
                .and()
                .rememberMe() //配置记住我功能
                //添加TokenRepository
                .tokenRepository(persistentTokenRepository())
                //添加过期时间
                .tokenValiditySeconds(secrityProperties.getBrowser().getRememberMeSeconds())
                //添加用户信息
                .userDetailsService(myUserDetailsService)
                .and()
                .authorizeRequests() //对请求进行授权
                //对/signIn.html,/code/*请求放行
                .antMatchers("/signIn.html","/code/*","/authentication/mobile","/oauth/**")
                .permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护

    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 配置RememberMeService中的TokenRepository
     * @return
     */
    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);
        //启动的时候自动创建表
//        tokenRepository.setCreateTableOnStartup(true);
        return tokenRepository;
    }

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

Spring Social第三方登录

第三方登录指的是例如国内的微信或者国外的FaceBook等,它登录的过程有8个步骤

  1. 比如用户访问某个音乐网站。
  2. 该音乐网站将用户导向微信认证服务器,并征询用户是否同意微信认证服务器给予该音乐网站授权。
  3. 用户同意授权。
  4. 微信认证服务器返回该音乐网站授权码。
  5. 音乐网站通过该授权吗向微信认证服务器申请令牌。
  6. 微信认证服务器向音乐网站发放令牌
  7. 音乐网站通过令牌获取微信认证服务器的用户信息。
  8. 音乐网站根据用户信息构建Authentication,并放入SecurityContext中。

流程如下图所示(这里第三方应用指代上面的音乐网站,服务提供商的认证服务器指代上面的微信认证服务器)

要使用Spring Social需要添加一个依赖

<dependency>
   <groupId>org.springframework.social</groupId>
   <artifactId>spring-social-web</artifactId>
   <version>1.1.6.RELEASE</version>
</dependency>

这里的服务提供商首先需要有一个接口ServiceProvider

public interface ServiceProvider<A> {

}

它有一个抽象实现类AbstractOAuth2ServiceProvider

public abstract class AbstractOAuth2ServiceProvider<S> implements OAuth2ServiceProvider<S> {
   //OAuth 2协议的相关操作
   private final OAuth2Operations oauth2Operations;
   
   /**
    * 构造器
    */
   public AbstractOAuth2ServiceProvider(OAuth2Operations oauth2Operations) {
      this.oauth2Operations = oauth2Operations;
   }

   //获取OAuth 2协议的相关操作
   public final OAuth2Operations getOAuthOperations() {
      return oauth2Operations;
   }

   //抽象方法,通过accessToken获取第三方需要认证才能访问的RestAPI
   public abstract S getApi(String accessToken);

}

当我们自己为服务提供商的时候需要继承该抽象类来完成我们自己的实现。

OAuth2Operations为一个接口

public interface OAuth2Operations {

   /**
    * 构建OAuth 2中授权码模式的url
    */ 
   String buildAuthorizeUrl(OAuth2Parameters parameters);

   /**
    * 构建其他授权模式的url
    */
   String buildAuthorizeUrl(GrantType grantType, OAuth2Parameters parameters);

   /**
    * 构建OAuth 2中授权码模式的url,但无需多次授权
    */ 
   String buildAuthenticateUrl(OAuth2Parameters parameters);

   /**
    * 构建其他授权模式的url,但无需多次授权
    */
   String buildAuthenticateUrl(GrantType grantType, OAuth2Parameters parameters);

   /**
    * 使用授权码模式获取授权
    */
   AccessGrant exchangeForAccess(String authorizationCode, String redirectUri, MultiValueMap<String, String> additionalParameters);

   /**
    * 用户密码模式获取授权
    */
   AccessGrant exchangeCredentialsForAccess(String username, String password, MultiValueMap<String, String> additionalParameters);

   /**
    * 刷新授权
    */
   @Deprecated
   AccessGrant refreshAccess(String refreshToken, String scope, MultiValueMap<String, String> additionalParameters);

   /**
    * 刷新授权
    */
   AccessGrant refreshAccess(String refreshToken, MultiValueMap<String, String> additionalParameters);

   /**
    * 第三方id和第三方secret的授权
    */
   AccessGrant authenticateClient();

   /**
    * 第三方id和第三方secret的授权
    */
   AccessGrant authenticateClient(String scope);

}

该接口的实现类为OAuth2Template,该类可以帮助我们去执行OAuth 2的流程

ApiBinding为一个接口,表示第三方获取用户信息的Api

public interface ApiBinding {
   
   /**
    * 调用API的时候是否是认证过的
    */
   public boolean isAuthorized();
   
}

该接口有一个抽象实现类AbstractOAuth2ApiBinding,帮助我们在第7步获取用户信息的实现。

以上这些都是服务提供商的接口以及实现类,现在我们来看一下第三方这边

Connection接口,获取第7步的用户信息

public interface Connection<A> extends Serializable {

   /**
    * 识别连接的key 
    */
   ConnectionKey getKey();
   
   /**
    * 获取连接的显示名称
    */
   String getDisplayName();

   /**
    * 获取配置文件的url
    */
   String getProfileUrl();

   /**
    * 获取图片url
    */
   String getImageUrl();

   /**
    * 同步 
    */
   void sync();
   
   /**
    * 测试连接
    */
   boolean test();
   
   /**
    * 是否超时
    */
   boolean hasExpired();

   /**
    * 刷新连接
    */
   void refresh();
   
   /**
    * 获取用户配置文件
    */
   UserProfile fetchUserProfile();
   
   /**
    * 更新用户状态
    */
   void updateStatus(String message);
   
   /**
    * 获取绑定的API
    */
   A getApi();

   /**
    * 创建保持连接状态的数据传输对象
    */
   ConnectionData createData();

}

它的实现类为OAuth2Connection,而Connection是由ConnectionFactory创建出来的。ConnectionFactory是一个抽象类,而OAuth2Connection是由该抽象类的子类OAuth2ConnectionFactory创建出来的。我们来看一下ConnectionFactory的源码。

public abstract class ConnectionFactory<A> {

   private final String providerId;
   
   private final ServiceProvider<A> serviceProvider;

   private final ApiAdapter<A> apiAdapter;
   
   public ConnectionFactory(String providerId, ServiceProvider<A> serviceProvider, ApiAdapter<A> apiAdapter) {
      this.providerId = providerId;
      this.serviceProvider = serviceProvider;
      this.apiAdapter = nullSafeApiAdapter(apiAdapter);
   }

   public String getProviderId() {
      return providerId;
   }

   protected ServiceProvider<A> getServiceProvider() {
      return serviceProvider;
   }

   protected ApiAdapter<A> getApiAdapter() {
      return apiAdapter;
   }

   // subclassing hooks
   
   public abstract Connection<A> createConnection(ConnectionData data);
   
   // internal helpers
   
   @SuppressWarnings("unchecked")
   private ApiAdapter<A> nullSafeApiAdapter(ApiAdapter<A> apiAdapter) {
      if (apiAdapter != null) {
         return apiAdapter;
      }
      return (ApiAdapter<A>) NullApiAdapter.INSTANCE;
   }

}

由源码可以看到,它包含了一个private final ServiceProvider<A> serviceProvider,ConnectionFactory中,会调用serviceProvider来走前面的整个7步的流程。但由于不同服务商提供的用户的字段都不同,而这里由是由ApiAdapter接口来完成的。

而服务提供商和第三方系统的用户的对应关系,是由UsersConnectionRepository的接口来存储到数据库的UserConnection表中。UsersConnectionRepository的实现类为JdbcUsersConnectionRepository,整个流程如下图

QQ登录

要开发一套QQ登录的功能,我们需要参考QQ官方的文档https://wiki.connect.qq.com/

首先我们从第7步获取QQ用户信息开始。创建一个QQ的接口和QQ用户实体类

@Data
public class QQUserInfo {
    /**
     * 返回码
     */
    private String ret;
    /**
     * 如果ret<0,会有相应的信息提示,返回数据全部用UTF-8编码
     */
    private String msg;
    private String openId;
    private String is_lost;
    /**
     * 省,直辖市
     */
    private String province;
    /**
     * 市,直辖市区
     */
    private String city;
    /**
     * 出生年月
     */
    private String year;
    /**
     * 用户在QQ的昵称
     */
    private String nickname;
    /**
     * 大小为30*30像素的QQ空间头像URL
     */
    private String figureurl;
    /**
     * 大小为50*50像素的QQ空间头像URL
     */
    private String figureurl_1;
    /**
     * 大小为100*100像素的QQ空间头像URL
     */
    private String figureurl_2;
    /**
     * 大小为40*40像素的QQ头像URL
     */
    private String figureurl_qq_1;
    /**
     * 大小为100*100像素的QQ头像URL。
     * 需要注意,不是所有的用户都拥有QQ的100*100的头像,但40*40像素则是一定会有的
     */
    private String figureurl_qq_2;
    /**
     * 性别。如果获取不到则默认返回"男"
     */
    private String gender;
    /**
     * 标识用户是否为黄钻用户(0:不是;1:是)
     */
    private String is_yellow_vip;
    /**
     * 标识用户是否为黄钻用户(0:不是;1:是)
     */
    private String vip;
    /**
     * 黄钻等级
     */
    private String yellow_vip_level;
    /**
     * 黄钻等级
     */
    private String level;
    /**
     * 标识是否为年费黄钻用户(0:不是;1:是)
     */
    private String is_yellow_year_vip;

}
public interface QQ {
    /**
     * 获取QQ用户信息
     * @return
     */
    QQUserInfo getUserInfo();
}

添加一个QQ接口的实现类,并且该实现类需要继承AbstractOAuth2ApiBinding的抽象类

@Slf4j
public class QQImpl extends AbstractOAuth2ApiBinding implements QQ {
    /**
     * 获取openid的url
     */
    private static final String URL_GET_OPENID = "https://graph.qq.com/oauth2.0/me?access_token=%s";
    /**
     * 获取用户信息的url
     */
    private static final String URL_GET_USERINFO = "https://graph.qq.com/user/get_user_info?oauth_consumer_key=%s&openid=%s";
    private String appId;
    private String openId;

    public QQImpl(String access_token,String appId) {
        //将access_token以参数形式进行传递
        super(access_token, TokenStrategy.ACCESS_TOKEN_PARAMETER);
        this.appId = appId;
        String url = String.format(URL_GET_OPENID,access_token);
        //访问获取openid的url得到一段json字符串
        //字符串形式如{"client_id":"YOUR_APPID","openid":"YOUR_OPENID"}
        String result = getRestTemplate().getForObject(url,String.class);
        log.info(result);
        //从返回的字符串中截取出openid
        this.openId = StringUtils.substringBetween(result,"\"openid\":","}");
    }

    @Override
    public QQUserInfo getUserInfo() {
        String url = String.format(URL_GET_USERINFO,appId,openId);
        //从获取用户信息的url得到一段json字符串
        //字符串形式如{
        //        "ret":0,
        //        "msg":"",
        //        "nickname":"YOUR_NICK_NAME",
        //        ...
        //        }
        String result = getRestTemplate().getForObject(url,String.class);
        log.info(result);
        //反序列化成对象
        return JSONObject.parseObject(result,QQUserInfo.class);
    }
}

现在我们要组装一个ServiceProvider,编写一个AbstractOAuth2ServiceProvider的子类

public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQ> {
    private String appId;
    /**
     * 获取QQ授权码的地址
     */
    private static final String URL_AUTHORIZE = "https://graph.qq.com/oauth2.0/authorize";
    /**
     * 获取QQ access_token的地址
     */
    private static final String URL_ACCESS_TOKEN = "https://graph.qq.com/oauth2.0/token";

    /**
     * 需要向QQ亮明第三方应用的身份,包括appId,addSecret
     * @param appId
     * @param appSecret
     */
    public QQServiceProvider(String appId,String appSecret) {
        super(new OAuth2Template(appId,appSecret,URL_AUTHORIZE,URL_ACCESS_TOKEN));
    }

    @Override
    public QQ getApi(String accessToken) {
        return new QQImpl(accessToken,appId);
    }
}

至此,我们服务提供商部分的代码就完成了。

现在要写第三方应用的,我们需要先构建一个APIAdapter来适配用户信息。

public class QQAdapter implements ApiAdapter<QQ> {
    /**
     * 我们假设认定QQ一直是通的
     * @param api
     * @return
     */
    @Override
    public boolean test(QQ api) {
        return true;
    }

    @Override
    public void setConnectionValues(QQ api, ConnectionValues values) {
        QQUserInfo userInfo = api.getUserInfo();
        values.setDisplayName(userInfo.getNickname());
        values.setImageUrl(userInfo.getFigureurl_qq_1());
        //QQ没有个人主页,如果是微博,可以把微博的个人主页url填在这
        values.setProfileUrl(null);
        //服务商的openID
        values.setProviderUserId(userInfo.getOpenId());
    }

    @Override
    public UserProfile fetchUserProfile(QQ api) {
        return null;
    }

    @Override
    public void updateStatus(QQ api, String message) {

    }
}

根据ServiceProvider,APIAdapter来构建ConnectionFactory

public class QQConnectionFactory extends OAuth2ConnectionFactory<QQ> {
    public QQConnectionFactory(String providerId, String appId,String appSecret) {
        super(providerId, new QQServiceProvider(appId,appSecret), new QQAdapter());
    }
}

要向数据库中添加第三方和QQ的用户映射数据,我们需要写一个配置类,这里需要增加一个依赖

<dependency>
   <groupId>org.springframework.social</groupId>
   <artifactId>spring-social-config</artifactId>
   <version>1.1.6.RELEASE</version>
</dependency>

写一个配置类

@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {
    @Autowired
    private DataSource dataSource;

    /**
     * 连接数据库配置
     * Encryptors.noOpText()不做加解密
     * @param connectionFactoryLocator 在系统中查找用哪一个ConnectionFactory来构建数据
     * @return
     */
    @Override
    public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
        return new JdbcUsersConnectionRepository(dataSource,connectionFactoryLocator, Encryptors.noOpText());
    }
}

在数据库中生成一张表,SQL如下

create table UserConnection (userId varchar(255) not null,
 providerId varchar(255) not null,
 providerUserId varchar(255),
 rank int not null,
 displayName varchar(255),
 profileUrl varchar(512),
 imageUrl varchar(512),
 accessToken varchar(512) not null,
 secret varchar(512),
 refreshToken varchar(512),
 expireTime bigint,
 primary key (userId, providerId, providerUserId));
create unique index UserConnectionRank on UserConnection(userId, providerId, rank);

该表的前三个字段构成了整个表的主键,表示第三方本身的用户Id和服务提供商的一种对应关系。通过服务提供商的providerId和providerUserId,我们可以拿到第三方的userId。然后通过该userId获取完整的用户信息(即之前说的UserDetails)。要实现该功能,我们需要添加一个依赖

<dependency>
   <groupId>org.springframework.social</groupId>
   <artifactId>spring-social-security</artifactId>
   <version>1.1.6.RELEASE</version>
</dependency>

修改MyUserDetailsService,实现SocialUserDetailsService接口

@Service("myUserDetailsService")
@Slf4j
public class MyUserDetailsService implements UserDetailsService,SocialUserDetailsService {
    @Autowired
    private PasswordEncoder passwordEncoder;
    /**
     * 根据用户名查找用户信息,该用户信息可以从数据库中取出,
     * 然后拼装成UserDetails对象
     * @param username
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("表单登录用户名:" + username);
        return buildUser(username);
    }

    /**
     * 根据用户Id查找用户信息
     * @param userId
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public SocialUserDetails loadUserByUserId(String userId) throws UsernameNotFoundException {
        log.info("社交登录用户Id:" + userId);
        return buildUser(userId);
    }

    private SocialUserDetails buildUser(String userTarget) {
        String password = passwordEncoder.encode("123456");
        log.info("密码:" + password);
        //该SocialUser类是SpringSocialSecurity自带实现SocialUserDetails接口的一个用户类
        //继承于User,而SocialUserDetails接口继承于UserDetails
        //使用加密工具对密码进行加密
        //第三个参数为是否可用,第四个参数为账户是否过期,第五个参数为密码是否过期,第六个参数为账户是否被锁定
        //其第七个属性为用户权限
        return new SocialUser(userTarget,password
                ,true,true,true,true
                , AuthorityUtils.commaSeparatedStringToAuthorityList("admin,ROLE_USER"));
    }
}
public interface SocialUserDetails extends UserDetails {

   /**
    * 获取用户id
    */
   String getUserId();
   
}
public class SocialUser extends User implements SocialUserDetails {

   private static final long serialVersionUID = 1L;

   public SocialUser(String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {
      super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
   }

   public SocialUser(String username, String password, Collection<? extends GrantedAuthority> authorities) {
      super(username, password, authorities);
   }

   public String getUserId() {
      return getUsername();
   }

}

现在我们需要增加相关的属性配置,但Springboot 1.X中有一个SocialProperties,2.X没有,所以我们需要自己写一个

@Data
@NoArgsConstructor
public abstract class SocialProperties {
    private String appId;
    private String appSecret;
}

然后是QQ的属性配置

@Data
public class QQProperties extends SocialProperties {
    private String providerId = "qq";
}

对QQ的属性进行封装

@Data
public class SocialQQProperties {
    private QQProperties qq = new QQProperties();
}

将封装的属性放入SecrityProperties

@ConfigurationProperties(prefix = "gj.secrity")
@Data
public class SecrityProperties {
    private BrowserProperties browser = new BrowserProperties();
    private ValidateCodeProperties code = new ValidateCodeProperties();
    private SocialQQProperties social = new SocialQQProperties();
}

配置QQ的连接工厂,即ConnectionFactory,要创建连接工厂,我们需要继承于一个适配器类SocialAutoConfigurerAdapter,但该类在Springboot 1.X中存在,Springboot 2.X中并不存在,所以我们需要自己建一个

public abstract class SocialAutoConfigurerAdapter extends SocialConfigurerAdapter {
    public SocialAutoConfigurerAdapter() {
    }
    public void addConnectionFactories(ConnectionFactoryConfigurer configurer, Environment environment) {
        configurer.addConnectionFactory(this.createConnectionFactory());
    }
    protected abstract ConnectionFactory<?> createConnectionFactory();
}

然后自己建一个QQ的自动配置子类去继承该类

/**
 * @ConditionalOnProperty 当在配置文件中配置了gj.secrity.social.qq.app-id配置了
 * 值,整个配置类才生效,否则不生效
 */
@Configuration
@ConditionalOnProperty(prefix = "gj.secrity.social.qq",name = "app-id")
public class QQAutoConfig extends SocialAutoConfigurerAdapter {
    @Autowired
    private SecrityProperties secrityProperties;

    @Override
    protected ConnectionFactory<?> createConnectionFactory() {
        QQProperties qqProperties = secrityProperties.getSocial().getQq();
        return new QQConnectionFactory(qqProperties.getProviderId(),qqProperties.getAppId(),qqProperties.getAppSecret());
    }

    @Override
    public UserIdSource getUserIdSource() {
        return new SessionUserIdSource();
    }
}

如果不在配置文件中配置,该类不会生效,所以我们要在配置文件中进行配置(以下配置的值需要到QQ官方申请成为开发者可以获取,这里a,b是只是示例配置)

gj:
  secrity:
    browser:
      loginType: REDIRECT
      rememberMeSeconds: 604800
    code:
      image:
        width: 100
        height: 23
        length: 6
        expireIn: 60
        url: /me,/current/**
      sms:
        url: /me,/current/**
    social:
      qq:
        app-id: a
        app-secret: b

修改SocialConfig,将SpringSocial的过滤器加到SpringSecurity过滤器链中

@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {
    @Autowired
    private DataSource dataSource;

    /**
     * 连接数据库配置
     * Encryptors.noOpText()不做加解密
     * @param connectionFactoryLocator 在系统中查找用哪一个ConnectionFactory来构建数据
     * @return
     */
    @Override
    public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
        return new JdbcUsersConnectionRepository(dataSource,connectionFactoryLocator, Encryptors.noOpText());
    }

    /**
     * SpringSocial过滤器,引导用户做QQ登录用的
     * @return
     */
    @Bean
    public SpringSocialConfigurer qqSocialConfig() {
        return new SpringSocialConfigurer();
    }
}

将该过滤器增加到过滤器链中,修改SecrityConfig

/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
@Slf4j
public class SecrityConfig extends AbstratactChannelSecurityConfig {
    @Autowired
    private SecrityProperties secrityProperties;
    @Autowired
    private DataSource dataSource;
    @Autowired
    private UserDetailsService myUserDetailsService;
    @Autowired
    private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;
    @Autowired
    private ValidateCodeSecurityConfig validateCodeSecurityConfig;
    @Autowired
    private SpringSocialConfigurer qqSocialConfig;
    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许表单登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //进行表单认证
        applyPasswordAuthenticationConfig(http);
        //将验证码过滤器放入到SpringSecurity过滤器链,并放在UsernamePasswordAuthenticationFilter前
        http.apply(validateCodeSecurityConfig)
                .and()
                //将短信验证码认证过滤器放入到SpringSecurity过滤器链
                .apply(smsCodeAuthenticationSecurityConfig)
                .and()
                //将QQ认证过滤器放入到SpringSecurity过滤器链
                //拦截一些请求,引导用户去做社交登录(这里是QQ登录)
                .apply(qqSocialConfig)
                .and()
                .rememberMe() //配置记住我功能
                //添加TokenRepository
                .tokenRepository(persistentTokenRepository())
                //添加过期时间
                .tokenValiditySeconds(secrityProperties.getBrowser().getRememberMeSeconds())
                //添加用户信息
                .userDetailsService(myUserDetailsService)
                .and()
                .authorizeRequests() //对请求进行授权
                //对/signIn.html,/code/*请求放行
                .antMatchers("/signIn.html","/code/*","/authentication/mobile","/oauth/**")
                .permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护

    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 配置RememberMeService中的TokenRepository
     * @return
     */
    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);
        //启动的时候自动创建表
//        tokenRepository.setCreateTableOnStartup(true);
        return tokenRepository;
    }

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

增加登录页signIn.html的QQ登录项

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
    <h2>标准登录页面</h2>
    <h3>表单登录</h3>
    <form action="/authentication/form" method="post">
        <table>
            <tr>
                <td>用户名</td>
                <td><input type="text" name="username"></td>
            </tr>
            <tr>
                <td>密码</td>
                <td><input type="password" name="password"></td>
            </tr>
            <tr>
                <td>图形验证码</td>
                <td>
                    <input type="text" name="imageCode">
                    <img src="/code/image?width=200">
                </td>
            </tr>
            <tr>
                <td colspan="2"><input name="remember-me" type="checkbox" value="true" />记住我</td>
            </tr>
            <tr>
                <td colspan="2"><button type="submit">登录</button> </td>
            </tr>
        </table>
    </form>
    <h3>短信登录</h3>
    <form action="/authentication/mobile" method="post">
        <table>
            <tr>
                <td>手机号</td>
                <td><input type="text" name="mobile" value="13600000000"></td>
            </tr>
            <tr>
                <td>短信验证码</td>
                <td>
                    <input type="text" name="smsCode">
                    <a href="/code/sms?mobile=13600000000">发送验证码</a>
                </td>
            </tr>
            <tr>
                <td colspan="2"><button type="submit">登录</button> </td>
            </tr>
        </table>
    </form>
    <h3>社交登录</h3>
    <a href="/auth/qq">QQ登录</a>
</body>
</html>

这里需要对链接的路径进行说明,/auth是SocialAuthenticationFilter(社交认证拦截器)默认的拦截url

private static final String DEFAULT_FILTER_PROCESSES_URL = "/auth";

而/qq是QQAutoConfig在构建ConnectionFactory时传入的第一个参数qqProperties.getProviderId(),而该providerId则默认为

private String providerId = "qq";

点击QQ登录,由于我们的app-id,app-secret并非真实的,所以会报错

Spring Secrity OAuth 2

OAuth 2的整体结构如下图所示

要使用Spring OAuth 2需要添加依赖

<dependency>
   <groupId>org.springframework.cloud</groupId>
   <artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

由于该依赖包含了

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-security</artifactId>
</dependency>

所以spring-boot-starter-security可以不写。但由于spring-cloud-starter-oauth2属于Spring Cloud而不是Springboot的,所以我们还需要加上Spring CLoud的依赖(本人Springboot为2.1.9版本)

<dependencyManagement>
   <dependencies>
      <dependency>
         <groupId>org.springframework.cloud</groupId>
         <artifactId>spring-cloud-dependencies</artifactId>
         <version>Greenwich.SR2</version>
         <type>pom</type>
         <scope>import</scope>
      </dependency>
   </dependencies>
</dependencyManagement>

授权码模式

增加配置

security:
  oauth2:
    client:
      client-id: robetid
      client-secret: robetsceret

如果不进行以上配置,则每次启动会随机一个client-id以及client-secret

增加OAuth认证授权配置

/**
 * @EnableAuthorizationServer允许开启认证中心
 * AuthorizationServerConfigurerAdapter为认证中心适配器
 */
@Configuration
@EnableAuthorizationServer
public class OAuthAuthenticationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private PasswordEncoder passwordEncoder;

    /**
     * 配置第三方应用详情信息
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory() //在内存中设置clients,也可以在数据库中设置
                //clientid与配置相同
                .withClient("robetid")
                //clientsecret新版本Springboot必须加密,设置与配置相同
                .secret(passwordEncoder.encode("robetsceret"))
                //grant_type在授权吗模式下必须为authorization_code
                .authorizedGrantTypes("authorization_code","password")
                //跳转页面,获取code授权码用
                .redirectUris("http://example.com");
    }
}

修改SecrityConfig

/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
public class SecrityConfig extends WebSecurityConfigurerAdapter {
    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许Basic登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin() //基于表单认证
                .loginPage("/signIn.html")
                .loginProcessingUrl("/authentication/form")
                .and()
                .authorizeRequests() //对请求进行授权
                //对/oauth/**请求放行
                .antMatchers("/signIn.html","/oauth/**")
                .permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

经过以上设置启动项目,访问获取授权码的url

http://127.0.0.1:8080/oauth/authorize?response_type=code&client_id=robetid&redirect_uri=http://example.com&scope=all

  • 此处response_type必须为code
  • client_id必须与配置项相同
  • redirect_uri与OAuthAuthenticationServerConfig中设置相同
  • scope为all表示所有范围

如果此时未登录,会进入登录界面,登录后进入授权界面,其中Approve为授权,Deny为取消授权

如果选择Deny,点击按钮,会进入跳转url,即example.com,但此时我们拿不到授权码

若授权Approve后,可以获取我们的授权码

其中WAbem8就为我们的授权码。

现在我们要通过该授权码拿取access_token,由于拿取token的接口为POST,所以使用postman工具进行获取

127.0.0.1:8080/oauth/token?grant_type=authorization_code&client_id=robetid&code=WAbem8&redirect_uri=http://example.com&scope=all

除了以上的参数设置外,还需要设置OAuth认证头,其中的Usernane和Password跟配置中相同

访问后可以获取访问其他接口的授权access_token

{
     "access_token" "5f2925bc-0d97-4390-bd14-e6803b2a43b5" ,
     "token_type" "bearer" ,
     "expires_in" 43199 ,
     "scope" "all"
}
用户名密码模式
要使用用户名密码模式,我们得首先在SecrityConfig中添加一个认证管理器的Bean
@Configuration
@EnableWebSecurity
public class SecrityConfig extends WebSecurityConfigurerAdapter {
    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许Basic登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.httpBasic()
                .and()
                .authorizeRequests() //对请求进行授权
//                .antMatchers("/oauth/token").permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护
    }

    /**
     * 认证管理器
     * @return
     * @throws Exception
     */
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

并将该认证管理器放入认证的endpoint中,修改认证方式为password

@Configuration
@EnableAuthorizationServer
public class OAuthAuthenticationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private PasswordEncoder bCryptPasswordEncoder;
    @Autowired
    private AuthenticationManager authenticationManager;

    /**
     * 配置第三方应用详情信息
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory() //在内存中设置clients,也可以在数据库中设置
                //clientid与配置相同
                .withClient("robetid")
                //clientsecret新版本Springboot必须加密,设置与配置相同
                .secret(bCryptPasswordEncoder.encode("robetsceret"))
                //支持授权码和密码两种认证方式
                .authorizedGrantTypes("authorization_code","password")
                .scopes("all")
                //跳转页面,获取code授权码用
                .redirectUris("http://example.com");
    }

    /**
     * 将认证管理器放入endpoint中
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManager);
    }
}

添加一个UserDetailsService接口的实现类

@Service
public class MyUser implements UserDetailsService {
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return new User(username,passwordEncoder.encode("123456")
                , AuthorityUtils.commaSeparatedStringToAuthorityList("admin,ROLE_USER"));
    }
}

此处用户名随意,密码为123456

此时我们可以进行用户名,密码认证了

不过此时这个access_token还无法发挥作用,我们随便写一个Controller

@RestController
public class TestController {
    @GetMapping("/test")
    public String test(@RequestParam String param) {
        return param;
    }
}

对其进行访问会要求我们进行认证

输入任意用户名和密码123456,就可以获取结果,而一旦认证之后都可以一直访问,无需access_token

为了可以使用access_token,我们需要加一个资源服务配置

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated();
    }
}

这样我们就可以用access_token来访问该Controller了

access_token需要放在请求头中,key为Authorization,value以Bearer(或者小写bearer)开头。

 令牌请求流程分析

之前在OAuth2.0用户名,密码登录解析 做了用户名,密码的登录解析,今天来看一下四种模式的流程分析。

上图中,绿色的为具体的类,蓝色的为接口,下方括号中有它们的实现类

  • ClientDetailsService 第三方应用详情配置接口,类似与UserDetailsService,其定义如下
public interface ClientDetailsService {

  /**
   * 通过clientId获取第三方信息
   */
  ClientDetails loadClientByClientId(String clientId) throws ClientRegistrationException;

}

我们可以看到它有2个实现类,一个在内存中配置第三方信息,一个是在数据库中配置

其中ClientDetails也是一个接口,类似于UserDetails,表示第三方详情

public interface ClientDetails extends Serializable {

   /**
    * 获取第三方ClientId
    */
   String getClientId();

   /**
    * 获取第三方client通过后能够使用的资源
    */
   Set<String> getResourceIds();

   /**
    * 请求是否需要Secret
    */
   boolean isSecretRequired();

   /**
    * 获取clientsecret
    */
   String getClientSecret();

   /**
    * 是否第三方被限制在一个范围内
    */
   boolean isScoped();

   /**
    * 获取第三方的scope
    */
   Set<String> getScope();

   /**
    * 获取第三方的认证方式
    */
   Set<String> getAuthorizedGrantTypes();

   /**
    * 当使用授权码模式时,获取重定向url
    */
   Set<String> getRegisteredRedirectUri();

   /**
    * 获取第三方其中一种认证方式的权限
    */
   Collection<GrantedAuthority> getAuthorities();

   /**
    * 获取令牌access_token的有效期
    */
   Integer getAccessTokenValiditySeconds();

   /**
    * 获取刷新令牌refresh_token的有效期
    */
   Integer getRefreshTokenValiditySeconds();
   
   /**
    * 第三方是否需要在范围scope内得到批准
    */
   boolean isAutoApprove(String scope);

   /**
    * 获取第三方的附加信息,可能不是OAuth所必须的
    */
   Map<String, Object> getAdditionalInformation();

}
  • TokenRequest 封装了各种授权模式的请求,包含了各种授权模式中的请求参数,如

此处为授权码模式的请求参数,其他模式的与此不同,由以下代码可以看出参数是由Map形式封装的

public class TokenRequest extends BaseRequest {

   private String grantType;

   protected TokenRequest() {
   }

   /**
    * 
    */
   public TokenRequest(Map<String, String> requestParameters, String clientId, Collection<String> scope,
         String grantType) {
      setClientId(clientId);
      setRequestParameters(requestParameters);
      setScope(scope);
      this.grantType = grantType;
   }

   public String getGrantType() {
      return grantType;
   }

   public void setGrantType(String grantType) {
      this.grantType = grantType;
   }

   public void setClientId(String clientId) {
      super.setClientId(clientId);
   }

   public void setScope(Collection<String> scope) {
      super.setScope(scope);
   }

   public void setRequestParameters(Map<String, String> requestParameters) {
      super.setRequestParameters(requestParameters);
   }

   public OAuth2Request createOAuth2Request(ClientDetails client) {
      Map<String, String> requestParameters = getRequestParameters();
      HashMap<String, String> modifiable = new HashMap<String, String>(requestParameters);
      // Remove password if present to prevent leaks
      modifiable.remove("password");
      modifiable.remove("client_secret");
      // Add grant type so it can be retrieved from OAuth2Request
      modifiable.put("grant_type", grantType);
      return new OAuth2Request(modifiable, client.getClientId(), client.getAuthorities(), true, this.getScope(),
            client.getResourceIds(), null, null, null);
   }

}
  • TokenGranter 授权方式,为一个接口,OAuth 2的四种授权方式分别是其四种实现类(AuthorizationCodeTokenGranter授权码模式实现类,grant_type为authorization_code;ClientCredentialsTokenGranter客户端授权模式,grant_type为client_credentials;ImplicitTokenGranter简化授权模式,grant_type为implicit;ResourceOwnerPasswordTokenGranter密码授权模式,grant_type为password;CompositeTokenGranter组合授权模式,可以将以上4种授权模式添加到集合中)
public interface TokenGranter {
   /**
    * 根据授权方式以及该授权方式的请求参数来返回该授权方式的access_token
    */
   OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest);

}
  • OAuth2Request 将TokenRequest的参数Map分解成具体的OAuth2认证请求所需要的参数

 

  • Authentication 前面介绍过,表示用户认证信息的封装。
  • OAuth2Authencication 由上面两项组合而成,表示哪个第三方应用和哪个用户给予授权,授权模式是什么,授权参数是什么。
  • AuthorizationServerTokenServices 认证服务器令牌服务接口
public interface AuthorizationServerTokenServices {

   /**
    * 创建access_token
    */
   OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException;

   /**
    * 刷新access_token
    */
   OAuth2AccessToken refreshAccessToken(String refreshToken, TokenRequest tokenRequest)
         throws AuthenticationException;

   /**
    * 重新从accessTokenStore中获取access_token
    */
   OAuth2AccessToken getAccessToken(OAuth2Authentication authentication);

}

它的实现类只有一个DefaultTokenServices,在该实现类中我们可以看到2个接口引用——TokenStore令牌存取器,TokenEnhancer令牌增强器,可以用于改造令牌,给令牌中加入一些自己的东西.

改造认证登录请求

在上图中,我们会在验证登录成功处理器中对OAuth 2的获取token请求进行改造。

修改SecrityConfig,不再做任何HttpSecurity的处理

@Configuration
@EnableWebSecurity
public class SecrityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 认证管理器
     * @return
     * @throws Exception
     */
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper();
    }
}

增加一个表单认证成功处理器

@Slf4j
@Component
public class OAuthAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Autowired
    private ClientDetailsService clientDetailsService;
    @Autowired
    private AuthorizationServerTokenServices jwtTokenServices;
    @Autowired
    private ObjectMapper objectMapper;
    @Autowired
    private PasswordEncoder bCryptPasswordEncoder;

    @Override
    @SuppressWarnings("unchecked")
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        log.info("登录成功");
        String header = request.getHeader("Authorization");
        if (header == null || !header.startsWith("Basic ")) {
            throw new UnapprovedClientAuthenticationException("请求头中无client信息");
        }
        //解码该请求头
        String[] tokens = extractAndDecodeHeader(header,request);
        assert tokens.length == 2;
        String clientId = tokens[0];
        String clientSecret = tokens[1];
        //获取clientDetails
        ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId);
        if (clientDetails == null) {
            throw new UnapprovedClientAuthenticationException("clientId对应的配置信息不存在:" + clientId);
        }else if (!bCryptPasswordEncoder.matches(clientSecret,clientDetails.getClientSecret())) {
            throw new UnapprovedClientAuthenticationException("clientSecret不匹配:" + clientId);
        }
        //创建一个自定义类型的token请求
        TokenRequest tokenRequest = new TokenRequest(Collections.EMPTY_MAP,clientId,clientDetails.getScope(),"custom");
        //将该token请求生成一个OAuth2请求
        OAuth2Request oAuth2Request = tokenRequest.createOAuth2Request(clientDetails);
        //将该OAuth2请求和用户认证信息生成一个OAuth2认证
        OAuth2Authentication oAuth2Authentication = new OAuth2Authentication(oAuth2Request,authentication);
        //根据OAuth2认证创建一个OAuth2的token
        OAuth2AccessToken token = jwtTokenServices.createAccessToken(oAuth2Authentication);
        response.setContentType("application/json;charset=UTF-8");
        log.info("成功后的token:" + token.toString());
        response.getWriter().write(objectMapper.writeValueAsString(token));
    }

    /**
     * 解码请求头Authorization的加密信息为clientId和clientSecret
     * @param header
     * @param request
     * @return
     * @throws IOException
     */
    private String[] extractAndDecodeHeader(String header, HttpServletRequest request)
            throws IOException {

        byte[] base64Token = header.substring(6).getBytes("UTF-8");
        byte[] decoded;
        try {
            decoded = Base64.getDecoder().decode(base64Token);
        }
        catch (IllegalArgumentException e) {
            throw new BadCredentialsException(
                    "Failed to decode basic authentication token");
        }

        String token = new String(decoded, "UTF-8");

        int delim = token.indexOf(":");

        if (delim == -1) {
            throw new BadCredentialsException("Invalid basic authentication token");
        }
        return new String[] { token.substring(0, delim), token.substring(delim + 1) };
    }

在ResourceServerConfig中进行配置

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    @Autowired
    private OAuthAuthenticationSuccessHandler oAuthAuthenticationSuccessHandler;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .successHandler(oAuthAuthenticationSuccessHandler)
                .and()
                .authorizeRequests() //对请求进行授权
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护
    }
}

因为是表单认证,我们没有做自定义处理,所以请求路径为/login,这里必须添加上Authorzation的信息

根据返回的access_token再去请求我们的Controller

配置token存储器

现在我们获取的access_token,如果系统重启的话,则会消失,则我们的客户端访问服务器需要重新认证获取新的access_token,但我们可以把获取到的access_token存储起来,即便系统重启,该access_token也不会消失。

我们可以把access_token存储在Redis中,首先在pom增加Redis的依赖

dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
   <groupId>redis.clients</groupId>
   <artifactId>jedis</artifactId>
   <version>2.9.0</version>
</dependency>

在配置文件中添加Redis的配置

spring:
  redis:
    host: 127.0.0.1
    port: 6379
    password: xxxxx
    timeout: 10000
    lettuce:
      pool:
        min-idle: 0
        max-idle: 8
        max-active: 8
        max-wait: -1

修改我们的OAuthAuthenticationServerConfig即可

@Configuration
@EnableAuthorizationServer
public class OAuthAuthenticationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private PasswordEncoder bCryptPasswordEncoder;
    @Autowired
    private AuthenticationManager authenticationManager;
    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    /**
     * 配置第三方应用详情信息
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory() //在内存中设置clients,也可以在数据库中设置
                //clientid与配置相同
                .withClient("robetid")
                //clientsecret新版本Springboot必须加密,设置与配置相同
                .secret(bCryptPasswordEncoder.encode("robetsceret"))
                //令牌有效期
                .accessTokenValiditySeconds(7200)
                //支持授权码和密码两种认证方式
                .authorizedGrantTypes("authorization_code","password","refresh_token")
                .scopes("all")
                //跳转页面,获取code授权码用
                .redirectUris("http://example.com");
    }

    /**
     * 将认证管理器和令牌存储器放入endpoint中
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.tokenStore(tokenStore())
                .authenticationManager(authenticationManager);
    }

    /**
     * 令牌存储
     * @return
     */
    @Bean
    public TokenStore tokenStore() {
        return new RedisTokenStore(redisConnectionFactory);
    }
}

在我们获取access_token之后,打开Redis,我们可以看到Redis中存储了一些有关access_token的数据

JwtToken存储器和JwtToken转换器

我们在认证成功处理器中有这么一行代码

//根据OAuth2认证创建一个OAuth2的token
OAuth2AccessToken token = jwtTokenServices.createAccessToken(oAuth2Authentication);

我们来看一下它的源码

@Transactional
public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException {
   //从tokenStore中拿取存在的access_token 
   OAuth2AccessToken existingAccessToken = tokenStore.getAccessToken(authentication);
   OAuth2RefreshToken refreshToken = null;
   //如果可以拿取到该access_token且未过期,则直接返回该access_token
   if (existingAccessToken != null) {
      //如果过期了
      if (existingAccessToken.isExpired()) {
         if (existingAccessToken.getRefreshToken() != null) {
            refreshToken = existingAccessToken.getRefreshToken();
            // The token store could remove the refresh token when the
            // access token is removed, but we want to
            // be sure...
            tokenStore.removeRefreshToken(refreshToken);
         }
         tokenStore.removeAccessToken(existingAccessToken);
      }
      else {
         //如果认证信息改变的时候重新存储该access_token
         tokenStore.storeAccessToken(existingAccessToken, authentication);
         return existingAccessToken;
      }
   }

   if (refreshToken == null) {
      refreshToken = createRefreshToken(authentication);
   }
 
   else if (refreshToken instanceof ExpiringOAuth2RefreshToken) {
      ExpiringOAuth2RefreshToken expiring = (ExpiringOAuth2RefreshToken) refreshToken;
      if (System.currentTimeMillis() > expiring.getExpiration().getTime()) {
         refreshToken = createRefreshToken(authentication);
      }
   }
   //如果从tokenStore中拿取不到access_token,则重新生成一个access_token
   OAuth2AccessToken accessToken = createAccessToken(authentication, refreshToken);
   //将生成的access_token存储到tokenStore中
   tokenStore.storeAccessToken(accessToken, authentication);
   // In case it was modified
   refreshToken = accessToken.getRefreshToken();
   if (refreshToken != null) {
      tokenStore.storeRefreshToken(refreshToken, authentication);
   }
   return accessToken;

}

我们再来看一下重新生成access_token的代码

private OAuth2AccessToken createAccessToken(OAuth2Authentication authentication, OAuth2RefreshToken refreshToken) {
   //使用默认的token生成方式UUID来生成access_token
   DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(UUID.randomUUID().toString());
   //设置token过期时间
   int validitySeconds = getAccessTokenValiditySeconds(authentication.getOAuth2Request());
   if (validitySeconds > 0) {
      token.setExpiration(new Date(System.currentTimeMillis() + (validitySeconds * 1000L)));
   }
   token.setRefreshToken(refreshToken);
   token.setScope(authentication.getOAuth2Request().getScope());
   //如果token增强器不为空,将返回token增强器生成的token,否则返回UUID生成的token
   return accessTokenEnhancer != null ? accessTokenEnhancer.enhance(token, authentication) : token;
}

从源码我们可以看到,现在生成的access_token都是由UUID来生成的一段字符串。由UUID生成的令牌本身没有任何信息,只是一段随机字符串,而我们要获取令牌所包含的用户认证信息,第三方信息都需要到令牌存储器(tokenStore)里面去获取。

JWT(Json Web Token)

  • 自包含    由JWT生成的token本身里面是有信息的,包含了用户认证信息,第三方信息的内容,我们拿到该令牌直接解析就知道里面包含的内容。
  • 密签        可以用一个指定的密钥进行签名,而不是加密,其作用是防止篡改。
  • 可扩展    它所包含的信息可以自定义,根据需要放入所需要的信息。

我们现在配置文件中添加一个access_token的配置

access_token:
  store-jwt: true

表示使用jwttoken存储器

修改OAuthAuthenticationServerConfig

@Configuration
@EnableAuthorizationServer
public class OAuthAuthenticationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private PasswordEncoder bCryptPasswordEncoder;
    @Autowired
    private AuthenticationManager authenticationManager;
    @Autowired
    private RedisConnectionFactory redisConnectionFactory;
    @Value("${access_token.store-jwt}")
    private boolean storeWithJwt;
    /**
     * 配置第三方应用详情信息
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory() //在内存中设置clients,也可以在数据库中设置
                //clientid与配置相同
                .withClient("robetid")
                //clientsecret新版本Springboot必须加密,设置与配置相同
                .secret(bCryptPasswordEncoder.encode("robetsceret"))
                //令牌有效期
                .accessTokenValiditySeconds(7200)
                //支持授权码和密码两种认证方式
                .authorizedGrantTypes("authorization_code","password","refresh_token")
                .scopes("all")
                //跳转页面,获取code授权码用
                .redirectUris("http://example.com");
    }

    /**
     * 将认证管理器和令牌存储器放入endpoint中
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.tokenStore(tokenStore())
                .authenticationManager(authenticationManager);
        if (storeWithJwt) {
            endpoints.accessTokenConverter(jwtAccessTokenConverter());
        }
    }

    /**
     * 令牌存储
     * @return
     */
    @Bean
    public TokenStore tokenStore() {
        if (storeWithJwt) {
            return new JwtTokenStore(jwtAccessTokenConverter());
        }
        return new RedisTokenStore(redisConnectionFactory);
    }

    /**
     * Jwt资源令牌转换器
     * @return accessTokenConverter
     */
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter();
        accessTokenConverter.setSigningKey("guanjian");
        return accessTokenConverter;
    }
}

则我们在登录后返回的access_token如下

为了标示该access_token自带信息,我们进入http://jwt.calebb.net/

将我们获取到的access_token复制粘贴进去,可见

之前我们说了jwtToken是可扩展的

我们先来写一个jwt的增强器(Enhancer)

@Component
public class OAuthJwtTokenEnhancer implements TokenEnhancer {
    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        Map<String,Object> info = new HashMap<>();
        info.put("company","Wishing");
        ((DefaultOAuth2AccessToken)accessToken).setAdditionalInformation(info);
        return accessToken;
    }
}

这里我们给jwttoken增加一个内容company,Wishing

修改OAuthAuthenticationServerConfig

@Configuration
@EnableAuthorizationServer
public class OAuthAuthenticationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private PasswordEncoder bCryptPasswordEncoder;
    @Autowired
    private AuthenticationManager authenticationManager;
    @Autowired
    private RedisConnectionFactory redisConnectionFactory;
    @Autowired
    private OAuthJwtTokenEnhancer oAuthJwtTokenEnhancer;
    @Value("${access_token.store-jwt}")
    private boolean storeWithJwt;
    /**
     * 配置第三方应用详情信息
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory() //在内存中设置clients,也可以在数据库中设置
                //clientid与配置相同
                .withClient("robetid")
                //clientsecret新版本Springboot必须加密,设置与配置相同
                .secret(bCryptPasswordEncoder.encode("robetsceret"))
                //令牌有效期
                .accessTokenValiditySeconds(7200)
                //支持授权码和密码两种认证方式
                .authorizedGrantTypes("authorization_code","password","refresh_token")
                .scopes("all")
                //跳转页面,获取code授权码用
                .redirectUris("http://example.com");
    }

    /**
     * 将认证管理器和令牌存储器放入endpoint中
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.tokenStore(tokenStore())
                .authenticationManager(authenticationManager);
        if (storeWithJwt) {
            TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
            List<TokenEnhancer> enhancers = new ArrayList<>();
            enhancers.add(oAuthJwtTokenEnhancer);
            enhancers.add(jwtAccessTokenConverter());
            enhancerChain.setTokenEnhancers(enhancers);
            endpoints.tokenEnhancer(enhancerChain)
                    .accessTokenConverter(jwtAccessTokenConverter());
        }
    }

    /**
     * 令牌存储
     * @return
     */
    @Bean
    public TokenStore tokenStore() {
        if (storeWithJwt) {
            return new JwtTokenStore(jwtAccessTokenConverter());
        }
        return new RedisTokenStore(redisConnectionFactory);
    }

    /**
     * Jwt资源令牌转换器
     * @return accessTokenConverter
     */
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter();
        accessTokenConverter.setSigningKey("guanjian");
        return accessTokenConverter;
    }
}

重新登录,获取access_token

我们将获取到的access_token粘贴到http://jwt.calebb.net/

现在我们可以看到解析出来的东西多了一个company:Winshing。当然我们也可以自己写代码来解析jwtToken的内容。

新增加jwt的依赖

<dependency>
   <groupId>io.jsonwebtoken</groupId>
   <artifactId>jjwt</artifactId>
   <version>0.9.0</version>
</dependency>

在我们的测试Controller中新增加一个parse方法

@RestController
public class TestController {
    @GetMapping("/test")
    public String test(@RequestParam String param) {
        return param;
    }

    @GetMapping("/me")
    public Object me(Authentication user) {
        return user;
    }

    @GetMapping("/parse")
    public Claims parse(@RequestParam String jwtToken) throws UnsupportedEncodingException {
        return Jwts.parser().setSigningKey("guanjian".getBytes("UTF-8"))
                .parseClaimsJws(jwtToken).getBody();
    }
}

在ResourceServerConfig中对该方法进行放行

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    @Autowired
    private OAuthAuthenticationSuccessHandler oAuthAuthenticationSuccessHandler;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .successHandler(oAuthAuthenticationSuccessHandler)
                .and()
                .authorizeRequests() //对请求进行授权
                .antMatchers("/parse")
                .permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护
    }
}

对该方法进行访问,我们可以看到被解析的结果

基于授权码模式的jwt SSO单点登录

我们先来看一下jwt SSO单点登录的整个流程

这整个流程都是通过HTTP交互的,并不跟某种语言绑定,整个过程中只用到认证服务器和客户端,并不牵涉资源服务器的管理。当然我们这里依然使用Spring OAuth来搭建

先搭建一个父子结构的项目,搭建方式请参考idea中Springboot项目如何做成父子结构

父项目中的pom如下

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.guanjian</groupId>
    <artifactId>ssodemo</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>pom</packaging>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.9.RELEASE</version>
    </parent>

    <modules>
        <module>autherticationserver</module>
        <module>app1</module>
        <module>app2</module>
    </modules>

    <properties>
        <java.version>1.8</java.version>
        <spring-cloud.version>Greenwich.SR2</spring-cloud.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
</project>

建立认证服务器子项目

pom

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
   xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
   <modelVersion>4.0.0</modelVersion>
   <artifactId>autherticationserver</artifactId>
   <packaging>jar</packaging>

   <parent>
      <groupId>com.guanjian</groupId>
      <artifactId>ssodemo</artifactId>
      <version>1.0-SNAPSHOT</version>
   </parent>

   <build>
      <plugins>
         <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
         </plugin>
      </plugins>
   </build>

</project>

配置文件

logging:
  level:
    root: info
    com.guanjian: debug
server:
  port: 9999
  servlet:
    context-path: /server

首先依然是用户

@Service
public class MyUser implements UserDetailsService {
    @Autowired
    private PasswordEncoder bCryptPasswordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return new User(username,bCryptPasswordEncoder.encode("123456")
                , AuthorityUtils.commaSeparatedStringToAuthorityList("admin,ROLE_USER"));
    }
}

然后是认证服务,我们这里需要给两个应用app1和app2做认证

@Configuration
@EnableAuthorizationServer
public class OAuthAuthenticationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private PasswordEncoder bCryptPasswordEncoder;
    @Autowired
    private AuthenticationManager authenticationManager;
    /**
     * 配置第三方应用详情信息
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory() //在内存中设置clients,也可以在数据库中设置
                //clientid与配置相同
                .withClient("app1")
                //clientsecret新版本Springboot必须加密,设置与配置相同
                .secret(bCryptPasswordEncoder.encode("app1sceret"))
                //令牌有效期
                .accessTokenValiditySeconds(7200)
                //支持授权码和密码两种认证方式
                .authorizedGrantTypes("authorization_code","password","refresh_token")
                .redirectUris("http://127.0.0.1:8080/app1/login")
                .scopes("all")
                .and()
                .withClient("app2")
                .secret(bCryptPasswordEncoder.encode("app2sceret"))
                //令牌有效期
                .accessTokenValiditySeconds(7200)
                //支持授权码和密码两种认证方式
                .authorizedGrantTypes("authorization_code","password","refresh_token")
                .redirectUris("http://127.0.0.1:8081/app2/login")
                .scopes("all");
    }

    /**
     * 认证过程配置
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.tokenStore(tokenStore())
                .authenticationManager(authenticationManager)
                .accessTokenConverter(jwtAccessTokenConverter());

    }

    /**
     * 认证服务器安全配置
     * @param security
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.allowFormAuthenticationForClients()
                .tokenKeyAccess("isAuthenticated()");
    }

    /**
     * 令牌存储
     * @return
     */
    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    /**
     * Jwt资源令牌转换器
     * @return accessTokenConverter
     */
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter();
        accessTokenConverter.setSigningKey("guanjian");
        return accessTokenConverter;
    }
}

最后是Http安全设置

@Configuration
@EnableWebSecurity
public class SecrityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 认证管理器
     * @return
     * @throws Exception
     */
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .and()
                .authorizeRequests() //对请求进行授权
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper();
    }
}

然后是app1的pom

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
   xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
   <modelVersion>4.0.0</modelVersion>
   <artifactId>app1</artifactId>
   <packaging>jar</packaging>

   <parent>
      <groupId>com.guanjian</groupId>
      <artifactId>ssodemo</artifactId>
      <version>1.0-SNAPSHOT</version>
   </parent>

   <build>
      <plugins>
         <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
         </plugin>
      </plugins>
   </build>

</project>

配置文件

security:
  oauth2:
    client:
      client-id: app1
      client-secret: app1sceret
      user-authorization-uri: http://127.0.0.1:9999/server/oauth/authorize
      access-token-uri: http://127.0.0.1:9999/server/oauth/token
    resource:
      jwt:
        key-uri: http://127.0.0.1:9999/server/oauth/token_key
        key-value: guanjian
server:
  port: 8080
  servlet:
    context-path: /app1

http的安全配置

@Configuration
@EnableOAuth2Sso
public class SecrityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
            http.authorizeRequests()
                    .anyRequest()
                    .authenticated()
                    .and()
                    .csrf().disable();

    }
}

这里@EnableOAuth2Sso就是打开SSO的单点登录

我们新建一个index.html文件,位于resource/static下

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>SSO App1</title>
</head>
<body>
    <h1>SSO Demo App1</h1>
    <a href="http://127.0.0.1:8081/app2/index.html">访问app2</a>
</body>
</html>

app2的设置

配置文件

security:
  oauth2:
    client:
      client-id: app2
      client-secret: app2sceret
      user-authorization-uri: http://127.0.0.1:9999/server/oauth/authorize
      access-token-uri: http://127.0.0.1:9999/server/oauth/token
    resource:
      jwt:
        key-uri: http://127.0.0.1:9999/server/oauth/token_key
        key-value: guanjian
server:
  port: 8081
  servlet:
    context-path: /app2

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>SSO App2</title>
</head>
<body>
    <h1>SSO Demo App2</h1>
    <a href="http://127.0.0.1:8080/app1/index.html">访问app1</a>
</body>
</html>

其他与app1相同

我们先访问app1的index.html,它会跳转到认证服务器的登录页面

输入用户名密码后会让我们进行授权

经过授权,就可以访问app1的index.html了

点击访问app2,同样要进行一次授权,但此时无需登录了

授权后就可以访问app2的index.html了

以后再互相访问,就可以直接进入各自的页面。

去除授权页面

我们每次登录成功后会有一个这样的授权页面,其实它是有认证服务器的WhitelabelApprovalEndpoint类在起作用,这个类我们无法修改,但是我们可以重写一个Controller来覆盖掉它

@FrameworkEndpoint
@SessionAttributes("authorizationRequest")
public class WhitelabelApprovalEndpoint {

   @RequestMapping("/oauth/confirm_access")
   public ModelAndView getAccessConfirmation(Map<String, Object> model, HttpServletRequest request) throws Exception {
      final String approvalContent = createTemplate(model, request);
      if (request.getAttribute("_csrf") != null) {
         model.put("_csrf", request.getAttribute("_csrf"));
      }
      View approvalView = new View() {
         @Override
         public String getContentType() {
            return "text/html";
         }

         @Override
         public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
            response.setContentType(getContentType());
            response.getWriter().append(approvalContent);
         }
      };
      return new ModelAndView(approvalView, model);
   }

   protected String createTemplate(Map<String, Object> model, HttpServletRequest request) {
      AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get("authorizationRequest");
      String clientId = authorizationRequest.getClientId();

      StringBuilder builder = new StringBuilder();
      builder.append("<html><body><h1>OAuth Approval</h1>");
      builder.append("<p>Do you authorize \"").append(HtmlUtils.htmlEscape(clientId));
      builder.append("\" to access your protected resources?</p>");
      builder.append("<form id=\"confirmationForm\" name=\"confirmationForm\" action=\"");

      String requestPath = ServletUriComponentsBuilder.fromContextPath(request).build().getPath();
      if (requestPath == null) {
         requestPath = "";
      }

      builder.append(requestPath).append("/oauth/authorize\" method=\"post\">");
      builder.append("<input name=\"user_oauth_approval\" value=\"true\" type=\"hidden\"/>");

      String csrfTemplate = null;
      CsrfToken csrfToken = (CsrfToken) (model.containsKey("_csrf") ? model.get("_csrf") : request.getAttribute("_csrf"));
      if (csrfToken != null) {
         csrfTemplate = "<input type=\"hidden\" name=\"" + HtmlUtils.htmlEscape(csrfToken.getParameterName()) +
               "\" value=\"" + HtmlUtils.htmlEscape(csrfToken.getToken()) + "\" />";
      }
      if (csrfTemplate != null) {
         builder.append(csrfTemplate);
      }

      String authorizeInputTemplate = "<label><input name=\"authorize\" value=\"Authorize\" type=\"submit\"/></label></form>";

      if (model.containsKey("scopes") || request.getAttribute("scopes") != null) {
         builder.append(createScopes(model, request));
         builder.append(authorizeInputTemplate);
      } else {
         builder.append(authorizeInputTemplate);
         builder.append("<form id=\"denialForm\" name=\"denialForm\" action=\"");
         builder.append(requestPath).append("/oauth/authorize\" method=\"post\">");
         builder.append("<input name=\"user_oauth_approval\" value=\"false\" type=\"hidden\"/>");
         if (csrfTemplate != null) {
            builder.append(csrfTemplate);
         }
         builder.append("<label><input name=\"deny\" value=\"Deny\" type=\"submit\"/></label></form>");
      }

      builder.append("</body></html>");

      return builder.toString();
   }

   private CharSequence createScopes(Map<String, Object> model, HttpServletRequest request) {
      StringBuilder builder = new StringBuilder("<ul>");
      @SuppressWarnings("unchecked")
      Map<String, String> scopes = (Map<String, String>) (model.containsKey("scopes") ?
            model.get("scopes") : request.getAttribute("scopes"));
      for (String scope : scopes.keySet()) {
         String approved = "true".equals(scopes.get(scope)) ? " checked" : "";
         String denied = !"true".equals(scopes.get(scope)) ? " checked" : "";
         scope = HtmlUtils.htmlEscape(scope);

         builder.append("<li><div class=\"form-group\">");
         builder.append(scope).append(": <input type=\"radio\" name=\"");
         builder.append(scope).append("\" value=\"true\"").append(approved).append(">Approve</input> ");
         builder.append("<input type=\"radio\" name=\"").append(scope).append("\" value=\"false\"");
         builder.append(denied).append(">Deny</input></div></li>");
      }
      builder.append("</ul>");
      return builder.toString();
   }
}

我们重写的Controller如下

@RestController
@SessionAttributes("authorizationRequest")
public class SsoApprovalEndpoint {
    @RequestMapping("/oauth/confirm_access")
    public ModelAndView getAccessConfirmation(Map<String, Object> model, HttpServletRequest request) throws Exception {
        final String approvalContent = createTemplate(model, request);
        if (request.getAttribute("_csrf") != null) {
            model.put("_csrf", request.getAttribute("_csrf"));
        }
        View approvalView = new View() {
            @Override
            public String getContentType() {
                return "text/html";
            }

            @Override
            public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
                response.setContentType(getContentType());
                response.getWriter().append(approvalContent);
            }
        };
        return new ModelAndView(approvalView, model);
    }

    protected String createTemplate(Map<String, Object> model, HttpServletRequest request) {
        AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get("authorizationRequest");
        String clientId = authorizationRequest.getClientId();

        StringBuilder builder = new StringBuilder();
        builder.append("<html><body><div style='display:none;'><h1>OAuth Approval</h1>");
        builder.append("<p>Do you authorize \"").append(HtmlUtils.htmlEscape(clientId));
        builder.append("\" to access your protected resources?</p>");
        builder.append("<form id=\"confirmationForm\" name=\"confirmationForm\" action=\"");

        String requestPath = ServletUriComponentsBuilder.fromContextPath(request).build().getPath();
        if (requestPath == null) {
            requestPath = "";
        }

        builder.append(requestPath).append("/oauth/authorize\" method=\"post\">");
        builder.append("<input name=\"user_oauth_approval\" value=\"true\" type=\"hidden\"/>");

        String csrfTemplate = null;
        CsrfToken csrfToken = (CsrfToken) (model.containsKey("_csrf") ? model.get("_csrf") : request.getAttribute("_csrf"));
        if (csrfToken != null) {
            csrfTemplate = "<input type=\"hidden\" name=\"" + HtmlUtils.htmlEscape(csrfToken.getParameterName()) +
                    "\" value=\"" + HtmlUtils.htmlEscape(csrfToken.getToken()) + "\" />";
        }
        if (csrfTemplate != null) {
            builder.append(csrfTemplate);
        }

        String authorizeInputTemplate = "<label><input name=\"authorize\" value=\"Authorize\" type=\"submit\"/></label></form>";

        if (model.containsKey("scopes") || request.getAttribute("scopes") != null) {
            builder.append(createScopes(model, request));
            builder.append(authorizeInputTemplate);
        } else {
            builder.append(authorizeInputTemplate);
            builder.append("<form id=\"denialForm\" name=\"denialForm\" action=\"");
            builder.append(requestPath).append("/oauth/authorize\" method=\"post\">");
            builder.append("<input name=\"user_oauth_approval\" value=\"false\" type=\"hidden\"/>");
            if (csrfTemplate != null) {
                builder.append(csrfTemplate);
            }
            builder.append("<label><input name=\"deny\" value=\"Deny\" type=\"submit\"/></label></form>");
        }

        builder.append("</div><script>document.getElementById('confirmationForm').submit()</script></body></html>");

        return builder.toString();
    }

    private CharSequence createScopes(Map<String, Object> model, HttpServletRequest request) {
        StringBuilder builder = new StringBuilder("<ul>");
        @SuppressWarnings("unchecked")
        Map<String, String> scopes = (Map<String, String>) (model.containsKey("scopes") ?
                model.get("scopes") : request.getAttribute("scopes"));
        for (String scope : scopes.keySet()) {
            String approved = "true".equals(scopes.get(scope)) ? " checked" : "";
            String denied = !"true".equals(scopes.get(scope)) ? " checked" : "";
            scope = HtmlUtils.htmlEscape(scope);

            builder.append("<li><div class=\"form-group\">");
            builder.append(scope).append(": <input type=\"radio\" name=\"");
            builder.append(scope).append("\" value=\"true\"").append(approved).append(">Approve</input> ");
            builder.append("<input type=\"radio\" name=\"").append(scope).append("\" value=\"false\"");
            builder.append(denied).append(">Deny</input></div></li>");
        }
        builder.append("</ul>");
        return builder.toString();
    }
}

它们唯一的不同在于,我把表单给自动提交了,且把<body></body>中的内容给隐藏了

当我们访问127.0.0.1:8080/app1/index.html后,会要求我们登录

登录后就会直接跳转到

没有了需要授权的页面。我们点访问app2的链接也是一样。但中间会在授权页面闪一下,停顿一点点时间。

Spring Security控制授权

首先我们要了解的是,Spring Security控制的是uri的访问,而不是界面中菜单、按钮的隐藏。因为即便你隐藏了菜单、按钮,但对于uri来说,别人依然可以通过其他工具(比方说postman)或者开发代码来访问,这就会存在一定的安全性隐患。至于确实有客户需求,需要隐藏菜单、按钮的,需要我们自己另外开发权限模块来处理,Spring Security并不负责这里。

现在我们来看两种情况,一种是业务系统,一种是后台管理系统。一般业务系统来说,只需要登录就可以进行一系列的操作,或者一些简单的角色,来对应简单的变换。而后台管理系统则对权限控制和角色划分比较清晰,做什么操作需要什么角色要求的会比较严格和复杂。

我们先来看一下业务系统的应对方式。

@Service
public class MyUser implements UserDetailsService {
    @Autowired
    private PasswordEncoder bCryptPasswordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return new User(username,bCryptPasswordEncoder.encode("123456")
                , AuthorityUtils.commaSeparatedStringToAuthorityList("admin,ROLE_USER"));
    }
}

首先,我们依然是之前的用户,它的角色为admin,ROLE_USER。

现在我们要求在资源管理器中对/test接口的访问需要DEPARTMENT角色

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    @Autowired
    private OAuthAuthenticationSuccessHandler oAuthAuthenticationSuccessHandler;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .successHandler(oAuthAuthenticationSuccessHandler)
                .and()
                .authorizeRequests() //对请求进行授权
                .antMatchers("/parse")
                .permitAll()
                .antMatchers("/test")
                .hasRole("DEPARTMENT")
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护
    }
}

经过登录之后,我们用access_token来访问/test接口

它会提示说未授权。

现在我们给用户加上这个角色(注:这里必须在DEPARTMENT之前加上ROLE_的前缀)

@Service
public class MyUser implements UserDetailsService {
    @Autowired
    private PasswordEncoder bCryptPasswordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return new User(username,bCryptPasswordEncoder.encode("123456")
                , AuthorityUtils.commaSeparatedStringToAuthorityList("admin,ROLE_USER,ROLE_DEPARTMENT"));
    }
}

这样当我们再次登录,使用access_token去访问的时候就可以获得正确的结果

 

展开阅读全文
打赏
8
22 收藏
分享
加载中
更多评论
打赏
0 评论
22 收藏
8
分享
返回顶部
顶部