Spring Security---验证码详解
- 验证码实现的三种方式
- 验证码的组成部分
- session存储验证码
- 共享session存储验证码
- 基于对称算法的验证码
- 基于session的图片验证码实现
- 基于session的图片验证码实现
- 验证码生成之配置使用kaptcha
- 验证码生成之session保存
- 验证码用户访问
- 验证码之安全校验
- Spring Security完整配置代码
- 前端代码
- 测试
验证码实现的三种方式
- session存储验证码,不适用于集群应用
- 共享session存储验证码,适用于集群应用
- 基于对称算法的验证码,适用于集群应用
验证码的组成部分
验证码实际上和谜语有点像,分为谜面和谜底。谜面通常是图片,谜底通常为文字。谜面用于展现,谜底用于校验。
- 对于字符型验证码。比如:谜面是显示字符串"ABGH"的图片,谜底是字符串"ABGH"
- 对于计算类验证码。比如:谜面是“1 1=”的图片,谜底是“2”
- 对于拖拽类的验证码。比如:谜面是一个拖拽式的拼图,谜底是拼图位置的坐标
总之,不管什么形式的谜面,最后用户的输入内容要和谜底进行验证。
session存储验证码
图中蓝色为服务端、澄粉色为客户端。
这是一种最典型的验证码实现方式,实现方式也比较简单。
- 应用服务端随机的生成验证码文字
- 将验证码文字存到session里面
- 根据验证码文字生成验证码图片,响应给客户端
- 检查用户输入的内容与验证码谜底是否一致
这种实现方式的优点就是比较简单,缺点就是:因为一套应用部署一个session,当我们把应用部署多套如:A、B、C,他们各自有一个session并且不共享。导致的结果就是验证码和图片由A生成,但是验证请求发送到了B,这样就不可能验证通过。
共享session存储验证码
分布式应用验证码的实现,实际上不是验证码的问题,而是如何保证session唯一性或共享性的问题。主要的解决方案有两种:
- 通常我们实现负载均衡应用的前端都是使用nginx或者haproxy,二者都可以配置负载均衡策略。其中一种策略就是:你的客户端ip上一次请求的是A应用,你的下一次请求还转发给A应用。这样就保证了session的唯一性。但是这种方式有可能会导致A、B、C应用其中一个或两个分配了大量的请求,而另外一个处理很少的请求,导致负载并不均衡。
- 另外一种非常通用的方式就是将分布式应用的session统一管理,也就是说原来A、B、C各自的session都存在自己的内存中,现在更改为统一存储到一个地方,大家一起用。这样就实现了session的唯一和共享,是实现分布式应用session管理的有效途径。在Spring框架内,最成熟的解决方案就是spring session redis 。可自行参考实现。
基于对称算法的验证码
可能出于主机资源的考虑,可能出于系统架构的考量,有些应用是无状态的
- 什么是无状态应用:就是不保存用户状态的应用。
- 什么是用户状态:比如当你登陆之后,在session中保存的用户的名称、组织等等信息。
- 所以可以简单的理解,无状态应用就是无session应用。当然这并不完全准确。
那么对于这些无状态的应用,我们就无法使用session,或者换个说法从团队开发规范上就不让使用session。那么我们的验证码该怎么做?
- 同样,首先要生成随机的验证码(谜底),但是不做任何存储操作
- 将谜底(验证码文字)加上时间串、应用信息等组成一个字符串进行加密。必须是对称加密,也就是说可以解密的加密算法。
- 生成验证码图片,并与加密后的密文,通过cookies一并返回给客户端。
- 当用户输入验证码提交登录之后,服务端解密cookies中的密文(主要是验证码文字),与用户的输入进行验证比对。
这种做法的缺陷是显而易见的:实际上就是将验证码文字在客户端服务端之间走了一遍。虽然是加密后的验证码文字,但是有加密就必须有解密,否则无法验证。所以更为稳妥的做法是为每一个用户生成密钥,并将密钥保存到数据库里面,在对应的阶段内调用密钥进行加密或者解密。
从密码学的角度讲,没有一种对称的加密算法是绝对安全的。所以更重要的是保护好你的密钥。正如没有一把锁头是绝对安全的,更重要的是保护好你的钥匙。
基于session的图片验证码实现
本节基于google开源的验证码实现类库kaptcha,作为验证码工具实现验证码功能开发。验证码工具类通常要具有以下三种功能方法:
- 生成验证码文字或其他用于校验的数据形式(即谜底)
- 生成验证码前端显示图片或拼图等(即谜面)
- 用于校验用户输入与谜底的校验方法(如果是纯文字,就自己比对以下就可以。如果是基于物理图形拖拽、旋转等方式,需要专用的校验方法)
基于session的图片验证码实现
本节基于google开源的验证码实现类库kaptcha,作为验证码工具实现验证码功能开发。验证码工具类通常要具有以下三种功能方法:
- 生成验证码文字或其他用于校验的数据形式(即谜底)
- 生成验证码前端显示图片或拼图等(即谜面)
- 用于校验用户输入与谜底的校验方法(如果是纯文字,就自己比对以下就可以。如果是基于物理图形拖拽、旋转等方式,需要专用的校验方法)
这种验证码类库有很多,但是都是基于以上逻辑。我们本节使用kaptcha。
验证码生成之配置使用kaptcha
代码语言:javascript复制<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
<exclusions>
<exclusion>
<artifactId>javax.servlet-api</artifactId>
<groupId>javax.servlet</groupId>
</exclusion>
</exclusions>
</dependency>
- 假设我们的配置文件是application.yml,新建一个单独的文件叫做kaptcha.properties。因为kaptcha的配置不符合yaml的规范格式,所以只能采用properties。需配合注解PropertySourc使用。
- 假设我们的配置文件是application.properties,将下面这段代码加入进去即可,不用单独建立文件。
- 下面的验证码配置,从英文单词的角度很容易理解,当我们需要调整验证码的边框、颜色、大小、字体等属性的时候,可以修改这些配置。
kaptcha.border=no
kaptcha.border.color=105,179,90
kaptcha.image.width=100
kaptcha.image.height=45
kaptcha.session.key=code
kaptcha.textproducer.font.color=blue
kaptcha.textproducer.font.size=35
kaptcha.textproducer.char.length=4
kaptcha.textproducer.font.names=宋体,楷体,微软雅黑
下面的代码加载了配置文件中的kaptcha配置(参考Spring Boot的配置加载),如果是独立的properties文件,需加上PropertySource注解说明。
另外,我们通过加载完成的配置,初始化captchaProducer的Spring Bean,用于生成验证码。
代码语言:javascript复制@Component
@PropertySource(value = {"classpath:kaptcha.properties"})
public class CaptchaConfig {
@Value("${kaptcha.border}")
private String border;
@Value("${kaptcha.border.color}")
private String borderColor;
@Value("${kaptcha.textproducer.font.color}")
private String fontColor;
@Value("${kaptcha.image.width}")
private String imageWidth;
@Value("${kaptcha.image.height}")
private String imageHeight;
@Value("${kaptcha.textproducer.char.length}")
private String charLength;
@Value("${kaptcha.textproducer.font.names}")
private String fontNames;
@Value("${kaptcha.textproducer.font.size}")
private String fontSize;
@Value("${kaptcha.session.key}")
private String sessionKey;
@Bean(name = "captchaProducer")
public DefaultKaptcha getKaptchaBean() {
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
Properties properties = new Properties();
properties.setProperty("kaptcha.border", border);
properties.setProperty("kaptcha.border.color", borderColor);
properties.setProperty("kaptcha.textproducer.font.color", fontColor);
properties.setProperty("kaptcha.image.width", imageWidth);
properties.setProperty("kaptcha.image.height", imageHeight);
properties.setProperty("kaptcha.session.key", sessionKey);
properties.setProperty("kaptcha.textproducer.char.length", charLength);
properties.setProperty("kaptcha.textproducer.font.names", fontNames);
properties.setProperty("kaptcha.textproducer.font.size",fontSize);
//kapcha的配置类
Config config = new Config(properties);
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
}
至此,Kaptcha开源验证码软件的配置我们就完成了,如果发现IDEA环境下配置文件读取中文乱码,修改如下配置。
验证码生成之session保存
生成验证码的Controller。同时需要开放路径"/kaptcha"的访问权限,配置成不需登录也无需任何权限即可访问的路径。
- 通过captchaProducer.createText()生成验证码文字,并和失效时间一起保存到CaptchaImageVO中。
- 将CaptchaImageVO验证码信息类对象,保存到session中。(这个类的代码后文有介绍)
- 通过captchaProducer.createImage(capText)生成验证码图片,并通过ServletOutputStream返回给前端
@Controller
public class CodeController {
//kapcha验证码生成
@Resource
DefaultKaptcha captchaProducer;
@RequestMapping("/kaptcha")
public void getKaptchaImage(HttpServletRequest request, HttpServletResponse response) throws IOException {
HttpSession session = request.getSession();
// 禁止服务器缓存
response.setDateHeader("Expires",0);
// 设置标准的 HTTP/1.1 no-cache headers.
response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
// 设置IE扩展 HTTP/1.1 no-cache headers (use addHeader).
response.addHeader("Cache-Control", "post-check=0, pre-check=0");
// 设置标准 HTTP/1.0 不缓存图片
response.setHeader("Pragma", "no-cache");
// 返回一个 jpeg图片, 默认是text/html
response.setContentType("image/jpeg");
// 生成验证码
String capText = captchaProducer.createText(); // 为图片创建文本
//创建验证码对象----验证码,过期时间
CaptchaImageVO captchaImageVO = new CaptchaImageVO(capText,2 * 60);
//将验证码存到session
session.setAttribute(Constants.KAPTCHA_SESSION_KEY, captchaImageVO);
//将图片返回给前端
try(ServletOutputStream out = response.getOutputStream();) {
BufferedImage bi = captchaProducer.createImage(capText);
ImageIO.write(bi, "jpg", out);
out.flush();
}//使用try-with-resources不用手动关闭流
}
}
我们要把CaptchaImageVO保存到session里面。所以该类中不要加图片,只保存验证码文字和失效时间,用于后续验证即可。把验证码图片保存起来既没有用处,又浪费内存。
代码语言:javascript复制@Data
public class CaptchaImageVO {
//验证码文字
private String code;
//验证码失效时间
private LocalDateTime expireTime;
public CaptchaImageVO(String code, int expireAfterSeconds){
this.code = code;
this.expireTime = LocalDateTime.now().plusSeconds(expireAfterSeconds);
}
//验证码是否失效
public boolean isExpried() {
return LocalDateTime.now().isAfter(expireTime);
}
public String getCode() {
return code;
}
}
验证码用户访问
把如下代码加入到登录页面合适的位置,注意图片img标签放到登录表单中。
代码语言:javascript复制<img src="/kaptcha" id="kaptcha" width="110px" height="40px"/>
<script>
window.onload=function(){
var kaptchaImg = document.getElementById("kaptcha");
kaptchaImg.onclick = function(){
kaptchaImg.src = "/kaptcha?" Math.floor(Math.random() * 100)
}
}
</script>
- 实现的效果是,页面初始化即加载验证码。以后每一次点击,都会更新验证码。
- 注意:一定设置width和height,否则图片无法显示。
- 需要为“/kaptcha”配置permitAll公开访问权限,否则无法访问到
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("admin")
.antMatchers("/user/**").hasRole("user")
.antMatchers("/kaptcha").permitAll()//放行验证码的显示请求,不需要认证
.anyRequest().authenticated()
....
验证码之安全校验
- 编写我们的自定义图片验证码过滤器CaptchaCodeFilter,过滤器中拦截登录请求
- CaptchaCodeFilter过滤器中从seesion获取验证码文字与用户输入比对,比对通过执行其他过滤器链
- 比对不通过,抛出SessionAuthenticationException异常,交给AuthenticationFailureHandler处理
- 最后将CaptchaCodeFilter放在UsernamePasswordAuthenticationFilter表单过滤器之前执行。
import com.google.code.kaptcha.Constants;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.session.SessionAuthenticationException;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.ServletRequestBindingException;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.annotation.Resource;
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;
import java.util.Objects;
@Component
public class CaptchaCodeFilter extends OncePerRequestFilter {
@Resource
MyFailHandler myAuthenticationFailureHandler;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
// 必须是登录的post请求才能进行验证,其他的直接放行
if("/login".equals(request.getRequestURI())
&&request.getMethod().equalsIgnoreCase("post")){
try{
//1.验证谜底与用户输入是否匹配
validate(new ServletWebRequest(request));
}catch(AuthenticationException e){
//2.捕获步骤1中校验出现异常,交给失败处理类进行进行处理
myAuthenticationFailureHandler.onAuthenticationFailure(request,response,e);
return;
}
}
//通过校验,就放行
filterChain.doFilter(request,response);
}
private void validate(ServletWebRequest request) throws ServletRequestBindingException {
HttpSession session = request.getRequest().getSession();
//获取用户登录界面输入的code
String codeInRequest = ServletRequestUtils.getStringParameter(
request.getRequest(),"code");
if(codeInRequest.isEmpty()){
throw new SessionAuthenticationException("验证码不能为空");
}
// 获取session池中的验证码谜底
CaptchaImageVO codeInSession = (CaptchaImageVO)
session.getAttribute(Constants.KAPTCHA_SESSION_KEY);
if(Objects.isNull(codeInSession)) {
throw new SessionAuthenticationException("您输入的验证码不存在");
}
// 校验服务器session池中的验证码是否过期
if(codeInSession.isExpried()) {
session.removeAttribute(Constants.KAPTCHA_SESSION_KEY);
throw new SessionAuthenticationException("验证码已经过期");
}
// 请求验证码校验
if(!codeInSession.getCode().equals(codeInRequest)) {
throw new SessionAuthenticationException("验证码不匹配");
}
}
}
- 上面代码中之所以抛出SessionAuthenticationException异常,因为该异常是AuthenticationException的子类,同时也是针对Session数据校验的异常。可以在doFilterInternal中被捕获,交给MyAuthenticationFailureHandler处理。MyAuthenticationFailureHandler 只认识AuthenticationException及其子类
- codeInRequest是用户请求输入的验证码
- codeInSession是用户请求验证码图片时,保存在session中的验证码谜底。
@Component
//继承该类,是因为其默认的实现,可以简化我们的代码
public class MyFailHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
String errorMsg="用户名或密码错误";
//验证码错误
//如果是验证码错误,响应JSON数据给前端
if(e instanceof SessionAuthenticationException)
{
errorMsg=e.getMessage();
httpServletResponse.setContentType("text/plain;charset=UTF-8");
httpServletResponse.getWriter().write(errorMsg);
return;
}
//如果是用户名密码错误,调用父类的方法,默认跳转到登录页面
super.onAuthenticationFailure(httpServletRequest,httpServletResponse,e);
}
}
最后将CaptchaCodeFilter过滤器放到用户名密码登录过滤器之前执行。login.html登录请求中要传递参数:code
Spring Security完整配置代码
代码语言:javascript复制@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private ObjectMapper objectMapper=new ObjectMapper();
@Resource
private CaptchaCodeFilter captchaCodeFilter;
@Bean
PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/js/**", "/css/**","/images/**");
}
//数据源注入
@Autowired
DataSource dataSource;
//持久化令牌配置
@Bean
JdbcTokenRepositoryImpl jdbcTokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
return jdbcTokenRepository;
}
//用户配置
@Override
@Bean
protected UserDetailsService userDetailsService() {
JdbcUserDetailsManager manager = new JdbcUserDetailsManager();
manager.setDataSource(dataSource);
if (!manager.userExists("dhy")) {
manager.createUser(User.withUsername("dhy").password("123").roles("admin").build());
}
if (!manager.userExists("大忽悠")) {
manager.createUser(User.withUsername("大忽悠").password("123").roles("user").build());
}
return manager;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.//处理需要认证的请求
authorizeRequests()
//放行请求,前提:是对应的角色才行
.antMatchers("/admin/**").hasRole("admin")
.antMatchers("/user/**").hasRole("user")
//无需登录凭证,即可放行
.antMatchers("/kaptcha").permitAll()//放行验证码的显示请求
//剩余的请求都需要认证才可以放行
.anyRequest().authenticated()
.and()
//表单形式登录的个性化配置
.formLogin()
.loginPage("/login.html").permitAll()
.loginProcessingUrl("/login").permitAll()
.defaultSuccessUrl("/main.html")//可以记住上一次的请求路径
//登录失败的处理器
.failureHandler(new MyFailHandler())
.and()
//退出登录相关设置
.logout()
//退出登录的请求,是再没退出前发出的,因此此时还有登录凭证
//可以访问
.logoutUrl("/logout")
//此时已经退出了登录,登录凭证没了
//那么想要访问非登录页面的请求,就必须保证这个请求无需凭证即可访问
.logoutSuccessUrl("/logout.html").permitAll()
//退出登录的时候,删除对应的cookie
.deleteCookies("JSESSIONID")
.and()
//记住我相关设置
.rememberMe()
//预定义key相关设置,默认是一串uuid
.key("dhy")
//令牌的持久化
.tokenRepository(jdbcTokenRepository())
.and()
//将CaptchaCodeFilter过滤器放到用户名密码登录过滤器之前执行
.addFilterBefore(captchaCodeFilter, UsernamePasswordAuthenticationFilter.class)
//csrf关闭
.csrf().disable();
}
//角色继承
@Bean
RoleHierarchy roleHierarchy() {
RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
hierarchy.setHierarchy("ROLE_admin > ROLE_user");
return hierarchy;
}
}
前端代码
代码语言:javascript复制<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
<body>
用户名:<input type="text" name="username" id="name"/><br/>
密码:<input type="password" name="password" id="pwd"/><br/>
验证码: <input type="text" id="code"/>
<img src="/kaptcha" id="kaptcha" width="110px" height="40px"/></br>
记住我: <input type="checkbox" name="remember-me" id="remember-me"/><br/>
<input type="submit" id="loginBtn" value="登录" />
</body>
</html>
<script>
window.onload=function()
{
//获取验证码的dom对象
var kaptchaImg = document.getElementById("kaptcha");
//图片被点击
kaptchaImg.onclick = function(){
//重新请求设置一遍路径,路径后面跟上随机数,防止从浏览器缓存中获取数据
kaptchaImg.src = "/kaptcha?" Math.floor(Math.random() * 100)
}
}
$("#loginBtn").click(function ()
{
$.ajax({
url:'login',
type:'post',
data: {
'username':$("#name").val(),'password':$("#pwd").val(),
'code':$("#code").val(),'remember-me':$("#remember-me").val()
}
,success: function (res)
{
alert(res)
}
})
})
</script>
测试
不输入验证码:
验证码输入错误:
验证码过期:
验证码输入成功,但是用户名密码密码错误,失败处理器调用父类方法调回到登录页面:
验证码输入成功,用户名密码输入成功,跳转到主页