Go 后台接口 401/403 排查:JWT 过期、刷新请求和权限码怎么定位

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,多半是代理、网关或跨域预检配置的问题。

一套排查顺序

我一般按这个顺序看,能少走不少弯路:

  1. Network 里确认请求有没有 Authorization。
  2. 后端日志确认 token 是缺失、过期,还是签名错误。
  3. 只对 401 触发刷新 token,403 直接提示无权限。
  4. 查角色、菜单、按钮、接口权限码是否一致。
  5. 部署环境再查 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、刷新流程和权限码查,问题通常很快就能落到一两行配置或一张关系表上。

相关推荐
漫随流水1 小时前
Java——springboot web案例
java·开发语言·spring boot
从此以后自律2 小时前
Java Object 类常用方法全讲解
java·开发语言
XH华2 小时前
C++语言第二章类和对象(下)
开发语言·c++
从零开始的代码生活_2 小时前
C++ stack、queue 与 priority_queue:容器适配器原理与实战
开发语言·c++·后端·学习·算法
techdashen2 小时前
Go 1.25 新增 `reflect.TypeAssert`:更直接、更高效地从 `reflect.Value` 取出类型值
开发语言·后端·golang
2zcode2 小时前
项目文档:基于MATLAB低采样率ISAR成像的快速稀疏重建算法研究
开发语言·算法·matlab
xixi09243 小时前
JMeter5.6.3基础使用教程1
java·开发语言
এ慕ོ冬℘゜3 小时前
原生 JS 实现自定义单选日历组件(无 UI 库、纯手写、禁用过去日期)
开发语言·javascript·ui
Ulyanov3 小时前
刚体动力学方程——牛顿-欧拉与陀螺效应的奥秘
开发语言·python·目标跟踪·雷达电子对抗·导引头