表单验证码登录
表单登录验证码验证,一般在用户名、密码提交登录前,添加过滤器,先验证验证码的有效性(开发中一般用的这种),然后再提交用户名、密码。文章下面还会使用另一种方法:验证码和用户名、密码一起同时提交登录。
在Spring Security
中,两种实现方式为:
- 使用自定义过滤器(
Filter
),在提交用户名、密码前,先验证验证码的有效性 - 验证码和用户名、密码一起在
Spring Security
中进行验证
一、验证码生成
新建一个包validateCode
放置所有验证码相关的类。
1.1、验证码实体对象
@Data
public class ValidateCode {
private BufferedImage image;
private String code;
private LocalDateTime expireTime;
/**
* @param expirtSecond 设置过期时间,单位秒
*/
public ValidateCode(BufferedImage image, String code, int expirtSecond){
this.image = image;
this.code = code;
// expireSecond秒后的时间
this.expireTime = LocalDateTime.now().plusSeconds(expirtSecond);
}
/**
* 验证码是否过期
*/
public boolean isExpired(){
return LocalDateTime.now().isAfter(expireTime);
}
}
1.2、生成验证码:
@Service
public class ValidateCodeCreateService {
public ValidateCode createImageCode() {
// 宽度
// 从请求参数中获取数据,否则,读取配置文件配置值
int width = 80;
// 高度
int height = 30;
// 认证码长度
int charLength = 4;
// 过期时间(秒)
int expireTime = 60;
BufferedImage image = new BufferedImage(width, height,
BufferedImage.TYPE_INT_RGB);
// 获取图形上下文
Graphics g = image.getGraphics();
// 生成随机类
Random random = new Random();
// 设定背景色
g.setColor(getRandColor(200, 250));
g.fillRect(0, 0, width, height);
// 设定字体
g.setFont(new Font("Times New Roman", Font.PLAIN, 18));
// 随机产生155条干扰线,使图象中的认证码不易被其它程序探测到
g.setColor(getRandColor(160, 200));
for (int i = 0; i < 155; i++) {
int x = random.nextInt(width);
int y = random.nextInt(height);
int xl = random.nextInt(12);
int yl = random.nextInt(12);
g.drawLine(x, y, x + xl, y + yl);
}
// 取随机产生的认证码
String sRand = "";
for (int i = 0; i < charLength; i++) {
String rand = String.valueOf(random.nextInt(10));
sRand += rand;
// 将认证码显示到图象中
g.setColor(new Color(20 + random.nextInt(110), 20 + random
.nextInt(110), 20 + random.nextInt(110)));
// 调用函数出来的颜色相同,可能是因为种子太接近,所以只能直接生成
g.drawString(rand, 13 * i + 6, 16);
}
// 图象生效
g.dispose();
return new ValidateCode(image, sRand, expireTime);
}
/**
* 给定范围获得随机颜色
*/
private Color getRandColor(int fc, int bc) {
Random random = new Random();
if (fc > 255) {
fc = 255;
}
if (bc > 255) {
bc = 255;
}
int r = fc + random.nextInt(bc - fc);
int g = fc + random.nextInt(bc - fc);
int b = fc + random.nextInt(bc - fc);
return new Color(r, g, b);
}
}
验证码图片生成接口
@RestController
public class ValidateCodeController {
@Autowired
private ValidateCodeCreateService validateCodeCreateService;
@GetMapping("/get-validate-code")
public void getImageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
// 创建验证码
ValidateCode validateCode = validateCodeCreateService.createImageCode();
// 将验证码放到session中(也可放在Redis中,可设置过期时间)
request.getSession().setAttribute("validate-code", validateCode);
// 返回验证码给前端
ImageIO.write(validateCode.getImage(), "JPEG", response.getOutputStream());
}
}
二、登录页面配置
修改resources/templates
下登录页面,添加验证码选项:
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<meta charset="UTF-8">
<title>登录页面</title>
</head>
<body>
<form th:action="@{/my-login}" method="post">
<div><label> 用户名 : <input type="text" name="username"/> </label></div>
<div><label> 密码: <input type="password" name="password"/> </label></div>
<div>验证码:
<input type="text" class="form-control" name="validateCode" required="required" placeholder="验证码">
<img src="get-validate-code" title="看不清,请点我" onclick="refresh(this)" />
</div>
<button type="submit" class="btn">登录</button>
</form>
<script>
function refresh(obj) { obj.src = "get-validate-code"; }
</script>
</body>
</html>
WebSecurityConfig
配置:
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
// ...
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
// 获取验证码允许匿名访问
.antMatchers("/get-validate-code").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/user-login").permitAll()
.loginProcessingUrl("/my-login")
// ...
}
}
正常项目已经配置好,启动项目,访问localhost:8080/hello
跳转到自定义的登录页面:
随便输入内容提交,登录失败,返回:
输入正确的用户名、密码,验证码随意输入登录,登录成功,返回:
可以看到,这里Spring Security
默认只验证用户名、密码,没有验证验证码是否正确。所以下面开始实现登录验证码验证,有以下两种种实现方式:
- 使用自定义过滤器(
Filter
),在校验用户名、密码前判断验证码合法性,验证通过后,通过用户名和密码登录 - 验证码和用户名、密码一起提交到后台登录
三、过滤器验证
原理:在 Spring Security
处理登录请求前,先验证验证码,如果正确,放行去登录;如果不正确,返回失败处理。
2.1、验证码过滤器
自定义一个过滤器,OncePerRequestFilter
(该Filter
保证每次请求只过滤一次):
public class ValidateCodeFilter extends OncePerRequestFilter {
// URL正则匹配
private static final PathMatcher pathMatcher = new AntPathMatcher();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 只有登录请求‘/authentication/form’,并且为'post'请求时,才校验
if ("POST".equals(request.getMethod())
&& pathMatcher.match("/anthentication/form", request.getServletPath())) {
try {
codeValidate(request);
} catch (ValidateCodeException e) {
// 验证码不通过,跳到错误处理器处理
response.setContentType("application/json;charset=UTF-8");
response.getWriter().append(
new ObjectMapper().createObjectNode()
.put("status", "500")
.put("msg", e.getMessage())
.toString());
// 异常后,不执行后面
return;
}
}
doFilter(request, response, filterChain);
}
private void codeValidate(HttpServletRequest request) throws JsonProcessingException {
// 获取到传入的验证码
String codeInRequest = request.getParameter("validateCode");
ValidateCode codeInSession = (ValidateCode) request.getSession(false).getAttribute("validate-code");
// 校验验证码是否正确
if (StringUtils.isEmpty(codeInRequest)) {
throw new ValidateCodeException("验证码的值不能为空");
}
if (codeInSession == null) {
throw new ValidateCodeException("验证码不存在");
}
if (codeInSession.isExpired()) {
throw new ValidateCodeException("验证码已过期");
}
if (!StringUtils.equals(codeInSession.getCode(), codeInRequest)) {
throw new ValidateCodeException("验证码不匹配");
}
// 校验正确后,移除session中验证码
request.getSession(false).removeAttribute("validate-code");
}
}
class ValidateCodeException extends AuthenticationException {
public ValidateCodeException(String message) {
super(message);
}
}
2.2、配置过滤器
Spring Security
对于用户名/密码登录验证是通过 UsernamePasswordAuthenticationFilter
处理的,只要在它之前执行验证码过滤器即可:
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
...
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// 验证码过滤器在用户名、密码校验前
.addFilterBefore(new ValidateCodeFilter(), UsernamePasswordAuthenticationFilter.class)
.authorizeRequests()
.antMatchers("/get-validate-code").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/user-login").permitAll()
.loginProcessingUrl("/my-login")
}
}
2.4、运行程序
启动项目,访问localhost:8080/login
到登录页,随机输入内容登录:
点击登录后,后台验证验证码错误,显示如下:
输入正确的验证码,而用户名、密码错误:
全部正确时,返回用户信息:
四、和用户名、密码同时验证
上面使用过滤器实现了验证码功能,该过滤器是先验证验证码,验证成功就让 Spring Security
验证用户名和密码。
如果用户登录是需要多个登录字段,不单单是用户名和密码,这时候可以考虑自定义 Spring Security
的验证逻辑。
3.1、WebAuthenticationDetails
Spring security
默认只会处理用户名和密码信息,如果我们需要增加验证码字段验证,则需要拿到验证码。而WebAuthenticationDetails
类提供了获取用户登录时携带的额外信息的功能,可以通过该类拿到验证码。所以我们需要自定义类继承该类拿到验证码:
public class CustomWebAuthenticationDetails extends WebAuthenticationDetails {
@Getter // 设置getter方法,以便拿到验证码
private final String validateCode;
public CustomWebAuthenticationDetails(HttpServletRequest request) {
super(request);
// 拿页面传来的验证码
validateCode = request.getParameter("validateCode");
}
}
3.2、AuthenticationDetailSource
把自定义CustomWebAuthenticationDetails
,放入 AuthenticationDetailsSource
中来替换原本的 WebAuthenticationDetails
,因此还得实现自定义 CustomAuthenticationDetailsSource
,设置为我们自定义的 CustomWebAuthenticationDetails
:
@Component("authenticationDetailsSource")
public class CustomAuthenticationDetailsSource implements AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> {
@Override
public WebAuthenticationDetails buildDetails(HttpServletRequest httpRequest) {
return new CustomWebAuthenticationDetails(httpRequest);
}
}
3.3、Spring Security配置
将 CustomAuthenticationDetailsSource
注入Spring Security
中,替换掉默认的 AuthenticationDetailsSource
。
修改 WebSecurityConfig,将其注入,然后在config()中使用 authenticationDetailsSource(authenticationDetailsSource)方法来指定它。
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
// 省略其他
@Autowired
private AuthenticationDetailsSource authenticationDetailsSource;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/get-validate-code").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/user-login").permitAll()
.loginProcessingUrl("/my-login")
.authenticationDetailsSource(authenticationDetailsSource);
http.csrf().disable();
}
}
3.4、AuthenticationProvider
通过自定义CustomWebAuthenticationDetails
和CustomAuthenticationDetailsSource
将验证码和用户名、密码一起加入了Spring Security
中,但默认的认证中还不会对验证码进行校验,需要重写UserDetailsAuthenticationProvider
进行校验。
@Component
public class CustomAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
@Autowired
private CustomUserDetailsService userDetailsService;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
// 获取登录提交的用户名和密码
String inputPassword = (String) authentication.getCredentials();
// 获取登录提交的验证码
CustomWebAuthenticationDetails details = (CustomWebAuthenticationDetails) authentication.getDetails();
String validateCode = details.getValidateCode();
// 验证码校验
checkValidateCode(validateCode);
// 验证用户名
if (!passwordEncoder.matches(inputPassword, userDetails.getPassword())) {
throw new BadCredentialsException("密码错误");
}
}
@Override
protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken) throws AuthenticationException {
return userDetailsService.loadUserByUsername(username);
}
private void checkValidateCode(String validateCode) {
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();
ValidateCode codeInSession = (ValidateCode) request.getSession(false).getAttribute("validate-code");
if (StringUtils.isEmpty(validateCode)) {
throw new ValidateCodeException("验证码的值不能为空");
}
if (codeInSession == null) {
throw new ValidateCodeException("验证码不存在");
}
if (codeInSession.isExpired()) {
// 移除session中验证码
request.getSession(false).removeAttribute("validate-code");
throw new ValidateCodeException("验证码已过期");
}
if (!StringUtils.equals(codeInSession.getCode(), validateCode)) {
throw new ValidateCodeException("验证码不匹配");
}
// 移除session中验证码
request.getSession(false).removeAttribute("validate-code");
}
}
class ValidateCodeException extends AuthenticationException {
ValidateCodeException(String message) {
super(message);
}
}
在 WebSecurityConfig
中将其注入,并在 configure(AuthenticationManagerBuilder auth)
方法中通过 auth.authenticationProvider()
指定使用
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomAuthenticationProvider authenticationProvider;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// auth.userDetailsService(userDetailsService);
auth.authenticationProvider(authenticationProvider);
}
}
启动程序测试即可。