Spring Security添加圖形驗(yàn)證碼
添加驗(yàn)證碼大致可以分為三個(gè)步驟:根據(jù)隨機(jī)數(shù)生成驗(yàn)證碼圖片;將驗(yàn)證碼圖片顯示到登錄頁(yè)面;認(rèn)證流程中加入驗(yàn)證碼校驗(yàn)。Spring Security的認(rèn)證校驗(yàn)是由UsernamePasswordAuthenticationFilter過(guò)濾器完成的,所以我們的驗(yàn)證碼校驗(yàn)邏輯應(yīng)該在這個(gè)過(guò)濾器之前。下面一起學(xué)習(xí)下如何加入驗(yàn)證碼校驗(yàn)功能。
生成圖形驗(yàn)證碼
驗(yàn)證碼功能需要用到以下依賴:
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-captcha</artifactId>
<version>5.3.10</version>
</dependency>
這個(gè)工具類的用戶可以參見(jiàn)該工具的官方文檔
接著定義一個(gè)ValidateCodeController,用于處理生成驗(yàn)證碼請(qǐng)求:
@Slf4j
@RestController
public class ValidateController {
public final static String SESSION_KEY_IMAGE_CODE = "SESSION_KEY_IMAGE_CODE";
@GetMapping("/code/image")
public void createCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
//設(shè)置response響應(yīng)
response.setCharacterEncoding("UTF-8");
response.setHeader("Pragma", "No-cache");
response.setHeader("Cache-Control", "no-cache");
response.setDateHeader("Expires", 0);
response.setContentType("image/jpeg");
//定義圖形驗(yàn)證碼的長(zhǎng)、寬、驗(yàn)證碼字符數(shù)、干擾元素個(gè)數(shù)
CircleCaptcha captcha = CaptchaUtil.createCircleCaptcha(100, 38, 4, 20);
System.out.println(captcha.getCode());
//將驗(yàn)證碼放到HttpSession里面
request.getSession().setAttribute(SESSION_KEY_IMAGE_CODE, captcha.getCode());
log.info("本次生成的驗(yàn)證碼為:" + captcha.getCode() + ",已存放到HttpSession中");
//圖形驗(yàn)證碼寫(xiě)出,可以寫(xiě)出到文件,也可以寫(xiě)出到流
//輸出瀏覽器
OutputStream out=response.getOutputStream();
captcha.write(out);
out.flush();
out.close();
}
}
使用hutool的CaptchaUtil.createCircleCaptcha方法生成驗(yàn)證碼對(duì)象,將生成的驗(yàn)證碼對(duì)象存儲(chǔ)到Session中,并通過(guò)IO流將生成的圖片輸出到登錄頁(yè)面上。
改造登錄頁(yè)
在登錄頁(yè)面加上如下代碼:
<span style="display: inline">
<input type="text" name="imageCode" placeholder="驗(yàn)證碼" style="width: 50%;"/>
<img src="/code/image"/>
</span>
<img>
標(biāo)簽的src屬性對(duì)應(yīng)ValidateController的createCode方法。
要使生成驗(yàn)證碼的請(qǐng)求不被攔截,需要在SecurityConfig的configure方法中配置免攔截:
@Override
protected void configure(HttpSecurity http) throws Exception {
...
.antMatchers("/code/image").permitAll() // 無(wú)需認(rèn)證的請(qǐng)求路徑
.anyRequest() // 所有請(qǐng)求
...
}
重啟項(xiàng)目,訪問(wèn)http://localhost:8080/loginPage
認(rèn)證流程添加驗(yàn)證碼校驗(yàn)
在校驗(yàn)驗(yàn)證碼的過(guò)程中,可能會(huì)拋出各種驗(yàn)證碼類型的異常,比如“驗(yàn)證碼錯(cuò)誤”、“驗(yàn)證碼已過(guò)期”等,所以我們定義一個(gè)驗(yàn)證碼類型的異常類:
public class ValidateCodeException extends AuthenticationException {
private static final long serialVersionUID = 5022575393500654458L;
ValidateCodeException(String message) {
super(message);
}
}
注意,這里繼承的是AuthenticationException而不是Exception。
我們都知道,Spring Security實(shí)際上是由許多過(guò)濾器組成的過(guò)濾器鏈,處理用戶登錄邏輯的過(guò)濾器為UsernamePasswordAuthenticationFilter,而驗(yàn)證碼校驗(yàn)過(guò)程應(yīng)該是在這個(gè)過(guò)濾器之前的,即只有驗(yàn)證碼校驗(yàn)通過(guò)后采去校驗(yàn)用戶名和密碼。由于Spring Security并沒(méi)有直接提供驗(yàn)證碼校驗(yàn)相關(guān)的過(guò)濾器接口,所以我們需要自己定義一個(gè)驗(yàn)證碼校驗(yàn)的過(guò)濾器ValidateCodeFilter:
@Component
public class ValidateCodeFilter extends OncePerRequestFilter {
@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
if ("/form".equalsIgnoreCase(httpServletRequest.getRequestURI())
&& "post".equalsIgnoreCase(httpServletRequest.getMethod())) {
try {
HttpSession session = httpServletRequest.getSession();
String codeInReq = httpServletRequest.getParameter("imageCode");
validateCode(session,codeInReq);
} catch (ValidateCodeException e) {
authenticationFailureHandler.onAuthenticationFailure(httpServletRequest, httpServletResponse, e);
return;
}
}
filterChain.doFilter(httpServletRequest, httpServletResponse);
}
private void validateCode(HttpSession session,String codeInRequest) throws ServletRequestBindingException {
String codeInSession = (String)session.getAttribute(ValidateController.SESSION_KEY_IMAGE_CODE);
if (StringUtils.isBlank(codeInRequest)) {
throw new ValidateCodeException("驗(yàn)證碼不能為空!");
}
if (codeInSession == null) {
throw new ValidateCodeException("驗(yàn)證碼不存在!");
}
if (!codeInRequest.equalsIgnoreCase(codeInSession)) {
throw new ValidateCodeException("驗(yàn)證碼不正確!");
}
session.removeAttribute(ValidateController.SESSION_KEY_IMAGE_CODE);
}
}
ValidateCodeFilter繼承了org.springframework.web.filter.OncePerRequestFilter,該過(guò)濾器只會(huì)執(zhí)行一次。
在doFilterInternal方法中我們判斷了請(qǐng)求URL是否為/form,該路徑對(duì)應(yīng)登錄form表單的action路徑,請(qǐng)求的方法是否為POST,是的話進(jìn)行驗(yàn)證碼校驗(yàn)邏輯,否則直接執(zhí)行filterChain.doFilter讓代碼往下走。當(dāng)在驗(yàn)證碼校驗(yàn)的過(guò)程中捕獲到異常時(shí),調(diào)用Spring Security的校驗(yàn)失敗處理器AuthenticationFailureHandler進(jìn)行處理。
validateCode的校驗(yàn)邏輯是validateCode方法
我們分別從Session中獲取了ImageCode對(duì)象和請(qǐng)求參數(shù)imageCode(對(duì)應(yīng)登錄頁(yè)面的驗(yàn)證碼<input>框name屬性),然后進(jìn)行了各種判斷并拋出相應(yīng)的異常。當(dāng)驗(yàn)證碼過(guò)期或者驗(yàn)證碼校驗(yàn)通過(guò)時(shí),我們便可以刪除Session中的ImageCode屬性了。
驗(yàn)證碼校驗(yàn)過(guò)濾器定義好了,怎么才能將其添加到UsernamePasswordAuthenticationFilter前面呢?很簡(jiǎn)單,只需要在SecurityConfig的configure方法中添加些許配置即可:
@Autowired
private ValidateCodeFilter validateCodeFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class) // 添加驗(yàn)證碼校驗(yàn)過(guò)濾器
.formLogin() // 表單登錄
// http.httpBasic() // HTTP Basic
.loginPage("/authentication/require") // 登錄跳轉(zhuǎn) URL
.loginProcessingUrl("/login") // 處理表單登錄 URL
.successHandler(authenticationSucessHandler) // 處理登錄成功
.failureHandler(authenticationFailureHandler) // 處理登錄失敗
.and()
.authorizeRequests() // 授權(quán)配置
.antMatchers("/authentication/require",
"/login.html",
"/code/image").permitAll() // 無(wú)需認(rèn)證的請(qǐng)求路徑
.anyRequest() // 所有請(qǐng)求
.authenticated() // 都需要認(rèn)證
.and().csrf().disable();
}
上面代碼中,我們注入了ValidateCodeFilter,然后通過(guò)addFilterBefore方法將ValidateCodeFilter驗(yàn)證碼校驗(yàn)過(guò)濾器添加到了UsernamePasswordAuthenticationFilter前面。
大功告成,重啟項(xiàng)目,訪問(wèn)http://localhost:8080/loginPage,當(dāng)不輸入驗(yàn)證碼時(shí)點(diǎn)擊登錄,
當(dāng)輸入錯(cuò)誤的驗(yàn)證碼時(shí)點(diǎn)擊登錄,
當(dāng)驗(yàn)證碼通過(guò),并且用戶名密碼正確時(shí),頁(yè)面顯示如下: