简介
Security默认是不支持手机短信验证码登录因此整个认证流程需要我们自己开发,这里我们分多篇文章讲解这篇文章只完成发送手机验证码
短信验证码实体类
spring-security-core项目com.spring.security.validate.code路径SmsCode类
import lombok.Data;
import java.awt.image.BufferedImage;
import java.time.LocalDateTime;
/**
* 手机验证码
*
*/
@Data
public class SmsCode {
private String code;
private LocalDateTime expireTime;
public SmsCode(String code, int expireIn) {
this.code = code;
this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
}
public boolean isExpire() {
return LocalDateTime.now().isAfter(expireTime);
}
}
发现图形验证码的实体类字段是包含手机验证码的实体类字段,改造图形验证码的实体类继承手机验证码的实体类
修改SmsCode类为ValidateCode类
package com.spring.security.validate.code;
import lombok.Data;
import java.awt.image.BufferedImage;
import java.time.LocalDateTime;
/**
* 验证码实体类
*/
@Data
public class ValidateCode {
private String code;
private LocalDateTime expireTime;
public ValidateCode(String code, int expireIn) {
this.code = code;
this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
}
public ValidateCode(String code, LocalDateTime expireTime) {
this.code = code;
this.expireTime = expireTime;
}
public boolean isExpire() {
return LocalDateTime.now().isAfter(expireTime);
}
}
ImageCode类继承ValidateCode类
package com.spring.security.validate.code;
import lombok.Data;
import java.awt.image.BufferedImage;
import java.time.LocalDateTime;
/**
* 图片的代码
*/
@Data
public class ImageCode extends ValidateCode {
private BufferedImage image;
public ImageCode(BufferedImage image, String code, int expireIn) {
super(code, expireIn);
this.image = image;
}
public ImageCode(BufferedImage image, String code, LocalDateTime expireTime) {
super(code, expireTime);
this.image = image;
}
}
配置系统参数
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://127.0.0.1/auto_test?useUnicode=yes&characterEncoding=UTF-8&useSSL=false
username: root
password: 123456
hk:
security:
browser:
loginSucess: /index #登录成功跳转
loginType: JSON #登录成功 失败返回值类型
loginPage: /signIn.html #登录页面
oginFailure: /failure #登录失败跳转
rememberMeSeconds: 3600 #记住我过期时间 秒
code:
image:
width: 90 # 验证码图片宽度
height: 20 # 验证码图片长度
length: 6 # 验证码位数
expireIn: 60 # 验证码有效时间 60s
url: /login #需要过滤的路径/login,/login/*
sms:
length: 6 # 验证码位数
expireIn: 60 # 验证码有效时间 60s
url: /login #需要过滤的路径/login,/login/*
系统参数封装
core项目com.spring.security.properties路径SmsCodeProperties类
package com.spring.security.properties;
import lombok.Data;
/**
* 手机号验证码属性
*/
@Data
public class SmsCodeProperties {
// 验证码位数
private int length = 6;
// 验证码有效时间 60s
private int expireIn = 60;
//要拦截的路径
private String url;
}
发现图形验证码的系统属性是包含手机验证码的系统属性,改造图形验证码的系统属性继承手机验证码的系统属性
改造ImageCodeProperties类,手机验证码默认length是6在ImageCodeProperties新建构造方法去修改图形验证码length默认值
package com.spring.security.properties;
import lombok.Data;
/**
* 图像编码性能
*/
@Data
public class ImageCodeProperties extends SmsCodeProperties {
//设置验证码默认长度
public ImageCodeProperties(){
setLength(4);
}
// 验证码图片宽度
private int width = 60;
// 验证码图片长度
private int height = 20;
}
修改ValidateCodeProperties类加入SmsCodeProperties
package com.spring.security.properties;
import lombok.Data;
/**
* 验证代码属性
*/
@Data
public class ValidateCodeProperties {
private ImageCodeProperties image = new ImageCodeProperties();
private SmsCodeProperties sms = new SmsCodeProperties();
}
生成手机验证码接口
修改ValidateCodeGenerator类适用两种验证码
package com.spring.security.validate.code;
import org.springframework.web.context.request.ServletWebRequest;
/**
* 验证代码生成器
*
*/
public interface ValidateCodeGenerator {
ValidateCode createCode(ServletWebRequest request);
}
同目录创建SmsCodeGenerator类实现ValidateCodeGenerator类
package com.spring.security.validate.code;
import com.spring.security.properties.SecurityProperties;
import lombok.Data;
import org.apache.commons.lang3.RandomStringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.context.request.ServletWebRequest;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.Random;
/**
* 短信验证码生成器
*/
@Data
@Component("smsCodeGenerator")
public class SmsCodeGenerator implements ValidateCodeGenerator {
@Autowired
private SecurityProperties securityProperties;
/**
* 短信验证码生成器
*/
@Override
public ValidateCode createCode(ServletWebRequest request) {
//生成随机码
String code = RandomStringUtils.randomNumeric(securityProperties.getCode().getSms().getLength());
return new ValidateCode(code, securityProperties.getCode().getSms().getExpireIn());
}
}
发送手机验证码接口
core项目com.spring.security.sms路径创建SmsCodeSender接口
package com.spring.security.sms;
public interface SmsCodeSender {
void send(String mobile, String code);
}
同目录创建接口DefaultSmsCodeSender实现SmsCodeSender类
真实项目需要把输出改为接第三方接口发送短信
package com.spring.security.sms;
public class DefaultSmsCodeSender implements SmsCodeSender {
@Override
public void send(String mobile, String code) {
System.out.println("向手机号:" + mobile + " 发送验证码:" + code);
}
}
修改ValidateCodeBeanConfig类把SmsCodeGenerator加入到Bean
package com.spring.security.validate.code;
import com.spring.security.properties.SecurityProperties;
import com.spring.security.sms.DefaultSmsCodeSender;
import com.spring.security.sms.SmsCodeSender;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 验证代码Bean配置
*/
@Configuration
public class ValidateCodeBeanConfig {
@Autowired
private SecurityProperties securityProperties;
/**
* 不存在imageCodeGenerator再使用下面的bean
*
* @return
*/
@Bean
@ConditionalOnMissingBean(name = "imageCodeGenerator")
public ValidateCodeGenerator imageCodeGenerator() {
ImageCodeGenerator codeGenerator = new ImageCodeGenerator();
codeGenerator.setSecurityProperties(securityProperties);
return codeGenerator;
}
@Bean
@ConditionalOnMissingBean(SmsCodeSender.class)
public SmsCodeSender smsCodeSender() {
return new DefaultSmsCodeSender();
}
}
手机验证码访问接口
修改ValidateController类加访问获取手机验证码方法
package com.spring.security.validate.code;
import com.spring.security.sms.SmsCodeSender;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.web.bind.ServletRequestBindingException;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.ServletWebRequest;
import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 验证控制器
*/
@RestController
public class ValidateController {
public final static String SESSION_KEY_IMAGE_CODE = "SESSION_KEY_IMAGE_CODE";
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
@Autowired
private ValidateCodeGenerator imageCodeGenerator;
@Autowired
private ValidateCodeGenerator smsCodeGenerator;
@Autowired
private SmsCodeSender smsCodeSender;
/**
* 创建验证码
*
* @param request 请求
* @param response 响应
* @throws IOException IOException
*/
@GetMapping("/code/image")
public void createCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
ImageCode imageCode = (ImageCode) imageCodeGenerator.createCode(new ServletWebRequest(request));
sessionStrategy.setAttribute(new ServletWebRequest(request), SESSION_KEY_IMAGE_CODE, imageCode);
ImageIO.write(imageCode.getImage(), "jpeg", response.getOutputStream());
}
/**
* 发送短信验证码
* @param request
* @param response
* @throws IOException
*/
@GetMapping("/code/sms")
public void createSmsCode(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletRequestBindingException {
ValidateCode smsCode = smsCodeGenerator.createCode(new ServletWebRequest(request));
sessionStrategy.setAttribute(new ServletWebRequest(request), SESSION_KEY_IMAGE_CODE, smsCode);
//获取手机号
String mobile = ServletRequestUtils.getRequiredStringParameter(request,"mobile");
//发送短信
smsCodeSender.send(mobile,smsCode.getCode());
}
}
修改BrowserSecurityConfig把/code/sms放到不需要认证
【Spring Security + OAuth2 + JWT入门到实战】7. 图形验证码
简介
在登录界面图形添加验证码,流程1.开发生成图像验证码接口 2.在认证流程中加入图像验证码校验
图形验证码实体类
spring-security-core项目
import lombok.Data;
import java.awt.image.BufferedImage;
import java.time.LocalDateTime;
/**
* 图片的代码
*/
@Data
public class ImageCode {
private BufferedImage image;
private String code;
private LocalDateTime expireTime;
public ImageCode(BufferedImage image, String code, int expireIn) {
this.image = image;
this.code = code;
this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
}
public boolean isExpire() {
return LocalDateTime.now().isAfter(expireTime);
}
}
我们是做一个可以复用的框架,这些参数也弄到系统配置。
配置系统参数
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://127.0.0.1/auto_test?useUnicode=yes&characterEncoding=UTF-8&useSSL=false
username: root
password: 123456
hk:
security:
browser:
loginSucess: /index #登录成功跳转
loginType: JSON #登录成功 失败返回值类型
loginPage: /signIn.html #登录页面
oginFailure: /failure #登录失败跳转
code:
image:
width: 90 # 验证码图片宽度
height: 20 # 验证码图片长度
length: 6 # 验证码位数
expireIn: 60 # 验证码有效时间 60s
url: /login #需要过滤的路径/login,/login/*
注意:code这里多了一层image是因为考虑到后期还有短信验证码。
创建ValidateCodeProperties类,core项目properties目录
package com.spring.security.properties;
import lombok.Data;
/**
* 验证代码属性
*/
@Data
public class ValidateCodeProperties {
private ImageCodeProperties image = new ImageCodeProperties();
}
同目录创建ImageCodeProperties类
package com.spring.security.properties;
import lombok.Data;
/**
* 图像编码属性
*/
@Data
public class ImageCodeProperties {
// 验证码图片宽度
private int width = 60;
// 验证码图片长度
private int height = 20;
// 验证码位数
private int length = 4;
// 验证码有效时间 60s
private int expireIn = 60;
//要拦截的路径
private String url;
}
生成图像验证码接口
同目录创建接口ValidateCodeGenerator类
package com.spring.security.validate.code;
import org.springframework.web.context.request.ServletWebRequest;
/**
* 验证代码生成器
*
*/
public interface ValidateCodeGenerator {
ImageCode createImageCode(ServletWebRequest request);
}
同目录创建接口ValidateCodeGenerator实现ImageCodeGenerator类
package com.spring.security.validate.code;
import com.spring.security.properties.SecurityProperties;
import lombok.Data;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.context.request.ServletWebRequest;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.Random;
/**
* 图像代码生成器
*/
@Data
public class ImageCodeGenerator implements ValidateCodeGenerator {
@Autowired
private SecurityProperties securityProperties;
/**
* 创建图片的代码
*
* @param request
* @return {@link ImageCode}
*/
@Override
public ImageCode createImageCode(ServletWebRequest request) {
//先从用户获取 如果取不到再获取配置
// 验证码图片宽度
int width = ServletRequestUtils.getIntParameter(request.getRequest(), "width", securityProperties.getCode().getImage().getWidth());
// 验证码图片长度
int height = ServletRequestUtils.getIntParameter(request.getRequest(), "height", securityProperties.getCode().getImage().getHeight());
// 验证码位数
int length = securityProperties.getCode().getImage().getLength();
// 验证码有效时间 60s
int expireIn = securityProperties.getCode().getImage().getExpireIn();
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.ITALIC, 20));
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);
}
StringBuilder sRand = new StringBuilder();
for (int i = 0; i < length; i++) {
String rand = String.valueOf(random.nextInt(10));
sRand.append(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 ImageCode(image, sRand.toString(), expireIn);
}
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);
}
}
图像验证码访问接口
同目录创建ValidateController控制器
package com.spring.security.validate.code;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.ServletWebRequest;
import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 验证控制器
*/
@RestController
public class ValidateController {
public final static String SESSION_KEY_IMAGE_CODE = "SESSION_KEY_IMAGE_CODE";
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
@Autowired
private ValidateCodeGenerator imageCodeGenerator;
/**
* 创建验证码
*
* @param request 请求
* @param response 响应
* @throws IOException IOException
*/
@GetMapping("/code/image")
public void createCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
ImageCode imageCode = imageCodeGenerator.createImageCode(new ServletWebRequest(request));
sessionStrategy.setAttribute(new ServletWebRequest(request), SESSION_KEY_IMAGE_CODE, imageCode);
ImageIO.write(imageCode.getImage(), "jpeg", response.getOutputStream());
}
}
图形验证码拦截器
同目录创建ValidateCodeFilter拦截器
package com.spring.security.validate.code;
import com.spring.security.properties.SecurityProperties;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
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.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashSet;
import java.util.Set;
/**
* 验证代码过滤
*/
@Component
public class ValidateCodeFilter extends OncePerRequestFilter implements InitializingBean {
@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
@Autowired
private SecurityProperties securityProperties;
private Set<String> urls = new HashSet<>();
@Override
public void afterPropertiesSet() throws ServletException {
super.afterPropertiesSet();
String[] configUrls = StringUtils.splitPreserveAllTokens(securityProperties.getCode().getImage().getUrl(), ",");
for (String configUrl : configUrls) {
urls.add(configUrl);
}
//加入固定提交地址
urls.add("/authentication/form");
}
/**
* 做过滤器内部
*
* @param httpServletRequest http Servlet请求
* @param httpServletResponse
* @param filterChain 过滤器链
* @throws ServletException Servlet异常
* @throws IOException IOException
*/
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
boolean action = false;
for (String url : urls) {
if (StringUtils.startsWithIgnoreCase(url, httpServletRequest.getRequestURI())
&& StringUtils.startsWithIgnoreCase(httpServletRequest.getMethod(), "post")) {
action = true;
}
}
if (action) {
try {
validateCode(new ServletWebRequest(httpServletRequest));
} catch (ValidateCodeException e) {
authenticationFailureHandler.onAuthenticationFailure(httpServletRequest, httpServletResponse, e);
return;
}
}
filterChain.doFilter(httpServletRequest, httpServletResponse);
}
/**
* 验证代码
*
* @param servletWebRequest servlet的Web请求
* @throws ServletRequestBindingException Servlet请求绑定异常
*/
private void validateCode(ServletWebRequest servletWebRequest) throws ServletRequestBindingException {
ImageCode codeInSession = (ImageCode) sessionStrategy.getAttribute(servletWebRequest, ValidateController.SESSION_KEY_IMAGE_CODE);
String codeInRequest = ServletRequestUtils.getStringParameter(servletWebRequest.getRequest(), "imageCode");
if (StringUtils.isEmpty(codeInRequest)) {
throw new ValidateCodeException("验证码不能为空!");
}
if (codeInSession == null) {
throw new ValidateCodeException("验证码不存在!");
}
if (codeInSession.isExpire()) {
sessionStrategy.removeAttribute(servletWebRequest, ValidateController.SESSION_KEY_IMAGE_CODE);
throw new ValidateCodeException("验证码已过期!");
}
if (!StringUtils.startsWithIgnoreCase(codeInSession.getCode(), codeInRequest)) {
throw new ValidateCodeException("验证码不正确!");
}
sessionStrategy.removeAttribute(servletWebRequest, ValidateController.SESSION_KEY_IMAGE_CODE);
}
}
异常处理
同目录创建ValidateCodeException异常处理类
package com.spring.security.validate.code;
import org.springframework.security.core.AuthenticationException;
public class ValidateCodeException extends AuthenticationException {
private static final long serialVersionUID = 5022575393500654458L;
public ValidateCodeException(String message) {
super(message);
}
}
配置Bean
同目录创建ValidateCodeBeanConfig配置Bean类
package com.spring.security.validate.code;
import com.spring.security.properties.SecurityProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 验证代码Bean配置
*/
@Configuration
public class ValidateCodeBeanConfig {
@Autowired
private SecurityProperties securityProperties;
/**
* 不存在imageCodeGenerator再使用下面的bean
* @return
*/
@Bean
@ConditionalOnMissingBean(name = "imageCodeGenerator")
public ValidateCodeGenerator imageCodeGenerator(){
ImageCodeGenerator codeGenerator = new ImageCodeGenerator();
codeGenerator.setSecurityProperties(securityProperties);
return codeGenerator;
}
}
配置图形验证码拦截器
修改BrowserSecurityConfig类把图形验证码拦截器加入到security拦截器里
package com.spring.security;
import com.spring.security.authentication.HkAuthenticationFailureHandler;
import com.spring.security.authentication.HkAuthenticationSuccessHandler;
import com.spring.security.properties.SecurityProperties;
import com.spring.security.validate.code.ValidateCodeFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private SecurityProperties securityProperties;
@Autowired
private HkAuthenticationSuccessHandler hkAuthenticationSuccessHandler;
@Autowired
private HkAuthenticationFailureHandler hkAuthenticationFailureHandler;
@Autowired
private ValidateCodeFilter validateCodeFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
//配置
http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class) // 添加验证码校验过滤器
.formLogin()
.loginPage("/authentication/require")//登录页面路径
// 处理登录请求路径
.loginProcessingUrl("/authentication/form")
.successHandler(hkAuthenticationSuccessHandler) // 处理登录成功
.failureHandler(hkAuthenticationFailureHandler) // 处理登录失败
.and()
.authorizeRequests() // 授权配置
//不需要认证的路径
.antMatchers("/authentication/require","/signIn.html","/code/image",securityProperties.getBrowser().getLoginPage(),"/failure").permitAll()
.anyRequest() // 所有请求
.authenticated() // 都需要认证
.and().csrf().disable();
}
}
改造登录页面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>标准登录页面</title>
</head>
<body>
<h2>标准登录页面</h2>
<h3>表单登录</h3>
<form action="/authentication/form" method="post">
<table>
<tr>
<td>用户名:</td>
<td><input type="text" name="username"></td>
</tr>
<tr>
<td>密码:</td>
<td><input type="password" name="password"></td>
</tr>
<tr>
<td>验证码:</td>
<td><input type="text" name="imageCode" placeholder="验证码"/>
<img src="/code/image"/></td>
</tr>
<tr>
<td>
</td>
<td>
<!--名称是固定的-->
<input type="checkbox" value="true" name="remember-me">记住我
</td>
</tr>
<tr>
<td colspan="2">
<button type="submit">登录</button>
</td>
</tr>
</table>
</form>
<h2>标准登录页面</h2>
<h3>短信登录登录</h3>
<form action="/authentication/mobile" method="post">
<table>
<tr>
<td>手机号:</td>
<td><input type="text" name="mobile" value="18711111111"></td>
</tr>
<tr>
<td>短信验证码:</td>
<td><input type="text" name="smsCode">
<a href="/code/sms?mobile=18711111111">获取验证码</a>
</td>
</tr>
<tr>
<td colspan="2">
<button type="submit">登录</button>
</td>
</tr>
</table>
</form>
</body>
</html>
测试
点击获取验证码,看控制台
来源:oschina
链接:https://my.oschina.net/u/1046143/blog/3186715