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...你們有好的建議和想法能夠一塊兒溝通交流。

相關文章
相關標籤/搜索