Spring Security:手機短信驗證碼功能實現


實現流程

前排提示:需要對spring security底層用戶名密碼登陸源碼有所了解。不了解的可以看我上一篇博客:https://www.cnblogs.com/wwjj4811/p/14474866.html

類比用戶名密碼登陸流程:

image-20210303161208437

1.進入MobileValidateFilter,對用戶輸入的驗證碼進行校驗比對

2.手機認證過濾器MobileAuthenticationFilter,校驗手機號是否存在

3.自定義MobileAuthenticationToken提供給 MobileAuthenticationFilter

4.自定義MobileAuthenticationProvider提供給 ProviderManager 處理

5.創建針對手機號查詢用戶信息的MobileUserDetailsService,交給 MobileAuthenticationProvider

6.自定義MobileAuthenticationConfig配置類將上面組件連接起來,添加到容器中

7.將MobileAuthenticationConfig添加到spring security安全配置的過濾器鏈上

准備工作

自定義全局頁面響應類

@Data
public class R {

    // 響應業務狀態
    private Integer code;

    // 響應消息
    private String message;

    // 響應中的數據
    private Object data;

    public R() {
    }
    public R(Object data) {
        this.code = 200;
        this.message = "OK";
        this.data = data;
    }
    public R(String message, Object data) {
        this.code = 200;
        this.message = message;
        this.data = data;
    }

    public R(Integer code, String message, Object data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }

    public static R ok() {
        return new R(null);
    }
    public static R ok(String message) {
        return new R(message, null);
    }
    public static R ok(Object data) {
        return new R(data);
    }
    public static R ok(String message, Object data) {
        return new R(message, data);
    }

    public static R build(Integer code, String message) {
        return new R(code, message, null);
    }

    public static R build(Integer code, String message, Object data) {
        return new R(code, message, data);
    }

    public String toJsonString() {
        return JSON.toJSONString(this);
    }


    /**
     * JSON字符串轉成 R 對象
     */
    public static R format(String json) {
        try {
            return JSON.parseObject(json, R.class);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}

自定義認證成功處理器和失敗處理器

@Component
public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

    @Autowired
    SecurityProperties securityProperties;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        if(LoginResponseType.JSON.equals(securityProperties.getAuthentication().getLoginType())){
            R result = R.ok("認證成功");
            String string = result.toJsonString();
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(result.toJsonString());
        }else {
            super.onAuthenticationSuccess(request, response, authentication);
        }
    }
}
@Component
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Autowired
    SecurityProperties securityProperties;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
       if(LoginResponseType.JSON.equals(securityProperties.getAuthentication().getLoginType())){
           R result = R.build(HttpStatus.UNAUTHORIZED.value(), exception.getMessage());
           String string = result.toJsonString();
           response.setContentType("application/json;charset=UTF-8");
           response.getWriter().write(result.toJsonString());
       }else {
           String referer = request.getHeader("Referer");
           String lastUrl = StringUtils.substringBefore(referer,"?");
           super.setDefaultFailureUrl(lastUrl+"?error");
           super.onAuthenticationFailure(request, response, exception);
       }
    }
}

前端代碼thymeleaf(省略css)

<form th:action="@{/mobile/form}" action="index.html" method="post">
        <div class="input-group mb-3">
          <input id="mobile" name="mobile" type="text" class="form-control" placeholder="手機號碼">
          <div class="input-group-append">
            <div class="input-group-text">
              <span class="fa fa-user"></span>
            </div>
          </div>
        </div>
        <div class="mb-3 row">
            <div class="col-7">
          <input type="text" name="code" class="form-control" placeholder="驗證碼">
          </div>
          <div class="col-5">
              <a id="sendCode" th:attr="code_url=@{/code/mobile?mobile=}" class="btn  btn-outline-primary btn-large" href="#"> 獲取驗證碼 </a>
          </div>
        </div>
          <div class="col-4">
            <button type="submit" class="btn btn-primary btn-block">登錄</button>
          </div>
        </div>

      </form>

配置類

@Data
@Component
@ConfigurationProperties(prefix = "boot.security")
public class SecurityProperties {

    private AuthenticationProperties authentication;

    @Data
    public static class AuthenticationProperties {

        private String[] permitPaths;

    }

}

配置文件:

boot:
  security:
    authentication:
     permitPaths:
       - /code/mobile
       - /mobile/page
       - /mobile/form

發送驗證碼模擬

public interface SmsSend {
    boolean sendSms(String mobile, String content);
}

@Slf4j
@Component
public class SmsCodeSender implements SmsSend{
    @Override
    public boolean sendSms(String mobile, String content) {
        String sendContent = String.format("短信驗證碼:%s", content);
        log.info("手機號:{},驗證碼:{}",mobile,content);
        return true;
    }
}

發送驗證碼接口

@Controller
public class MobileLoginController {

    public static final String SESSION_KEY = "SESSION_KEY_MOBILE_CODE";

    @Autowired
    SmsSend smsSend;

    /**
     * 前往手機短信登錄頁
     */
    @RequestMapping("/mobile/page")
    public String toMobilePage(){
        return "login-mobile";
    }

    @RequestMapping("/code/mobile")
    @ResponseBody
    public R smsCode(HttpServletRequest request){
        //這里用apache的隨機數工具類生成模擬發送驗證碼
        //並將驗證碼存入session中,實際工作中可以存入到redis中,並加上電話號碼作為標識
        String code = RandomStringUtils.randomNumeric(4);
        request.getSession().setAttribute(SESSION_KEY, code);
        smsSend.sendSms(request.getParameter("mobile"), code);
        return R.ok();
    }
}

實現MobileUserDetailsService

需要實現UserDetailsService接口。這里我就不查詢數據庫了,直接默認能查詢到一個User。(實際工作根據需要去實現該方法)

/**
 * 通過手機號獲取用戶信息和權限
 */
@Component
public class MobileUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String mobile) throws UsernameNotFoundException {
        //模擬查到了用戶
        return new User("wj", "",true, true, true, true,
                AuthorityUtils.commaSeparatedStringToAuthorityList(""));
    }
}

流程實現

MobileValidateFilter實現

@Component
public class MobileValidateFilter extends OncePerRequestFilter {

    @Autowired
    CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //1.判斷是否是手機登錄,且post請求
        if ("/mobile/form".equals(request.getRequestURI())
                && "post".equalsIgnoreCase(request.getMethod())) {
            try {
                validate(request);
            } catch (AuthenticationException e) {
                //處理異常
                customAuthenticationFailureHandler.onAuthenticationFailure(request, response, e);
                return;
            }
        }
        //放行請求
        filterChain.doFilter(request, response);
    }

    private void validate(HttpServletRequest request) {
        //從session中獲取驗證碼
        String sessionCode = (String)request.getSession().getAttribute(MobileLoginController.SESSION_KEY);
        String code = request.getParameter("code");
        //判斷驗證碼是否為空
        if(StringUtils.isBlank(code)){
            throw new ValidateCodeException("驗證碼不能為空");
        }
        //判斷session中驗證碼和用戶輸入的是否相同
        if(!sessionCode.equalsIgnoreCase(code)){
            throw new ValidateCodeException("驗證碼輸入錯誤");
        }
    }

}

MobileAuthenticationFilter實現

模仿UseranamePasswordAuthenticationFilter的實現,繼承AbstractAuthenticationProcessingFilter抽象類,因為輸入驗證碼,不需要用戶輸入密碼,所以比UseranamePasswordAuthenticationFilter少一個字段

public class MobileAuthenticationFilter extends AbstractAuthenticationProcessingFilter {


    private String mobileParameter = "mobile";
    private boolean postOnly = true;

    public MobileAuthenticationFilter() {
        super(new AntPathRequestMatcher("/mobile/form", "POST"));
    }

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

        MobileAuthenticationToken authRequest = new MobileAuthenticationToken(mobile);

        //sessionID,hostname
        setDetails(request, authRequest);

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

    /**
     * 獲取用戶輸入的電話號碼
     */
    @Nullable
    protected String obtainMobile(HttpServletRequest request) {
        return request.getParameter(mobileParameter);
    }

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

}

MobileAuthenticationToken實現

模仿UseranamePasswordAuthenticationToken,繼承AbstractAuthenticationToken

public class MobileAuthenticationToken extends AbstractAuthenticationToken {

    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    private final Object principal;//認證前是手機號,認證后是用戶信息

    public MobileAuthenticationToken(Object principal) {
        super(null);
        this.principal = principal;
        setAuthenticated(false);
    }

    public MobileAuthenticationToken(Object principal,
                                               Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        super.setAuthenticated(true); // must use super, as we override
    }

    /**
     * 不需要密碼,所以返回一個null
     */
    @Override
    public Object getCredentials() {
        return null;
    }

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

    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        if (isAuthenticated) {
            throw new IllegalArgumentException("");
        }

        super.setAuthenticated(false);
    }

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

MobileAuthenticationProvider實現

DaoAuthenticationProvider的繼承樹如下:

image-20210303164338804

我們需要實現AuthenticationProvider接口:

public class MobileAuthenticationProvider implements AuthenticationProvider {

    private UserDetailsService userDetailsService;

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

    /**
     * 認證處理:
     * 1.通過手機號碼,查詢用戶信息(UserDetailsService實現)
     * 2.查詢到用戶信息,則認為認證通過,封裝Authentication對象
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        MobileAuthenticationToken mobileAuthenticationToken
                = (MobileAuthenticationToken)authentication;
        String mobile = (String) mobileAuthenticationToken.getPrincipal();
        UserDetails userDetails = userDetailsService.loadUserByUsername(mobile);
        if(Objects.isNull(userDetails)){
            throw new AuthenticationServiceException("手機號未注冊");
        }
        //認證通過
        MobileAuthenticationToken token = new MobileAuthenticationToken(userDetails, userDetails.getAuthorities());
        token.setDetails(mobileAuthenticationToken.getDetails());
        return token;
    }

    /**
     * 通過這個方法,來選擇對應的Provider,即選擇MobileAuthenticationProvider
     */
    @Override
    public boolean supports(Class<?> authentication) {
        return (MobileAuthenticationToken.class.isAssignableFrom(authentication));
    }
}

MobileAuthenticationConfig配置類

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

    @Autowired
    CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;

    @Autowired
    CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

    @Autowired
    UserDetailsService mobileUserDetailsService;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        MobileAuthenticationFilter mobileAuthenticationFilter = new MobileAuthenticationFilter();
        mobileAuthenticationFilter.setAuthenticationManager(
                http.getSharedObject(AuthenticationManager.class));
        //成功和失敗的處理器
        mobileAuthenticationFilter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
        mobileAuthenticationFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);

        MobileAuthenticationProvider provider = new MobileAuthenticationProvider();
        provider.setUserDetailsService(mobileUserDetailsService);

        //provider綁定到HttpSecurity上
        // MobileAuthenticationFilter放到UsernamePasswordAuthenticationFilter之后
        http.authenticationProvider(provider)
                .addFilterAfter(mobileAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

spring security配置類

@Slf4j
@Configuration
@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;

    @Autowired
    private CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

    @Autowired
    private MobileValidateFilter mobileValidateFilter;

    @Autowired
    private MobileAuthenticationConfig mobileAuthenticationConfig;

    /**
     * @Author wen.jie
     * @Description 資源權限配置
     * @Date 2021/3/1 13:55
     **/
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //自定義驗證表單
        http.addFilterBefore(mobileValidateFilter, UsernamePasswordAuthenticationFilter.class)
                .formLogin()
                .loginPage(securityProperties.getAuthentication().getLoginPage())
                .successHandler(customAuthenticationSuccessHandler)
                .failureHandler(customAuthenticationFailureHandler)
                .and()
                .authorizeRequests()//認證請求
               .antMatchers(securityProperties.getAuthentication().getPermitPaths()).permitAll()//放行指定請求
                .anyRequest().authenticated();//所有訪問請求都需認證
        //將手機認證添加到過濾器鏈上
        http.apply(mobileAuthenticationConfig);
    }

}

啟動項目,控制台會打印security的過濾器鏈

WebAsyncManagerIntegrationFilter
SecurityContextPersistenceFilter
HeaderWriterFilter
CsrfFilter
LogoutFilter
MobileValidateFilter
UsernamePasswordAuthenticationFilter
MobileAuthenticationFilter
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
AnonymousAuthenticationFilter
SessionManagementFilter
ExceptionTranslationFilter
FilterSecurityInterceptor

記住我功能

默認情況下,記住我功能使用的是NullRememberMeServices

修改很簡單,MobileAuthenticationConfig中添加一行

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

    @Autowired
    CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;

    @Autowired
    CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

    @Autowired
    UserDetailsService mobileUserDetailsService;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        MobileAuthenticationFilter mobileAuthenticationFilter = new MobileAuthenticationFilter();
        mobileAuthenticationFilter.setAuthenticationManager(
                http.getSharedObject(AuthenticationManager.class));

        //記住我功能
        mobileAuthenticationFilter.setRememberMeServices(http.getSharedObject(RememberMeServices.class));

        //成功和失敗的處理器
        mobileAuthenticationFilter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
        mobileAuthenticationFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);

        //設置provider的UserDetailsService
        MobileAuthenticationProvider provider = new MobileAuthenticationProvider();
        provider.setUserDetailsService(mobileUserDetailsService);

        //provider綁定到HttpSecurity上
        // MobileAuthenticationFilter放到UsernamePasswordAuthenticationFilter之后
        http.authenticationProvider(provider)
                .addFilterAfter(mobileAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

同時修改spring security配置類:

@Slf4j
@Configuration
@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired @Qualifier("customUserDetailsService")
    private UserDetailsService userDetailsService;

    @Autowired
    private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;

    @Autowired
    private CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

    @Autowired
    private DataSource dataSource;

    @Autowired
    private MobileValidateFilter mobileValidateFilter;

    @Autowired
    private MobileAuthenticationConfig mobileAuthenticationConfig;

    @Bean
    public JdbcTokenRepositoryImpl jdbcTokenRepository(){
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        jdbcTokenRepository.setDataSource(dataSource);
        //是否啟動項目時自動創建表
        jdbcTokenRepository.setCreateTableOnStartup(false);
        return jdbcTokenRepository;
    }

    /**
     * @Author wen.jie
     * @Description 資源權限配置
     * @Date 2021/3/1 13:55
     **/
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //自定義驗證表單
        http.addFilterBefore(mobileValidateFilter, UsernamePasswordAuthenticationFilter.class)
                .formLogin()
                .successHandler(customAuthenticationSuccessHandler)
                .failureHandler(customAuthenticationFailureHandler)
                .and()
                .authorizeRequests()//認證請求
                .antMatchers(securityProperties.getAuthentication().getPermitPaths()).permitAll()//放行指定請求
                .anyRequest().authenticated()//所有訪問請求都需認證
                .and()
                .rememberMe()//記住我
                .tokenRepository(jdbcTokenRepository())//保存登陸信息
                .tokenValiditySeconds(60*60*24*7);//記住我功能有效時常
        //將手機認證添加到過濾器鏈上
        http.apply(mobileAuthenticationConfig);
    }

}

記住我功能的表結構可自行百度


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM