Spring Security 實戰乾貨:從零手寫一個驗證碼登陸

1. 前言

前面關於Spring Security寫了兩篇文章,一篇是介紹UsernamePasswordAuthenticationFilter,另外一篇是介紹 AuthenticationManager。不少同窗表示沒法理解這兩個東西有什麼用,能解決哪些實際問題?因此今天就對這兩篇理論進行實戰運用,咱們從零寫一個短信驗證碼登陸並適配到Spring Security體系中。若是你在閱讀中有什麼疑問能夠回頭看看這兩篇文章,能解決不少疑惑。html

固然你能夠修改爲郵箱或者其它通信設備的驗證碼登陸。

2. 驗證碼生命週期

驗證碼存在有效期,通常5分鐘。 通常邏輯是用戶輸入手機號後去獲取驗證碼,服務端對驗證碼進行緩存。在最大有效期內用戶只能使用驗證碼驗證成功一次(避免驗證碼浪費);超過最大時間後失效。

驗證碼的緩存生命週期:java

public interface CaptchaCacheStorage {

    /**
     * 驗證碼放入緩存.
     *
     * @param phone the phone
     * @return the string
     */
    String put(String phone);

    /**
     * 從緩存取驗證碼.
     *
     * @param phone the phone
     * @return the string
     */
    String get(String phone);

    /**
     * 驗證碼手動過時.
     *
     * @param phone the phone
     */
    void expire(String phone);
}

咱們通常會藉助於緩存中間件,好比RedisEhcacheMemcached等等來作這個事情。爲了方便收看該教程的同窗們所使用的不一樣的中間件。這裏我結合Spring Cache特地抽象了驗證碼的緩存處理。web

private static final String SMS_CAPTCHA_CACHE = "captcha";
@Bean
CaptchaCacheStorage captchaCacheStorage() {
    return new CaptchaCacheStorage() {

        @CachePut(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone")
        @Override
        public String put(String phone) {
            return RandomUtil.randomNumbers(5);
        }

        @Cacheable(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone")
        @Override
        public String get(String phone) {
            return null;
        }

        @CacheEvict(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone")
        @Override
        public void expire(String phone) {

        }
    };
}
務必保證緩存的可靠性,這與用戶的體驗息息相關。

接着咱們就來編寫驗證碼服務了,驗證碼服務的核心功能有兩個:發送驗證碼驗證碼校驗。其它的諸如統計、黑名單、歷史記錄可根據實際業務定製。這裏只實現核心功能。spring

/**
 * 驗證碼服務.
 * 兩個功能: 發送和校驗.
 *
 * @param captchaCacheStorage the captcha cache storage
 * @return the captcha service
 */
@Bean
public CaptchaService captchaService(CaptchaCacheStorage captchaCacheStorage) {
    return new CaptchaService() {
        @Override
        public boolean sendCaptcha(String phone) {
            String existed = captchaCacheStorage.get(phone);
            if (StringUtils.hasText(existed)) {
                // 節約成本的話若是緩存中有可用的驗證碼 再也不發新的驗證碼
                log.warn("captcha code 【 {} 】 is available now", existed);
                return false;
            }
            // 生成驗證碼並放入緩存
            String captchaCode = captchaCacheStorage.put(phone);
            log.info("captcha: {}", captchaCode);

            //todo 這裏自行完善調用第三方短信服務發送驗證碼
            return true;
        }

        @Override
        public boolean verifyCaptcha(String phone, String code) {
            String cacheCode = captchaCacheStorage.get(phone);

            if (Objects.equals(cacheCode, code)) {
                // 驗證經過手動過時
                captchaCacheStorage.expire(phone);
                return true;
            }
            return false;
        }
    };
}

接下來就能夠根據CaptchaService編寫短信發送接口/captcha/{phone}了。數據庫

@RestController
@RequestMapping("/captcha")
public class CaptchaController {

    @Resource
    CaptchaService captchaService;


    /**
     * 模擬手機號發送驗證碼.
     *
     * @param phone the mobile
     * @return the rest
     */
    @GetMapping("/{phone}")
    public Rest<?> captchaByMobile(@PathVariable String phone) {
        //todo 手機號 正則自行驗證

        if (captchaService.sendCaptcha(phone)){
            return RestBody.ok("驗證碼發送成功");
        }
        return RestBody.failure(-999,"驗證碼發送失敗");
    }

}

3. 集成到Spring Security

下面的教程就必須用到前兩篇介紹的知識了。咱們要實現驗證碼登陸就必須定義一個Servlet Filter進行處理。它的做用這裏再重複一下:緩存

  • 攔截短信登陸接口。
  • 獲取登陸參數並封裝爲Authentication憑據。
  • 交給AuthenticationManager認證。

咱們須要先定製AuthenticationAuthenticationManagerapp

3.1 驗證碼憑據

Authentication在我看來就是一個載體,在未獲得認證以前它用來攜帶登陸的關鍵參數,好比用戶名和密碼、驗證碼;在認證成功後它攜帶用戶的信息和角色集。因此模仿UsernamePasswordAuthenticationToken 來實現一個CaptchaAuthenticationToken,去掉沒必要要的功能,抄就完事兒了:dom

package cn.felord.spring.security.captcha;

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

import java.util.Collection;

/**
 * 驗證碼認證憑據.
 * @author felord.cn
 */
public class CaptchaAuthenticationToken extends AbstractAuthenticationToken {

    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    private final Object principal;
    private String captcha;

    /**
     * 此構造函數用來初始化未授信憑據.
     *
     * @param principal   the principal
     * @param captcha the captcha
     * @see CaptchaAuthenticationToken#CaptchaAuthenticationToken(Object, String, Collection)
     */
    public CaptchaAuthenticationToken(Object principal, String captcha) {
        super(null);
        this.principal =  principal;
        this.captcha = captcha;
        setAuthenticated(false);
    }

    /**
     * 此構造函數用來初始化授信憑據.
     *
     * @param principal       the principal
     * @param captcha     the captcha
     * @param authorities the authorities
     * @see CaptchaAuthenticationToken#CaptchaAuthenticationToken(Object, String)
     */
    public CaptchaAuthenticationToken(Object principal, String captcha,
                                      Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        this.captcha = captcha;
        super.setAuthenticated(true); // must use super, as we override
    }

    public Object getCredentials() {
        return this.captcha;
    }

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

        super.setAuthenticated(false);
    }

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
        captcha = null;
    }

3.2 驗證碼認證管理器

咱們還須要定製一個AuthenticationManager來對上面定義的憑據CaptchaAuthenticationToken進行認證處理。下面這張圖有必要再拿出來看一下:ide

ProviderManager

要定義AuthenticationManager只須要定義其實現ProviderManager。而ProviderManager又須要依賴AuthenticationProvider。因此咱們要實現一個專門處理CaptchaAuthenticationTokenAuthenticationProviderAuthenticationProvider的流程是:函數

  1. CaptchaAuthenticationToken拿到手機號、驗證碼。
  2. 利用手機號從數據庫查詢用戶信息,並判斷用戶是不是有效用戶,實際上就是實現UserDetailsService接口
  3. 驗證碼校驗。
  4. 校驗成功則封裝授信的憑據。
  5. 校驗失敗拋出認證異常。

根據這個流程實現以下:

package cn.felord.spring.security.captcha;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceAware;
import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityMessageSource;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.util.Assert;

import java.util.Collection;
import java.util.Objects;

/**
 * 驗證碼認證器.
 * @author felord.cn
 */
@Slf4j
public class CaptchaAuthenticationProvider implements AuthenticationProvider, InitializingBean, MessageSourceAware {
    private final GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
    private final UserDetailsService userDetailsService;
    private final CaptchaService captchaService;
    private MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();

    /**
     * Instantiates a new Captcha authentication provider.
     *
     * @param userDetailsService the user details service
     * @param captchaService     the captcha service
     */
    public CaptchaAuthenticationProvider(UserDetailsService userDetailsService, CaptchaService captchaService) {
        this.userDetailsService = userDetailsService;
        this.captchaService = captchaService;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Assert.isInstanceOf(CaptchaAuthenticationToken.class, authentication,
                () -> messages.getMessage(
                        "CaptchaAuthenticationProvider.onlySupports",
                        "Only CaptchaAuthenticationToken is supported"));

        CaptchaAuthenticationToken unAuthenticationToken = (CaptchaAuthenticationToken) authentication;

        String phone = unAuthenticationToken.getName();
        String rawCode = (String) unAuthenticationToken.getCredentials();

        UserDetails userDetails = userDetailsService.loadUserByUsername(phone);

        // 此處省略對UserDetails 的可用性 是否過時  是否鎖定 是否失效的檢驗  建議根據實際狀況添加  或者在 UserDetailsService 的實現中處理
        if (Objects.isNull(userDetails)) {
            throw new BadCredentialsException("Bad credentials");
        }

        // 驗證碼校驗
        if (captchaService.verifyCaptcha(phone, rawCode)) {
            return createSuccessAuthentication(authentication, userDetails);
        } else {
            throw new BadCredentialsException("captcha is not matched");
        }

    }

    @Override
    public boolean supports(Class<?> authentication) {
        return CaptchaAuthenticationToken.class.isAssignableFrom(authentication);
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        Assert.notNull(userDetailsService, "userDetailsService must not be null");
        Assert.notNull(captchaService, "captchaService must not be null");
    }

    @Override
    public void setMessageSource(MessageSource messageSource) {
        this.messages = new MessageSourceAccessor(messageSource);
    }

    /**
     * 認證成功將非授信憑據轉爲授信憑據.
     * 封裝用戶信息 角色信息。
     *
     * @param authentication the authentication
     * @param user           the user
     * @return the authentication
     */
    protected Authentication createSuccessAuthentication(Authentication authentication, UserDetails user) {

        Collection<? extends GrantedAuthority> authorities = authoritiesMapper.mapAuthorities(user.getAuthorities());
        CaptchaAuthenticationToken authenticationToken = new CaptchaAuthenticationToken(user, null, authorities);
        authenticationToken.setDetails(authentication.getDetails());

        return authenticationToken;
    }

}

而後就能夠組裝ProviderManager了:

ProviderManager providerManager = new ProviderManager(Collections.singletonList(captchaAuthenticationProvider));

通過3.13.2的準備,咱們的準備工做就完成了。

3.3 驗證碼認證過濾器

定製好驗證碼憑據和驗證碼認證管理器後咱們就能夠定義驗證碼認證過濾器了。修改一下UsernamePasswordAuthenticationFilter就能知足需求:

package cn.felord.spring.security.captcha;

import org.springframework.lang.Nullable;
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 javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class CaptchaAuthenticationFilter extends AbstractAuthenticationProcessingFilter {


    public static final String SPRING_SECURITY_FORM_PHONE_KEY = "phone";
    public static final String SPRING_SECURITY_FORM_CAPTCHA_KEY = "captcha";


    public CaptchaAuthenticationFilter() {
        super(new AntPathRequestMatcher("/clogin", "POST"));
    }

    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response) throws AuthenticationException {

        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + request.getMethod());
        }

        String phone = obtainPhone(request);
        String captcha = obtainCaptcha(request);

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

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

        phone = phone.trim();

        CaptchaAuthenticationToken authRequest = new CaptchaAuthenticationToken(
                phone, captcha);

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

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

    @Nullable
    protected String obtainCaptcha(HttpServletRequest request) {
        return request.getParameter(SPRING_SECURITY_FORM_CAPTCHA_KEY);
    }

    @Nullable
    protected String obtainPhone(HttpServletRequest request) {
        return request.getParameter(SPRING_SECURITY_FORM_PHONE_KEY);
    }

    protected void setDetails(HttpServletRequest request,
                              CaptchaAuthenticationToken authRequest) {
        authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
    }

}

這裏咱們指定了攔截驗證碼登錄的請求爲:

POST /clogin?phone=手機號&captcha=驗證碼 HTTP/1.1
Host: localhost:8082

接下來就是配置了。

3.4 配置

我把全部的驗證碼認證的相關配置集中了起來,並加上了註釋。

package cn.felord.spring.security.captcha;

import cn.hutool.core.util.RandomUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.util.StringUtils;

import java.util.Collections;
import java.util.Objects;

/**
 * 驗證碼認證配置.
 *
 * @author felord.cn
 * @since 13 :23
 */
@Slf4j
@Configuration
public class CaptchaAuthenticationConfiguration {
    private static final String SMS_CAPTCHA_CACHE = "captcha";

    /**
     * spring cache 管理驗證碼的生命週期.
     *
     * @return the captcha cache storage
     */
    @Bean
    CaptchaCacheStorage captchaCacheStorage() {
        return new CaptchaCacheStorage() {

            @CachePut(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone")
            @Override
            public String put(String phone) {
                return RandomUtil.randomNumbers(5);
            }

            @Cacheable(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone")
            @Override
            public String get(String phone) {
                return null;
            }

            @CacheEvict(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone")
            @Override
            public void expire(String phone) {

            }
        };
    }

    /**
     * 驗證碼服務.
     * 兩個功能: 發送和校驗.
     *
     * @param captchaCacheStorage the captcha cache storage
     * @return the captcha service
     */
    @Bean
    public CaptchaService captchaService(CaptchaCacheStorage captchaCacheStorage) {
        return new CaptchaService() {
            @Override
            public boolean sendCaptcha(String phone) {
                String existed = captchaCacheStorage.get(phone);
                if (StringUtils.hasText(existed)) {
                    // 節約成本的話若是緩存存在可用的驗證碼 再也不發新的驗證碼
                    log.warn("captcha code 【 {} 】 is available now", existed);
                    return false;
                }
                // 生成驗證碼並放入緩存
                String captchaCode = captchaCacheStorage.put(phone);
                log.info("captcha: {}", captchaCode);

                //todo 這裏自行完善調用第三方短信服務
                return true;
            }

            @Override
            public boolean verifyCaptcha(String phone, String code) {
                String cacheCode = captchaCacheStorage.get(phone);

                if (Objects.equals(cacheCode, code)) {
                    // 驗證經過手動過時
                    captchaCacheStorage.expire(phone);
                    return true;
                }
                return false;
            }
        };
    }

    /**
     * 自行實現根據手機號查詢可用的用戶,這裏簡單舉例.
     * 注意該接口可能出現多態。因此最好加上註解@Qualifier
     *
     * @return the user details service
     */
    @Bean
    @Qualifier("captchaUserDetailsService")
    public UserDetailsService captchaUserDetailsService() {
        // 驗證碼登錄後密碼無心義了可是須要填充一下
        return username -> User.withUsername(username).password("TEMP")
                //todo  這裏權限 你須要本身注入
                .authorities(AuthorityUtils.createAuthorityList("ROLE_ADMIN", "ROLE_APP")).build();
    }

    /**
     * 驗證碼認證器.
     *
     * @param captchaService     the captcha service
     * @param userDetailsService the user details service
     * @return the captcha authentication provider
     */
    @Bean
    public CaptchaAuthenticationProvider captchaAuthenticationProvider(CaptchaService captchaService,
                                                                       @Qualifier("captchaUserDetailsService")
                                                                               UserDetailsService userDetailsService) {
        return new CaptchaAuthenticationProvider(userDetailsService, captchaService);
    }


    /**
     * 驗證碼認證過濾器.
     *
     * @param authenticationSuccessHandler  the authentication success handler
     * @param authenticationFailureHandler  the authentication failure handler
     * @param captchaAuthenticationProvider the captcha authentication provider
     * @return the captcha authentication filter
     */
    @Bean
    public CaptchaAuthenticationFilter captchaAuthenticationFilter(AuthenticationSuccessHandler authenticationSuccessHandler,
                                                                   AuthenticationFailureHandler authenticationFailureHandler,
                                                                   CaptchaAuthenticationProvider captchaAuthenticationProvider) {
        CaptchaAuthenticationFilter captchaAuthenticationFilter = new CaptchaAuthenticationFilter();
        // 配置 authenticationManager
        ProviderManager providerManager = new ProviderManager(Collections.singletonList(captchaAuthenticationProvider));
        captchaAuthenticationFilter.setAuthenticationManager(providerManager);
        // 成功處理器
        captchaAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
        // 失敗處理器
        captchaAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);

        return captchaAuthenticationFilter;
    }
}

然而這並無完,你須要將CaptchaAuthenticationFilter配置到整個Spring Security的過濾器鏈中,這種看了胖哥教程的同窗應該很是熟悉了。

配置驗證碼認證過濾器到WebSecurityConfigurerAdapter中

請特別注意:務必保證登陸接口和驗證碼接口能夠匿名訪問,若是是動態權限能夠給接口添加 ROLE_ANONYMOUS 角色。

大功告成,測試以下:

模擬驗證碼登陸

並且原先的登陸方式不受影響。

4. 總結

經過對UsernamePasswordAuthenticationFilterAuthenticationManager的系統學習,咱們瞭解了Spring Security認證的整個流程,本文是對這兩篇的一個實際運用。相信看到這一篇後你就不會對前幾篇的圖解懵逼了,這也是理論到實踐的一次嘗試。DEMO 能夠經過我的博客felord.cn相關文章獲取。

關注公衆號:Felordcn 獲取更多資訊

我的博客:https://felord.cn

相關文章
相關標籤/搜索