This commit is contained in:
hero920103 committed 2026-10-19 01:13:17 +08:00
1 parent 5718a00e31
commit 1850ac640e
226 files changed
+37772 -12071

No files matched your search

+172
View File
@@ -0,0 +1,172 @@
// Package identity 是所有接入端唯一的"当前请求是谁、能不能用这个端"入口。
//
// 背景:此前 88 个控制器各自从 Authorization 头解令牌、各自拼 user_type 白名单,
// 一共 6 种写法,签发方与消费方一旦口径不一致就会出现"令牌有效却全判无权访问"
// 这类故障(认证中心换码路径曾硬编码 user_type=tenant 即为一例)。
// 新代码一律用 Require,存量控制器把自行解析的那段换成 Claims,
// 全仓不再有任何一处自己拼「SplitN Authorization + ParseToken + 比 user_type」。
package identity
import (
"errors"
"net/http"
"strings"
"server/pkg/jwtutil"
"github.com/golang-jwt/jwt/v5"
)
// End 接入端。令牌中的 user_type 必须属于所放行端之一,才能调用该端接口。
type End string
const (
EndPlatform End = "platform" // 平台端 /platform/**
EndBackend End = "backend" // 租户(用户)端 /backend/**
EndApp End = "app" // 移动端与小程序 /api/**
)
// 错误分类直接决定 HTTP 状态码,因此必须与前端拦截器行为对齐:
// 令牌类问题返 401,request.js 会静默续期后用新令牌重放;
// 端不匹配返 403,续期救不回来,若也返 401 只会白跑一轮刷新再失败。
var (
ErrNoToken = errors.New("未提供认证信息")
ErrBadFormat = errors.New("认证信息格式错误")
ErrExpired = errors.New("token已过期")
ErrInvalid = errors.New("无效的token")
ErrWrongEnd = errors.New("无权访问")
)
// Identity 一次请求解析出的调用者身份。
// TenantID 只来自令牌,不接受任何请求参数覆盖,这是租户间隔离的唯一依据。
type Identity struct {
UserID int // 认证中心 identity_id
Username string
TenantID uint64 // 平台端账号为 0
End End
ClientID string
Sid string
}
// normalizeEnd 把令牌里的 user_type 归一到端。
// "tenant" 是认证中心换码路径早期硬编码签发的历史值,只可能来自 backend 登录流程,
// 按 backend 放行;否则存量会话会在收口后集体变 403,而重新登录才能救回来。
func normalizeEnd(userType string) End {
if userType == "tenant" {
return EndBackend
}
return End(userType)
}
// Claims 解析 Authorization 头(含 "Bearer " 前缀)并校验其所属端在 allowed 之内,
// 返回原始 claims。
//
// 存量控制器普遍按 claims.TenantId / claims.UserID 取值,逐一改成 Identity 既无收益也易出错;
// 收口的目标是「解析与判端只有这一处」,取值方式可以沿用旧的。新代码请用 Require。
func Claims(auth string, allowed ...End) (*jwtutil.Claims, error) {
auth = strings.TrimSpace(auth)
if auth == "" {
return nil, ErrNoToken
}
parts := strings.SplitN(auth, " ", 2)
// 认证方案名按 RFC 7235 大小写不敏感,个别控制器一直用 EqualFold,这里统一放宽。
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return nil, ErrBadFormat
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
return nil, classify(err)
}
end := normalizeEnd(claims.UserType)
granted := false
for _, a := range allowed {
if a == end {
granted = true
break
}
}
if !granted {
return nil, ErrWrongEnd
}
return claims, nil
}
// PathEnd 按路由前缀判定接口属于哪个端;前缀不属于任何端时 ok=false。
func PathEnd(path string) (End, bool) {
p := strings.ToLower(path)
switch {
case strings.HasPrefix(p, "/platform/"):
return EndPlatform, true
case strings.HasPrefix(p, "/backend/"):
return EndBackend, true
case strings.HasPrefix(p, "/api/"), strings.HasPrefix(p, "/app/"):
return EndApp, true
}
return "", false
}
// ClaimsByPath 给「同一个控制器同时挂在平台端和租户端前缀下」的接口用
// (站点设置、功能模块清单就是这种)。放行哪个端由请求路径决定,
// 而不是拿任意一端的合法令牌都能读写另一端的数据。
func ClaimsByPath(auth, path string) (*jwtutil.Claims, error) {
if end, ok := PathEnd(path); ok {
return Claims(auth, end)
}
return Claims(auth, EndPlatform, EndBackend, EndApp)
}
// Require 解析请求的 Bearer 令牌,并校验其所属端在 allowed 之内。
// 用法:identity.Require(c.Ctx.Request, identity.EndBackend),
// 确实需要跨端的接口再传多个,如 (..., identity.EndBackend, identity.EndApp)。
func Require(r *http.Request, allowed ...End) (*Identity, error) {
if r == nil {
return nil, ErrNoToken
}
claims, err := Claims(r.Header.Get("Authorization"), allowed...)
if err != nil {
return nil, err
}
tid := claims.TenantId
if tid < 0 {
tid = 0
}
return &Identity{
UserID: claims.UserID,
Username: claims.Username,
TenantID: uint64(tid),
End: normalizeEnd(claims.UserType),
ClientID: claims.ClientID,
Sid: claims.Sid,
}, nil
}
// classify 把令牌解析错误归一成"过期"或"无效"两类,二者都属令牌问题、都返 401,
// 区分开是为了让前端与日志能看出是该续期还是该重新登录。
// golang-jwt 的过期错误可能被包装,历史代码也用过裸字符串判断,两者都保留。
func classify(err error) error {
if errors.Is(err, jwt.ErrTokenExpired) || strings.Contains(err.Error(), "expired") {
return ErrExpired
}
return ErrInvalid
}
// Status 返回该错误应使用的 HTTP 状态码。
func Status(err error) int {
if errors.Is(err, ErrWrongEnd) {
return http.StatusForbidden
}
return http.StatusUnauthorized
}
// IsAuth 判断错误是否出自本包的鉴权分类。
// 供「一个 fail(err) 同时兜业务错误与鉴权错误」的控制器使用:
// 业务错误要按自己的语义给状态码,鉴权错误则必须走 Status,
// 否则坏令牌会被当成 400 参数错误返回,前端既不会续期也不会跳登录。
func IsAuth(err error) bool {
return errors.Is(err, ErrNoToken) || errors.Is(err, ErrBadFormat) ||
errors.Is(err, ErrExpired) || errors.Is(err, ErrInvalid) ||
errors.Is(err, ErrWrongEnd)
}
+114
View File
@@ -0,0 +1,114 @@
package identity
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"server/pkg/jwtutil"
"github.com/golang-jwt/jwt/v5"
)
// 这些用例只覆盖不依赖数据库的身份判定:端归属、令牌错误分类、状态码映射。
func mint(t *testing.T, userType string) string {
t.Helper()
tok, err := jwtutil.GenerateToken(4, "hero920103", 2, userType)
if err != nil {
t.Fatalf("签发测试令牌失败: %v", err)
}
return tok
}
func requestWith(auth string) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/backend/modules/getTenantList", nil)
if auth != "" {
r.Header.Set("Authorization", auth)
}
return r
}
func TestRequireAcceptsMatchingEnd(t *testing.T) {
id, err := Require(requestWith("Bearer "+mint(t, string(EndBackend))), EndBackend)
if err != nil {
t.Fatalf("backend 令牌应放行,实际报错 %v", err)
}
if id.End != EndBackend || id.UserID != 4 || id.TenantID != 2 || id.Username != "hero920103" {
t.Fatalf("身份解析结果不符:%+v", id)
}
}
func TestRequireRejectsForeignEnd(t *testing.T) {
// 认证中心换码路径曾签发 user_type=tenant,落到这里就是「令牌有效但端不匹配」。
// 这类失败续期救不回来,必须是 403,不能触发前端静默续期重放。
for _, userType := range []string{"tenant", "user", "employee", "platform", ""} {
_, err := Require(requestWith("Bearer "+mint(t, userType)), EndBackend)
if !errors.Is(err, ErrWrongEnd) {
t.Fatalf("user_type=%q 应判为端不匹配,实际 %v", userType, err)
}
if got := Status(err); got != http.StatusForbidden {
t.Fatalf("user_type=%q 期望 403,实际 %d", userType, got)
}
}
}
func TestRequireAllowsMultipleEnds(t *testing.T) {
r := requestWith("Bearer " + mint(t, string(EndApp)))
if _, err := Require(r, EndBackend, EndApp); err != nil {
t.Fatalf("跨端接口应接受 app 令牌,实际 %v", err)
}
if _, err := Require(requestWith("Bearer "+mint(t, string(EndApp))), EndBackend); !errors.Is(err, ErrWrongEnd) {
t.Fatalf("单端接口不应接受 app 令牌,实际 %v", err)
}
}
func TestRequireTokenErrorsAreUnauthorized(t *testing.T) {
cases := []struct {
name string
auth string
want error
}{
{"无令牌", "", ErrNoToken},
{"非 Bearer", "Token abc", ErrBadFormat},
{"缺载荷", "Bearer", ErrBadFormat},
{"乱码令牌", "Bearer aaa.bbb.ccc", ErrInvalid},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := Require(requestWith(c.auth), EndBackend)
if !errors.Is(err, c.want) {
t.Fatalf("期望 %v,实际 %v", c.want, err)
}
if got := Status(err); got != http.StatusUnauthorized {
t.Fatalf("令牌类错误应返 401 让前端续期,实际 %d", got)
}
})
}
}
func TestClassifyExpired(t *testing.T) {
// SignToken/GenerateToken 都把非正数 TTL 钳成默认值,造不出真实过期令牌,
// 因此这里直接验证错误分类:被包装的过期错误也要认出来。
wrapped := fmt.Errorf("parse token: %w", jwt.ErrTokenExpired)
if got := classify(wrapped); !errors.Is(got, ErrExpired) {
t.Fatalf("包装后的过期错误应归类为 ErrExpired,实际 %v", got)
}
if got := classify(errors.New("token is expired by 1h")); !errors.Is(got, ErrExpired) {
t.Fatalf("裸字符串过期错误应归类为 ErrExpired,实际 %v", got)
}
if got := classify(jwt.ErrTokenSignatureInvalid); !errors.Is(got, ErrInvalid) {
t.Fatalf("签名错误应归类为 ErrInvalid,实际 %v", got)
}
if got := Status(ErrExpired); got != http.StatusUnauthorized {
t.Fatalf("过期应返 401,实际 %d", got)
}
}
func TestRequireNilRequest(t *testing.T) {
if _, err := Require(nil, EndBackend); !errors.Is(err, ErrNoToken) {
t.Fatalf("空请求应判为未提供认证信息,实际 %v", err)
}
}