Spring Security 入門(二):圖形驗證碼和手機短信驗證碼


本文在前文 Spring Security 入門(一):認證和原理分析 的基礎上介紹圖形驗證碼和手機短信驗證碼登錄的實現。

圖形驗證碼

在用戶登錄時,一般通過表單的方式進行登錄都會要求用戶輸入驗證碼,Spring Security默認沒有實現圖形驗證碼的功能,所以需要我們自己實現。

實現流程分析

前文中實現的用戶名、密碼登錄是在UsernamePasswordAuthenticationFilter過濾器進行認證的,而圖形驗證碼一般是在用戶名、密碼認證之前進行驗證的,所以需要在UsernamePasswordAuthenticationFilter過濾器之前添加一個自定義過濾器 ImageCodeValidateFilter,用來校驗用戶輸入的圖形驗證碼是否正確。自定義過濾器繼承 OncePerRequestFilter 類,該類是 Spring 提供的在一次請求中只會調用一次的 filter。

自定義的過濾器 ImageCodeValidateFilter 首先會判斷請求是否為 POST 方式的登錄表單提交請求,如果是就將其攔截進行圖形驗證碼校驗。如果驗證錯誤,會拋出自定義異常類對象 ValidateCodeException,該異常類需要繼承 AuthenticationException 類。在自定義過濾器中,我們需要手動捕獲自定義異常類對象,並將捕獲到自定義異常類對象交給自定義失敗處理器進行處理。


kpatcha 使用

Kaptcha 是谷歌提供的生成圖形驗證碼的工具,參考地址為:https://github.com/penggle/kaptcha,依賴如下:

<!-- 圖形驗證碼工具 kaptcha -->
<dependency>
    <groupId>com.github.penggle</groupId>
    <artifactId>kaptcha</artifactId>
    <version>2.3.2</version>
</dependency>

☕ 創建 KaptchaConfig 配置類

package com.example.config;

import com.google.code.kaptcha.Constants;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Properties;

/**
 * 圖形驗證碼的配置類
 */
@Configuration
public class KaptchaConfig {

    @Bean
    public DefaultKaptcha captchaProducer() {
        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
        Properties properties = new Properties();
        // 是否有邊框
        properties.setProperty(Constants.KAPTCHA_BORDER, "yes");
        // 邊框顏色
        properties.setProperty(Constants.KAPTCHA_BORDER_COLOR, "192,192,192");
        // 驗證碼圖片的寬和高
        properties.setProperty(Constants.KAPTCHA_IMAGE_WIDTH, "110");
        properties.setProperty(Constants.KAPTCHA_IMAGE_HEIGHT, "40");
        // 驗證碼顏色
        properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_FONT_COLOR, "0,0,0");
        // 驗證碼字體大小
        properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_FONT_SIZE, "32");
        // 驗證碼生成幾個字符
        properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_CHAR_LENGTH, "4");
        // 驗證碼隨機字符庫
        properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_CHAR_STRING, "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYAZ");
        // 驗證碼圖片默認是有線條干擾的,我們設置成沒有干擾
        properties.setProperty(Constants.KAPTCHA_NOISE_IMPL, "com.google.code.kaptcha.impl.NoNoise");
        Config config = new Config(properties);
        defaultKaptcha.setConfig(config);
        return defaultKaptcha;
    }
}

kaptcha 配置的參數說明(定義在 Constants 常量類中):

  • kaptcha.border:是否有圖片邊框,合法值:yes,no;默認值為 yes。
  • kaptcha.border.color:邊框顏色,合法值:rgb 或者 white,black,blue;默認值為 black。
  • kaptcha.border.thickness 邊框厚度,合法值:>0;默認為 1。
  • kaptcha.image.width:圖片寬,默認值為 200px。
  • kaptcha.image.height:圖片高,默認值為 50px。
  • kaptcha.producer.impl:圖片實現類,默認值為com.google.code.kaptcha.impl.DefaultKaptcha
  • kaptcha.textproducer.impl:文本實現類,默認值為com.google.code.kaptcha.text.impl.DefaultTextCreator
  • kaptcha.textproducer.char.string:文本集合,驗證碼值從此集合中獲取,默認值為 abcde2345678gfynmnpwx
  • kaptcha.textproducer.char.length:驗證碼長度,默認值為 5。
  • kaptcha.textproducer.font.names:字體,默認值為 Arial, Courier。
  • kaptcha.textproducer.font.size:字體大小,默認值為 40px。
  • kaptcha.textproducer.font.color:字體顏色,合法值:rgb 或者 white,black,blue;默認值為black。
  • kaptcha.textproducer.char.space:文字間隔,默認值為 2px。
  • kaptcha.noise.impl:干擾實現類,com.google.code.kaptcha.impl.NoNoise為沒有干擾。默認值為 com.google.code.kaptcha.impl.DefaultNoise
  • kaptcha.noise.color:干擾線顏色,合法值:rgb 或者 white,black,blue;默認值為 black。
  • kaptcha.obscurificator.impl:圖片樣式,合法值:水紋 com.google.code.kaptcha.impl.WaterRipple,魚眼 com.google.code.kaptcha.impl.FishEyeGimpy, 陰影 com.google.code.kaptcha.impl.ShadowGimpy;默認值為 com.google.code.kaptcha.impl.WaterRipple
  • kaptcha.background.impl:背景實現類,默認值為com.google.code.kaptcha.impl.DefaultBackground
  • kaptcha.background.clear.from:背景顏色漸變,開始顏色,默認值為light grey
  • kaptcha.background.clear.to:背景顏色漸變, 結束顏色,默認值為 white。
  • kaptcha.word.impl:文字渲染器 實現類,默認值為 com.google.code.kaptcha.text.impl.DefaultWordRenderer
  • kaptcha.session.key:session key,默認值為KAPTCHA_SESSION_KEY
  • kaptcha.session.date:session date,默認值為KAPTCHA_SESSION_DATE

☕ 創建驗證碼的實體類 CheckCode

package com.example.entity;

import java.io.Serializable;
import java.time.LocalDateTime;

public class CheckCode implements Serializable {
    private String code;           // 驗證碼字符
    private LocalDateTime expireTime;  // 過期時間

    /**
     * @param code 驗證碼字符
     * @param expireTime 過期時間,單位秒
     */
    public CheckCode(String code, int expireTime) {
        this.code = code;
        this.expireTime = LocalDateTime.now().plusSeconds(expireTime);
    }

    public CheckCode(String code) {
        // 默認驗證碼 60 秒后過期
        this(code, 60);
    }

    // 是否過期
    public boolean isExpried() {
        return this.expireTime.isBefore(LocalDateTime.now());
    }

    public String getCode() {
        return this.code;
    }
}

☕ 在 LoginController 中添加獲取圖形驗證碼的 Controller 方法

package com.example.constans;

public class Constants {
    // Session 中存儲圖形驗證碼的屬性名
    public static final String KAPTCHA_SESSION_KEY = "KAPTCHA_SESSION_KEY";
}

@Controller
public class LoginController {

    @Autowired
    private DefaultKaptcha defaultKaptcha;
    //...
    
    @GetMapping("/code/image")
    public void imageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 創建驗證碼文本
        String capText = defaultKaptcha.createText();
        // 創建驗證碼圖片
        BufferedImage image = defaultKaptcha.createImage(capText);

        // 將驗證碼文本放進 Session 中
        CheckCode code = new CheckCode(capText);
        request.getSession().setAttribute(Constants.KAPTCHA_SESSION_KEY, code);

        // 將驗證碼圖片返回,禁止驗證碼圖片緩存
        response.setHeader("Cache-Control", "no-store");
        response.setHeader("Pragma", "no-cache");
        response.setDateHeader("Expires", 0);
        response.setContentType("image/jpeg");
        ImageIO.write(image, "jpg", response.getOutputStream());
    }    
}

☕ 在 login.html 中添加驗證碼功能

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>登錄</title>
</head>
<body>
    <h3>表單登錄</h3>
    <form method="post" th:action="@{/login/form}">
        <input type="text" name="name" placeholder="用戶名"><br>
        <input type="password" name="pwd" placeholder="密碼"><br>

        <input name="imageCode" type="text" placeholder="驗證碼"><br>
        <img th:onclick="this.src='/code/image?'+Math.random()" th:src="@{/code/image}" alt="驗證碼"/><br>
        <div th:if="${param.error}">
            <span th:text="${session.SPRING_SECURITY_LAST_EXCEPTION.message}" style="color:red">用戶名或密碼錯誤</span>
        </div>
        <button type="submit">登錄</button>
    </form>
</body>
</html>

☕ 更改安全配置類 SpringSecurityConfig,設置訪問/code/image不需要任何權限

@EnableWebSecurity       // 開啟 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    //...
    /**
     * 定制基於 HTTP 請求的用戶訪問控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //...
        // 開啟基於 HTTP 請求訪問控制
        http.authorizeRequests()
                // 以下訪問不需要任何權限,任何人都可以訪問
                .antMatchers("/login/page", "/code/image").permitAll()
                // 以下訪問需要 ROLE_ADMIN 權限
                .antMatchers("/admin/**").hasRole("ADMIN")
                // 以下訪問需要 ROLE_USER 權限
                .antMatchers("/user/**").hasAuthority("ROLE_USER")
                // 其它任何請求訪問都需要先通過認證
                .anyRequest().authenticated();
		//...            
    }
    //...
}

☕ 測試

訪問localhost:8080/login/page,出現圖形驗證的信息


自定義驗證碼過濾器

⭐️ 創建自定義異常類 ValidateCodeException

package com.example.exception;

import org.springframework.security.core.AuthenticationException;

/**
 * 自定義驗證碼校驗錯誤的異常類,繼承 AuthenticationException
 */
public class ValidateCodeException extends AuthenticationException {
    public ValidateCodeException(String msg, Throwable t) {
        super(msg, t);
    }

    public ValidateCodeException(String msg) {
        super(msg);
    }
}

⭐ 自定義圖形驗證碼校驗過濾器 ImageCodeValidateFilter

package com.example.config.security;

import com.example.constans.Constants;
import com.example.entity.CheckCode;
import com.example.exception.ValidateCodeException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
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 javax.servlet.http.HttpSession;
import java.io.IOException;

@Component
public class ImageCodeValidateFilter extends OncePerRequestFilter {

    private String codeParamter = "imageCode";  // 前端輸入的圖形驗證碼參數名

    @Autowired
    private CustomAuthenticationFailureHandler authenticationFailureHandler;  // 自定義認證失敗處理器

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 非 POST 方式的表單提交請求不校驗圖形驗證碼
        if ("/login/form".equals(request.getRequestURI()) && "POST".equals(request.getMethod())) {
            try {
                // 校驗圖形驗證碼合法性
                validate(request);
            } catch (ValidateCodeException e) {
                // 手動捕獲圖形驗證碼校驗過程拋出的異常,將其傳給失敗處理器進行處理
                authenticationFailureHandler.onAuthenticationFailure(request, response, e);
                return;
            }
        }

        // 放行請求,進入下一個過濾器
        filterChain.doFilter(request, response);
    }

    // 判斷驗證碼的合法性
    private void validate(HttpServletRequest request) {
        // 獲取用戶傳入的圖形驗證碼值
        String requestCode = request.getParameter(this.codeParamter);
        if(requestCode == null) {
            requestCode = "";
        }
        requestCode = requestCode.trim();

        // 獲取 Session
        HttpSession session = request.getSession();
        // 獲取存儲在 Session 里的驗證碼值
        CheckCode savedCode = (CheckCode) session.getAttribute(Constants.KAPTCHA_SESSION_KEY);
        if (savedCode != null) {
            // 隨手清除驗證碼,無論是失敗,還是成功。客戶端應在登錄失敗時刷新驗證碼
            session.removeAttribute(Constants.KAPTCHA_SESSION_KEY);
        }

        // 校驗出錯,拋出異常
        if (StringUtils.isBlank(requestCode)) {
            throw new ValidateCodeException("驗證碼的值不能為空");
        }

        if (savedCode == null) {
            throw new ValidateCodeException("驗證碼不存在");
        }

        if (savedCode.isExpried()) {
            throw new ValidateCodeException("驗證碼過期");
        }

        if (!requestCode.equalsIgnoreCase(savedCode.getCode())) {
            throw new ValidateCodeException("驗證碼輸入錯誤");
        }
    }
}

⭐️ 更改安全配置類 SpringSecurityConfig,將自定義過濾器添加過濾器鏈中

@EnableWebSecurity       // 開啟 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    //...
    @Autowired
    private ImageCodeValidateFilter imageCodeValidateFilter; // 自定義過濾器(圖形驗證碼校驗)
    //...
    
    /**
     * 定制基於 HTTP 請求的用戶訪問控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //...        
        // 將自定義過濾器(圖形驗證碼校驗)添加到 UsernamePasswordAuthenticationFilter 之前
        http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);
    }
    //...
}

完整的安全配置類 SpringSecurityConfig 如下:

package com.example.config;

import com.example.config.security.CustomAuthenticationFailureHandler;
import com.example.config.security.CustomAuthenticationSuccessHandler;
import com.example.config.security.ImageCodeValidateFilter;
import com.example.service.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@EnableWebSecurity       // 開啟 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private CustomUserDetailsService userDetailsService;

    @Autowired
    private CustomAuthenticationSuccessHandler authenticationSuccessHandler; // 自定義認證成功處理器

    @Autowired
    private CustomAuthenticationFailureHandler authenticationFailureHandler; // 自定義認證失敗處理器

    @Autowired
    private ImageCodeValidateFilter imageCodeValidateFilter; // 自定義過濾器(圖形驗證碼校驗)

    /**
     * 密碼編碼器,密碼不能明文存儲
     */
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        // 使用 BCryptPasswordEncoder 密碼編碼器,該編碼器會將隨機產生的 salt 混入最終生成的密文中
        return new BCryptPasswordEncoder();
    }

    /**
     * 定制用戶認證管理器來實現用戶認證
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 采用內存存儲方式,用戶認證信息存儲在內存中
        // auth.inMemoryAuthentication()
        //        .withUser("admin").password(passwordEncoder()
        //        .encode("123456")).roles("ROLE_ADMIN");
        
        // 不再使用內存方式存儲用戶認證信息,而是動態從數據庫中獲取
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    /**
     * 定制基於 HTTP 請求的用戶訪問控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 啟動 form 表單登錄
        http.formLogin()
                // 設置登錄頁面的訪問路徑,默認為 /login,GET 請求;該路徑不設限訪問
                .loginPage("/login/page")
                // 設置登錄表單提交路徑,默認為 loginPage() 設置的路徑,POST 請求
                .loginProcessingUrl("/login/form")
                // 設置登錄表單中的用戶名參數,默認為 username
                .usernameParameter("name")
                // 設置登錄表單中的密碼參數,默認為 password
                .passwordParameter("pwd")
                // 認證成功處理,如果存在原始訪問路徑,則重定向到該路徑;如果沒有,則重定向 /index
                //.defaultSuccessUrl("/index")
                // 認證失敗處理,重定向到指定地址,默認為 loginPage() + ?error;該路徑不設限訪問
                //.failureUrl("/login/page?error");
                // 不再使用 defaultSuccessUrl() 和 failureUrl() 方法進行認證成功和失敗處理,
                // 使用自定義的認證成功和失敗處理器
                .successHandler(authenticationSuccessHandler)
                .failureHandler(authenticationFailureHandler);

        // 開啟基於 HTTP 請求訪問控制
        http.authorizeRequests()
                // 以下訪問不需要任何權限,任何人都可以訪問
                .antMatchers("/login/page", "/code/image").permitAll()
                // 以下訪問需要 ROLE_ADMIN 權限
                .antMatchers("/admin/**").hasRole("ADMIN")
                // 以下訪問需要 ROLE_USER 權限
                .antMatchers("/user/**").hasAuthority("ROLE_USER")
                // 其它任何請求訪問都需要先通過認證
                .anyRequest().authenticated();

        // 關閉 csrf 防護
        http.csrf().disable();
        
        // 將自定義過濾器(圖形驗證碼校驗)添加到 UsernamePasswordAuthenticationFilter 之前
        http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);        
    }

    /**
     * 定制一些全局性的安全配置,例如:不攔截靜態資源的訪問
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        // 靜態資源的訪問不需要攔截,直接放行
        web.ignoring().antMatchers("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
    }
}

⭐ 測試

訪問localhost:8080/login/page,等待 60 秒后,輸入正確的用戶名、密碼和驗證碼:

驗證碼過期,重定向到localhost:8080/login/page?error,顯示錯誤信息:


手機短信驗證碼

一般登錄除了用戶名、密碼登錄,還可以使用手機短信驗證碼登錄,Spring Security默認沒有實現手機短信驗證碼的功能,所以需要我們自己實現。

實現流程分析

手機短信驗證碼登錄和前面的帶有圖形驗證碼的用戶名、密碼登錄流程類似,紅色標記的部分是需要我們自定義實現的類。

我們首先分析下帶有圖形驗證碼的用戶名、密碼登錄流程:

  1. 在 ImageCodeValidateFilter 過濾器中校驗用戶輸入的圖形驗證碼是否正確。

  2. 在 UsernamePasswordAuthenticationFilter 過濾器中將 username 和 password 生成一個用於認證的 Token(UsernamePasswordAuthenticationToken),並將其傳遞給 ProviderManager 接口的實現類 AuthenticationManager。

  3. AuthenticationManager 管理器尋找到一個合適的處理器 DaoAuthenticationProvider 來處理 UsernamePasswordAuthenticationToken。

  4. DaoAuthenticationProvider 通過 UserDetailsService 接口的實現類 CustomUserDetailsService 從數據庫中獲取指定 username 的相關信息,並校驗用戶輸入的 password。如果校驗成功,那就認證通過,用戶信息類對象 Authentication 標記為已認證。

  5. 認證通過后,將已認證的用戶信息對象 Authentication 存儲到 SecurityContextHolder 中,最終存儲到 Session 中。

仿照上述流程,我們分析手機短信驗證碼登錄流程:

  1. 仿照 ImageCodeValidateFilter 過濾器設計 MobileVablidateFilter 過濾器,該過濾器用來校驗用戶輸入手機短信驗證碼。
  2. 仿照 UsernamePasswordAuthenticationFilter 過濾器設計 MobileAuthenticationFilter 過濾器,該過濾器將用戶輸入的手機號生成一個 Token(MobileAuthenticationToken),並將其傳遞給 ProviderManager 接口的實現類 AuthenticationManager。
  3. AuthenticationManager 管理器尋找到一個合適的處理器 MobileAuthenticationProvider 來處理 MobileAuthenticationToken,該處理器是仿照 DaoAuthenticationProvider 進行設計的。
  4. MobileAuthenticationProvider 通過 UserDetailsService 接口的實現類 MobileUserDetailsService 從數據庫中獲取指定手機號對應的用戶信息,此處不需要進行任何校驗,直接將用戶信息類對象 Authentication 標記為已認證。
  5. 認證通過后,將已認證的用戶信息對象 Authentication 存儲到 SecurityContextHolder 中,最終存儲到 Session 中,此處的操作不需要我們編寫。

最后通過自定義配置類 MobileAuthenticationConfig 組合上述組件,並添加到安全配置類 SpringSecurityConfig 中。


模擬發送短信驗證碼

✏️ 在 UserMapper 接口中添加根據 mobile 查詢用戶的方法

public interface UserMapper {
    //...
    @Select("select * from user where mobile = #{mobile}")
    User selectByMobile(String mobile);    
}

✏️ 創建 UserService 類,編寫判斷指定 mobile 是否存在的方法

package com.example.service;

import com.example.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    /**
     * 判斷指定 mobile 是否存在
     */
    public boolean isExistByMobile(String mobile) {
        return userMapper.selectByMobile(mobile) != null;
    }
}

✏️ 創建 MobileCodeSendService 類,模擬手機短信驗證碼發送服務

package com.example.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@Slf4j
public class MobileCodeSendService {

    /**
     * 模擬發送手機短信驗證碼
     */
    public void send(String mobile, String code) {
        String sendContent = String.format("驗證碼為 %s,請勿泄露!", code);
        log.info("向手機號 " + mobile + " 發送短信:" + sendContent);
    }
}

✏️ 在 LoginController 中添加手機短信驗證碼相關的 Controller 方法

public class Constants {
    //...
    // Session 中存儲手機短信驗證碼的屬性名
    public static final String MOBILE_SESSION_KEY = "MOBILE_SESSION_KEY";
}
@Controller
public class LoginController {
    //...
    @Autowired 
    private MobileCodeSendService mobileCodeSendService;  // 模擬手機短信驗證碼發送服務

    @Autowired
    private UserService userService;   
    //...
    
    @GetMapping("/mobile/page")
    public String mobileLoginPage() {  // 跳轉到手機短信驗證碼登錄頁面
        return "login-mobile";
    }

    @GetMapping("/code/mobile")
    @ResponseBody
    public Object sendMoblieCode(String mobile, HttpServletRequest request) { 
        // 隨機生成一個 4 位的驗證碼
        String code = RandomStringUtils.randomNumeric(4);

        // 將手機驗證碼文本存儲在 Session 中,設置過期時間為 10 * 60s
        CheckCode mobileCode = new CheckCode(code, 10 * 60);
        request.getSession().setAttribute(Constants.MOBILE_SESSION_KEY, mobileCode);

        // 判斷該手機號是否注冊
        if(!userService.isExistByMobile(mobile)) {
            return new ResultData<>(1, "該手機號不存在!");
        }

        // 模擬發送手機短信驗證碼到指定用戶手機
        mobileCodeSendService.send(mobile, code);
        return new ResultData<>(0, "發送成功!");
    }    
}

✏️ 編寫手機短信驗證碼登錄頁面 login-mobile.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>登錄頁面</title>
    <script src="https://s3.pstatp.com/cdn/expire-1-M/jquery/3.3.1/jquery.min.js"></script>
</head>
<body>
    <form method="post" th:action="@{/mobile/form}">
        <input id="mobile" name="mobile" type="text" placeholder="手機號碼"><br>
        <div>
            <input name="mobileCode" type="text" placeholder="驗證碼">
            <button type="button" id="sendCode">獲取驗證碼</button>
        </div>
        <div th:if="${param.error}">
            <span th:text="${session.SPRING_SECURITY_LAST_EXCEPTION.message}" style="color:red">用戶名或密碼錯誤</span>
        </div>
        <button type="submit">登錄</button>
    </form>

    <script>
        // 獲取手機短信驗證碼
        $("#sendCode").click(function () {
            var mobile = $('#mobile').val().trim();
            if(mobile == '') {
                alert("手機號不能為空");
                return;
            }
            // /code/mobile?mobile=123123123
            var url = "/code/mobile?mobile=" + mobile;
            $.get(url, function(data){
                alert(data.msg);
            });
        });
    </script>
</body>
</html>

✏️ 更改安全配置類 SpringSecurityConfig,設置訪問/mobile/page/code/mobile不需要任何權限

@EnableWebSecurity       // 開啟 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    //...
    /**
     * 定制基於 HTTP 請求的用戶訪問控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //...
        // 開啟基於 HTTP 請求訪問控制
        http.authorizeRequests()
                // 以下訪問不需要任何權限,任何人都可以訪問
                .antMatchers("/login/page", "/code/image","/mobile/page", "/code/mobile").permitAll()
                // 以下訪問需要 ROLE_ADMIN 權限
                .antMatchers("/admin/**").hasRole("ADMIN")
                // 以下訪問需要 ROLE_USER 權限
                .antMatchers("/user/**").hasAuthority("ROLE_USER")
                // 其它任何請求訪問都需要先通過認證
                .anyRequest().authenticated();
		//...            
    }
    //...
}

✏️ 測試

訪問localhost:8080/mobile/page,頁面顯示:

手機號碼輸入11111111111,控制台輸出:

向手機號 11111111111 發送短信:驗證碼為 2561,請勿泄露!

瀏覽器彈出窗口顯示”發送成功“。


自定義認證流程配置

📚 自定義短信驗證碼校驗過濾器 MobileValidateFilter

package com.example.config.security.mobile;

import com.example.config.security.CustomAuthenticationFailureHandler;
import com.example.constans.Constants;
import com.example.entity.CheckCode;
import com.example.exception.ValidateCodeException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
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 javax.servlet.http.HttpSession;
import java.io.IOException;

/**
 * 手機短信驗證碼校驗
 */
@Component
public class MobileCodeValidateFilter extends OncePerRequestFilter {

    private String codeParamter = "mobileCode";  // 前端輸入的手機短信驗證碼參數名

    @Autowired
    private CustomAuthenticationFailureHandler authenticationFailureHandler; // 自定義認證失敗處理器

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 非 POST 方式的手機短信驗證碼提交請求不進行校驗
        if("/mobile/form".equals(request.getRequestURI()) && "POST".equals(request.getMethod())) {
            try {
                // 檢驗手機驗證碼的合法性
                validate(request);
            } catch (ValidateCodeException e) {
                // 將異常交給自定義失敗處理器進行處理
                authenticationFailureHandler.onAuthenticationFailure(request, response, e);
                return;
            }
        }

        // 放行,進入下一個過濾器
        filterChain.doFilter(request, response);
    }

    /**
     * 檢驗用戶輸入的手機驗證碼的合法性
     */
    private void validate(HttpServletRequest request) {
        // 獲取用戶傳入的手機驗證碼值
        String requestCode = request.getParameter(this.codeParamter);
        if(requestCode == null) {
            requestCode = "";
        }
        requestCode = requestCode.trim();


        // 獲取 Session
        HttpSession session = request.getSession();
        // 獲取 Session 中存儲的手機短信驗證碼
        CheckCode savedCode = (CheckCode) session.getAttribute(Constants.MOBILE_SESSION_KEY);

        if (savedCode != null) {
            // 隨手清除驗證碼,無論是失敗,還是成功。客戶端應在登錄失敗時刷新驗證碼
            session.removeAttribute(Constants.MOBILE_SESSION_KEY);
        }

        // 校驗出錯,拋出異常
        if (StringUtils.isBlank(requestCode)) {
            throw new ValidateCodeException("驗證碼的值不能為空");
        }

        if (savedCode == null) {
            throw new ValidateCodeException("驗證碼不存在");
        }

        if (savedCode.isExpried()) {
            throw new ValidateCodeException("驗證碼過期");
        }

        if (!requestCode.equalsIgnoreCase(savedCode.getCode())) {
            throw new ValidateCodeException("驗證碼輸入錯誤");
        }
    }
}

📚 更改自定義失敗處理器 CustomAuthenticationFailureHandler,原先的處理器在認證失敗時,會直接重定向到/login/page?error顯示認證異常信息。現在我們有兩種登錄方式,應該進行以下處理:

  • 帶圖形驗證碼的用戶名、密碼方式登錄方式出現認證異常,重定向到/login/page?error
  • 手機短信驗證碼方式登錄出現認證異常,重定向到/mobile/page?error
package com.example.config.security;

import com.example.entity.ResultData;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
* 繼承 SimpleUrlAuthenticationFailureHandler 處理器,該類是 failureUrl() 方法使用的認證失敗處理器
*/
@Component
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

   @Autowired
   private ObjectMapper objectMapper;

   @Override
   public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
       String xRequestedWith = request.getHeader("x-requested-with");
       // 判斷前端的請求是否為 ajax 請求
       if ("XMLHttpRequest".equals(xRequestedWith)) {
           // 認證成功,響應 JSON 數據
           response.setContentType("application/json;charset=utf-8");
           response.getWriter().write(objectMapper.writeValueAsString(new ResultData<>(1, "認證失敗!")));
       }else {
           // 用戶名、密碼方式登錄出現認證異常,需要重定向到 /login/page?error
           // 手機短信驗證碼方式登錄出現認證異常,需要重定向到 /mobile/page?error
           // 使用 Referer 獲取當前登錄表單提交請求是從哪個登錄頁面(/login/page 或 /mobile/page)鏈接過來的
           String refer = request.getHeader("Referer");
           String lastUrl = StringUtils.substringBefore(refer, "?");
           // 設置默認的重定向路徑
           super.setDefaultFailureUrl(lastUrl + "?error");
           // 調用父類的 onAuthenticationFailure() 方法
           super.onAuthenticationFailure(request, response, e);
       }
   }
}

📚 自定義短信驗證碼認證過濾器 MobileAuthenticationFilter,仿照 UsernamePasswordAuthenticationFilter 過濾器進行編寫

package com.example.config.security.mobile;

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 org.springframework.util.Assert;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 手機短信驗證碼認證過濾器,仿照 UsernamePasswordAuthenticationFilter 過濾器編寫
 */
public class MobileAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    private String mobileParamter = "mobile";  // 默認手機號參數名為 mobile
    private boolean postOnly = true;    // 默認請求方式只能為 POST

    protected MobileAuthenticationFilter() {
        // 默認登錄表單提交路徑為 /mobile/form,POST 方式請求
        super(new AntPathRequestMatcher("/mobile/form", "POST"));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        //(1) 默認情況下,如果請求方式不是 POST,會拋出異常
        if(postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }else {
            //(2) 獲取請求攜帶的 mobile
            String mobile = request.getParameter(mobileParamter);
            if(mobile == null) {
                mobile = "";
            }
            mobile = mobile.trim();

            //(3) 使用前端傳入的 mobile 構造 Authentication 對象,標記該對象未認證
            // MobileAuthenticationToken 是我們自定義的 Authentication 類,后續介紹
            MobileAuthenticationToken authRequest = new MobileAuthenticationToken(mobile);
            //(4) 將請求中的一些屬性信息設置到 Authentication 對象中,如:remoteAddress,sessionId
            this.setDetails(request, authRequest);
            //(5) 調用 ProviderManager 類的 authenticate() 方法進行身份認證
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }

    @Nullable
    protected String obtainMobile(HttpServletRequest request) {
        return request.getParameter(this.mobileParamter);
    }

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

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

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

    public String getMobileParameter() {
        return mobileParamter;
    }
}

📚 自定義用戶信息封裝類 MobileAuthenticationToken,仿照 UsernamePasswordAuthenticationToken 類進行編寫

package com.example.config.security.mobile;

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;

public class MobileAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = 520L;
    private final Object principal;

    /**
     * 認證前,使用該構造器進行封裝信息
     */
    public MobileAuthenticationToken(Object principal) {
        super((Collection) null);     // 用戶權限為 null
        this.principal = principal;   // 前端傳入的手機號
        this.setAuthenticated(false); // 標記為未認證
    }

    /**
     * 認證成功后,使用該構造器封裝用戶信息
     */
    public MobileAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);          // 用戶權限集合
        this.principal = principal;  // 封裝認證用戶信息的 UserDetails 對象,不再是手機號
        super.setAuthenticated(true); // 標記認證成功
    }

    @Override
    public Object getCredentials() {
        // 由於使用手機短信驗證碼登錄不需要密碼,所以直接返回 null
        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");
        } else {
            super.setAuthenticated(false);
        }
    }

    @Override
    public void eraseCredentials() {
        // 手機短信驗證碼認證方式不必去除額外的敏感信息,所以直接調用父類方法
        super.eraseCredentials();
    }
}

📚 自定義短信驗證碼認證的處理器 MobileAuthenticationProvider,仿照 DaoAuthenticationProvider 處理器進行編寫

package com.example.config.security.mobile;

import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.security.authentication.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.SpringSecurityMessageSource;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsChecker;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.util.Assert;

public class MobileAuthenticationProvider implements AuthenticationProvider {

    private UserDetailsService userDetailsService;
    protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
    private UserDetailsChecker authenticationChecks = new MobileAuthenticationProvider.DefaultAuthenticationChecks();

    /**
     * 處理認證
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        //(1) 如果入參的 Authentication 類型不是 MobileAuthenticationToken,拋出異常
        Assert.isInstanceOf(MobileAuthenticationToken.class, authentication, () -> {
            return this.messages.getMessage("MobileAuthenticationProvider.onlySupports", "Only MobileAuthenticationToken is supported");
        });

        // 獲取手機號
        String mobile = authentication.getPrincipal() == null ? "NONE_PROVIDED" : authentication.getName();
        //(2) 根據手機號從數據庫中查詢用戶信息
        UserDetails user = this.userDetailsService.loadUserByUsername(mobile);
        if (user == null) {
            //(3) 未查詢到用戶信息,拋出異常
            throw new AuthenticationServiceException("該手機號未注冊");
        }

        //(4) 檢查賬號是否鎖定、賬號是否可用、賬號是否過期、密碼是否過期
        this.authenticationChecks.check(user);

        //(5) 查詢到了用戶信息,則認證通過,構建標記認證成功用戶信息類對象 AuthenticationToken
        MobileAuthenticationToken result = new MobileAuthenticationToken(user, user.getAuthorities());
        // 需要把認證前 Authentication 對象中的 details 信息加入認證后的 Authentication
        result.setDetails(authentication.getDetails());
        return result;
    }

    /**
     * ProviderManager 管理器通過此方法來判斷是否采用此 AuthenticationProvider 類
     * 來處理由 AuthenticationFilter 過濾器傳入的 Authentication 對象
     */
    @Override
    public boolean supports(Class<?> authentication) {
        // isAssignableFrom 返回 true 當且僅當調用者為父類.class,參數為本身或者其子類.class
        // ProviderManager 會獲取 MobileAuthenticationFilter 過濾器傳入的 Authentication 類型
        // 所以當且僅當 authentication 的類型為 MobileAuthenticationToken 才返回 true
        return MobileAuthenticationToken.class.isAssignableFrom(authentication);
    }

    /**
     * 此處傳入自定義的 MobileUserDetailsSevice 對象
     */
    public void setUserDetailsService(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    public UserDetailsService getUserDetailsService() {
        return userDetailsService;
    }

    /**
     * 檢查賬號是否鎖定、賬號是否可用、賬號是否過期、密碼是否過期
     */
    private class DefaultAuthenticationChecks implements UserDetailsChecker {
        private DefaultAuthenticationChecks() {
        }

        @Override
        public void check(UserDetails user) {
            if (!user.isAccountNonLocked()) {
                throw new LockedException(MobileAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.locked", "User account is locked"));
            } else if (!user.isEnabled()) {
                throw new DisabledException(MobileAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.disabled", "User is disabled"));
            } else if (!user.isAccountNonExpired()) {
                throw new AccountExpiredException(MobileAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.expired", "User account has expired"));
            } else if (!user.isCredentialsNonExpired()) {
                throw new CredentialsExpiredException(MobileAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.credentialsExpired", "User credentials have expired"));
            }
        }
    }
}

📚 自定義 MobileUserDetailsService 類,MobileAuthenticationProvider 處理器傳入的 UserDetailsService 對象的類型需要我們自定義

package com.example.service;

import com.example.entity.User;
import com.example.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class MobileUserDetailsService implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String mobile) throws UsernameNotFoundException {
        //(1) 從數據庫嘗試讀取該用戶
        User user = userMapper.selectByMobile(mobile);
        // 用戶不存在,拋出異常
        if (user == null) {
            throw new UsernameNotFoundException("用戶不存在");
        }

        //(2) 將數據庫形式的 roles 解析為 UserDetails 的權限集合
        // AuthorityUtils.commaSeparatedStringToAuthorityList() 是 Spring Security 提供的方法,用於將逗號隔開的權限集字符串切割為可用權限對象列表
        user.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles()));

        //(3) 返回 UserDetails 對象
        return user;
    }
}

📚 自定義短信驗證碼認證方式配置類 MobileAuthenticationConfig,將上述組件進行管理

package com.example.config.security.mobile;

import com.example.config.security.CustomAuthenticationFailureHandler;
import com.example.config.security.CustomAuthenticationSuccessHandler;
import com.example.service.MobileUserDetailsService;
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.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.stereotype.Component;

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

    @Autowired
    private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;  // 自定義認證成功處理器

    @Autowired
    private CustomAuthenticationFailureHandler customAuthenticationFailureHandler;  // 自定義認證失敗處理器

    @Autowired
    private MobileCodeValidateFilter mobileCodeValidaterFilter;  // 手機短信驗證碼校驗過濾器

    @Autowired
    private MobileUserDetailsService userDetailsService;  // 手機短信驗證方式的 UserDetail

    @Override
    public void configure(HttpSecurity http) throws Exception {
        //(1) 將短信驗證碼認證的自定義過濾器綁定到 HttpSecurity 中
        //(1.1) 創建手機短信驗證碼認證過濾器的實例 filer
        MobileAuthenticationFilter filter = new MobileAuthenticationFilter();

        //(1.2) 設置 filter 使用 AuthenticationManager(ProviderManager 接口實現類) 認證管理器
        // 多種登錄方式應該使用同一個認證管理器實例,所以獲取 Spring 容器中已經存在的 AuthenticationManager 實例
        AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
        filter.setAuthenticationManager(authenticationManager);

        //(1.3) 設置 filter 使用自定義成功和失敗處理器
        filter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
        filter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);

        //(1.4) 設置 filter 使用 SessionAuthenticationStrategy 會話管理器
        // 多種登錄方式應該使用同一個會話管理器實例,獲取 Spring 容器已經存在的 SessionAuthenticationStrategy 實例
        SessionAuthenticationStrategy sessionAuthenticationStrategy = http.getSharedObject(SessionAuthenticationStrategy.class);
        filter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy);

        //(1.5) 在 UsernamePasswordAuthenticationFilter 過濾器之前添加 MobileCodeValidateFilter 過濾器
        // 在 UsernamePasswordAuthenticationFilter 過濾器之后添加 MobileAuthenticationFilter 過濾器
        http.addFilterBefore(mobileCodeValidaterFilter, UsernamePasswordAuthenticationFilter.class);
        http.addFilterAfter(filter, UsernamePasswordAuthenticationFilter.class);

        //(2) 將自定義的 MobileAuthenticationProvider 處理器綁定到 HttpSecurity 中
        //(2.1) 創建手機短信驗證碼認證過濾器的 AuthenticationProvider 實例,並指定所使用的 UserDetailsService
        MobileAuthenticationProvider provider = new MobileAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);

        //(2.2) 將該 AuthenticationProvider 實例綁定到 HttpSecurity 中
        http.authenticationProvider(provider);
    }
}

📚 將上述自定義配置類 MobileAuthenticationConfig 綁定到最終的安全配置類 SpringSecurityConfig 中

@EnableWebSecurity       // 開啟 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    //...
    @Autowired
    private MobileAuthenticationConfig mobileAuthenticationConfig; // 手機短信驗證碼認證方式的配置類
    //...
    /**
     * 定制基於 HTTP 請求的用戶訪問控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //...
        // 將手機短信驗證碼認證的配置與當前的配置綁定
        http.apply(mobileAuthenticationConfig);        
    }
    //...   
}

完整的安全配置類如下:

package com.example.config;

import com.example.config.security.CustomAuthenticationFailureHandler;
import com.example.config.security.CustomAuthenticationSuccessHandler;
import com.example.config.security.ImageCodeValidateFilter;
import com.example.config.security.mobile.MobileAuthenticationConfig;
import com.example.service.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@EnableWebSecurity       // 開啟 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private CustomUserDetailsService userDetailsService;

    @Autowired
    private CustomAuthenticationSuccessHandler authenticationSuccessHandler; // 自定義認證成功處理器

    @Autowired
    private CustomAuthenticationFailureHandler authenticationFailureHandler; // 自定義認證失敗處理器

    @Autowired
    private ImageCodeValidateFilter imageCodeValidateFilter; // 自定義過濾器(圖形驗證碼校驗)

    @Autowired
    private MobileAuthenticationConfig mobileAuthenticationConfig; // 手機短信驗證碼認證方式的配置類

    /**
     * 密碼編碼器,密碼不能明文存儲
     */
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        // 使用 BCryptPasswordEncoder 密碼編碼器,該編碼器會將隨機產生的 salt 混入最終生成的密文中
        return new BCryptPasswordEncoder();
    }

    /**
     * 定制用戶認證管理器來實現用戶認證
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 采用內存存儲方式,用戶認證信息存儲在內存中
        // auth.inMemoryAuthentication()
        //        .withUser("admin").password(passwordEncoder()
        //        .encode("123456")).roles("ROLE_ADMIN");
        
        // 不再使用內存方式存儲用戶認證信息,而是動態從數據庫中獲取
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    /**
     * 定制基於 HTTP 請求的用戶訪問控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 啟動 form 表單登錄
        http.formLogin()
                // 設置登錄頁面的訪問路徑,默認為 /login,GET 請求;該路徑不設限訪問
                .loginPage("/login/page")
                // 設置登錄表單提交路徑,默認為 loginPage() 設置的路徑,POST 請求
                .loginProcessingUrl("/login/form")
                // 設置登錄表單中的用戶名參數,默認為 username
                .usernameParameter("name")
                // 設置登錄表單中的密碼參數,默認為 password
                .passwordParameter("pwd")
                // 認證成功處理,如果存在原始訪問路徑,則重定向到該路徑;如果沒有,則重定向 /index
                //.defaultSuccessUrl("/index")
                // 認證失敗處理,重定向到指定地址,默認為 loginPage() + ?error;該路徑不設限訪問
                //.failureUrl("/login/page?error");
                // 不再使用 defaultSuccessUrl() 和 failureUrl() 方法進行認證成功和失敗處理,
                // 使用自定義的認證成功和失敗處理器
                .successHandler(authenticationSuccessHandler)
                .failureHandler(authenticationFailureHandler);

        // 開啟基於 HTTP 請求訪問控制
        http.authorizeRequests()
                // 以下訪問不需要任何權限,任何人都可以訪問
                .antMatchers("/login/page", "/code/image","/mobile/page", "/code/mobile").permitAll()
                // 以下訪問需要 ROLE_ADMIN 權限
                .antMatchers("/admin/**").hasRole("ADMIN")
                // 以下訪問需要 ROLE_USER 權限
                .antMatchers("/user/**").hasAuthority("ROLE_USER")
                // 其它任何請求訪問都需要先通過認證
                .anyRequest().authenticated();

        // 關閉 csrf 防護
        http.csrf().disable();

        // 將自定義過濾器(圖形驗證碼校驗)添加到 UsernamePasswordAuthenticationFilter 之前
        http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);

        // 將手機短信驗證碼認證的配置與當前的配置綁定
        http.apply(mobileAuthenticationConfig);
    }

    /**
     * 定制一些全局性的安全配置,例如:不攔截靜態資源的訪問
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        // 靜態資源的訪問不需要攔截,直接放行
        web.ignoring().antMatchers("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
    }
}

📚 測試

訪問localhost:8080/mobile/page

手機號碼輸入11111111111,控制台輸出:

向手機號 11111111111 發送短信:驗證碼為 6979,請勿泄露!

瀏覽器彈出窗口顯示”發送成功“,輸入正確短信驗證碼進行認證之后,瀏覽器重定向到/index


免責聲明!

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



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