在 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 的生成與解析:
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.9.1</version>
</dependency>
該版本穩定且功能完善,支持 HS256 等簽名算法,滿足本次需求。
3. JWT 工具類:封裝令牌核心操作
封裝JwtUtil
工具類,實現令牌的生成、校驗等核心功能,代碼如下
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
)是驗證流程的核心,需完成驗證碼校驗、用戶信息驗證、令牌生成等步驟,代碼如下:
@RestController
public class LoginController {@Autowiredprivate JwtUtil jwtUtil; // 注入JWT工具類@Autowiredprivate UserService userService; // 用戶服務@Autowiredprivate 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 獲取驗證碼(生成驗證碼接口會將uuid
和code
存入 Redis),驗證過期和正確性,對應生成驗證碼接口的交互邏輯。 - 返回結果:登錄成功時,返回
data
對象包含token
和expire
,與接口文檔中登錄成功的返回結構一致;失敗時返回對應錯誤信息。
5. Token 校驗接口實現
基于JwtUtil
的checkToken
方法,實現/checkToken
接口:
@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
的校驗方法,返回status
為ok
或error
,完全符合接口文檔要求。
可以先在前端的 permission.js里代碼進行修改,
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) {//校驗TokencheckToken(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();
});
修改前的邏輯(注釋部分)
原本的代碼實現了完整的權限控制流程:
- 用戶登錄后,獲取用戶角色和權限信息(通過?
store.dispatch("user/getInfo")
)。 - 根據用戶角色動態生成可訪問的路由(通過?
store.dispatch("permission/generateRoutes")
)。 - 使用?
router.addRoutes()
?動態添加路由,確保用戶只能訪問其權限范圍內的頁面。
修改后的邏輯(直接?next()
)
當你將這部分代碼注釋掉并直接調用?next()
?時,會發生以下變化:
權限控制失效:
所有用戶(無論是否登錄、擁有何種角色)都可以訪問任意路由,包括需要特定權限的頁面。例如,普通用戶可能可以訪問管理員頁面。動態路由未生成:
router.addRoutes()
?未執行,意味著基于用戶角色的動態路由配置不會生效。應用可能只能訪問靜態定義的基礎路由。用戶信息未獲取:
store.dispatch("user/getInfo")
?未執行,Vuex 中不會存儲用戶角色、權限等信息,導致頁面上可能無法正確顯示與用戶相關的內容(如用戶名、頭像、導航菜單)。