Spring Security 解析(四) ——短信登陸開發

Spring Security 解析(四) —— 短信登陸開發

  在學習Spring Cloud 時,遇到了受權服務oauth 相關內容時,老是隻知其一;不知其二,所以決定先把Spring Security 、Spring Security Oauth2 等權限、認證相關的內容、原理及設計學習並整理一遍。本系列文章就是在學習的過程當中增強印象和理解所撰寫的,若有侵權請告知。git

項目環境:github

- JDK1.8redis

- Spring boot 2.xspring

- Spring Security 5.x緩存

1、如何在Security的基礎上實現短信登陸功能?

  回顧下Security實現表單登陸的過程:app

https://user-gold-cdn.xitu.io/2019/9/2/16cf275b9ca9a2bd?w=1340&h=872&f=jpeg&s=86789

  從流程中咱們發現其在登陸過程當中存在特殊處理或者說擁有其餘姊妹實現子類的:dom

- AuthenticationFilter:用於攔截登陸請求;ide

- 未認證的Authentication 對象,做爲認證方法的入參;函數

- AuthenticationProvider 進行認證處理。工具

  所以咱們能夠徹底經過自定義 一個 SmsAuthenticationFilter 進行攔截 ,一個 SmsAuthenticationToken 來進行傳輸認證數據,一個 SmsAuthenticationProvider 進行認證業務處理。因爲咱們知道 UsernamePasswordAuthenticationFilter 的 doFilter 是經過 AbstractAuthenticationProcessingFilter 來實現的,而 UsernamePasswordAuthenticationFilter 自己只實現了attemptAuthentication() 方法。按照這樣的設計,咱們的 SmsAuthenticationFilter 也 只實現 attemptAuthentication() 方法,那麼如何進行驗證碼的驗證呢?這時咱們須要在 SmsAuthenticationFilter 前 調用 一個 實現驗證碼的驗證過濾 filter :ValidateCodeFilter。整理實現事後的流程以下圖:

https://user-gold-cdn.xitu.io/2019/9/2/16cf275bc967fc81?w=1258&h=934&f=jpeg&s=134973

2、短信登陸認證開發

(一) SmsAuthenticationFilter 實現

  模擬UsernamePasswordAuthenticationFilter實現SmsAuthenticationFilter後其代碼以下:

@EqualsAndHashCode(callSuper = true)
@Data
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    // 獲取request中傳遞手機號的參數名
    private String mobileParameter = SecurityConstants.DEFAULT_PARAMETER_NAME_MOBILE;

    private boolean postOnly = true;

    // 構造函數,主要配置其攔截器要攔截的請求地址url
    public SmsCodeAuthenticationFilter() {
        super(new AntPathRequestMatcher(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_MOBILE, "POST"));
    }


    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        // 判斷請求是否爲 POST 方式
        if (postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        // 調用 obtainMobile 方法從request中獲取手機號
        String mobile = obtainMobile(request);

        if (mobile == null) {
            mobile = "";
        }

        mobile = mobile.trim();

        // 建立 未認證的  SmsCodeAuthenticationToken  對象
        SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);

        setDetails(request, authRequest);
        
        // 調用 認證方法
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    /**
     * 獲取手機號
     */
    protected String obtainMobile(HttpServletRequest request) {
        return request.getParameter(mobileParameter);
    }
    
    /**
     * 原封不動照搬UsernamePasswordAuthenticationFilter 的實現 (注意這裏是 SmsCodeAuthenticationToken  )
     */
    protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {
        authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
    }

    /**
     * 開放設置 RemmemberMeServices 的set方法
     */
    @Override
    public void setRememberMeServices(RememberMeServices rememberMeServices) {
        super.setRememberMeServices(rememberMeServices);
    }
}複製代碼

其內部實現主要有幾個注意點:

- 設置傳輸手機號的參數屬性

- 構造方法調用父類的有參構造方法,主要用於設置其要攔截的url

- 照搬UsernamePasswordAuthenticationFilter 的 attemptAuthentication() 的實現 ,其內部須要改造有2點:一、 obtainMobile 獲取 手機號信息 二、建立 SmsCodeAuthenticationToken 對象

- 爲了實現短信登陸也擁有記住個人功能,這裏開放 setRememberMeServices() 方法用於設置 rememberMeServices 。

(二) SmsAuthenticationToken 實現

  同樣的咱們模擬UsernamePasswordAuthenticationToken實現SmsAuthenticationToken:

public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {

    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;


    private final Object principal;

    /**
     * 未認證時,內容爲手機號
     * @param mobile
     */
    public SmsCodeAuthenticationToken(String mobile) {
        super(null);
        this.principal = mobile;
        setAuthenticated(false);
    }

    /**
     *
     * 認證成功後,其中爲用戶信息
     *
     * @param principal
     * @param authorities
     */
    public SmsCodeAuthenticationToken(Object principal,
                                      Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        super.setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return null;
    }

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

    @Override
    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");
        }

        super.setAuthenticated(false);
    }

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
    }
}複製代碼

  對比UsernamePasswordAuthenticationToken,咱們減小了 credentials(能夠理解爲密碼),其餘的基本上是原封不動。

(三) SmsAuthenticationProvider 實現

  因爲SmsCodeAuthenticationProvider 是一個全新的不一樣的認證委託實現,所以這個咱們按照本身的設想寫,沒必要參照 DaoAuthenticationProvider。看下咱們本身實現的代碼:

@Data
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {

    private UserDetailsService userDetailsService;


    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;

        UserDetails user = userDetailsService.loadUserByUsername((String) authenticationToken.getPrincipal());

        if (user == null) {
            throw new InternalAuthenticationServiceException("沒法獲取用戶信息");
        }

        SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(user, user.getAuthorities());

        authenticationResult.setDetails(authenticationToken.getDetails());

        return authenticationResult;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
    }
}複製代碼

  經過直接繼承 AuthenticationProvider實現其接口方法 authenticate() 和 supports() 。 supports() 咱們直接參照其餘Provider寫的,這個主要是判斷當前處理的Authentication是否爲SmsCodeAuthenticationToken或其子類。 authenticate() 咱們就直接調用 userDetailsService的loadUserByUsername()方法簡單實現,由於驗證碼已經在 ValidateCodeFilter 驗證經過了,因此這裏咱們只要能經過手機號查詢到用戶信息那就直接判頂當前用戶認證成功,而且生成 已認證 的 SmsCodeAuthenticationToken返回。

(四) ValidateCodeFilter 實現

   正如咱們以前描述的同樣ValidateCodeFilter只作驗證碼的驗證,這裏咱們設置經過redis獲取生成驗證碼來對比用戶輸入的驗證碼:

@Component
public class ValidateCodeFilter extends OncePerRequestFilter implements InitializingBean {

    /**
     * 驗證碼校驗失敗處理器
     */
    @Autowired
    private AuthenticationFailureHandler authenticationFailureHandler;
    /**
     * 系統配置信息
     */
    @Autowired
    private SecurityProperties securityProperties;

    @Resource
    private StringRedisTemplate stringRedisTemplate;


    /**
     * 存放全部須要校驗驗證碼的url
     */
    private Map<String, String> urlMap = new HashMap<>();
    /**
     * 驗證請求url與配置的url是否匹配的工具類
     */
    private AntPathMatcher pathMatcher = new AntPathMatcher();

    /**
     * 初始化要攔截的url配置信息
     */
    @Override
    public void afterPropertiesSet() throws ServletException {
        super.afterPropertiesSet();

        urlMap.put(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_MOBILE, SecurityConstants.DEFAULT_PARAMETER_NAME_CODE_SMS);
        addUrlToMap(securityProperties.getSms().getSendSmsUrl(), SecurityConstants.DEFAULT_PARAMETER_NAME_CODE_SMS);
    }

    /**
     * 講系統中配置的須要校驗驗證碼的URL根據校驗的類型放入map
     *
     * @param urlString
     * @param smsParam
     */
    protected void addUrlToMap(String urlString, String smsParam) {
        if (StringUtils.isNotBlank(urlString)) {
            String[] urls = StringUtils.splitByWholeSeparatorPreserveAllTokens(urlString, ",");
            for (String url : urls) {
                urlMap.put(url, smsParam);
            }
        }
    }

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

        String code = request.getParameter(getValidateCode(request));
        if (code != null) {
            try {
                String oldCode = stringRedisTemplate.opsForValue().get(request.getParameter(SecurityConstants.DEFAULT_PARAMETER_NAME_MOBILE));
                if (StringUtils.equalsIgnoreCase(oldCode,code)) {
                    logger.info("驗證碼校驗經過");
                } else {
                    throw new ValidateCodeException("驗證碼失效或錯誤!");
                }
            } catch (AuthenticationException e) {
                authenticationFailureHandler.onAuthenticationFailure(request, response, e);
                return;
            }
        }
        chain.doFilter(request, response);
    }

    /**
     * 獲取校驗碼
     *
     * @param request
     * @return
     */
    private String getValidateCode(HttpServletRequest request) {
        String result = null;
        if (!StringUtils.equalsIgnoreCase(request.getMethod(), "get")) {
            Set<String> urls = urlMap.keySet();
            for (String url : urls) {
                if (pathMatcher.match(url, request.getRequestURI())) {
                    result = urlMap.get(url);
                }
            }
        }
        return result;
    }
}複製代碼

這裏主要看下 doFilterInternal 實現驗證碼驗證邏輯便可。

3、如何將設置SMS的Filter加入到FilterChain生效呢?

這裏咱們須要引進新的配置類 SmsCodeAuthenticationSecurityConfig,其實現代碼以下:

@Component
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    @Autowired
    private AuthenticationSuccessHandler authenticationSuccessHandler ;

    @Autowired
    private AuthenticationFailureHandler authenticationFailureHandler;

    @Resource
    private UserDetailsService userDetailsService;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
        // 設置 AuthenticationManager
        smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        // 分別設置成功和失敗處理器
        smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
        smsCodeAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);
        // 設置 RememberMeServices
        smsCodeAuthenticationFilter.setRememberMeServices(http
                .getSharedObject(RememberMeServices.class));

        // 建立 SmsCodeAuthenticationProvider 並設置 userDetailsService
        SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
        smsCodeAuthenticationProvider.setUserDetailsService(userDetailsService);

        // 將Provider添加到其中
        http.authenticationProvider(smsCodeAuthenticationProvider)
                // 將過濾器添加到UsernamePasswordAuthenticationFilter後面
                .addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

    }複製代碼

最後咱們須要 在 SpringSecurityConfig 配置類中引用 SmsCodeAuthenticationSecurityConfig :

http.addFilterBefore(validateCodeFilter, AbstractPreAuthenticatedProcessingFilter.class)
                .apply(smsCodeAuthenticationSecurityConfig)
                . ...複製代碼

4、新增發送驗證碼接口和驗證碼登陸表單

   新增發送驗證碼接口(主要設置成無權限訪問):

@GetMapping("/send/sms/{mobile}")
    public void sendSms(@PathVariable String mobile) {
        // 隨機生成 6 位的數字串
        String code = RandomStringUtils.randomNumeric(6);
        // 經過 stringRedisTemplate 緩存到redis中 
        stringRedisTemplate.opsForValue().set(mobile, code, 60 * 5, TimeUnit.SECONDS);
        // 模擬發送短信驗證碼
        log.info("向手機: " + mobile + " 發送短信驗證碼是: " + code);
    }複製代碼

   新增驗證碼登陸表單:

// 注意這裏的請求接口要與 SmsAuthenticationFilter的構造函數 設置的一致
<form action="/loginByMobile" method="post">
    <table>
        <tr>
            <td>手機號:</td>
            <td><input type="text" name="mobile" value="15680659123"></td>
        </tr>
        <tr>
            <td>短信驗證碼:</td>
            <td>
                <input type="text" name="smsCode">
                <a href="/send/sms/15680659123">發送驗證碼</a>
            </td>
        </tr>
        <tr>
            <td colspan='2'><input name="remember-me" type="checkbox" value="true"/>記住我</td>
        </tr>
        <tr>
            <td colspan="2">
                <button type="submit">登陸</button>
            </td>
        </tr>
    </table>
</form>複製代碼

5、我的總結

  其實實現另外一種登陸方式,關鍵點就在與 filter 、 AuthenticationToken、AuthenticationProvider 這3個點上。整理出來就是: 經過自定義 一個 SmsAuthenticationFilter 進行攔截 ,一個 AuthenticationToken 來進行傳輸認證數據,一個 AuthenticationProvider 進行認證業務處理。因爲咱們知道 UsernamePasswordAuthenticationFilter 的 doFilter 是經過 AbstractAuthenticationProcessingFilter 來實現的,而 UsernamePasswordAuthenticationFilter 自己只實現了attemptAuthentication() 方法。按照這樣的設計,咱們的 AuthenticationFilter 也 只實現 attemptAuthentication() 方法,但同時須要在 AuthenticationFilter 前 調用 一個 實現驗證過濾 filter :ValidatFilter。 正以下面的流程圖同樣,能夠按照這種方式添加任意一種登陸方式:

https://user-gold-cdn.xitu.io/2019/9/2/16cf275bc967fc81?w=1258&h=934&f=jpeg&s=134973

   本文介紹短信登陸開發的代碼能夠訪問代碼倉庫中的 security 模塊 ,項目的github 地址 : https://github.com/BUG9/spring-security

         若是您對這些感興趣,歡迎star、follow、收藏、轉發給予支持!

相關文章
相關標籤/搜索