前言
相信大家在網上沖浪都遇到過登錄時輸入圖片驗證碼的情況,既然我們已經學習了 Spring Security,也上手實現過幾個案例,那不妨來研究一下如何實現這一功能。
首先需要明確的是,登錄時輸入圖片驗證碼,屬于認證功能的一部分,所以本文不涉及授權功能。
認證流程簡析
在上文中,我們介紹了認證流程,以及相關的關鍵類,可知 AuthenticationProvider 定義了 Spring Security 中的驗證邏輯,該類的類關系圖:

我們來看下 AuthenticationProvider 的定義:
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
boolean supports(Class<?> authentication);
}
可以看到,AuthenticationProvider 中就兩個方法:
- authenticate 方法用來做驗證,就是驗證用戶身份。
- supports 則用來判斷當前的 AuthenticationProvider 是否支持對應的 Authentication。
這里又涉及到一個東西,就是 Authentication。Authentication 本身是一個接口,從這個接口中,我們可以得到用戶身份信息,密碼,細節信息,認證信息,以及權限列表。我們來看下 Authentication 的定義:
package org.springframework.security.core;
public interface Authentication extends Principal, Serializable {
// 獲取用戶的權限
Collection<? extends GrantedAuthority> getAuthorities();
//獲取用戶憑證,一般是密碼,認證之后會移出,來保證安全性
Object getCredentials();
//獲取用戶攜帶的詳細信息,Web應用中一般是訪問者的ip地址和sessionId
Object getDetails();
// 獲取當前用戶
Object getPrincipal();
//判斷當前用戶是否認證成功
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
官方文檔里說過,當用戶提交登錄信息時,會將用戶名和密碼進行組合成一個實例
UsernamePasswordAuthenticationToken,而這個類是 Authentication 的一個常用的實現類,用來進行用戶名和密碼的認證,類似的還有 RememberMeAuthenticationToken,它用于記住我功能。
Spring Security 支持多種不同的認證方式,不同的認證方式對應不同的身份類型,每個 AuthenticationProvider 需要實現supports()方法來表明自己支持的認證方式,如我們使用表單方式認證,在提交請求時 Spring Security 會生成
UsernamePasswordAuthenticationToken,它是一個 Authentication,里面封裝著用戶提交的用戶名、密碼信息。而對應的,哪個 AuthenticationProvider 來處理它?
我們在 DaoAuthenticationProvider 的基類
AbstractUserDetailsAuthenticationProvider 發現以下代碼:
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
也就是說當web表單提交用戶名密碼時,Spring Security 由 DaoAuthenticationProvider 處理。
DaoAuthenticationProvider 的父類是
AbstractUserDetailsAuthenticationProvider, 在該類中的 authenticate()方法用于處理認證邏輯,這里就不粘貼代碼了,該方法大致邏輯如下:
- 首先實例化UserDetails對象,調用了retrieveUser方法獲取到了一個user對象,retrieveUser是一個抽象方法。該方法進一步會調用我們自己在登錄時候的寫的 loadUserByUsername 方法,具體在自定義的 UserDetailsService 或 InMemoryUserDetailsManager 等。
- 如果沒拿到信息就會拋出異常,如果查到了就會去調用preAuthenticationChecks的check(user)方法去進行預檢查。在預檢查中進行了三個檢查,因為UserDetail類中有四個布爾類型,去檢查其中的三個,用戶是否鎖定、用戶是否過期,用戶是否可用。
- 預檢查之后緊接著去調用了additionalAuthenticationChecks方法去進行附加檢查,這個方法也是一個抽象方法,檢查密碼是否匹配,在DaoAuthenticationProvider 的 additionalAuthenticationChecks 方法中去具體實現,在里面進行了加密解密去校驗當前的密碼是否匹配。我們想要校驗圖片驗證碼,就可以和密碼一起校驗,即我們重寫 additionalAuthenticationChecks 方法。
- 最后在 postAuthenticationChecks.check 方法中檢查密碼是否過期。
- 所有的檢查都通過,則認為用戶認證是成功的。用戶認證成功之后,會將這些認證信息和user傳遞進去,調用createSuccessAuthentication方法。
DaoAuthenticationProvider 中的
additionalAuthenticationChecks 方法用于比對密碼,邏輯比較簡單,就是將 password 加密后與事先保存好的密碼做比對。代碼如下:
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
} else {
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
}
實操
自定義認證
我們復用之前的項目
springboot-security-inmemory,通過 postman 進行測試,不需要額外構建 html 頁面。
改動內容包括自定義 DaoAuthenticationProvider 實現類,重寫
additionalAuthenticationChecks 方法,以及生成圖片驗證碼。
項目增加如下依賴:
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
復制代碼
創建 VerifyService 獲取驗證碼圖片
@Service
public class VerifyService {
public Producer getProducer() {
Properties properties = new Properties();
properties.setProperty("kaptcha.image.width", "150");
properties.setProperty("kaptcha.image.height", "50");
properties.setProperty("kaptcha.textproducer.char.string", "0123456789");
properties.setProperty("kaptcha.textproducer.char.length", "4");
Config config = new Config(properties);
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
}
這段配置很簡單,我們就是提供了驗證碼圖片的寬高、字符庫以及生成的驗證碼字符長度。
VerifyCodeController 文件中增加圖片返回接口:
@RestController
@Slf4j
public class VerifyCodeController {
@Autowired
VerifyService verifyService;
@GetMApping("/verify-code")
public void getVerifyCodePng(HttpServletRequest request, HttpServletResponse resp)
throws IOException {
resp.setDateHeader("Expires", 0);
resp.setHeader("Cache-Control",
"no-store, no-cache, must-revalidate");
resp.addHeader("Cache-Control", "post-check=0, pre-check=0");
resp.setHeader("Pragma", "no-cache");
resp.setContentType("image/jpeg");
Producer producer = verifyService.getProducer();
String text = producer.createText();
HttpSession session = request.getSession();
session.setAttribute("verify_code", text);
BufferedImage image = producer.createImage(text);
try (ServletOutputStream out = resp.getOutputStream()) {
ImageIO.write(image, "jpg", out);
}
}
}
自定義 DaoAuthenticationProvider 實現類
public class MyAuthenticationProvider extends DaoAuthenticationProvider {
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
// 驗證碼比對
HttpServletRequest req = ((ServletRequestAttributes) RequestContextHolder
.getRequestAttributes()).getRequest();
String code = req.getParameter("code");
HttpSession session = req.getSession(false);
String verify_code = (String) session.getAttribute("verify_code");
if (code == null || verify_code == null || !code.equals(verify_code)) {
throw new AuthenticationServiceException("驗證碼錯誤");
}
// 密碼比對
super.additionalAuthenticationChecks(userDetails, authentication);
}
}
案例比較簡單,生成驗證碼圖片時,順便存放到 session 中,登錄驗證時從 session 中獲取驗證碼字符串,然后與傳來的驗證碼進行比對。
修改 SecurityConfig
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Override
@Bean
protected AuthenticationManager authenticationManager() throws Exception {
ProviderManager manager = new ProviderManager(Arrays.asList(myAuthenticationProvider()));
return manager;
}
@Bean
@Override
protected UserDetailsService userDetailsService() {
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
manager.createUser(User.withUsername("hresh").password("123").roles("admin").build());
return manager;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable()
.authorizeRequests()
.antMatchers("/verify-code").permitAll()
.antMatchers("/code").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.successHandler((req, resp, auth) -> {
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
out.write(new ObjectMapper().writeValueAsString(Result.ok(auth.getPrincipal())));
out.flush();
out.close();
})
.failureHandler((req, resp, e) -> {
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
out.write(new ObjectMapper().writeValueAsString(Result.failed(e.getMessage())));
out.flush();
out.close();
})
.permitAll();
}
@Bean
MyAuthenticationProvider myAuthenticationProvider() {
MyAuthenticationProvider myAuthenticationProvider = new MyAuthenticationProvider();
myAuthenticationProvider.setPasswordEncoder(passwordEncoder());
myAuthenticationProvider.setUserDetailsService(userDetailsService());
return myAuthenticationProvider;
}
}
測試
首先獲取圖片驗證碼

輸入正確的驗證碼和錯誤的密碼,進行登錄:

如果輸入錯誤的驗證碼

問題
使用AirPost測試遇到的問題
controller文件中設置了兩個api,一個方法往session中加了一個值,另一個方法從sesion中取值,結果兩次操作的sessionId不同。
代碼如下所示:
@GetMapping("/verify-code")
public void getVerifyCodePng(HttpServletRequest request) {
Producer producer = verifyService.getProducer();
String text = producer.createText();
HttpSession session = request.getSession();
session.setAttribute("verify_code", text);
session.setAttribute("user", "hresh");
log.info("code is " + text + " session id is " + session.getId());
}
@GetMapping("/code")
public String getVerifyCode(HttpServletRequest request) {
HttpSession session = request.getSession();
String verify_code = (String) session.getAttribute("verify_code");
log.info("input code is " + verify_code + " session id is " + session.getId());
return verify_code;
}
執行結果:
input code is 8045 session id is 77EBBF046128BC3618C825F62C0A2099
input code is null session id is A69A7D10EAFB0471B5D658489522739D
網上有類似的問題,可以參考這篇文章:blog.csdn.NET/weixin_4164…
相關問題還可以看這篇文章:跨域訪問sessionid不一致問題
總結
上面的例子主要是針對認證功能做一點增強,在實際應用中,其他的登錄場景也可以考慮這種方案,例如目前廣為流行的手機號碼動態登錄,就可以使用這種方式認證。
后續我們還會自定義認證流程中的密碼比對,以及授權流程中的權限比對,使之更佳貼近實際應用場景。