恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
SpringBoot_4:注册登录人机验证与邮件发送
首页
资讯中心
/
SpringBoot_4:注册登录人机验证与邮件发送
SpringBoot_4:注册登录人机验证与邮件发送
发布时间:2026/8/21 8:00:07
目录一、注册界面的人机验证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 groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.28/version /dependency编写CaptchaControllerpackage 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 ApplicationResultString 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 srcdata:image/jpeg;base64,Base64字符串 /就可以展示了。只需要增加一个输入框用户输入后进行校验即可。校验也非常简单先从session中拿到正确答案然后和用户输入的答案做比较就可以了。同时做好超时判断。我们需要在发送邮箱前做人机校验。PostMapping(/email) Operation(summary 注册时发送邮件, description 根据email发送邮件) public ApplicationResultEmailVO 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 ConcurrentHashMapString, 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; IteratorMap.EntryString, EmailCaptcha iterator captchaMap.entrySet().iterator(); while (iterator.hasNext()) { Map.EntryString, 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 groupIdorg.springframework.boot/groupId artifactIdspring-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 headmeta 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 3122263582qq.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 ApplicationResultUser 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); }暂时先不考虑恶意请求加了人机校验最起码让攻击成本上去了。二、总结以上双重验证我没有使用前端代码去展示效果但是经过实测是完全可行的。人机验证邮箱可用性验证为我们的业务做更好的支撑。文中如有疏漏请指出作者会认真勘误。