JWT(JSON Web Token)是一种开放标准(RFC 7519),用于在各方之间安全地以 JSON 对象的形式传输信息。这些信息可以被验证和信任,因为它们是经过数字签名的(可以使用 HMAC 算法或 RSA/ECDSA 公钥加密算法)。
一、JWT 的基本结构
一个 JWT 通常由三部分组成,用点(.)分隔:
- Header(头部) :包含令牌类型(通常是
JWT)和所使用的签名算法(如HS256或RS256)。 - Payload(载荷) :包含声明(claims),即实际要传递的数据(如用户 ID、角色、过期时间等)。
- Signature(签名) :用于验证消息在传输过程中没有被篡改,并验证发送方身份。
例如:
xxxxx.yyyyy.zzzzz
二、JWT 的优势 vs Session 与 普通 Token
| 特性 | Session | 普通 Token | JWT |
|---|---|---|---|
| 是否有状态 | 有状态(服务端存) | 有状态(需查库) | 无状态 |
| 扩展性 | 差(需共享 session) | 一般 | 好(天然分布式) |
| 性能 | 每次查 session | 每次查 token | 只需验签,不查库 |
| 跨域支持 | 弱(依赖 Cookie) | 好 | 好 |
| 安全性 | 依赖 Cookie 安全 | 取决于实现 | 签名防篡改 |
| 撤销难度 | 容易(删 session) | 容易(删 token) | 难(需黑名单机制) |
三、JWT高效设计
主要思路是使用 双令牌(访问令牌 + 刷新令牌)+ Redis黑名单机制 来实现高效设计,大致流程如下:
sequenceDiagram
participant User as 用户
participant FE as 前端 (html+js)
participant BE as 后端 (Go)
Note over User,FE: 1. 用户输入账号密码
User->>FE: 提交登录表单
FE->>BE: POST /auth/login {username, password}
Note over BE: 验证凭证<br/>生成 Access Token (15分钟)<br/>生成 Refresh Token (7天)
BE-->>FE: { accessToken, refreshToken }
FE->>FE: 存储 accessToken (内存)<br/>存储 refreshToken (HttpOnly Cookie)
Note over User,FE: 2. 正常请求受保护资源
User->>FE: 访问网站页面
FE->>BE: GET /api/players<br/>Authorization: Bearer <accessToken>
BE-->>FE: 返回网站数据
Note over FE,BE: 3. Access Token 过期
FE->>BE: GET /api/players<br/>Authorization: Bearer <expiredToken>
BE-->>FE: 401 Unauthorized<br/>{ code: "TOKEN_EXPIRED" }
Note over FE: 检测到 TOKEN_EXPIRED<br/>自动触发刷新流程
FE->>BE: POST /auth/refresh<br/>(携带 HttpOnly Cookie)
BE->>BE: 验证 Refresh Token<br/>检查是否有效/未吊销
alt Refresh Token 有效
BE->>BE: 生成新的 Access Token
BE-->>FE: { accessToken: "new_token" }
FE->>FE: 更新内存中的 accessToken
FE->>BE: 重试原请求<br/>GET /api/players<br/>Authorization: Bearer <newToken>
BE-->>FE: 返回玩家数据
else Refresh Token 无效/过期
BE-->>FE: 401 Unauthorized<br/>{ code: "INVALID_REFRESH_TOKEN" }
FE->>FE: 清除所有认证状态<br/>跳转到登录页
FE->>User: 跳转到登录页面
end
📋 核心概念
| 令牌类型 | 有效期 | 存储位置 | 用途 | 验证 |
|---|---|---|---|---|
| Access Token | 15-30分钟 | 内存 | API请求认证 | 仅验证jwt数据合法性 |
| Refresh Token | 7-30天 | HttpOnly Cookie | 刷新Access Token | 验证Redis中Refresh Token |
四、代码实现
go后端实现
话不多说直接上代码,下面是完整的go demo代码
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/redis/go-redis/v9"
)
// 配置常量
const (
AccessTokenExpiry = 15 * time.Minute // 访问令牌失效时间
RefreshTokenExpiry = 7 * 24 * time.Hour // 刷新令牌失效时间
JWTAccessSecretKey = "your-super-secret-key-change-this-in-production" // 访问令牌密钥
JWTRefreshSecretKey = "your-super-secret-key-change-this-in-production" // 刷新令牌密钥
)
// Redis 连接
var redisClient *redis.Client
// User 用户结构
type User struct {
ID int `json:"id"`
Username string `json:"username"`
Password string `json:"password"` // hashed
}
// TokenClaims JWT声明结构
type TokenClaims struct {
UserID int `json:"user_id"`
jwt.RegisteredClaims
}
func createJWTToken(userID int, secretKey string) (string, error) {
claims := TokenClaims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenExpiry)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "game-backend",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secretKey))
}
// validateJWTToken 验证JWT令牌
func validateJWTToken(tokenString string, secretKey string) (*TokenClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(secretKey), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*TokenClaims); ok && token.Valid {
return claims, nil
}
return nil, fmt.Errorf("invalid token")
}
// createAccessToken 创建访问令牌
func createAccessToken(userID int) (string, error) {
return createJWTToken(userID, JWTAccessSecretKey)
}
// createRefreshToken 创建刷新令牌
func createRefreshToken(userID int) (string, error) {
token, err := createJWTToken(userID, JWTRefreshSecretKey)
if err != nil {
return "", err
}
// 将刷新令牌存储到Redis,设置过期时间
ctx := context.Background()
err = redisClient.SetEx(ctx, fmt.Sprintf("refresh_token:%d", userID), token, RefreshTokenExpiry).Err()
if err != nil {
return "", err
}
return token, nil
}
// validateAccessToken 验证访问JWT令牌
func validateAccessToken(tokenString string) (*TokenClaims, error) {
return validateJWTToken(tokenString, JWTAccessSecretKey)
}
// validateRefreshToken 检查刷新令牌是否有效
func validateRefreshToken(token string) (*TokenClaims, error) {
claims, err := validateJWTToken(token, JWTAccessSecretKey)
if err != nil {
return nil, err
}
// 从Redis获取存储的刷新令牌
ctx := context.Background()
storedRefreshToken, err := redisClient.Get(ctx, fmt.Sprintf("refresh_token:%d", claims.UserID)).Result()
if errors.Is(err, redis.Nil) {
return nil, err
} else if err != nil {
return nil, err
} else if token != storedRefreshToken {
return nil, fmt.Errorf("invalid refresh token")
}
return claims, nil
}
// revokeRefreshToken 吊销刷新令牌
func revokeRefreshToken(userID int) {
redisClient.Del(context.Background(), fmt.Sprintf("refresh_token:%d", userID))
}
// AuthMiddleware 认证中间件
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, `{"error": "Authorization header required"}`, http.StatusUnauthorized)
return
}
// 检查是否为Bearer格式
tokenString := ""
if strings.HasPrefix(authHeader, "Bearer ") {
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
} else {
http.Error(w, `{"error": "Bearer token required"}`, http.StatusUnauthorized)
return
}
claims, err := validateAccessToken(tokenString)
if err != nil {
http.Error(w, fmt.Sprintf(`{"error": "Invalid token: %v"}`, err), http.StatusUnauthorized)
return
}
// 将用户ID添加到请求上下文
ctx := context.WithValue(r.Context(), "userID", claims.UserID)
next(w, r.WithContext(ctx))
}
}
// LoginHandler 登录处理
func LoginHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed)
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest)
return
}
// TODO 从数据库读取 用户数据,并验证密码
user := User{
ID: 1,
Username: req.Username,
Password: req.Password,
}
// 创建访问令牌
accessToken, err := createAccessToken(user.ID)
if err != nil {
http.Error(w, `{"error": "Failed to create access token"}`, http.StatusInternalServerError)
return
}
// 创建刷新令牌
refreshToken, err := createRefreshToken(user.ID)
if err != nil {
http.Error(w, `{"error": "Failed to create refresh token"}`, http.StatusInternalServerError)
return
}
// 设置刷新令牌为HttpOnly Cookie
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: refreshToken,
HttpOnly: true,
Secure: false, // 在生产环境中应为true(需要HTTPS)
SameSite: http.SameSiteStrictMode,
MaxAge: int(RefreshTokenExpiry.Seconds()),
Path: "/",
})
// 返回访问令牌
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"accessToken": accessToken,
"message": "Login successful",
})
}
// RefreshHandler 刷新令牌处理
func RefreshHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed)
return
}
// 从Cookie获取刷新令牌
cookie, err := r.Cookie("refresh_token")
if err != nil {
http.Error(w, `{"error": "No refresh token in cookie"}`, http.StatusUnauthorized)
return
}
refreshToken := cookie.Value
// 检查刷新令牌是否有效
claims, err := validateRefreshToken(refreshToken)
if err != nil {
http.Error(w, fmt.Sprintf(`{"error": "Invalid refresh token: %v"}`, err), http.StatusUnauthorized)
return
}
// 创建新的访问令牌
newAccessToken, err := createAccessToken(claims.UserID)
if err != nil {
http.Error(w, `{"error": "Failed to create new access token"}`, http.StatusInternalServerError)
return
}
// 生成新的刷新令牌(令牌轮换)
newRefreshToken, err := createRefreshToken(claims.UserID)
if err != nil {
http.Error(w, `{"error": "Failed to create new refresh token"}`, http.StatusInternalServerError)
return
}
// 吊销旧的刷新令牌
revokeRefreshToken(claims.UserID)
// 设置新的刷新令牌Cookie
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: newRefreshToken,
HttpOnly: true,
Secure: false, // 在生产环境中应为true
SameSite: http.SameSiteStrictMode,
MaxAge: int(RefreshTokenExpiry.Seconds()),
Path: "/",
})
// 返回新的访问令牌
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"accessToken": newAccessToken,
"message": "Token refreshed successfully",
})
}
// LogoutHandler 登出处理
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed)
return
}
// 从上下文获取用户ID
userID := r.Context().Value("userID").(int)
// 吊销刷新令牌
revokeRefreshToken(userID)
// 清除刷新令牌Cookie
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: "",
HttpOnly: true,
MaxAge: -1,
Path: "/",
})
// 返回成功响应
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Logged out successfully",
})
}
// PlayersHandler 受保护的API接口 - 玩家列表
func PlayersHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed)
return
}
// 从上下文获取用户ID
userID := r.Context().Value("userID").(int)
// 模拟玩家数据
players := []map[string]interface{}{
{"id": 1, "name": "Player1", "level": 10, "coins": 1000},
{"id": 2, "name": "Player2", "level": 25, "coins": 2500},
{"id": 3, "name": "Player3", "level": 5, "coins": 500},
}
// 添加访问者信息
response := map[string]interface{}{
"players": players,
"accessed_by": userID,
"timestamp": time.Now().Format(time.RFC3339),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func main() {
// 连接Redis
opt, err := redis.ParseURL("redis://:123456@127.0.0.1:6379/0")
if err != nil {
return
}
redisClient = redis.NewClient(opt)
// TODO 连接数据库
// 无验证路由
http.HandleFunc("/auth/login", LoginHandler)
http.HandleFunc("/auth/refresh", RefreshHandler)
// 验证路由
http.HandleFunc("/auth/logout", AuthMiddleware(LogoutHandler))
http.HandleFunc("/api/players", AuthMiddleware(PlayersHandler))
// 启动服务器
fmt.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
前端部分代码
- 前端登录请求
const login = async (credentials) => {
const response = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
const { accessToken } = await response.json();
// 只存储 accessToken 到内存
setAccessToken(accessToken);
// refreshToken 自动存储在 HttpOnly Cookie 中
};
- API请求拦截,访问Token失效,刷新Token
// Axios 请求拦截器
axios.interceptors.request.use(config => {
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
// 响应拦截器 - 处理令牌刷新
axios.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response?.status === 401 &&
error.response.data.code === "TOKEN_EXPIRED" &&
!originalRequest._retry) {
originalRequest._retry = true;
try {
// 刷新令牌
const response = await axios.post('/auth/refresh');
const { accessToken: newToken } = response.data;
// 更新内存中的令牌
setAccessToken(newToken);
// 重试原请求
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return axios(originalRequest);
} catch (refreshError) {
// 刷新失败,跳转登录
logout();
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
评论区