资讯专栏INFORMATION COLUMN

Spring Security OAuth2 优雅的集成短信验证码登录以及第三方登录

yck / 2693人阅读

摘要:前言基于做微服务架构分布式系统时,作为认证的业内标准,也提供了全套的解决方案来支持在环境下使用,提供了开箱即用的组件。

前言

基于SpringCloud做微服务架构分布式系统时,OAuth2.0作为认证的业内标准,Spring Security OAuth2也提供了全套的解决方案来支持在Spring Cloud/Spring Boot环境下使用OAuth2.0,提供了开箱即用的组件。但是在开发过程中我们会发现由于Spring Security OAuth2的组件特别全面,这样就导致了扩展很不方便或者说是不太容易直指定扩展的方案,例如:

图片验证码登录

短信验证码登录

微信小程序登录

第三方系统登录

CAS单点登录

在面对这些场景的时候,预计很多对Spring Security OAuth2不熟悉的人恐怕会无从下手。基于上述的场景要求,如何优雅的集成短信验证码登录及第三方登录,怎么样才算是优雅集成呢?有以下要求:

不侵入Spring Security OAuth2的原有代码

对于不同的登录方式不扩展新的端点,使用/oauth/token可以适配所有的登录方式

可以对所有登录方式进行兼容,抽象一套模型只要简单的开发就可以集成登录

基于上述的设计要求,接下来将会在文章种详细介绍如何开发一套集成登录认证组件开满足上述要求。

阅读本篇文章您需要了解OAuth2.0认证体系、SpringBoot、SpringSecurity以及Spring Cloud等相关知识
思路

我们来看下Spring Security OAuth2的认证流程:

这个流程当中,切入点不多,集成登录的思路如下:

在进入流程之前先进行拦截,设置集成认证的类型,例如:短信验证码、图片验证码等信息。

在拦截的通知进行预处理,预处理的场景有很多,比如验证短信验证码是否匹配、图片验证码是否匹配、是否是登录IP白名单等处理

在UserDetailService.loadUserByUsername方法中,根据之前设置的集成认证类型去获取用户信息,例如:通过手机号码获取用户、通过微信小程序OPENID获取用户等等

接入这个流程之后,基本上就可以优雅集成第三方登录。

实现

介绍完思路之后,下面通过代码来展示如何实现:

第一步,定义拦截器拦截登录的请求
/**
 * @author LIQIU
 * @date 2018-3-30
 **/
@Component
public class IntegrationAuthenticationFilter extends GenericFilterBean implements ApplicationContextAware {

    private static final String AUTH_TYPE_PARM_NAME = "auth_type";

    private static final String OAUTH_TOKEN_URL = "/oauth/token";

    private Collection authenticators;

    private ApplicationContext applicationContext;

    private RequestMatcher requestMatcher;

    public IntegrationAuthenticationFilter(){
        this.requestMatcher = new OrRequestMatcher(
                new AntPathRequestMatcher(OAUTH_TOKEN_URL, "GET"),
                new AntPathRequestMatcher(OAUTH_TOKEN_URL, "POST")
        );
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {

        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        if(requestMatcher.matches(request)){
            //设置集成登录信息
            IntegrationAuthentication integrationAuthentication = new IntegrationAuthentication();
            integrationAuthentication.setAuthType(request.getParameter(AUTH_TYPE_PARM_NAME));
            integrationAuthentication.setAuthParameters(request.getParameterMap());
            IntegrationAuthenticationContext.set(integrationAuthentication);
            try{
                //预处理
                this.prepare(integrationAuthentication);

                filterChain.doFilter(request,response);

                //后置处理
                this.complete(integrationAuthentication);
            }finally {
                IntegrationAuthenticationContext.clear();
            }
        }else{
            filterChain.doFilter(request,response);
        }
    }

    /**
     * 进行预处理
     * @param integrationAuthentication
     */
    private void prepare(IntegrationAuthentication integrationAuthentication) {

        //延迟加载认证器
        if(this.authenticators == null){
            synchronized (this){
                Map integrationAuthenticatorMap = applicationContext.getBeansOfType(IntegrationAuthenticator.class);
                if(integrationAuthenticatorMap != null){
                    this.authenticators = integrationAuthenticatorMap.values();
                }
            }
        }

        if(this.authenticators == null){
            this.authenticators = new ArrayList<>();
        }

        for (IntegrationAuthenticator authenticator: authenticators) {
            if(authenticator.support(integrationAuthentication)){
                authenticator.prepare(integrationAuthentication);
            }
        }
    }

    /**
     * 后置处理
     * @param integrationAuthentication
     */
    private void complete(IntegrationAuthentication integrationAuthentication){
        for (IntegrationAuthenticator authenticator: authenticators) {
            if(authenticator.support(integrationAuthentication)){
                authenticator.complete(integrationAuthentication);
            }
        }
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }
}

在这个类种主要完成2部分工作:1、根据参数获取当前的是认证类型,2、根据不同的认证类型调用不同的IntegrationAuthenticator.prepar进行预处理

第二步,将拦截器放入到拦截链条中
/**
 * @author LIQIU
 * @date 2018-3-7
 **/
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private IntegrationUserDetailsService integrationUserDetailsService;

    @Autowired
    private WebResponseExceptionTranslator webResponseExceptionTranslator;

    @Autowired
    private IntegrationAuthenticationFilter integrationAuthenticationFilter;

    @Autowired
    private DatabaseCachableClientDetailsService redisClientDetailsService;

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        // TODO persist clients details
        clients.withClientDetails(redisClientDetailsService);
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints
                .tokenStore(new RedisTokenStore(redisConnectionFactory))
//                .accessTokenConverter(jwtAccessTokenConverter())
                .authenticationManager(authenticationManager)
                .exceptionTranslator(webResponseExceptionTranslator)
                .reuseRefreshTokens(false)
                .userDetailsService(integrationUserDetailsService);
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.allowFormAuthenticationForClients()
                .tokenKeyAccess("isAuthenticated()")
                .checkTokenAccess("permitAll()")
                .addTokenEndpointAuthenticationFilter(integrationAuthenticationFilter);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
        jwtAccessTokenConverter.setSigningKey("cola-cloud");
        return jwtAccessTokenConverter;
    }
}

通过调用security. .addTokenEndpointAuthenticationFilter(integrationAuthenticationFilter);方法,将拦截器放入到认证链条中。

第三步,根据认证类型来处理用户信息
@Service
public class IntegrationUserDetailsService implements UserDetailsService {

    @Autowired
    private UpmClient upmClient;

    private List authenticators;

    @Autowired(required = false)
    public void setIntegrationAuthenticators(List authenticators) {
        this.authenticators = authenticators;
    }

    @Override
    public User loadUserByUsername(String username) throws UsernameNotFoundException {
        IntegrationAuthentication integrationAuthentication = IntegrationAuthenticationContext.get();
        //判断是否是集成登录
        if (integrationAuthentication == null) {
            integrationAuthentication = new IntegrationAuthentication();
        }
        integrationAuthentication.setUsername(username);
        UserVO userVO = this.authenticate(integrationAuthentication);

        if(userVO == null){
            throw new UsernameNotFoundException("用户名或密码错误");
        }

        User user = new User();
        BeanUtils.copyProperties(userVO, user);
        this.setAuthorize(user);
        return user;

    }

    /**
     * 设置授权信息
     *
     * @param user
     */
    public void setAuthorize(User user) {
        Authorize authorize = this.upmClient.getAuthorize(user.getId());
        user.setRoles(authorize.getRoles());
        user.setResources(authorize.getResources());
    }

    private UserVO authenticate(IntegrationAuthentication integrationAuthentication) {
        if (this.authenticators != null) {
            for (IntegrationAuthenticator authenticator : authenticators) {
                if (authenticator.support(integrationAuthentication)) {
                    return authenticator.authenticate(integrationAuthentication);
                }
            }
        }
        return null;
    }
}

这里实现了一个IntegrationUserDetailsService ,在loadUserByUsername方法中会调用authenticate方法,在authenticate方法中会当前上下文种的认证类型调用不同的IntegrationAuthenticator 来获取用户信息,接下来来看下默认的用户名密码是如何处理的:

@Component
@Primary
public class UsernamePasswordAuthenticator extends AbstractPreparableIntegrationAuthenticator {

    @Autowired
    private UcClient ucClient;

    @Override
    public UserVO authenticate(IntegrationAuthentication integrationAuthentication) {
        return ucClient.findUserByUsername(integrationAuthentication.getUsername());
    }

    @Override
    public void prepare(IntegrationAuthentication integrationAuthentication) {

    }

    @Override
    public boolean support(IntegrationAuthentication integrationAuthentication) {
        return StringUtils.isEmpty(integrationAuthentication.getAuthType());
    }
}

UsernamePasswordAuthenticator只会处理没有指定的认证类型即是默认的认证类型,这个类中主要是通过用户名获取密码。接下来来看下图片验证码登录如何处理的:

/**
 * 集成验证码认证
 * @author LIQIU
 * @date 2018-3-31
 **/
@Component
public class VerificationCodeIntegrationAuthenticator extends UsernamePasswordAuthenticator {

    private final static String VERIFICATION_CODE_AUTH_TYPE = "vc";

    @Autowired
    private VccClient vccClient;

    @Override
    public void prepare(IntegrationAuthentication integrationAuthentication) {
        String vcToken = integrationAuthentication.getAuthParameter("vc_token");
        String vcCode = integrationAuthentication.getAuthParameter("vc_code");
        //验证验证码
        Result result = vccClient.validate(vcToken, vcCode, null);
        if (!result.getData()) {
            throw new OAuth2Exception("验证码错误");
        }
    }

    @Override
    public boolean support(IntegrationAuthentication integrationAuthentication) {
        return VERIFICATION_CODE_AUTH_TYPE.equals(integrationAuthentication.getAuthType());
    }
}

VerificationCodeIntegrationAuthenticator继承UsernamePasswordAuthenticator,因为其只是需要在prepare方法中验证验证码是否正确,获取用户还是用过用户名密码的方式获取。但是需要认证类型为"vc"才会处理
接下来来看下短信验证码登录是如何处理的:

@Component
public class SmsIntegrationAuthenticator extends AbstractPreparableIntegrationAuthenticator implements  ApplicationEventPublisherAware {

    @Autowired
    private UcClient ucClient;

    @Autowired
    private VccClient vccClient;

    @Autowired
    private PasswordEncoder passwordEncoder;

    private ApplicationEventPublisher applicationEventPublisher;

    private final static String SMS_AUTH_TYPE = "sms";

    @Override
    public UserVO authenticate(IntegrationAuthentication integrationAuthentication) {

        //获取密码,实际值是验证码
        String password = integrationAuthentication.getAuthParameter("password");
        //获取用户名,实际值是手机号
        String username = integrationAuthentication.getUsername();
        //发布事件,可以监听事件进行自动注册用户
        this.applicationEventPublisher.publishEvent(new SmsAuthenticateBeforeEvent(integrationAuthentication));
        //通过手机号码查询用户
        UserVO userVo = this.ucClient.findUserByPhoneNumber(username);
        if (userVo != null) {
            //将密码设置为验证码
            userVo.setPassword(passwordEncoder.encode(password));
            //发布事件,可以监听事件进行消息通知
            this.applicationEventPublisher.publishEvent(new SmsAuthenticateSuccessEvent(integrationAuthentication));
        }
        return userVo;
    }

    @Override
    public void prepare(IntegrationAuthentication integrationAuthentication) {
        String smsToken = integrationAuthentication.getAuthParameter("sms_token");
        String smsCode = integrationAuthentication.getAuthParameter("password");
        String username = integrationAuthentication.getAuthParameter("username");
        Result result = vccClient.validate(smsToken, smsCode, username);
        if (!result.getData()) {
            throw new OAuth2Exception("验证码错误或已过期");
        }
    }

    @Override
    public boolean support(IntegrationAuthentication integrationAuthentication) {
        return SMS_AUTH_TYPE.equals(integrationAuthentication.getAuthType());
    }

    @Override
    public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
        this.applicationEventPublisher = applicationEventPublisher;
    }
}

SmsIntegrationAuthenticator会对登录的短信验证码进行预处理,判断其是否非法,如果是非法的则直接中断登录。如果通过预处理则在获取用户信息的时候通过手机号去获取用户信息,并将密码重置,以通过后续的密码校验。

总结

在这个解决方案中,主要是使用责任链和适配器的设计模式来解决集成登录的问题,提高了可扩展性,并对spring的源码无污染。如果还要继承其他的登录,只需要实现自定义的IntegrationAuthenticator就可以。

项目地址:https://gitee.com/leecho/cola...
大家有好的建议和想法可以一起沟通交流。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/69060.html

相关文章

  • Spring Security Oauth2.0 实现短信验证登录

    摘要:验证码的发放校验逻辑比较简单,方法后通过全局判断请求中是否和手机号匹配集合,重点逻辑是令牌的参数 spring security oauth2 登录过程详解 ​ showImg(https://segmentfault.com/img/remote/1460000012811024); ​ 定义手机号登录令牌 /** * @author lengleng * @date 2018/...

    陆斌 评论0 收藏0
  • 前后端分离项目 — SpringSocial 绑定与解绑社交账号如微信、QQ

    摘要:我们以微信为例,首先我们发送一个请求,因为你已经登录了,所以后台可以获取当前是谁,然后就获取到请求的链接,最后就是跳转到这个链接上面去。 1、准备工作 申请QQ、微信相关AppId和AppSecret,这些大家自己到QQ互联和微信开发平台 去申请吧 还有java后台要引入相关的jar包,如下: org.springframework.security....

    tigerZH 评论0 收藏0
  • 前后端分离项目 — 基于SpringSecurity OAuth2.0用户认证

    摘要:前言现在的好多项目都是基于移动端以及前后端分离的项目,之前基于的前后端放到一起的项目已经慢慢失宠并淡出我们视线,尤其是当基于的微服务架构以及单页面应用流行起来后,情况更甚。使用生成是什么请自行百度。 1、前言 现在的好多项目都是基于APP移动端以及前后端分离的项目,之前基于Session的前后端放到一起的项目已经慢慢失宠并淡出我们视线,尤其是当基于SpringCloud的微服务架构以及...

    QLQ 评论0 收藏0
  • Spring Security

    摘要:框架具有轻便,开源的优点,所以本译见构建用户管理微服务五使用令牌和来实现身份验证往期译见系列文章在账号分享中持续连载,敬请查看在往期译见系列的文章中,我们已经建立了业务逻辑数据访问层和前端控制器但是忽略了对身份进行验证。 重拾后端之Spring Boot(四):使用JWT和Spring Security保护REST API 重拾后端之Spring Boot(一):REST API的搭建...

    keelii 评论0 收藏0
  • Spring 指南(目录)

    摘要:指南无论你正在构建什么,这些指南都旨在让你尽快提高工作效率使用团队推荐的最新项目版本和技术。使用进行消息传递了解如何将用作消息代理。安全架构的主题指南,这些位如何组合以及它们如何与交互。使用的主题指南以及如何为应用程序创建容器镜像。 Spring 指南 无论你正在构建什么,这些指南都旨在让你尽快提高工作效率 — 使用Spring团队推荐的最新Spring项目版本和技术。 入门指南 这些...

    only_do 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<