目录
一、注册界面的人机验证
1、Hultool图形验证码校验
2、邮件发送
3、邮件校验
二、总结
我们在开发注册接口时,需要考虑两个因素:
1.注册方是否是机器,恶意注册会浪费服务器资源
2.注册方提供的凭证是否可用,如果不加校验,那么恶意注册同样会浪费服务器资源
针对这问题1,我们可以引入人机验证,针对问题2,我们采用邮件机制。在邮件中存放校验码,校验正确后执行注册。
在开发登录接口时,我们则不需要人机验证了。只要账号和密码正确则执行登录。
一、注册界面的人机验证
人机验证的方式有多种,例如图形/文字验证码,图形点选验证,行为式验证,短信消息验证。
随着AI的不断发展,对人机验证提出了更高的要求。这里,我们计划使用邮件发送短信验证,邮件中存放验证码。但是为了阻止他人恶意注册,我们针对发送邮件这个过程也需要做人机验证。
1、Hultool图形验证码校验
前端向/captcha/get请求人机验证,此时后端生成一个随机字符串作为本次验证的答案,将此答案存入Session中和用户的邮箱绑定在一起,设置好过期时间。后端将此答案同时转化为图形验证码生成一串Base64的编码,整个过程后端不会向前端发送答案。当用户识别出验证码后,输入验证码挑战是否正确。后端拿到设置好的密码进行比对,如果挑战成功则视为人机验证通过。
答案和sessionId绑定在一起,无论验证是否通过都需要销毁答案。
设置超时时间也需要考虑,我们可以在session设置答案外,可以放入一个时间戳,等到校验时去比对时间戳来判断是否过期。
现在想一个问题,我们的验证码是基于session的,如果session都被销毁了,那么答案和时间戳都没有了。所以我们设置的过期时间不能比session的过期时间还长,通常session的过期时间是30分钟,验证码的过期时间我们设置成2分钟。
现在我们用代码实现:
引入hultool依赖
<!-- hutool 工具包,内置验证码 --> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.28</version> </dependency>编写CaptchaController
package com.miao.nanonap.controller.user; import cn.hutool.captcha.CaptchaUtil; import cn.hutool.captcha.LineCaptcha; import com.miao.nanonap.common.ApplicationResult; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpSession; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/captcha") @Tag(name = "图形验证码接口",description = "hutool生成图片验证码") public class CaptchaController { // session存储验证码的key public static final String CAPTCHA_SESSION_KEY = "CAPTCHA_CODE"; public static final String CAPTCHA_SESSION_TIME = "CAPTCHA_TIME"; @GetMapping("/get") @Operation(summary = "获取图片验证码", description = "返回base64图片,验证码存在session") public ApplicationResult<String> get(HttpSession session) { // 宽、高、字符数、干扰线数量 LineCaptcha lineCaptcha = CaptchaUtil.createLineCaptcha(130,40,4,20); // 把验证码文字存入session session.setAttribute(CAPTCHA_SESSION_KEY, lineCaptcha.getCode()); // 把时间戳也存入session用来校验是否验证码是否过期 session.setAttribute(CAPTCHA_SESSION_TIME, System.currentTimeMillis()); // 返回base64,前端直接 <img src="返回的base64"> String base64Img = lineCaptcha.getImageBase64(); return ApplicationResult.success(base64Img); } }访问这个接口会给我们生成这样一串Base64编码的图片。
使用转换工具即可显示:
前端如何展示呢,其实也很简单。
使用
<img src="data:image/jpeg;base64,Base64字符串" />就可以展示了。
只需要增加一个输入框,用户输入后进行校验即可。
校验也非常简单,先从session中拿到正确答案,然后和用户输入的答案做比较就可以了。同时做好超时判断。
我们需要在发送邮箱前做人机校验。
@PostMapping("/email") @Operation(summary = "注册时发送邮件", description = "根据email发送邮件") public ApplicationResult<EmailVO> email(HttpServletRequest request, @NotBlank @Parameter(name = "验证码",required = true) String captchaCode, @NotBlank @Parameter(name = "邮箱", required = true)String email) { HttpSession session = request.getSession(); // captcha过期校验 Long setTime = (Long) session.getAttribute(AppConfig.CAPTCHA_SESSION_TIME); Long expireTime = setTime + AppConfig.CAPTCHA_SESSION_EXPIRE_TIME; if(expireTime > System.currentTimeMillis()) { return ApplicationResult.fail(ErrorCodeEnum.CAPTCHA_EXPIRED.getCode(),ErrorCodeEnum.CAPTCHA_EXPIRED.toString()); } // 校验captcha是否正确 忽略大小写 String captcha = (String) session.getAttribute(AppConfig.CAPTCHA_SESSION_KEY); if(!captchaCode.equalsIgnoreCase(captcha)) { return ApplicationResult.fail(ErrorCodeEnum.CAPTCHA_ERROR.getCode(), ErrorCodeEnum.CAPTCHA_ERROR.toString()); } //校验成功后删除验证码 session.removeAttribute(AppConfig.CAPTCHA_SESSION_KEY); session.removeAttribute(AppConfig.CAPTCHA_SESSION_TIME); //执行发送邮件验证码逻辑 return ApplicationResult.success(userService.sendEmail(email)); }我们选择在controller里先进行校验。
2、邮件发送
人机校验通过后,才可以发送邮件。后端先和数据库进行比对,如果存在该邮箱则无法注册,反之生成一个四位的随机数作为验证码,发送邮件给用户,同时设置过期时间。
这里我用到了EmailVO作为响应结果,里面封装如下信息:
@Data @Schema(description = "用户登录响应") public class EmailVO { /** * true 发送成功 false 发送失败 * */ @Schema(name = "邮件发送是否成功") private boolean success; @Schema(name = "发送失败信息") private String msg; }而且我们意识到,邮件发送是否成功?发送给谁?里面的验证码是什么?如何判断过期?是不是要存放时间戳?
基于此,我们建立一个EmailCaptcha类。
@Data @AllArgsConstructor public class EmailCaptcha { private String email; // 绑定的邮箱 private String code; // 4位验证码 private Long createTime; // 创建时间戳 private Long expireTime; // 过期时间戳(5分钟后) private String scene; // 业务场景:REGISTER、LOGIN、FORGET_PWD /** * 判断验证码是否已过期 */ public boolean isExpired() { return System.currentTimeMillis() > expireTime; } }上面提到,这个验证码应该和用户邮箱之间进行绑定并存入内存中。
绑定我们可以考虑使用ConcurrentHashMap,因为这是线程安全的。
存入内存的话,我们就需要把这个类交给Spring管理,让Spring给我们生成单例Bean,只有这样才能共享一个hashmap,使用时使用@Autowired注解自动注入即可。
@Component @Slf4j public class CaptchaMemoryStorage { // 使用 ConcurrentHashMap 存储验证码,Key 是邮箱 + 场景的组合 private final ConcurrentHashMap<String, EmailCaptcha> captchaMap = new ConcurrentHashMap<>(); // 验证码有效期:5分钟 private static final long EXPIRE_MINUTES = 5; private static final long EXPIRE_MILLIS = EXPIRE_MINUTES * 60 * 1000; // 发送间隔限制:60秒 private static final long SEND_INTERVAL_MILLIS = 60 * 1000; /** * 生成并存储验证码 * @param email 邮箱 * @param scene 业务场景 * @return 生成的验证码 */ public String generateAndStore(String email, String scene) { // 生成4位数字验证码(1000-9999) String code = String.format("%04d", new Random().nextInt(9000) + 1000); long now = System.currentTimeMillis(); EmailCaptcha captcha = new EmailCaptcha( email, code, now, now + EXPIRE_MILLIS, scene ); // 存入内存,Key 用邮箱 + 场景组合 String key = buildKey(email, scene); captchaMap.put(key, captcha); log.info("验证码已生成并存储: email={}, scene={}, code={}, expireAt={}", email, scene, code, captcha.getExpireTime()); return code; } /** * 校验验证码 * @return true=校验通过,false=校验失败 */ public boolean verify(String email, String scene, String inputCode) { String key = buildKey(email, scene); EmailCaptcha captcha = captchaMap.get(key); // ① 不存在 if (captcha == null) { log.warn("验证码不存在: email={}, scene={}", email, scene); return false; } // ② 已过期 if (captcha.isExpired()) { captchaMap.remove(key); log.warn("验证码已过期: email={}, scene={}", email, scene); return false; } // ③ 比对验证码(忽略大小写) boolean matched = captcha.getCode().equalsIgnoreCase(inputCode); if (matched) { // ✅ 校验成功,立即删除(用完即焚) captchaMap.remove(key); log.info("验证码校验成功并已删除: email={}, scene={}", email, scene); } else { log.warn("验证码错误: email={}, scene={}, 输入={}, 正确={}", email, scene, inputCode, captcha.getCode()); } return matched; } /** * 检查是否发送过于频繁(60秒内) */ public boolean isSendTooFrequent(String email, String scene) { String key = buildKey(email, scene); EmailCaptcha captcha = captchaMap.get(key); if (captcha == null) { return false; } long elapsed = System.currentTimeMillis() - captcha.getCreateTime(); return elapsed < SEND_INTERVAL_MILLIS; } /** * 获取剩余冷却时间(秒) */ public long getRemainingCooldown(String email, String scene) { String key = buildKey(email, scene); EmailCaptcha captcha = captchaMap.get(key); if (captcha == null) { return 0; } long elapsed = System.currentTimeMillis() - captcha.getCreateTime(); if (elapsed >= SEND_INTERVAL_MILLIS) { return 0; } return (SEND_INTERVAL_MILLIS - elapsed) / 1000; } /** * 删除验证码 */ public void remove(String email, String scene) { captchaMap.remove(buildKey(email, scene)); } private String buildKey(String email, String scene) { return email + ":" + scene; } /** * 定时清理任务:每分钟执行一次,清除过期验证码 */ @Scheduled(fixedDelay = 60000) public void cleanExpiredCaptcha() { int beforeSize = captchaMap.size(); int removedCount = 0; Iterator<Map.Entry<String, EmailCaptcha>> iterator = captchaMap.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry<String, EmailCaptcha> entry = iterator.next(); if (entry.getValue().isExpired()) { iterator.remove(); removedCount++; } } if (removedCount > 0) { log.info("定时清理完成: 清理前={}, 清理后={}, 已清理={}", beforeSize, captchaMap.size(), removedCount); } } /** * 获取当前存储的验证码数量(用于监控) */ public int size() { return captchaMap.size(); } }现在有了邮箱的载体,那么我们需要考虑如何发送邮件了。
导入mail依赖。
<!-- 邮件--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-mail</artifactId> </dependency>做如下配置
mail: host: smtp.qq.com port: 465 # SSL端口 username: 发送方邮箱 password: smtp启用授权码,需要到自己的邮箱中申请smtp然后拿到授权码 protocol: smtps # 使用smtps协议 properties: mail: smtp: auth: true ssl: enable: true personal: "miao" #发送方名称,自定义这里的passowrd不是你的邮箱密码,而是授权码。具体如何获取请查阅相关资料。
编写MailUtils工具类,这里才是真正发邮件的地方。
package com.miao.nanonap.util; import jakarta.mail.internet.MimeMessage; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.mail.MailProperties; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Component; @Component @Slf4j public class MailUtils { private static JavaMailSender staticMailSender; private static String staticFrom; private static String staticPersonal; // Spring 启动时初始化静态变量 @Autowired public void init(JavaMailSender javaMailSender, MailProperties mailProperties) { MailUtils.staticMailSender = javaMailSender; MailUtils.staticFrom = mailProperties.getUsername(); MailUtils.staticPersonal = mailProperties.getProperties() .getOrDefault("personal", mailProperties.getUsername()); } /** * 静态方法:发送邮件(对外使用) */ public static void sendMail(String to, String subject, String content) { try { MimeMessage mimeMessage = staticMailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); helper.setFrom(staticFrom, staticPersonal); helper.setTo(to); helper.setSubject(subject); helper.setText(content, true); staticMailSender.send(mimeMessage); log.info("邮件发送成功: to={}", to); } catch (Exception e) { log.error("邮件发送失败: to={}, error={}", to, e.getMessage(), e); throw new RuntimeException("邮件发送失败", e); } } // ==================== 验证码邮件专用方法 ==================== /** * 发送验证码邮件(一站式调用) */ public static void sendCaptchaEmail(String to, String code) { String subject = "nano-nap 注册验证码"; String content = getCaptchaContent(to, code, subject); sendMail(to, subject, content); } // ==================== 内容生成器 ==================== /** * 获取验证码邮件内容(HTML格式) */ public static String getCaptchaContent(String to, String code, String scene) { String sceneName = getSceneDisplayName(scene); String userName = extractUserName(to); return buildEmailHtml(userName, sceneName, code); } // ==================== HTML 模板构建 ==================== /** * 构建完整的邮件 HTML 页面(简洁版) */ private static String buildEmailHtml(String userName, String sceneName, String code) { return String.format( "<!DOCTYPE html>" + "<html>" + "<head><meta charset=\"UTF-8\"><style>" + "body{font-family:arial,sans-serif;background:#f5f5f5;padding:20px;margin:0;}" + ".box{max-width:500px;margin:0 auto;background:#fff;border-radius:8px;padding:30px;box-shadow:0 2px 8px rgba(0,0,0,0.06);}" + ".title{font-size:20px;font-weight:bold;color:#333;margin-bottom:6px;}" + ".sub{color:#888;font-size:14px;margin-bottom:25px;}" + ".hr{border:none;border-top:1px solid #eee;margin:20px 0;}" + ".code{font-size:36px;font-weight:bold;color:#1a73e8;letter-spacing:8px;text-align:center;padding:16px;background:#f8f9fa;border-radius:6px;margin:16px 0;}" + ".tip{color:#666;font-size:14px;line-height:1.8;}" + ".footer{color:#aaa;font-size:12px;text-align:center;margin-top:25px;border-top:1px solid #eee;padding-top:18px;}" + "</style></head>" + "<body>" + "<div class=\"box\">" + " <div class=\"title\">nano-nap</div>" + " <div class=\"sub\">%s 验证</div>" + " <div class=\"hr\"></div>" + " <p style=\"color:#333;font-size:15px;\">您好,<strong>%s</strong></p>" + " <p style=\"color:#555;font-size:14px;\">您正在进行 <strong>%s</strong> 操作,验证码如下:</p>" + " <div class=\"code\">%s</div>" + " <p class=\"tip\">⏱️ 有效期 5 分钟 · 请勿泄露</p>" + " <p class=\"tip\" style=\"font-size:13px;color:#999;\">如非本人操作,请忽略此邮件。</p>" + " <div class=\"footer\">此邮件由 nano-nap 系统自动发送</div>" + "</div>" + "</body>" + "</html>", sceneName, // %s1: 场景名(如"注册") userName, // %s2: 用户名 sceneName, // %s3: 场景名(如"注册") code // %s4: 验证码 ); } // ==================== 辅助方法 ==================== /** * 从邮箱地址提取用户名(@前面的部分) */ private static String extractUserName(String email) { if (email == null || !email.contains("@")) { return "用户"; } return email.substring(0, email.indexOf("@")); } /** * 获取场景显示名称 */ private static String getSceneDisplayName(String scene) { if (scene == null) return "身份验证"; switch (scene.toUpperCase()) { case "REGISTER": return "注册"; case "LOGIN": return "登录"; case "FORGET_PWD": case "FORGETPWD": case "RESET_PWD": return "找回密码"; case "BIND_EMAIL": case "BIND": return "绑定邮箱"; case "UNBIND_EMAIL": return "解绑邮箱"; case "CHANGE_EMAIL": return "更换邮箱"; default: return "身份验证"; } } /** * 获取场景标签 */ private static String getSceneTag(String scene) { if (scene == null) return "身份验证"; switch (scene.toUpperCase()) { case "REGISTER": return "注册验证"; case "LOGIN": return "登录验证"; case "FORGET_PWD": case "FORGETPWD": case "RESET_PWD": return "密码找回"; case "BIND_EMAIL": case "BIND": return "邮箱绑定"; case "UNBIND_EMAIL": return "解绑验证"; case "CHANGE_EMAIL": return "更换验证"; default: return "身份验证"; } } }发送邮件需要用到JavaMailSender和一些对象,这些对象需要注入进来才能使用,这里我选择利用构造器注入,然后将MailUtis交给Spring管理,这样静态方法才能访问。
注意上面的sendMail是工具类里的方法,下面的sendMail是UserService里的方法。
@Override public EmailVO sendEmail(String email) { User user = userMapper.selectByEmail(email); // 用户已存在无法注册 if(user != null ) { log.error(ErrorCodeEnum.USER_EXIST.toString()); throw new ApplicationException(ErrorCodeEnum.USER_EXIST); } String code = captchaStorage.generateAndStore(email, "Register"); EmailVO vo = new EmailVO(); try { MailUtils.sendCaptchaEmail(email, code); // 发送成功 vo.setSuccess(true); vo.setMsg("发送成功"); }catch (Exception e) { //发送失败 captchaStorage.remove(email,"Register"); vo.setSuccess(false); vo.setMsg(e.getMessage()); log.error("发送失败",e); log.error("发送失败:{}",email); } return vo; }这就衔接了我们发送邮件的controller.
3、邮件校验
我们把验证码放入邮箱中,用户收到后填入注册表单中。后端只需要拿到用户注册信息和校验码校验即可。
这里我们的校验码,设定的是5分钟失效。校验成功后则从内存中删除校验码这条记录。失败则没有进行处理,因此存在一个漏洞。那就是用户完全可以在5分钟内从0000到9999都尝试一遍,从而完成注册。
我们的注册请求封装如下:
@Data @Schema(description = "用户注册请求") public class RegisterRequest { @NotBlank(message = "邮箱不能为空") @Email(message = "邮箱格式不正确") @Schema(description = "邮箱", requiredMode = Schema.RequiredMode.REQUIRED, example = "3122263582@qq.com") private String email; @NotBlank(message = "密码不能为空") @Size(min = 6, max = 15, message = "密码长度必须在6到15位之间") @Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED,example = "123456") private String password; @NotBlank(message = "验证码不能为空") @Schema(description = "验证码", requiredMode = Schema.RequiredMode.REQUIRED, example = "1234") private String captchaCode; }controller校验如下:
@PostMapping("/register") @Operation(summary = "用户注册", description = "通过邮箱注册") public ApplicationResult<User> register(@Valid @RequestBody RegisterRequest registerRequest, HttpServletRequest request) { // 邮箱校验 内含成功后删除 if(!captchaMemoryStorage.verify(registerRequest.getEmail(),"Register",registerRequest.getCaptchaCode())) { log.error("邮箱校验不通过,email:{}",registerRequest.getEmail()); return ApplicationResult.fail(ErrorCodeEnum.CAPTCHA_ERROR.getCode(), ErrorCodeEnum.CAPTCHA_ERROR.getMsg()); } User user = new User(); user.setEmail(registerRequest.getEmail()); user.setPassword(registerRequest.getPassword()); String ip = IpUtils.getClientIp(request); User registered = userService.register(user, ip); registered.setPassword(null); return ApplicationResult.success(registered); }暂时先不考虑恶意请求,加了人机校验最起码让攻击成本上去了。
二、总结
以上双重验证我没有使用前端代码去展示效果,但是经过实测是完全可行的。
人机验证+邮箱可用性验证为我们的业务做更好的支撑。
文中如有疏漏,请指出,作者会认真勘误。