SpringSceurity(5)---簡訊驗證碼登陸功能

雨點的名字發表於2020-06-27

SpringSceurity(5)---簡訊驗證碼登陸功能

有關SpringSceurity系列之前有寫文章

1、SpringSecurity(1)---認證+授權程式碼實現

2、SpringSecurity(2)---記住我功能實現

3、SpringSceurity(3)---圖形驗證碼功能實現

4、SpringSceurity(4)---簡訊驗證碼功能實現

一、簡訊登入驗證機制原理分析

瞭解簡訊驗證碼的登陸機制之前,我們首先是要了解使用者賬號密碼登陸的機制是如何的,我們來簡要分析一下Spring Security是如何驗證基於使用者名稱和密碼登入方式的,

分析完畢之後,再一起思考如何將簡訊登入驗證方式整合到Spring Security中。

1、賬號密碼登陸的流程

一般賬號密碼登陸都有附帶 圖形驗證碼記住我功能 ,那麼它的大致流程是這樣的。

1、 使用者在輸入使用者名稱,賬號、圖片驗證碼後點選登陸。那麼對於springSceurity首先會進入簡訊驗證碼Filter,因為在配置的時候會把它配置在
UsernamePasswordAuthenticationFilter之前,把當前的驗證碼的資訊跟存在session的圖片驗證碼的驗證碼進行校驗。

2、簡訊驗證碼通過後,進入 UsernamePasswordAuthenticationFilter 中,根據輸入的使用者名稱和密碼資訊,構造出一個暫時沒有鑑權的
 UsernamePasswordAuthenticationToken,並將 UsernamePasswordAuthenticationToken 交給 AuthenticationManager 處理。

3、AuthenticationManager 本身並不做驗證處理,他通過 for-each 遍歷找到符合當前登入方式的一個 AuthenticationProvider,並交給它進行驗證處理
,對於使用者名稱密碼登入方式,這個 Provider 就是 DaoAuthenticationProvider。

4、在這個 Provider 中進行一系列的驗證處理,如果驗證通過,就會重新構造一個新增了鑑權的 UsernamePasswordAuthenticationToken,並將這個
 token 傳回到 UsernamePasswordAuthenticationFilter 中。

5、在該 Filter 的父類 AbstractAuthenticationProcessingFilter 中,會根據上一步驗證的結果,跳轉到 successHandler 或者是 failureHandler。

流程圖

SpringSceurity(5)---簡訊驗證碼登陸功能

2、簡訊驗證碼登陸流程

因為簡訊登入的方式並沒有整合到Spring Security中,所以往往還需要我們自己開發簡訊登入邏輯,將其整合到Spring Security中,那麼這裡我們就模仿賬號

密碼登陸來實現簡訊驗證碼登陸。

1、使用者名稱密碼登入有個 UsernamePasswordAuthenticationFilter,我們搞一個SmsAuthenticationFilter,程式碼粘過來改一改。
2、使用者名稱密碼登入需要UsernamePasswordAuthenticationToken,我們搞一個SmsAuthenticationToken,程式碼粘過來改一改。
3、使用者名稱密碼登入需要DaoAuthenticationProvider,我們模仿它也 implenments AuthenticationProvider,叫做 SmsAuthenticationProvider。
SpringSceurity(5)---簡訊驗證碼登陸功能

這個圖是網上找到,自己不想畫了

我們自己搞了上面三個類以後,想要實現的效果如上圖所示。當我們使用簡訊驗證碼登入的時候:

1、先經過 SmsAuthenticationFilter,構造一個沒有鑑權的 SmsAuthenticationToken,然後交給 AuthenticationManager處理。

2、AuthenticationManager 通過 for-each 挑選出一個合適的 provider 進行處理,當然我們希望這個 provider 要是 SmsAuthenticationProvider。

3、驗證通過後,重新構造一個有鑑權的SmsAuthenticationToken,並返回給SmsAuthenticationFilter。
filter 根據上一步的驗證結果,跳轉到成功或者失敗的處理邏輯。

二、程式碼實現

1、SmsAuthenticationToken

首先我們編寫 SmsAuthenticationToken,這裡直接參考 UsernamePasswordAuthenticationToken 原始碼,直接粘過來,改一改。

說明

principal 原本代表使用者名稱,這裡保留,只是代表了手機號碼。
credentials 原本程式碼密碼,簡訊登入用不到,直接刪掉。
SmsCodeAuthenticationToken() 兩個構造方法一個是構造沒有鑑權的,一個是構造有鑑權的。
剩下的幾個方法去除無用屬性即可。

程式碼

public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {

    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    /**
     * 在 UsernamePasswordAuthenticationToken 中該欄位代表登入的使用者名稱,
     * 在這裡就代表登入的手機號碼
     */
    private final Object principal;

    /**
     * 構建一個沒有鑑權的 SmsCodeAuthenticationToken
     */
    public SmsCodeAuthenticationToken(Object principal) {
        super(null);
        this.principal = principal;
        setAuthenticated(false);
    }

    /**
     * 構建擁有鑑權的 SmsCodeAuthenticationToken
     */
    public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        // must use super, as we override
        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();
    }
}

2、SmsAuthenticationFilter

然後編寫 SmsAuthenticationFilter,參考 UsernamePasswordAuthenticationFilter 的原始碼,直接粘過來,改一改。

說明

原本的靜態欄位有 usernamepassword,都幹掉,換成我們的手機號欄位。
SmsCodeAuthenticationFilter() 中指定了這個 filter 的攔截 Url,我指定為 post 方式的 /sms/login
剩下來的方法把無效的刪刪改改就好了。

程式碼

public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    /**
     * form表單中手機號碼的欄位name
     */
    public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile";

    private String mobileParameter = "mobile";
    /**
     * 是否僅 POST 方式
     */
    private boolean postOnly = true;

    public SmsCodeAuthenticationFilter() {
        //簡訊驗證碼的地址為/sms/login 請求也是post
        super(new AntPathRequestMatcher("/sms/login", "POST"));
    }

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

        SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);

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

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

    protected String obtainMobile(HttpServletRequest request) {
        return request.getParameter(mobileParameter);
    }

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

    public String getMobileParameter() {
        return mobileParameter;
    }

    public void setMobileParameter(String mobileParameter) {
        Assert.hasText(mobileParameter, "Mobile parameter must not be empty or null");
        this.mobileParameter = mobileParameter;
    }

    public void setPostOnly(boolean postOnly) {
        this.postOnly = postOnly;
    }
}

3、SmsAuthenticationProvider

這個方法比較重要,這個方法首先能夠在使用簡訊驗證碼登陸時候被 AuthenticationManager 挑中,其次要在這個類中處理驗證邏輯。

說明

實現 AuthenticationProvider 介面,實現 authenticate() 和 supports() 方法。

程式碼

public class SmsCodeAuthenticationProvider implements AuthenticationProvider {

    private UserDetailsService userDetailsService;

    /**
     * 處理session工具類
     */
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    String SESSION_KEY_PREFIX = "SESSION_KEY_FOR_CODE_SMS";

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;

        String mobile = (String) authenticationToken.getPrincipal();

        checkSmsCode(mobile);

        UserDetails userDetails = userDetailsService.loadUserByUsername(mobile);
        // 此時鑑權成功後,應當重新 new 一個擁有鑑權的 authenticationResult 返回
        SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(userDetails, userDetails.getAuthorities());
        authenticationResult.setDetails(authenticationToken.getDetails());

        return authenticationResult;
    }

    private void checkSmsCode(String mobile) {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        // 從session中獲取圖片驗證碼
        SmsCode smsCodeInSession = (SmsCode) sessionStrategy.getAttribute(new ServletWebRequest(request), SESSION_KEY_PREFIX);
        String inputCode = request.getParameter("smsCode");
        if(smsCodeInSession == null) {
            throw new BadCredentialsException("未檢測到申請驗證碼");
        }

        String mobileSsion = smsCodeInSession.getMobile();
        if(!Objects.equals(mobile,mobileSsion)) {
            throw new BadCredentialsException("手機號碼不正確");
        }

        String codeSsion = smsCodeInSession.getCode();
        if(!Objects.equals(codeSsion,inputCode)) {
            throw new BadCredentialsException("驗證碼錯誤");
        }
    }

    @Override
    public boolean supports(Class<?> authentication) {
        // 判斷 authentication 是不是 SmsCodeAuthenticationToken 的子類或子介面
        return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
    }

    public UserDetailsService getUserDetailsService() {
        return userDetailsService;
    }

    public void setUserDetailsService(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }
}

4、SmsCodeAuthenticationSecurityConfig

既然自定義了攔截器,可以需要在配置裡做改動。

程式碼

@Component
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    @Autowired
    private SmsUserService smsUserService;
    @Autowired
    private AuthenctiationSuccessHandler authenctiationSuccessHandler;
    @Autowired
    private AuthenctiationFailHandler authenctiationFailHandler;

    @Override
    public void configure(HttpSecurity http) {
        SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
        smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(authenctiationSuccessHandler);
        smsCodeAuthenticationFilter.setAuthenticationFailureHandler(authenctiationFailHandler);

        SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
        //需要將通過使用者名稱查詢使用者資訊的介面換成通過手機號碼實現
        smsCodeAuthenticationProvider.setUserDetailsService(smsUserService);

        http.authenticationProvider(smsCodeAuthenticationProvider)
                .addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

5、SmsUserService

因為使用者名稱,密碼登陸最終是通過使用者名稱查詢使用者資訊,而手機驗證碼登陸是通過手機登陸,所以這裡需要自己再實現一個SmsUserService

@Service
@Slf4j
public class SmsUserService implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private RolesUserMapper rolesUserMapper;

    @Autowired
    private RolesMapper rolesMapper;

    /**
     * 手機號查詢使用者
     */
    @Override
    public UserDetails loadUserByUsername(String mobile) throws UsernameNotFoundException {
        log.info("手機號查詢使用者,手機號碼 = {}",mobile);
        //TODO 這裡我沒有寫通過手機號去查使用者資訊的sql,因為一開始我建user表的時候,沒有建mobile欄位,現在我也不想臨時加上去
        //TODO 所以這裡暫且寫死用使用者名稱去查詢使用者資訊(理解就好)
        User user = userMapper.findOneByUsername("小小");
        if (user == null) {
            throw new UsernameNotFoundException("未查詢到使用者資訊");
        }
        //獲取使用者關聯角色資訊 如果為空說明使用者並未關聯角色
        List<RolesUser> userList = rolesUserMapper.findAllByUid(user.getId());
        if (CollectionUtils.isEmpty(userList)) {
            return user;
        }
        //獲取角色ID集合
        List<Integer> ridList = userList.stream().map(RolesUser::getRid).collect(Collectors.toList());
        List<Roles> rolesList = rolesMapper.findByIdIn(ridList);
        //插入使用者角色資訊
        user.setRoles(rolesList);
        return user;
    }
}

6、總結

到這裡思路就很清晰了,我這裡在總結下。

1、首先從獲取驗證的時候,就已經把當前驗證碼資訊存到session,這個資訊包含驗證碼和手機號碼。

2、使用者輸入驗證登陸,這裡是直接寫在SmsAuthenticationFilter中先校驗驗證碼、手機號是否正確,再去查詢使用者資訊。我們也可以拆開成使用者名稱密碼登陸那樣一個
過濾器專門驗證驗證碼和手機號是否正確,正確在走驗證碼登陸過濾器。

3、在SmsAuthenticationFilter流程中也有關鍵的一步,就是使用者名稱密碼登陸是自定義UserService實現UserDetailsService後,通過使用者名稱查詢使用者名稱資訊而這裡是
通過手機號查詢使用者資訊,所以還需要自定義SmsUserService實現UserDetailsService後。


三、測試

1、獲取驗證碼

SpringSceurity(5)---簡訊驗證碼登陸功能

獲取驗證碼的手機號是 15612345678 。因為這裡沒有接第三方的簡訊SDK,只是在後臺輸出。

向手機號為:15612345678的使用者傳送驗證碼:254792

2、登陸

1)驗證碼輸入不正確

SpringSceurity(5)---簡訊驗證碼登陸功能

發現登陸失敗,同樣如果手機號碼輸入不對也是登陸失敗

2)登陸成功

SpringSceurity(5)---簡訊驗證碼登陸功能

當手機號碼 和 簡訊驗證碼都正確的情況下 ,登陸就成功了。


參考

1、Spring Security技術棧開發企業級認證與授權(JoJo)

2、SpringBoot 整合 Spring Security(8)——簡訊驗證碼登入



別人罵我胖,我會生氣,因為我心裡承認了我胖。別人說我矮,我就會覺得好笑,因為我心裡知道我不可能矮。這就是我們為什麼會對別人的攻擊生氣。
攻我盾者,乃我內心之矛(21)

相關文章