1. 程式人生 > >spring-security-oauth2(七) 自定義簡訊登陸開發

spring-security-oauth2(七) 自定義簡訊登陸開發

簡訊登陸開發

原理

基本原理:SmsAuthenticationFilter接受請求生成SmsAuthenticationToken,然後交給系統的AuthenticationManager進行管理,然後找到SmsAuthenticationProvider,然後再呼叫UserDetailsService進行簡訊驗證,SmsAuthenticationSecurityConfig進行配置 SmsCaptchaFilter驗證碼過濾器 在請求之前進行驗證驗證

簡訊驗證主要是複製參考使用者名稱密碼流程,參考前面的原始碼分析。

程式碼

SmsAuthenticationToken

package com.rui.tiger.auth.core.authentication.mobile;

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;

import java.util.Collection;

/**
 * 手機token
 * 參照:UsernamePasswordAuthenticationToken
 * @author CaiRui
 * @Date 2018/12/15 22:28
 */
public class SmsAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = 500L;
    private final Object principal;//使用者名稱
    //private Object credentials; 密碼 手機登入驗證碼在登入前已經驗證 考慮手機驗證碼通用 沒有放到這裡

    /**
     * 沒有認證成功
     * @param mobile 手機號
     */
    public SmsAuthenticationToken(Object mobile) {
        super((Collection)null);
        this.principal = mobile;
        this.setAuthenticated(false);
    }

    /**
     * 認證成功同時進行許可權設定
     * @param principal
     * @param authorities
     */
    public SmsAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        super.setAuthenticated(true);
    }

    public Object getCredentials() {
        return null;
    }

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

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

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

SmsAuthenticationFilter

package com.rui.tiger.auth.core.authentication.mobile;

import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.Assert;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * 手機登陸過濾器
 * 參照:UsernamePasswordAuthenticationFilter
 * @author CaiRui
 * @Date 2018/12/16 10:39
 */
public class SmsAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    // ~ Static fields/initializers
    // =====================================================================================

    public static final String TIGER_SECURITY_FORM_MOBILE_KEY = "mobile";
    private String mobileParameter = TIGER_SECURITY_FORM_MOBILE_KEY;
    //public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
    // private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;
    private boolean postOnly = true;

    // ~ Constructors
    // ===================================================================================================

    //TODO /authentication/mobile 這些引數應該配置到字典中待優化
    public SmsAuthenticationFilter() {
        // 攔截該路徑,如果是訪問該路徑,則標識是需要簡訊登入
        super(new AntPathRequestMatcher("/authentication/mobile", "POST"));
    }

    // ~ Methods
    // ========================================================================================================

    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 mobile = obtainMobile(request);
        if (mobile == null) {
            mobile = "";
        }
        mobile = mobile.trim();

        SmsAuthenticationToken authRequest = new SmsAuthenticationToken(mobile);

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

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


    /**
     * Enables subclasses to override the composition of the username, such as by
     * including additional values and a separator.
     *
     * @param request so that request attributes can be retrieved
     *
     * @return the username that will be presented in the <code>Authentication</code>
     * request token to the <code>AuthenticationManager</code>
     */
    protected String obtainMobile(HttpServletRequest request) {
        return request.getParameter(mobileParameter);
    }

    /**
     * Provided so that subclasses may configure what is put into the authentication
     * request's details property.
     *
     * @param request that an authentication request is being created for
     * @param authRequest the authentication request object that should have its details
     * set
     */
    protected void setDetails(HttpServletRequest request,
                              SmsAuthenticationToken authRequest) {
        authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
    }

    /**
     * Sets the parameter name which will be used to obtain the mobile from the login
     * request.
     *
     * @param mobileParameter the parameter name. Defaults to "mobile".
     */
    public void setMobileParameter(String mobileParameter) {
        Assert.hasText(mobileParameter, "mobile parameter must not be empty or null");
        this.mobileParameter = mobileParameter;
    }

    public String getMobileParameter() {
        return mobileParameter;
    }

    /**
     * Defines whether only HTTP POST requests will be allowed by this filter. If set to
     * true, and an authentication request is received which is not a POST request, an
     * exception will be raised immediately and authentication will not be attempted. The
     * <tt>unsuccessfulAuthentication()</tt> method will be called as if handling a failed
     * authentication.
     * <p>
     * Defaults to <tt>true</tt> but may be overridden by subclasses.
     */
    public void setPostOnly(boolean postOnly) {
        this.postOnly = postOnly;
    }

}

SmsAuthenticationProvider

package com.rui.tiger.auth.core.authentication.mobile;

import lombok.Getter;
import lombok.Setter;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

/**
 * @author CaiRui
 * @Date 2018/12/16 10:38
 */
public class SmsAuthenticationProvider implements AuthenticationProvider {
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        SmsAuthenticationToken smsCaptchaAuthenticationToken= (SmsAuthenticationToken) authentication;
        UserDetails user=userDetailsService.loadUserByUsername((String) smsCaptchaAuthenticationToken.getPrincipal());
        if(user==null){
            throw new InternalAuthenticationServiceException("無法獲取使用者資訊");
        }
        //認證通過
        SmsAuthenticationToken authenticationTokenResult=new SmsAuthenticationToken(user,user.getAuthorities());
        //將之前未認證的請求放進認證後的Token中
        authenticationTokenResult.setDetails(smsCaptchaAuthenticationToken.getDetails());
        return authenticationTokenResult;
    }

    //@Autowired
    @Getter
    @Setter
    private UserDetailsService userDetailsService;//

    /**
     * AuthenticationManager 驗證該Provider是否支援 認證
     * @param aClass
     * @return
     */
    @Override
    public boolean supports(Class<?> aClass) {
        return aClass.isAssignableFrom(SmsAuthenticationToken.class);
    }

}
SmsAuthenticationSecurityConfig 
package com.rui.tiger.auth.core.authentication.mobile;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.stereotype.Component;

/**
 * 手機許可權配置類
 * @author CaiRui
 * @Date 2018/12/16 13:42
 */
@Component
public class SmsAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    @Autowired
    private AuthenticationFailureHandler authenticationFailureHandler;
    @Autowired
    private AuthenticationSuccessHandler authenticationSuccessHandler;
    //實現類怎麼確定? 自定義的實現??
    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        SmsAuthenticationFilter filter = new SmsAuthenticationFilter();
        filter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        filter.setAuthenticationFailureHandler(authenticationFailureHandler);
        filter.setAuthenticationSuccessHandler(authenticationSuccessHandler);

        SmsAuthenticationProvider smsAuthenticationProvider = new SmsAuthenticationProvider();
        smsAuthenticationProvider.setUserDetailsService(userDetailsService);

        http
                // 註冊到AuthenticationManager中去
                .authenticationProvider(smsAuthenticationProvider)
                // 新增到 UsernamePasswordAuthenticationFilter 之後
                // 貌似所有的入口都是 UsernamePasswordAuthenticationFilter
                // 然後UsernamePasswordAuthenticationFilter的provider不支援這個地址的請求
                // 所以就會落在我們自己的認證過濾器上。完成接下來的認證
                .addFilterAfter(filter, UsernamePasswordAuthenticationFilter.class);
    }

}

SmsCaptchaFilter

package com.rui.tiger.auth.core.captcha.sms;

import com.rui.tiger.auth.core.captcha.CaptchaException;
import com.rui.tiger.auth.core.captcha.CaptchaProcessor;
import com.rui.tiger.auth.core.captcha.CaptchaVo;
import com.rui.tiger.auth.core.captcha.ImageCaptchaVo;
import com.rui.tiger.auth.core.properties.SecurityProperties;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.bind.ServletRequestBindingException;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 *  手機驗證碼過濾器
 * OncePerRequestFilter 過濾器只會呼叫一次
 *
 * @author CaiRui
 * @date 2018-12-10 12:23
 */
@Setter
@Getter
@Slf4j
public class SmsCaptchaFilter extends OncePerRequestFilter implements InitializingBean {

	//一般在配置類中進行注入

	private AuthenticationFailureHandler failureHandler;

	private SecurityProperties securityProperties;

	/**
	 * 驗證碼攔截的路徑
	 */
	private Set<String> interceptUrlSet = new HashSet<>();

	//session工具類
	private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
	//路徑匹配工具類
	private AntPathMatcher antPathMatcher = new AntPathMatcher();

	/**
	 * @throws ServletException
	 */

	@Override
	public void afterPropertiesSet() throws ServletException {
		super.afterPropertiesSet();
		//其它配置的需要驗證碼驗證的路徑
		String configInterceptUrl = securityProperties.getCaptcha().getSms().getInterceptUrl();
		if (StringUtils.isNotBlank(configInterceptUrl)) {
			String[] configInterceptUrlArray = StringUtils.split(configInterceptUrl, ",");
			interceptUrlSet = Stream.of(configInterceptUrlArray).collect(Collectors.toSet());
		}
		//簡訊登入請求驗證
		interceptUrlSet.add("/authentication/mobile");
	}

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

		log.info("驗證碼驗證請求路徑:[{}]", request.getRequestURI());
		boolean action = false;// 預設不放行
		for (String url : interceptUrlSet) {
			if (antPathMatcher.match(url, request.getRequestURI())) {
				action = true;
			}
		}
		if (action) {
			try {
				validate(request);
			} catch (CaptchaException captchaException) {
				//失敗呼叫我們的自定義失敗處理器
				failureHandler.onAuthenticationFailure(request, response, captchaException);
				//後續流程終止
				return;
			}

		}
		//後續過濾器繼續執行
		filterChain.doFilter(request, response);
	}

	/**
	 * 圖片驗證碼校驗
	 *
	 * @param request
	 */
	private void validate(HttpServletRequest request) throws ServletRequestBindingException {
		String smsSessionKey=CaptchaProcessor.CAPTCHA_SESSION_KEY+"sms";
		// 拿到之前儲存的imageCode資訊
		ServletWebRequest swr = new ServletWebRequest(request);
		CaptchaVo smsCaptchaInSession = (CaptchaVo) sessionStrategy.getAttribute(swr, smsSessionKey);
		String codeInRequest = ServletRequestUtils.getStringParameter(request, "smsCode");

		if (StringUtils.isBlank(codeInRequest)) {
			throw new CaptchaException("驗證碼的值不能為空");
		}
		if (smsCaptchaInSession == null) {
			throw new CaptchaException("驗證碼不存在");
		}
		if (smsCaptchaInSession.isExpried()) {
			sessionStrategy.removeAttribute(swr, smsSessionKey);
			throw new CaptchaException("驗證碼已過期");
		}
		if (!StringUtils.equals(smsCaptchaInSession.getCode(), codeInRequest)) {
			throw new CaptchaException("驗證碼不匹配");
		}
		//驗證通過 移除快取
		sessionStrategy.removeAttribute(swr, smsSessionKey);
	}
}

BrowserSecurityConfig 瀏覽器配置同步調整

package com.rui.tiger.auth.browser.config;

import com.rui.tiger.auth.core.authentication.TigerAuthenticationFailureHandler;
import com.rui.tiger.auth.core.authentication.TigerAuthenticationSuccessHandler;
import com.rui.tiger.auth.core.authentication.mobile.SmsAuthenticationSecurityConfig;
import com.rui.tiger.auth.core.captcha.CaptchaFilter;
import com.rui.tiger.auth.core.captcha.sms.SmsCaptchaFilter;
import com.rui.tiger.auth.core.properties.SecurityProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;

import javax.sql.DataSource;

/**
 * 瀏覽器security配置類
 *
 * @author CaiRui
 * @date 2018-12-4 8:41
 */
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {

	@Autowired
	private SecurityProperties securityProperties;
	@Autowired
	private TigerAuthenticationFailureHandler tigerAuthenticationFailureHandler;
	@Autowired
	private TigerAuthenticationSuccessHandler tigerAuthenticationSuccessHandler;
	@Autowired
	private DataSource dataSource;
	@Autowired
	private UserDetailsService userDetailsService;
	@Autowired
	private SmsAuthenticationSecurityConfig smsAuthenticationSecurityConfig;//簡訊登陸配置

	/**
	 * 密碼加密解密
	 *
	 * @return
	 */
	@Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}

	/**
	 * 記住我持久化資料來源
	 * JdbcTokenRepositoryImpl  CREATE_TABLE_SQL 建表語句可以先在資料庫中執行
	 *
	 * @return
	 */
	@Bean
	public PersistentTokenRepository persistentTokenRepository() {
		JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
		jdbcTokenRepository.setDataSource(dataSource);
		//第一次會執行CREATE_TABLE_SQL建表語句 後續會報錯 可以關掉
		//jdbcTokenRepository.setCreateTableOnStartup(true);
		return jdbcTokenRepository;
	}


	@Override
	protected void configure(HttpSecurity http) throws Exception {
		//加入圖片驗證碼過濾器
		CaptchaFilter captchaFilter = new CaptchaFilter();
		captchaFilter.setFailureHandler(tigerAuthenticationFailureHandler);
		captchaFilter.setSecurityProperties(securityProperties);
		captchaFilter.afterPropertiesSet();
		//簡訊驗證碼的配置
		SmsCaptchaFilter smsCaptchaFilter = new SmsCaptchaFilter();
		smsCaptchaFilter.setFailureHandler(tigerAuthenticationFailureHandler);
		smsCaptchaFilter.setSecurityProperties(securityProperties);
		smsCaptchaFilter.afterPropertiesSet();

		//將驗證碼的過濾器放在登陸的前面
		http.addFilterBefore(smsCaptchaFilter,UsernamePasswordAuthenticationFilter.class)
				.addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class)
				.formLogin()
				.loginPage("/authentication/require")//自定義登入請求
				.loginProcessingUrl("/authentication/form")//自定義登入表單請求
				.successHandler(tigerAuthenticationSuccessHandler)
				.failureHandler(tigerAuthenticationFailureHandler)
				.and()
				//記住我相關配置
				.rememberMe()
				.tokenRepository(persistentTokenRepository())
				.tokenValiditySeconds(securityProperties.getBrowser().getRemberMeSeconds())
				.userDetailsService(userDetailsService)
				.and()
				.authorizeRequests()
				.antMatchers(securityProperties.getBrowser().getLoginPage(),
						"/authentication/require", "/captcha/*")//此路徑放行 否則會陷入死迴圈
				.permitAll()
				.anyRequest()
				.authenticated()
				.and()
				.csrf().disable()//跨域關閉
				//簡訊登陸配置掛載
				.apply(smsAuthenticationSecurityConfig)
		;
	}

}

ok 幾個核心類都開發完畢下面我們進行測試下

測試

  1. 登入頁面
  2. 點擊發送簡訊驗證碼,後臺日誌檢視驗證碼       
  3. 返回到登入頁面,輸入驗證碼進行登陸確認
  4. 後臺複製真正傳送的驗證碼新增
  5. 提交簡訊登入

1.登入

2.傳送驗證碼 後臺日誌獲取 

3.輸入簡訊驗證碼 先輸入錯誤的

4.返回提示

其它各種情況自行除錯驗證。

總結