AI智慧社区--实现登录认证:验证码、JWT Token与接口校验
前言
最近在做一个AI智慧社区的后端项目,用的是Spring Boot + MyBatis + Redis + JWT这套组合。作为一个也在学习中的开发者,我发现很多教程讲技术原理讲得很好,但很少有人讲"拿到一份API接口文档后,怎么一步步把它变成能跑的代码"。
这篇文章就以验证码、登录、Token校验这三个接口为例,记录一下我从读文档到写完代码的完整过程,踩过的坑也一并分享。希望对同样在学后端的同学有点帮助。
项目技术栈:Spring Boot 2.2.6 / MyBatis-Plus / Redis / JWT / easy-captcha
一、技术栈一览
| 技术 | 用途 |
|---|---|
| Spring Boot 2.2.6 | 项目骨架,自动配置、内嵌Tomcat |
| MyBatis (mybatis-plus) | ORM,用注解SQL查数据库 |
| Redis | 存验证码,天然支持过期时间,比Session更适合前后端分离 |
| JWT (jjwt 0.9.1) | 生成无状态token,前端每次请求带上就能识别身份 |
| easy-captcha | 一行代码生成图形验证码的Base64 |
| SHA-256 | 密码单向哈希,数据库不存明文 |
| Lombok | 省掉getter/setter的样板代码 |
二、核心原理:三个接口串起来的认证流程
先看全局,这三个接口是怎么配合工作的:

这三个接口是整个系统的"门",后面所有业务接口都依赖token来识别"你是谁"。
三、怎么对照API文档写代码(方法论)
1.四步走:
- 看URL和请求方式 → 决定用
@GetMapping还是@PostMapping,路径写什么 - 看参数 → 决定用
@RequestBody接JSON,还是HttpServletRequest取Header,还是无参 - 看返回结构 → 对着JSON的key一个一个构造,用Map或VO封装
- 想中间逻辑 → 这一步文档不会告诉你,需要自己根据业务推断
2.举例
举个具体例子,拿登录接口来说,文档写的是:
| URL | GET /captcha |
| 参数 | 无 |
| 返回 | { "msg": "操作成功", "code": 200, "data": { "uuid": "b71fafb1a91b4961afb27372bd3af77c", "captcha": "data:image/png;base64,iVBORw0KGgoAAAA", "code": "nrew" } } |
3.翻译过程:
| POST | @PostMapping("/login") |
| 4个参数是JSON | 建一个 LoginForm 类,用 @RequestBody 接 |
| 返回有token和expire | 建一个 LoginVO 类 |
中间逻辑?文档没写,但常识告诉我:要验证验证码、查用户、比密码、生成token这就是"读文档→写代码"的完整思维链。
四、逐个接口讲解
1.接口一:生成验证码 GET /captcha
思路:生成一张随机字符的图片,转成Base64给前端展示。同时生成一个uuid作为这次验证码的"身份证",把正确答案存到Redis里,登录时用uuid去Redis取出来比对。
为什么用Redis而不是Session?因为前后端分离架构下,前端和后端可能不在同一个域,Session不好使。Redis天然支持设置过期时间,验证码过期自动删除,干净利落。
@GetMapping("/captcha")
public Result getCaptcha(){
// 1. 用easy-captcha生成验证码图片,130x48像素,4个字符
SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 4);
specCaptcha.setCharType(Captcha.TYPE_DEFAULT);
// 2. 拿到验证码文本(转小写,方便后面不区分大小写比对)
String code = specCaptcha.text().toLowerCase();
// 3. 图片转Base64字符串,前端直接放到<img>的src里就能显示
String imageBase64 = specCaptcha.toBase64();
// 4. 生成uuid,作为这次验证码的唯一标识
String uuid = UUID.randomUUID().toString().replace("-", "");
// 5. 存Redis,key带前缀方便管理,20分钟过期
String redisKey = "captcha:verify:" + uuid;
redisTemplate.opsForValue().set(redisKey, code, 20, TimeUnit.MINUTES);
// 6. 按文档要求的格式返回
Map<String, Object> data = new HashMap<>();
data.put("uuid", uuid);
data.put("captcha", imageBase64);
data.put("code", code); // 注意:生产环境不应该返回code,这里是开发方便调试
return Result.ok().put("data", data);
}
关键点:
SpecCaptcha是 easy-captcha 提供的,开箱即用,不用自己画图- Redis的key设计
captcha:verify:{uuid}带前缀是好习惯,避免和其他业务key冲突 - 文档返回里有
code字段(验证码明文),这在开发阶段方便前端调试,上线前应该去掉
2.接口二:登录 POST /login
思路:这是整个认证流程最核心的接口。接收用户名、密码、验证码、uuid,经过层层校验后,生成JWT token返回给前端。
(1)先看参数封装类 LoginForm:
@Data
public class LoginForm {
private String username;
private String password;
private String captcha;
private String uuid;
}
(2)返回值封装类 LoginVO:
@Data
public class LoginVO {
private String token;
private Integer expire;
}
这两个类完全是对着文档的参数和返回值建的,文档有什么字段,类里就有什么字段。
(3)Controller层很薄,只负责接参数和返回结果:
@PostMapping("/login")
public Result login(@RequestBody LoginForm form) {
LoginVO loginVO = loginService.login(form);
return Result.ok("登录成功!").put("data", loginVO);
}
(4)真正的业务逻辑在Service层
下面是登录接口的核心,也是需要重点理解的部分:

Service层完整代码:
@Service
public class LoginServiceImpl implements LoginService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private UserMapper userMapper;
@Override
public LoginVO login(LoginForm form) {
// ===== 第1步:验证验证码 =====
String key = "captcha:verify:" + form.getUuid();
String captchaInRedis = redisTemplate.opsForValue().get(key);
if (captchaInRedis == null) {
throw new RuntimeException("验证码已过期");
}
if (!captchaInRedis.equalsIgnoreCase(form.getCaptcha())) {
throw new RuntimeException("验证码错误");
}
// 验证通过后立即删除,防止同一个验证码被重复使用
redisTemplate.delete(key);
// ===== 第2步:查用户 =====
User user = userMapper.selectByUsername(form.getUsername());
if (user == null) {
throw new RuntimeException("用户名或密码错误");
}
// ===== 第3步:验证密码 =====
// 数据库存的是SHA-256哈希值,把用户输入的密码也做一次SHA-256再比对
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(form.getPassword().getBytes(StandardCharsets.UTF_8));
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
if (!user.getPassword().equals(hexString.toString())) {
throw new RuntimeException("用户名或密码错误");
}
} catch (Exception e) {
throw new RuntimeException("密码验证失败", e);
}
// ===== 第4步:检查账号状态 =====
if (user.getStatus() != 1) {
throw new RuntimeException("账号已被禁用");
}
// ===== 第5步:生成JWT token =====
String userId = String.valueOf(user.getUserId());
Integer expireSeconds = 1296000; // 15天
String token = JwtUtil.createToken(userId, expireSeconds);
// ===== 第6步:封装返回 =====
LoginVO vo = new LoginVO();
vo.setToken(token);
vo.setExpire(expireSeconds);
return vo;
}
}
(5)JWT工具类:
public class JwtUtil {
private static final String SECRET = "your-secret-key-here-make-it-long-and-secure";
public static String createToken(String userId, Integer expireSeconds) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + expireSeconds * 1000L);
return Jwts.builder()
.setSubject(userId) // 把userId放进token
.setIssuedAt(now) // 签发时间
.setExpiration(expireDate) // 过期时间
.signWith(SignatureAlgorithm.HS512, SECRET) // HS512签名
.compact();
}
}
关键点:
- 验证码用完即删(
redisTemplate.delete(key)),这是安全基本操作- 密码错误时提示"用户名或密码错误"而不是"密码错误",不给攻击者确认用户名是否存在的机会
- SHA-256是单向哈希,不可逆,数据库泄露了也拿不到明文密码
- JWT的subject里只放userId,不放敏感信息,因为JWT的payload是Base64编码(不是加密),谁都能解码看到
3.接口三:Token校验 GET /checkToken
思路:前端每次路由跳转或刷新页面时,调这个接口确认token还有没有效。后端解析token,成功就返回用户信息,失败就告诉前端token无效。
@GetMapping("/checkToken")
public Result checkToken(HttpServletRequest request) {
// 1. 从请求头取token
String token = request.getHeader("token");
if (token == null || token.isEmpty()) {
return Result.error("token为空");
}
try {
// 2. 解析token,如果token被篡改或过期,这里会直接抛异常
Claims claims = Jwts.parser()
.setSigningKey(SECRET)
.parseClaimsJws(token)
.getBody();
String userId = claims.getSubject();
// 3. 用userId查数据库拿用户信息
User user = userMapper.selectById(Integer.parseInt(userId));
// 4. 按文档格式组装返回数据
Map<String, Object> data = new HashMap<>();
data.put("userId", userId);
data.put("userName", user != null ? user.getUsername() : "admin");
data.put("roles", Collections.singletonList("admin"));
data.put("permissions", Collections.singletonList("*:*:*"));
return Result.ok().put("data", data);
} catch (Exception e) {
return Result.error("token无效或已过期");
}
}
关键点:
- token放在Header里而不是参数里,这是业界惯例,更安全
Jwts.parser().parseClaimsJws()会自动验证签名和过期时间,不需要手动判断- 这里的roles和permissions暂时写死了,后续接入角色权限模块后应该从数据库查
五、配套设施
1.统一返回格式 Result
继承HashMap是个取巧的做法——可以随意 .put("data", xxx) 往里塞任何字段,灵活适配文档里各种不同的返回结构。链式调用让代码写起来很顺手。
public class Result extends HashMap<String, Object> {
public static Result ok() {
Result result = new Result();
result.put("code", 200);
result.put("msg", "操作成功");
return result;
}
public static Result error(String msg) {
Result result = new Result();
result.put("code", 500);
result.put("msg", msg);
return result;
}
@Override
public Result put(String key, Object value) {
super.put(key, value);
return this; // 返回this实现链式调用
}
}
2.全局异常处理 GlobalExceptionHandler
有了这个,Service层直接 throw new RuntimeException("验证码错误") 就行,不用在Controller里写一堆try-catch。异常会被自动捕获,转成统一的JSON格式返回给前端。
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public Result handleRuntimeException(RuntimeException e) {
return Result.error(e.getMessage());
}
}
3.数据库访问 UserMapper
用MyBatis的注解SQL,简单查询不用写XML。配合 application.yml 里的 map-underscore-to-camel-case: true,自动把数据库的 user_id 映射到Java的 userId。
@Mapper
public interface UserMapper {
@Select("SELECT * FROM user WHERE username = #{username}")
User selectByUsername(String username);
@Select("SELECT * FROM user WHERE user_id = #{id}")
User selectById(int id);
}
六、项目结构
com.qcby.smartcommunity
├── config
│ └── GlobalExceptionHandler.java // 全局异常 → 统一错误返回
├── controller
│ └── LoginController.java // 三个接口都在这里
├── entity
│ └── User.java // 数据库实体
├── form
│ └── LoginForm.java // 登录请求参数
├── mapper
│ └── UserMapper.java // 数据库查询
├── service
│ ├── LoginService.java // 接口
│ └── impl/LoginServiceImpl.java // 登录核心逻辑
├── util
│ ├── JwtUtil.java // JWT生成
│ └── Result.java // 统一返回格式
└── vo
└── LoginVO.java // 登录返回值
每个类都有明确的职责:Controller只管接参数和返回,Service处理业务逻辑,Mapper负责数据库,Util放工具方法。这种分层不是为了好看,是为了改代码的时候知道去哪改。
七、避坑指南
1.JWT的SECRET散落在两个地方(JwtUtil和LoginController),这是个隐患。
当时赶进度没注意,后来发现如果改了一边忘了另一边,token就解析不了。应该统一放到配置文件或者常量类里。这种"能跑但不对"的代码,越早改越好。
2.SHA-256那段手写的哈希代码看着丑,但它是对的。
后来我知道可以用Hutool的 SecureUtil.sha256() 一行搞定,但手写一遍让我真正理解了哈希的过程——把字节数组转成十六进制字符串,每个字节补零对齐。这种"笨功夫"对新手来说是值得的。
3.全局异常处理是我加的第二个类(第一个是Controller),但它应该是第一个。
没有它的时候,Service抛异常,前端收到的是Spring默认的500错误页面,根本不是JSON格式,前端解析直接报错。加上之后,所有异常都变成了 {"code": 500, "msg": "xxx"} 的统一格式,前后端联调顺畅了很多。
4.Redis连不上的时候别慌。
我第一次跑验证码接口直接报错,以为代码写错了,debug了半小时才发现是Redis服务没启动。后来养成习惯,跑项目前先 redis-cli ping 确认一下。环境问题和代码问题要分开排查,不然会浪费大量时间在错误的方向上。
Result继承HashMap这个设计,一开始我觉得很奇怪。
为什么不用一个标准的泛型类 Result<T> ?后来联调的时候才体会到好处——文档里每个接口返回的data结构都不一样,有的是对象,有的是列表,有的还带分页。用HashMap可以随意 .put() 任何字段,不用为每个接口单独定义返回类型。灵活是真灵活,代价是没有编译期类型检查,写错字段名只有运行时才能发现。
八、心得体会
说几点做这三个接口过程中真正踩到的坑和感悟:
1.文档不是圣经,但它是我们的锚。
刚开始我也会纠结"这个字段到底要不要"、"返回格式能不能改"。后来想明白了——前端已经按文档写好了,你返回的JSON哪怕少一个字段、多一层嵌套,前端就炸了。所以先严格按文档来,跑通了再优化。
2.验证码那个code字段让我犹豫了一下。
文档返回里带了验证码明文,第一反应是"这不对吧,验证码答案怎么能直接告诉前端?"后来想明白了,这是开发阶段的便利设计——前端同学不用真的去看图片识别验证码,直接拿code字段填进去就能测登录流程。但这个字段上线前必须删掉,否则等于验证码形同虚设,任何人抓个包就能拿到答案。我在代码里加了个注释提醒自己,也建议你这么做,免得上线前忘了。
3.不要试图一次写完所有代码再测试。
我的节奏是写一个接口、用Postman测一个、通了再写下一个。验证码接口通了,才有信心写登录;登录通了,checkToken就是顺手的事。小步快跑,每一步都有正反馈,比憋大招强太多。
4.密码错误提示"用户名或密码错误"而不是"密码错误",这个细节是我从别人代码里学到的。
如果你提示"密码错误",攻击者就知道这个用户名是存在的,接下来只需要暴力破解密码就行。模糊提示虽然对用户不太友好,但安全性高很多。安全和体验的平衡,是后端开发绕不开的课题。
5.接口文档是前后端的契约。
写后端不是自己闷头写,而是在履行一份约定。参数名拼错一个字母、返回多嵌套一层、状态码用错,前端都会出问题。养成写完一个接口就对照文档自查一遍的习惯,比写完所有接口再联调效率高得多。
结尾
以上就是验证码、登录、Token校验这三个接口从文档到代码的完整过程。回头看其实逻辑不复杂,难的是第一次做的时候不知道从哪下手。总结一下我自己摸索出来的节奏:先读文档定好入参和出参的结构,再理清中间的业务逻辑,最后一个接口一个接口地写和测。不要想着一口气全写完,小步验证才是最快的路。
这三个接口只是系统的入口,后面还有角色权限、业务CRUD、文件上传这些模块要做。但认证这块搞明白了,后面的接口无非就是在token的保护下做数据的增删改查,套路是一样的。如果你也在学后端开发,希望这篇文章能给你一个"原来是这么回事"的感觉。有问题欢迎评论区交流,一起进步。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)