前言
相信大家在网上冲浪都遇到过登录时输入图片验证码的情况,既然我们已经学习了 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不一致问题
总结
上面的例子主要是针对认证功能做一点增强,在实际应用中,其他的登录场景也可以考虑这种方案,例如目前广为流行的手机号码动态登录,就可以使用这种方式认证。
后续我们还会自定义认证流程中的密码比对,以及授权流程中的权限比对,使之更佳贴近实际应用场景。