Files
2026-11-03 11:13:59 +08:00

182 lines
6.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/**
// EndGuest 游客轻身份:小程序手机号授权签发的临时令牌(user_type=guest),
// 仅免登录业务控制器显式放行,数据落在 yz_guest_data,转正后按手机号合并。
EndGuest End = "guest"
)
// 错误分类直接决定 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
}
// IsGuest 判断解析结果是否为游客令牌(user_type=guest)。
// 控制器据此分流:游客请求不进业务表,走 gueststore 通用存储。
func IsGuest(claims *jwtutil.Claims) bool {
return claims != nil && claims.UserType == string(EndGuest)
}
// 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)
}