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

前言

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

  1. 图片验证码登陆
  2. 短信验证码登陆
  3. 微信小程序登陆
  4. 第三方系统登陆
  5. CAS单点登陆

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

  1. 不侵入Spring Security OAuth2的原有代码
  2. 对于不一样的登陆方式不扩展新的端点,使用/oauth/token能够适配全部的登陆方式
  3. 能够对全部登陆方式进行兼容,抽象一套模型只要简单的开发就能够集成登陆

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

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

思路

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

clipboard.png

这个流程当中,切入点很少,集成登陆的思路以下:小程序

  1. 在进入流程以前先进行拦截,设置集成认证的类型,例如:短信验证码、图片验证码等信息。
  2. 在拦截的通知进行预处理,预处理的场景有不少,好比验证短信验证码是否匹配、图片验证码是否匹配、是不是登陆IP白名单等处理
  3. 在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<IntegrationAuthenticator> 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<String,IntegrationAuthenticator> 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部分工做:一、根据参数获取当前的是认证类型,二、根据不一样的认证类型调用不一样的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<IntegrationAuthenticator> authenticators;

    @Autowired(required = false)
    public void setIntegrationAuthenticators(List<IntegrationAuthenticator> 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 来获取用户信息,接下来来看下默认的用户名密码是如何处理的:app

@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<Boolean> 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<Boolean> 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...你们有好的建议和想法能够一块儿沟通交流。

相关文章
相关标签/搜索