基于 JWT 的登录验证功能实现详解

在 Web 应用中,登录验证是保障系统安全的核心环节。本文将结合具体接口文档,详细讲解如何基于 JWT(JSON Web Token)实现登录验证功能,包括 JWT 配置、工具类封装、登录流程处理等关键步骤,帮助开发者快速理解并落地类似功能。

一、需求分析:接口文档解读

本次实现的登录验证功能需满足以下接口文档要求,核心接口包括:

接口名称 请求方式 接口地址 核心功能描述
生成验证码 GET /captcha 生成验证码并存储(用于登录校验)
用户登录 POST /login 校验用户信息,生成 JWT 令牌返回
Token 校验 GET /checkToken 验证令牌有效性
退出登录 POST /logout 清除令牌,退出登录

其中,登录接口(/login) 是核心,需接收前端传递的username(用户名)、password(密码)、captcha(验证码)、uuid(验证码唯一标识),验证通过后返回token(令牌)和expire(令牌过期时间)。

二、技术选型:JWT 为何适合登录验证?

JWT 是一种基于 JSON 的轻量级令牌,用于在客户端和服务器之间安全传递信息。其优势在于:

  • 无状态:服务器无需存储会话信息,令牌本身包含用户身份等关键信息,适合分布式系统。
  • 安全性:通过签名机制确保令牌不被篡改。
  • 自包含:可在令牌中嵌入用户权限等信息,减少数据库查询。

本次使用jjwt库实现 JWT 功能,配合 Redis 存储验证码和令牌,兼顾安全性与效率。

三、实现步骤:从配置到接口落地

1. JWT 配置:基础参数定义

首先在配置文件中定义 JWT 的核心参数,用于生成和验证令牌:

复制代码
jwt:
  secret: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4K67DMlSPXbgG0MPp0gH  # 签名密钥(需保密)
  expire: 86400000  # 令牌过期时间(毫秒),此处为24小时
  subject: door  # 令牌主题(可选,用于标识令牌用途)
  • secret:签名密钥,生成令牌时用于加密,验证时用于解密,需确保安全性(建议生产环境使用更长更复杂的密钥)。
  • expire:对应登录接口返回的expire字段,控制令牌有效期。

2. 依赖导入:引入 JWT 工具库

pom.xml中引入jjwt依赖,用于处理 JWT 的生成与解析:

XML 复制代码
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

该版本稳定且功能完善,支持 HS256 等签名算法,满足本次需求。

3. JWT 工具类:封装令牌核心操作

封装JwtUtil工具类,实现令牌的生成、校验等核心功能,代码如下

java 复制代码
package com.qcby.community.util;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.util.Date;
import java.util.UUID;

@ConfigurationProperties(prefix = "jwt") // 绑定配置文件中jwt前缀的参数
@Component
public class JwtUtil {
    private long expire; // 过期时间(从配置文件注入)
    private String secret; // 签名密钥(从配置文件注入)
    private String subject; // 令牌主题(从配置文件注入)

    /**
     * 生成令牌
     * @param userId 用户ID(作为令牌中的核心标识)
     * @return 生成的JWT令牌字符串
     */
    public String createToken(String userId) {
        return Jwts.builder()
                .claim("userId", userId) // 自定义载荷:存储用户ID
                .setSubject(subject) // 令牌主题
                .setExpiration(new Date(System.currentTimeMillis() + expire)) // 过期时间
                .setId(UUID.randomUUID().toString()) // 唯一标识(可选)
                .signWith(SignatureAlgorithm.HS256, secret) // 使用HS256算法签名
                .compact(); // 组装令牌
    }

    /**
     * 校验令牌有效性
     * @param token 待校验的令牌
     * @return 校验结果(true:有效;false:无效)
     */
    public boolean checkToken(String token){
        if(StringUtils.isEmpty(token)){
            return false; // 令牌为空,直接无效
        }
        try {
            // 解析令牌(自动验证签名和过期时间)
            Jws<Claims> claimsJws = Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
            return true; // 解析成功,令牌有效
        } catch (Exception e) {
            // 解析失败(签名错误、过期等),令牌无效
            return false;
        }
    }

    // getter/setter(用于注入配置参数)
    public long getExpire() { return expire; }
    public void setExpire(long expire) { this.expire = expire; }
    public String getSecret() { return secret; }
    public void setSecret(String secret) { this.secret = secret; }
    public String getSubject() { return subject; }
    public void setSubject(String subject) { this.subject = subject; }
}

核心说明

  • createToken方法:根据用户 ID 生成令牌,包含用户标识、过期时间等信息,通过secret签名确保不可篡改,对应登录接口成功后返回的token
  • checkToken方法:用于验证令牌有效性(包括签名正确性和是否过期),对应/checkToken接口的核心逻辑。

4. 登录接口实现:完整流程处理

登录接口(/login)是验证流程的核心,需完成验证码校验、用户信息验证、令牌生成等步骤,代码如下:

java 复制代码
@RestController
public class LoginController {
    @Autowired
    private JwtUtil jwtUtil; // 注入JWT工具类
    @Autowired
    private UserService userService; // 用户服务
    @Autowired
    private RedisTemplate redisTemplate; // Redis模板(用于存储验证码和令牌)

    /**
     * 处理登录请求
     * @param loginForm 前端传递的登录参数(包含username、password、captcha、uuid)
     * @return 登录结果(成功返回token和expire;失败返回错误信息)
     */
    @PostMapping("/login")
    public Result login(@RequestBody LoginForm loginForm){
        // 1. 验证码校验(基于Redis)
        // 从Redis获取验证码(键为uuid,对应生成验证码接口返回的uuid)
        String code = (String) redisTemplate.opsForValue().get(loginForm.getUuid());
        if(code == null){
            return Result.ok().put("status", "fail").put("data", "验证码已过期");
        }
        if(!code.equals(loginForm.getCaptcha())){
            return Result.ok().put("status", "fail").put("data", "验证码错误");
        }

        // 2. 验证用户名
        QueryWrapper<User> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("username", loginForm.getUsername());
        User user = userService.getOne(queryWrapper);
        if(user == null){
            return Result.error("用户名错误");
        }

        // 3. 验证密码(SHA256加密比对)
        String password = SecureUtil.sha256(loginForm.getPassword()); // 前端密码加密
        if(!password.equals(user.getPassword())){ // 与数据库中加密后的密码比对
            return Result.error("密码错误");
        }

        // 4. 验证用户状态(是否被锁定)
        if(user.getStatus() == 0) {
            return Result.error("账号已被锁定,请联系管理员");
        }

        // 5. 登录成功:生成令牌并返回
        String token = jwtUtil.createToken(String.valueOf(user.getUserId())); // 生成token
        // 将token存入Redis(键为"communityuser-用户ID",过期时间与token一致)
        redisTemplate.opsForValue().set(
            "communityuser-"+user.getUserId(), 
            token, 
            jwtUtil.getExpire(), 
            TimeUnit.MILLISECONDS
        );

        // 组装返回结果(符合接口文档:data包含token和expire)
        Map<String,Object> map = new HashMap<>();
        map.put("token", token);
        map.put("expire", jwtUtil.getExpire());
        return Result.ok().put("data", map);
    }
}

流程对应接口文档说明

  • 参数接收LoginForm包含uuid(验证码标识)、captcha(验证码)、username(用户名)、password(密码),完全匹配登录接口的请求参数。
  • 验证码校验 :通过uuid从 Redis 获取验证码(生成验证码接口会将uuidcode存入 Redis),验证过期和正确性,对应生成验证码接口的交互逻辑。
  • 返回结果 :登录成功时,返回data对象包含tokenexpire,与接口文档中登录成功的返回结构一致;失败时返回对应错误信息。

5. Token 校验接口实现

基于JwtUtilcheckToken方法,实现/checkToken接口:

java 复制代码
@GetMapping("/checkToken")
public Result checkToken(HttpServletRequest request){
    // 从请求头获取token(假设前端将token放在Authorization头中)
    String token = request.getHeader("Authorization");
    boolean valid = jwtUtil.checkToken(token);
    if(valid){
        return Result.ok().put("status", "ok");
    }else{
        return Result.ok().put("status", "error");
    }
}

该接口直接调用JwtUtil的校验方法,返回statusokerror,完全符合接口文档要求。

可以先在前端的 permission.js里代码进行修改,

javascript 复制代码
import router from "./router";
import store from "./store";
import { Message } from "element-ui";
import NProgress from "nprogress";
import "nprogress/nprogress.css";
import { getToken } from "@/utils/auth";
import getPageTitle from "@/utils/get-page-title";
import { checkToken } from "@/api/sys/login";

NProgress.configure({ showSpinner: false });

const whiteList = ["/login", "/auth-redirect"]; // 没有重定向白名单

router.beforeEach(async (to, from, next) => {
  NProgress.start();
  // 设置页面标题
  document.title = getPageTitle(to.meta.title);
  let token = getToken();
  if (token) {
    //校验Token
    checkToken(token).then(res => {
      if (res.code === 200 && res.status === "error") {
        next({ path: "/error" });
      }
    });
    if (to.path === "/login") {
      // 如果已登录,请重定向到主页
      next({ path: "/" });
      NProgress.done();
    } else {
      // 确定用户是否通过getInfo获取了权限角色
      const hasRoles = store.getters.roles && store.getters.roles.length > 0;
      if (hasRoles) {
        next();
      } else {
        next();
        // try {
        //   // 获取用户信息
        //   const { routers } = await store.dispatch("user/getInfo");
        //   // 基于角色生成可访问的路由映射
        //   const accessRoutes = await store.dispatch(
        //     "permission/generateRoutes",
        //     { routers }
        //   );
        //   // 动态添加可访问的路由
        //   router.addRoutes(accessRoutes);
        //   // hack方法 确保addRoutes已完成
        //   // 设置replace:true,这样导航就不会留下历史记录
        //   next({ ...to, replace: true });
        // } catch (error) {
        //   // 删除令牌并转到登录页重新登录
        //   await store.dispatch("user/resetToken");
        //   Message.error(error || "Has Error");
        //   // next(`/login?redirect=${to.path}`)
        //   next("/login");
        //   NProgress.done();
        // }
      }
    }
  } else {
    /* 没有token */
    if (whiteList.indexOf(to.path) !== -1) {
      // 在免登录白名单,直接进入
      next();
    } else {
      // 否则全部重定向到登录页
      // next(`/login?redirect=${to.path}`)
      next("/login");
      NProgress.done();
    }
  }
});

router.afterEach(() => {
  // 结束进度条
  NProgress.done();
});

修改前的逻辑(注释部分)

原本的代码实现了完整的权限控制流程:

  1. 用户登录后,获取用户角色和权限信息(通过 store.dispatch("user/getInfo"))。
  2. 根据用户角色动态生成可访问的路由(通过 store.dispatch("permission/generateRoutes"))。
  3. 使用 router.addRoutes() 动态添加路由,确保用户只能访问其权限范围内的页面。

修改后的逻辑(直接 next()

当你将这部分代码注释掉并直接调用 next() 时,会发生以下变化:

  1. 权限控制失效

    所有用户(无论是否登录、拥有何种角色)都可以访问任意路由,包括需要特定权限的页面。例如,普通用户可能可以访问管理员页面。

  2. 动态路由未生成
    router.addRoutes() 未执行,意味着基于用户角色的动态路由配置不会生效。应用可能只能访问静态定义的基础路由。

  3. 用户信息未获取
    store.dispatch("user/getInfo") 未执行,Vuex 中不会存储用户角色、权限等信息,导致页面上可能无法正确显示与用户相关的内容(如用户名、头像、导航菜单)。

相关推荐
陈天伟教授1 天前
人工智能训练师认证教程(2)Python os入门教程
前端·数据库·python
陈文锦丫1 天前
MQ的学习
java·开发语言
乌暮1 天前
JavaEE初阶---线程安全问题
java·java-ee
爱笑的眼睛111 天前
GraphQL:从数据查询到应用架构的范式演进
java·人工智能·python·ai
Seven971 天前
剑指offer-52、正则表达式匹配
java
Elastic 中国社区官方博客1 天前
Elasticsearch:在分析过程中对数字进行标准化
大数据·数据库·elasticsearch·搜索引擎·全文检索
聪明努力的积极向上1 天前
【MYSQL】字符串拼接和参数化sql语句区别
数据库·sql·mysql
代码or搬砖1 天前
RBAC(权限认证)小例子
java·数据库·spring boot
神仙别闹1 天前
基于QT(C++)实现学本科教务系统(URP系统)
数据库·c++·qt
青蛙大侠公主1 天前
Thread及其相关类
java·开发语言