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

我們來看下 AuthenticationProvider 的定義:
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
boolean supports(Class<?> authentication);
}
可以看到,AuthenticationProvider 中就兩個方法:
- authenticate 方法用來做驗(yàn)證,就是驗(yàn)證用戶身份。
- supports 則用來判斷當(dāng)前的 AuthenticationProvider 是否支持對應(yīng)的 Authentication。
這里又涉及到一個東西,就是 Authentication。Authentication 本身是一個接口,從這個接口中,我們可以得到用戶身份信息,密碼,細(xì)節(jié)信息,認(rèn)證信息,以及權(quán)限列表。我們來看下 Authentication 的定義:
package org.springframework.security.core;
public interface Authentication extends Principal, Serializable {
// 獲取用戶的權(quán)限
Collection<? extends GrantedAuthority> getAuthorities();
//獲取用戶憑證,一般是密碼,認(rèn)證之后會移出,來保證安全性
Object getCredentials();
//獲取用戶攜帶的詳細(xì)信息,Web應(yīng)用中一般是訪問者的ip地址和sessionId
Object getDetails();
// 獲取當(dāng)前用戶
Object getPrincipal();
//判斷當(dāng)前用戶是否認(rèn)證成功
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
官方文檔里說過,當(dāng)用戶提交登錄信息時,會將用戶名和密碼進(jìn)行組合成一個實(shí)例
UsernamePasswordAuthenticationToken,而這個類是 Authentication 的一個常用的實(shí)現(xiàn)類,用來進(jìn)行用戶名和密碼的認(rèn)證,類似的還有 RememberMeAuthenticationToken,它用于記住我功能。
Spring Security 支持多種不同的認(rèn)證方式,不同的認(rèn)證方式對應(yīng)不同的身份類型,每個 AuthenticationProvider 需要實(shí)現(xiàn)supports()方法來表明自己支持的認(rèn)證方式,如我們使用表單方式認(rèn)證,在提交請求時 Spring Security 會生成
UsernamePasswordAuthenticationToken,它是一個 Authentication,里面封裝著用戶提交的用戶名、密碼信息。而對應(yīng)的,哪個 AuthenticationProvider 來處理它?
我們在 DaoAuthenticationProvider 的基類
AbstractUserDetailsAuthenticationProvider 發(fā)現(xiàn)以下代碼:
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
也就是說當(dāng)web表單提交用戶名密碼時,Spring Security 由 DaoAuthenticationProvider 處理。
DaoAuthenticationProvider 的父類是
AbstractUserDetailsAuthenticationProvider, 在該類中的 authenticate()方法用于處理認(rèn)證邏輯,這里就不粘貼代碼了,該方法大致邏輯如下:
- 首先實(shí)例化UserDetails對象,調(diào)用了retrieveUser方法獲取到了一個user對象,retrieveUser是一個抽象方法。該方法進(jìn)一步會調(diào)用我們自己在登錄時候的寫的 loadUserByUsername 方法,具體在自定義的 UserDetailsService 或 InMemoryUserDetailsManager 等。
- 如果沒拿到信息就會拋出異常,如果查到了就會去調(diào)用preAuthenticationChecks的check(user)方法去進(jìn)行預(yù)檢查。在預(yù)檢查中進(jìn)行了三個檢查,因?yàn)閁serDetail類中有四個布爾類型,去檢查其中的三個,用戶是否鎖定、用戶是否過期,用戶是否可用。
- 預(yù)檢查之后緊接著去調(diào)用了additionalAuthenticationChecks方法去進(jìn)行附加檢查,這個方法也是一個抽象方法,檢查密碼是否匹配,在DaoAuthenticationProvider 的 additionalAuthenticationChecks 方法中去具體實(shí)現(xiàn),在里面進(jìn)行了加密解密去校驗(yàn)當(dāng)前的密碼是否匹配。我們想要校驗(yàn)圖片驗(yàn)證碼,就可以和密碼一起校驗(yàn),即我們重寫 additionalAuthenticationChecks 方法。
- 最后在 postAuthenticationChecks.check 方法中檢查密碼是否過期。
- 所有的檢查都通過,則認(rèn)為用戶認(rèn)證是成功的。用戶認(rèn)證成功之后,會將這些認(rèn)證信息和user傳遞進(jìn)去,調(diào)用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"));
}
}
}
實(shí)操
自定義認(rèn)證
我們復(fù)用之前的項(xiàng)目
springboot-security-inmemory,通過 postman 進(jìn)行測試,不需要額外構(gòu)建 html 頁面。
改動內(nèi)容包括自定義 DaoAuthenticationProvider 實(shí)現(xiàn)類,重寫
additionalAuthenticationChecks 方法,以及生成圖片驗(yàn)證碼。
項(xiàng)目增加如下依賴:
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
復(fù)制代碼
創(chuàng)建 VerifyService 獲取驗(yàn)證碼圖片
@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;
}
}
這段配置很簡單,我們就是提供了驗(yàn)證碼圖片的寬高、字符庫以及生成的驗(yàn)證碼字符長度。
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 實(shí)現(xiàn)類
public class MyAuthenticationProvider extends DaoAuthenticationProvider {
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
// 驗(yàn)證碼比對
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("驗(yàn)證碼錯誤");
}
// 密碼比對
super.additionalAuthenticationChecks(userDetails, authentication);
}
}
案例比較簡單,生成驗(yàn)證碼圖片時,順便存放到 session 中,登錄驗(yàn)證時從 session 中獲取驗(yàn)證碼字符串,然后與傳來的驗(yàn)證碼進(jìn)行比對。
修改 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;
}
}
測試
首先獲取圖片驗(yàn)證碼

輸入正確的驗(yàn)證碼和錯誤的密碼,進(jìn)行登錄:

如果輸入錯誤的驗(yàn)證碼

問題
使用AirPost測試遇到的問題
controller文件中設(shè)置了兩個api,一個方法往session中加了一個值,另一個方法從sesion中取值,結(jié)果兩次操作的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;
}
執(zhí)行結(jié)果:
input code is 8045 session id is 77EBBF046128BC3618C825F62C0A2099
input code is null session id is A69A7D10EAFB0471B5D658489522739D
網(wǎng)上有類似的問題,可以參考這篇文章:blog.csdn.NET/weixin_4164…
相關(guān)問題還可以看這篇文章:跨域訪問sessionid不一致問題
總結(jié)
上面的例子主要是針對認(rèn)證功能做一點(diǎn)增強(qiáng),在實(shí)際應(yīng)用中,其他的登錄場景也可以考慮這種方案,例如目前廣為流行的手機(jī)號碼動態(tài)登錄,就可以使用這種方式認(rèn)證。
后續(xù)我們還會自定義認(rèn)證流程中的密碼比對,以及授權(quán)流程中的權(quán)限比對,使之更佳貼近實(shí)際應(yīng)用場景。