Go 后台接口 401/403 排查:JWT 过期、刷新请求和权限码怎么定位
很多后台项目的登录态问题,最后都会被前端同学描述成一句话:接口突然没权限了。
但 401 和 403 不是一类问题。401 多半是认证没过:没有 token、token 格式错、签名不对、过期了。403 是认证已经通过,但当前用户没有访问这个接口的权限。把这两个状态混在一起查,最容易在前端刷新 token、后端中间件和权限码配置之间来回绕。
这篇按一个 Go + Vue3 后台管理系统的常见链路来拆:请求头怎么带、JWT 怎么校验、刷新 token 怎么避免并发重复、接口权限码怎么对上菜单按钮。示例代码是通用写法,项目可以按自己的框架改。XYGo Admin 这类 GoFrame + Vue3 后台项目里也会遇到同样的问题,排查思路比具体框架更重要。
先把状态码分清
建议后端在认证中间件里只做一件事:判断用户是谁。权限中间件再判断这个用户能不能访问接口。
| 状态 | 触发位置 | 常见原因 | 前端动作 |
|---|---|---|---|
| 401 | 认证中间件 | token 缺失、过期、签名错误 | 尝试刷新 token,失败就回登录页 |
| 403 | 权限中间件 | 角色没有该接口权限、按钮权限没配置 | 提示无权限,不要刷新 token |
| 404 | 路由层 | 路由没注册、前缀写错 | 查接口路径和网关转发 |
| 500 | 业务层 | 代码异常、数据库错误 | 查日志和 trace id |
后端不要把所有失败都返回 200 + 业务码。CSDN 上很多后台教程为了省事会这么写,但真实项目里会增加排查成本。浏览器 Network 面板、网关日志和监控系统都更容易按 HTTP 状态码聚合问题。
请求头先做最小验证
前端请求拦截器不要写得太花。先确认每个接口都能稳定带上 Authorization。
ts
// src/utils/request.ts
import axios from 'axios'
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000,
})
request.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers = config.headers || {}
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export default request
浏览器里先看 Network:
text
Request Headers
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6...
如果这里没有 Authorization,先别查后端。常见原因是登录后 token 没写入、多个 axios 实例没有共用拦截器、上传文件接口用了原生 fetch、退出登录时清掉了 token 但页面还在发请求。
后端 JWT 校验只返回清楚的错误
Go 里可以把 token 提取、解析和错误分类拆开。下面是一个简化版本,方便看关键分支。
go
package middleware
import (
"errors"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID int64 `json:"user_id"`
jwt.RegisteredClaims
}
func Auth(secret []byte, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := r.Header.Get("Authorization")
if raw == "" || !strings.HasPrefix(raw, "Bearer ") {
http.Error(w, "missing token", http.StatusUnauthorized)
return
}
tokenText := strings.TrimPrefix(raw, "Bearer ")
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenText, claims, func(token *jwt.Token) (any, error) {
return secret, nil
})
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
http.Error(w, "token expired", http.StatusUnauthorized)
return
}
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
if token == nil || !token.Valid || claims.ExpiresAt == nil || claims.ExpiresAt.Time.Before(time.Now()) {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
这里故意没有查权限。只要 JWT 合法,就让请求进入下一层。这样日志里能看清楚:到底是用户身份没确认,还是身份确认后没有权限。
刷新 token 要处理并发
前端最容易写错的是刷新 token。一个页面同时发 5 个请求,access token 都过期了,如果每个请求都去刷新一次,就可能把 refresh token 打爆,或者后一个刷新结果覆盖前一个。
ts
let refreshing = false
let waitQueue: Array<(token: string) => void> = []
function subscribe(cb: (token: string) => void) {
waitQueue.push(cb)
}
function notify(token: string) {
waitQueue.forEach(cb => cb(token))
waitQueue = []
}
request.interceptors.response.use(
res => res,
async (error) => {
const response = error.response
const original = error.config
if (response?.status !== 401 || original._retry) {
return Promise.reject(error)
}
original._retry = true
if (refreshing) {
return new Promise(resolve => {
subscribe((token) => {
original.headers.Authorization = `Bearer ${token}`
resolve(request(original))
})
})
}
refreshing = true
try {
const refreshToken = localStorage.getItem('refresh_token')
const { data } = await axios.post('/api/auth/refresh', { refreshToken })
localStorage.setItem('access_token', data.accessToken)
notify(data.accessToken)
original.headers.Authorization = `Bearer ${data.accessToken}`
return request(original)
} catch (e) {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
location.href = '/login'
return Promise.reject(e)
} finally {
refreshing = false
}
}
)
注意:只有 401 才刷新。403 不刷新。403 刷新再多次也不会变成有权限,反而会让用户看到页面一直转圈。
403 要查权限码,不要查 token
后台系统常见的权限链路是:角色绑定菜单,菜单绑定按钮,按钮或接口绑定权限码。比如:
sql
CREATE TABLE sys_permission (
id bigint PRIMARY KEY AUTO_INCREMENT,
code varchar(128) NOT NULL UNIQUE,
name varchar(64) NOT NULL,
method varchar(16) NOT NULL,
path varchar(255) NOT NULL
);
INSERT INTO sys_permission(code, name, method, path) VALUES
('system:user:list', '用户列表', 'GET', '/api/system/user'),
('system:user:create', '新增用户', 'POST', '/api/system/user'),
('system:user:update', '编辑用户', 'PUT', '/api/system/user/:id'),
('system:user:delete', '删除用户', 'DELETE', '/api/system/user/:id');
后端权限中间件可以只关心两件事:当前接口需要哪个权限码,当前用户有没有这个权限码。
go
func RequirePermission(code string, userPerms map[string]bool, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !userPerms[code] {
http.Error(w, "permission denied", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
实际项目不会把 userPerms 写死在内存里,一般会从缓存或数据库读。这里的重点是:403 的排查对象是权限码和角色关系,不是 JWT 签名。
Nginx 代理别丢 Authorization
如果本地没问题,部署后所有接口都变 401,先查代理层。Nginx 默认通常会转发常见请求头,但项目里如果写了自定义 proxy_set_header,最好明确带上 Authorization。
nginx
location /api/ {
proxy_pass http://127.0.0.1:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Authorization $http_authorization;
}
验证命令也很简单:
bash
curl -i https://example.com/api/system/user \
-H 'Authorization: Bearer test-token'
后端临时打印一下请求头。如果服务端完全拿不到 Authorization,多半是代理、网关或跨域预检配置的问题。
一套排查顺序
我一般按这个顺序看,能少走不少弯路:
- Network 里确认请求有没有 Authorization。
- 后端日志确认 token 是缺失、过期,还是签名错误。
- 只对 401 触发刷新 token,403 直接提示无权限。
- 查角色、菜单、按钮、接口权限码是否一致。
- 部署环境再查 Nginx、网关、跨域和路径前缀。
如果项目本身有代码生成器,权限码最好在生成 CRUD 时一起生成,再由开发者手动确认是否开放给某个角色。XYGo Admin 的 GoFrame + Vue3 工程也可以作为这类链路的参考,仓库在 GitHub - z312193608/xygo-admin: Open-source admin framework built with GoFrame v2 and Vue 3, featuring RBAC, full-stack CRUD code generation, MySQL/PostgreSQL, plugins, and single-binary deployment. · GitHub 。但无论用哪个后台脚手架,401/403 的边界都不要混在一起。
最后放一个最小检查清单:
text
[ ] 前端每个 axios/fetch 实例都带 Authorization
[ ] access token 和 refresh token 分开存、分开过期
[ ] 401 才刷新 token,403 不刷新
[ ] 后端认证中间件只判断身份
[ ] 权限中间件只判断权限码
[ ] 菜单隐藏不等于接口禁用
[ ] Nginx 或网关没有丢 Authorization
[ ] 生产日志能区分 missing / expired / invalid / forbidden
后台权限问题看起来杂,其实大部分都卡在边界不清。先把 401 和 403 分开,再顺着请求头、JWT、刷新流程和权限码查,问题通常很快就能落到一两行配置或一张关系表上。