// 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) }