SpringSecurity扩展第三方登录的思路,是借鉴Spring Security密码登录流程的,不了解的同学可以回顾一下:Spring Security 密码登录流程源码

本文使用JustAuth扩展第三方登录,同时还预留了手机号登录的方式。

关于JustAuth,官网这样介绍:
史上最全的整合第三方登录的开源库,Login, so easy. 极简的API设计,已集成国内外十多家平台。

来一张JustAuth实现类的截图,大家感受下,集成了市面上的大部分登录,像QQ、微信、微博、淘宝、百度、github、码云等等。只需要配置一下,即可集成第三方登录。
image.png

开始

如下图,是密码登录的流程图,其中蓝色框是扩展登录需要重新写的,通过重写Filter获取认证用户,重写Provider和UserDetailsService给用户授权。
spring-security-login-extend-1.png
下面咱们一个一个介绍,扩展登录的蓝色框是如何实现的。

用户授权第三方登录

以码云为例,用户访问本系统/open/oauth/login/gitee,系统会跳转到gitee登录页,由用户登录,然后授权。

  1. @RestController
  2. public class ExtendLoginController {
  3. @Autowired
  4. private AuthRequestFactory factory;
  5. @GetMapping("/open/oauth")
  6. public List<String> list() {
  7. return factory.oauthList();
  8. }
  9. @GetMapping("/open/oauth/login/{type}")
  10. public void login(@PathVariable String type, HttpServletResponse response) throws IOException {
  11. AuthRequest authRequest = factory.get(type);
  12. response.sendRedirect(authRequest.authorize(AuthStateUtils.createState()));
  13. }
  14. }

获取认证用户

用户同意授权后,会进入filter。对标UsernamePasswordAuthenticationFilter,新建一个ExtendAuthenticationFilter,用来获取认证用户。

  1. @Slf4j
  2. @Service
  3. public class ExtendAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
  4. public static String EXTEND_LOGIN_URL = "/open/oauth/callback/**";
  5. private boolean postOnly = false;
  6. private AuthRequestFactory authRequestFactory;
  7. /**
  8. * 通过构造函数指定该 Filter 要拦截的 url 和 httpMethod
  9. */
  10. protected ExtendAuthenticationFilter() {
  11. super(new AntPathRequestMatcher(EXTEND_LOGIN_URL, null));
  12. }
  13. @Override
  14. public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
  15. // 当设置该 filter 只拦截 post 请求时,符合 pattern 的非 post 请求会触发异常
  16. if (this.postOnly && !request.getMethod().equals("POST")) {
  17. throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
  18. } else {
  19. // 1. 从请求中获取参数 用户登录扩展参数
  20. String extendKey = obtainExtendKey(request);
  21. String extendCredentials = obtainCredentials(request);
  22. String extendType = obtainExtendType(request);
  23. // 2. 封装成 Token 调用 AuthenticationManager 的 authenticate 方法,该方法中根据 Token 的类型去调用对应 Provider 的 authenticated
  24. ExtendAuthenticationToken token;
  25. if (StrUtil.isNotBlank(extendKey)) {
  26. token = new ExtendAuthenticationToken(extendKey, extendType, extendCredentials);
  27. }else {
  28. // 从第三方拿到用户信息
  29. token = new ExtendAuthenticationToken(obtainAuthUser(request));
  30. }
  31. this.setDetails(request, token);
  32. // 3. 返回 authenticated 方法的返回值
  33. return this.getAuthenticationManager().authenticate(token);
  34. }
  35. }
  36. /**
  37. * 获取扩展登录extendKey,可以是用户名、手机号等,根据业务需要去扩展
  38. */
  39. protected String obtainExtendKey(HttpServletRequest request) {
  40. return request.getParameter(UserUtil.EXTEND_KEY_PARAMETER);
  41. }
  42. /**
  43. * 获取扩展登录extendCredentials,可以是手机号的验证码等,根据业务需要去扩展
  44. */
  45. protected String obtainCredentials(HttpServletRequest request) {
  46. return request.getParameter(UserUtil.EXTEND_CREDENTIALS_PARAMETER);
  47. }
  48. /**
  49. * 获取扩展登录类型
  50. */
  51. protected String obtainExtendType(HttpServletRequest request) {
  52. return request.getParameter(UserUtil.EXTEND_TYPE_PARAMETER);
  53. }
  54. /**
  55. * 获取 justauth 登录后的用户信息
  56. */
  57. protected AuthUser obtainAuthUser(HttpServletRequest request) {
  58. String type = getCallbackType(request);
  59. AuthRequest authRequest = authRequestFactory.get(type);
  60. // 登录后,从第三方拿到用户信息
  61. AuthResponse response = authRequest.login(getCallback(request));
  62. log.info("【justauth 第三方登录 response】= {}", JSONUtil.toJsonStr(response));
  63. // 第三方登录成功
  64. if (response.getCode() == AuthResponseStatus.SUCCESS.getCode()) {
  65. AuthUser authUser = (AuthUser) response.getData();
  66. return authUser;
  67. }
  68. return null;
  69. }
  70. /**
  71. * 从请求中构建 AuthCallback
  72. */
  73. private AuthCallback getCallback(HttpServletRequest request) {
  74. AuthCallback authCallback = AuthCallback.builder()
  75. .code(request.getParameter("code"))
  76. .auth_code(request.getParameter("auth_code"))
  77. .authorization_code(request.getParameter("authorization_code"))
  78. .oauthToken(request.getParameter("oauth_token"))
  79. .state(request.getParameter("state"))
  80. .oauthVerifier(request.getParameter("oauth_verifier"))
  81. .build();
  82. return authCallback;
  83. }
  84. /**
  85. * 获取路径参数:回调类型
  86. */
  87. private String getCallbackType(HttpServletRequest request) {
  88. // /context/open/oauth/callback/gitee
  89. String uri = request.getRequestURI();
  90. // "/open/oauth/callback/".length()
  91. int common = EXTEND_LOGIN_URL.length() - 2;
  92. int start = uri.indexOf(EXTEND_LOGIN_URL.substring(0, common));
  93. if(start == -1) {
  94. log.warn("【justauth 第三方登录 response】回调类型为空,uri={}", uri);
  95. return null;
  96. }
  97. // gitee
  98. return uri.substring(start + common);
  99. }
  100. protected void setDetails(HttpServletRequest request, ExtendAuthenticationToken authRequest) {
  101. authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
  102. }
  103. public void setPostOnly(boolean postOnly) {
  104. this.postOnly = postOnly;
  105. }
  106. public void setAuthRequestFactory(AuthRequestFactory authRequestFactory) {
  107. this.authRequestFactory = authRequestFactory;
  108. }
  109. }

首先验证是否为POST请求,验证通过后,开始进入主题。

  1. 从请求中获取用户扩展登录参数,包括扩展登录key(如手机号)、扩展登录凭证(如手机验证码)、扩展登录类型;
  2. 如果扩展登录key不为空,则使用扩展登录三要素生成token;
  3. 如果扩展登录key为空,使用obtainAuthUser方法获取AuthUser,该方法中,获取了回调的参数,带着回调参数向第三方获取当前登录的用户信息AuthUser,使用AuthUser生成token;
  4. 设置相关参数,默认为远程地址和会话id;
  5. 调用ProviderManager#authenticate方法认证。

ProviderManager

ProviderManager#authenticate方法,主要是根据token的类型,找到匹配的filter去认证

合适的认证器做认证

ExtendAuthenticationProvider对标AbstractAuthenticationProcessingFilter,该filter主要干了3件事:

  • 从数据库中,获取用户详情ExtendUserDetailsService#loadUserByExtendKey();
  • 前置校验:校验用户是否锁定、不可用、过期;
  • 附加校验:在密码登录中,校验的是密码是否正确;在手机号登录中,校验的是验证码是否正确;在第三方认证时,该方法不需要实现;
    /**
    * 查找用户详情
    */
    protected UserDetails retrieveUser(String extendKey,
                                     ExtendAuthenticationToken authentication)
      throws AuthenticationException {
      try {
          UserDetails loadedUser = this.getExtendUserDetailsService().loadUserByExtendKey(authentication);
          if (loadedUser == null) {
              throw new InternalAuthenticationServiceException(
                  "UserDetailsService returned null, which is an interface contract violation");
          }
          return loadedUser;
      }
      catch (UsernameNotFoundException | InternalAuthenticationServiceException ex) {
          throw ex;
      } catch (Exception ex) {
          throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
      }
    }
    

ExtendUserDetailsService

ExtendUserDetailsService对标的是我们熟悉的UserDetailsService,用来获取用户详情,我这里实现了一个默认实现:

@Slf4j
@Service
public class ExtendUserDetailsServiceImpl implements ExtendUserDetailsService {

    @Autowired
    private DefaultUserDetailsService userDetailsService;
    @Autowired
    private SystemService systemService;

    /**
     * 扩展第三方登录
     */
    @Override
    public UserDetails loadUserByExtendKey(ExtendAuthenticationToken token) throws UsernameNotFoundException {
        if (!(ObjectUtil.isNotEmpty(token.getPrincipal()) && token.getPrincipal() instanceof AuthUser)) {
            log.info("extend, type={}", token.getExtendType());
            // 当扩展登录key是用户名时
            return userDetailsService.loadUserByUsername(token.getExtendKey());
        }

        AuthUser authUser = (AuthUser) token.getPrincipal();
        // 1. 根据 gitee 唯一id 查找用户信息
        /**
         * 这里要求 user 表中有 authUser.getSource()+'_id' 字段(小写,如 gitee_id),authUser.getSource()的取值见 {@link AuthDefaultSource}
         */
        UserVO userVO = systemService.loadUserByBiz(authUser.getSource().toLowerCase() + "_id", authUser.getUuid());

        // 2. 用户不存在 --> 新增(注册)用户,之后返回 UserDetails
        if (ObjectUtil.isNull(userVO) || StrUtil.isBlank(userVO.getUserId())) {
            UserDTO user = new UserDTO();
            user.setUserName(authUser.getUsername());
            user.setNickName(authUser.getNickname());
            user.setAvatar(authUser.getAvatar());
            user.setRemark(authUser.getRemark());
            if (StrUtil.equalsIgnoreCase(authUser.getSource(), AuthDefaultSource.GITEE.getName())) {
                user.setGiteeId(authUser.getUuid());
            }
            UserVO registerUser = systemService.registerUser(user);
            return new LoginUser(registerUser, IpUtils.getIpAddr(ServletUtils.getRequest()), LocalDateTime.now(), authUser.getSource());
        }

        // 3. 用户存在 --> 返回 UserDetails
        return new LoginUser(userVO, IpUtils.getIpAddr(ServletUtils.getRequest()), LocalDateTime.now(), LoginType.EXTEND);
    }

}

根据gitee 唯一id 查找用户信息,这里取了个巧,要求 user 表中有 authUser.getSource()+’_id’ 字段(小写,如 gitee_id)。当然,也可以设计一个第三方登录的关系表,查找用户信息。

用户存在,构造UserDetails返回;不存在,根据获取的authUser,向系统注册用户,然后再构造UserDetails返回。

配置

以上面这些自定义的类,构造SecurityConfigurerAdapter

/**
 * 扩展第三方登录配置
 *
 * @author songyinyin
 * @date 2020/5/4 下午 07:58
 */
@Configuration
public class ExtendAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    @Autowired
    private AuthRequestFactory authRequestFactory;
    @Autowired
    private ExtendUserDetailsService extendUserDetailsService;
    @Autowired
    private AuthenticationSuccessHandler successHandler;
    @Autowired
    private AuthenticationFailureHandler failureHandler;

    @Override
    public void configure(HttpSecurity builder) throws Exception {

        // 1. 初始化 ExtendAuthenticationFilter
        ExtendAuthenticationFilter filter = new ExtendAuthenticationFilter();
        filter.setAuthenticationManager(builder.getSharedObject(AuthenticationManager.class));
        filter.setAuthenticationSuccessHandler(successHandler);
        filter.setAuthenticationFailureHandler(failureHandler);
        filter.setAuthRequestFactory(authRequestFactory);

        // 2. 初始化 ExtendAuthenticationProvider
        ExtendAuthenticationProvider provider = new ExtendAuthenticationProvider();
        provider.setExtendUserDetailsService(extendUserDetailsService);

        // 3. 将设置完毕的 Filter 与 Provider 添加到配置中,将自定义的 Filter 加到 UsernamePasswordAuthenticationFilter 之前
        builder.authenticationProvider(provider).addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class);
    }
}

最后,将自定义的 SecurityConfigurerAdapter 添加到配置中,主要使用 http.apply(extendAuthenticationSecurityConfig) 方法将我们自定义的配置加入到 SpringSecurity 中,如下:

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    // 省略注入各种自定义配置类

    /**
     * 扩展用户登录
     */
    @Autowired
    private ExtendAuthenticationSecurityConfig extendAuthenticationSecurityConfig;

    /**
     * 配置认证方式等
     *
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);
    }

    /**
     * http相关的配置,包括登入登出、异常处理、会话管理等
     *
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable();
        http.apply(extendAuthenticationSecurityConfig) // 扩展用户登录
                .and().authorizeRequests()
                // 放行接口
                .antMatchers(GitsResourceServerConfiguration.AUTH_WHITELIST).permitAll()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated()
                // 异常处理(权限拒绝、登录失效等)
                .and().exceptionHandling()
                .authenticationEntryPoint(anonymousAuthenticationEntryPoint)//匿名用户访问无权限资源时的异常处理
                .accessDeniedHandler(accessDeniedHandler)//登录用户没有权限访问资源
                // 登入
                .and().formLogin().permitAll()//允许所有用户
                .successHandler(loginSuccessHandler)//登录成功处理逻辑
                .failureHandler(loginFailureHandler)//登录失败处理逻辑
                // 登出
                .and().logout().permitAll()//允许所有用户
                .logoutSuccessHandler(logoutSuccessHandler)//登出成功处理逻辑
                .deleteCookies(RestHttpSessionIdResolver.AUTH_TOKEN)
                // 会话管理
                .and().sessionManagement().invalidSessionStrategy(invalidSessionHandler) // 超时处理
                .maximumSessions(1)//同一账号同时登录最大用户数
                .expiredSessionStrategy(sessionInformationExpiredHandler) // 顶号处理
        ;

    }
}

总结

可以看出,扩展第三方登录,与密码登录的流程基本一致,在获取登录参数、查找用户信息时,略有不同。再梳理一下第三方登录的流程图,方便大家理解。

spring-security-login-extend-2 (1).png

本文的所有代码都已经开源,地址如下:

github:https://github.com/dudiao/gits
码云:https://gitee.com/songyinyin/gits

本篇文章在Spring Security的基础上,实现了第三方登录的集成,同时预留出来了手机号验证码登录。比起 spring-social 更加轻量级一点,而且 spring-social 有一年没更新了。

Spring Security灵活性很强,可以留言说说你是怎么使用Spring Security进行扩展登录的。