fix(permissions):修复租户端的权限漏洞
This commit is contained in:
1 parent
a247b3a53b
commit
72fb0318f5
18 files changed
+315
-89
No files matched your search
@@ -221,7 +221,7 @@ cmd(路由注册)
|
||||
|
||||
### 多租户隔离
|
||||
|
||||
行级隔离:所有业务表包含 `tenant_id` 字段,GoFrame ORM 中间件全局注入 `WHERE tenant_id = ?`。请求链路通过 Context 传递 tenant_id。
|
||||
行级隔离:所有业务表包含 `tenant_id` 字段。**没有 ORM 全局注入/hook 这类安全网**——隔离完全靠 logic 层手写过滤:租户身份一律取 `middleware.GetTenantID(ctx)`(来自 TenantAuth 注入的 ctx,客户端无法伪造),所有涉及租户数据的查询/更新必须显式带 `tenant_id` 条件;按 ID 取对象时用双键校验(`WHERE id = ? AND tenant_id = ?`)。新增查询漏写该条件即直接跨租户,评审时必须逐条核对。
|
||||
|
||||
### 路由前缀
|
||||
|
||||
|
||||
+6
-2
@@ -245,7 +245,10 @@ var (
|
||||
|
||||
// Tenant — public endpoints use g.Meta middleware:"-" to skip auth
|
||||
group.Group("/tenant", func(g *ghttp.RouterGroup) {
|
||||
g.Middleware(middleware.DemoMode, middleware.MaintenanceMode, middleware.TenantAuth, middleware.Idempotency)
|
||||
// OperationLog 与管理后台共用:中间件按 ctx 的 userType/tenantId 落
|
||||
// aud_operation_logs,租户写操作(成员/Key/工单等)此前完全无留痕,
|
||||
// 且租户端"操作日志"页因 tenant_id 恒为 0 永远为空,挂上后一并修复。
|
||||
g.Middleware(middleware.DemoMode, middleware.MaintenanceMode, middleware.TenantAuth, middleware.Idempotency, middleware.OperationLog)
|
||||
g.Bind(tenantController.NewV1())
|
||||
})
|
||||
|
||||
@@ -283,7 +286,8 @@ var (
|
||||
// Idempotency 必须排在 OpenPlatformAuth 之后:它按认证主体隔离幂等键。
|
||||
s.Group("/api/open", func(group *ghttp.RouterGroup) {
|
||||
group.Middleware(middleware.MiddlewareHandlerResponse)
|
||||
group.Middleware(middleware.DemoMode, middleware.MaintenanceMode, middleware.OpenPlatformAuth, middleware.Idempotency)
|
||||
// OperationLog 以应用身份(open_app + 应用ID)记录写操作,与管理/租户端共用
|
||||
group.Middleware(middleware.DemoMode, middleware.MaintenanceMode, middleware.OpenPlatformAuth, middleware.Idempotency, middleware.OperationLog)
|
||||
group.Bind(openController.NewV1())
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
@@ -260,24 +259,6 @@ func (s *sAdmin) ListSensitiveAccessLogs(ctx context.Context, req *v1.SensitiveL
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LogSensitiveAccess inserts a sensitive data access log entry.
|
||||
func LogSensitiveAccess(ctx context.Context, userID int64, userType, resourceType string, resourceID int64, action, reason, ip, userAgent string) error {
|
||||
_, err := common.AuditModelCtx(ctx, "aud_sensitive_access_logs").Data(do.AudSensitiveAccessLogs{
|
||||
UserId: userID,
|
||||
UserType: userType,
|
||||
ResourceType: resourceType,
|
||||
ResourceId: resourceID,
|
||||
Action: action,
|
||||
Reason: reason,
|
||||
IpAddress: ip,
|
||||
UserAgent: userAgent,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "记录敏感数据访问日志失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MaskSensitiveData 委托给 common.MaskSensitiveData
|
||||
func MaskSensitiveData(data string) string {
|
||||
return common.MaskSensitiveData(data)
|
||||
@@ -397,6 +378,9 @@ func (s *sAdmin) GetRequestAuditLogDetail(ctx context.Context, req *v1.RequestAu
|
||||
if record == nil {
|
||||
return nil, common.NewNotFoundError("审计日志")
|
||||
}
|
||||
// 详情含请求/响应体(full 级别下为明文对话内容),属敏感读取,落留痕
|
||||
common.LogSensitiveAccess(ctx, common.GetCtxUserID(ctx), "admin", "request_log", req.Id, "view", "查看请求审计日志详情")
|
||||
|
||||
b, _ := json.Marshal(record)
|
||||
var detail map[string]any
|
||||
_ = json.Unmarshal(b, &detail)
|
||||
|
||||
@@ -161,6 +161,7 @@ func (s *sAdmin) Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLog
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "create session")
|
||||
}
|
||||
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
|
||||
|
||||
// Generate token pair
|
||||
tokenPair, err := common.GenerateTokenPair(ctx, user.Id, "admin", user.Role, 0, sessionID, jti)
|
||||
@@ -241,13 +242,14 @@ func (s *sAdmin) Logout(ctx context.Context, _ *v1.AdminLogoutReq) (*v1.AdminLog
|
||||
|
||||
// Refresh handles token refresh.
|
||||
func (s *sAdmin) Refresh(ctx context.Context, req *v1.AdminRefreshReq) (*v1.AdminRefreshRes, error) {
|
||||
refreshTokenHash := common.HashRefreshToken(req.RefreshToken)
|
||||
|
||||
// Find session by refresh token hash
|
||||
session, err := common.GetSessionByRefreshHash(ctx, refreshTokenHash)
|
||||
// 定位会话并做重放检测:已轮换的旧令牌再次出现时,会话在内部被整体吊销
|
||||
session, replayed, err := common.GetSessionByRefreshToken(ctx, req.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, common.NewUnauthorizedError("会话不存在")
|
||||
}
|
||||
if replayed {
|
||||
return nil, common.NewUnauthorizedError("登录状态已失效,请重新登录")
|
||||
}
|
||||
if session == nil {
|
||||
return nil, common.NewUnauthorizedError("会话已过期或不存在")
|
||||
}
|
||||
@@ -261,17 +263,10 @@ func (s *sAdmin) Refresh(ctx context.Context, req *v1.AdminRefreshReq) (*v1.Admi
|
||||
return nil, common.NewBusinessError(consts.CodeTokenRevoked, consts.MsgTokenRevoked)
|
||||
}
|
||||
|
||||
// Generate new refresh token
|
||||
newRefreshToken, err := common.GenerateRefreshToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newRefreshTokenHash := common.HashRefreshToken(newRefreshToken)
|
||||
|
||||
// Rotate session
|
||||
// Rotate session(新令牌内嵌会话 ID,供下次重放检测定位)
|
||||
ipAddress := g.RequestFromCtx(ctx).GetClientIp()
|
||||
deviceInfo := common.ExtractDeviceInfo(ctx)
|
||||
err = common.RefreshSession(ctx, session.Id, refreshTokenHash, newRefreshTokenHash, ipAddress, deviceInfo)
|
||||
newRefreshToken, err := common.RotateSessionRefreshToken(ctx, session.Id, req.RefreshToken, ipAddress, deviceInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ func (s *sAdmin) Verify2FA(ctx context.Context, req *v1.Admin2FAVerifyReq) (*v1.
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "create session")
|
||||
}
|
||||
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
|
||||
|
||||
// Generate token pair
|
||||
tokenPair, err := common.GenerateTokenPair(ctx, user.Id, "admin", user.Role, 0, sessionID, jti)
|
||||
|
||||
@@ -7,7 +7,10 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
)
|
||||
|
||||
// Audit levels
|
||||
@@ -33,6 +36,29 @@ func GetAuditConfig(ctx context.Context) (string, error) {
|
||||
return GetAuditLevel(ctx), nil
|
||||
}
|
||||
|
||||
// LogSensitiveAccess 记录敏感数据访问(查看 Key 明文、审计详情等"读取"行为)。
|
||||
// OperationLog 只覆盖写操作,敏感读取靠本函数留痕;IP/UA 从请求上下文自动提取。
|
||||
// 写入失败只记应用日志不阻断业务 —— 留痕是辅助防线,不影响主流程。
|
||||
func LogSensitiveAccess(ctx context.Context, userID int64, userType, resourceType string, resourceID int64, action, reason string) {
|
||||
ip, ua := "", ""
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
ip = r.GetClientIp()
|
||||
ua = r.GetHeader("User-Agent")
|
||||
}
|
||||
if _, err := AuditModelCtx(ctx, "aud_sensitive_access_logs").Data(do.AudSensitiveAccessLogs{
|
||||
UserId: userID,
|
||||
UserType: userType,
|
||||
ResourceType: resourceType,
|
||||
ResourceId: resourceID,
|
||||
Action: action,
|
||||
Reason: reason,
|
||||
IpAddress: ip,
|
||||
UserAgent: ua,
|
||||
}).Insert(); err != nil {
|
||||
g.Log().Warningf(ctx, "记录敏感数据访问日志失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAuditLevels 返回全局审计级别和租户审计级别,两者完全独立。
|
||||
// 全局级别从 sys_options 读取,默认 full。
|
||||
// 租户级别从 tnt_tenants.settings JSONB 读取,未设置时默认 masked。
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
@@ -159,6 +161,36 @@ func GenerateRefreshToken() (string, error) {
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// BindSessionIDToRefreshToken 在刷新令牌尾部附加会话 ID(格式:random.sessionID)。
|
||||
// 附加段不参与哈希存储(库中存的是随机段的哈希),仅用于令牌被轮换后仍能定位
|
||||
// 所属会话 —— 重放检测的前提。随机段为纯 hex,"." 是无歧义分隔符。
|
||||
func BindSessionIDToRefreshToken(token string, sessionID int64) string {
|
||||
return fmt.Sprintf("%s.%d", token, sessionID)
|
||||
}
|
||||
|
||||
// SplitRefreshToken 拆出刷新令牌的随机段与内嵌会话 ID。
|
||||
// ok=false 表示存量旧格式令牌(纯随机段、无会话 ID),调用方需走兼容路径。
|
||||
func SplitRefreshToken(token string) (randomPart string, sessionID int64, ok bool) {
|
||||
idx := strings.LastIndex(token, ".")
|
||||
if idx <= 0 || idx == len(token)-1 {
|
||||
return "", 0, false
|
||||
}
|
||||
sid, err := strconv.ParseInt(token[idx+1:], 10, 64)
|
||||
if err != nil || sid <= 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
return token[:idx], sid, true
|
||||
}
|
||||
|
||||
// HashRefreshTokenForCompare 计算用于与库中哈希比对的值:
|
||||
// 新格式令牌哈希随机段,存量令牌哈希全文(历史版本的存储口径)。
|
||||
func HashRefreshTokenForCompare(token string) string {
|
||||
if randomPart, _, ok := SplitRefreshToken(token); ok {
|
||||
return HashRefreshToken(randomPart)
|
||||
}
|
||||
return HashRefreshToken(token)
|
||||
}
|
||||
|
||||
// GenerateJti generates a new UUID-based JWT ID for session revocation tracking.
|
||||
func GenerateJti() string {
|
||||
return uuid.New().String()
|
||||
|
||||
@@ -65,6 +65,65 @@ func TestGenerateRefreshToken_Unique(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BindSessionIDToRefreshToken / SplitRefreshToken ────────────────
|
||||
|
||||
func TestRefreshTokenSessionIDRoundTrip(t *testing.T) {
|
||||
random, _ := GenerateRefreshToken()
|
||||
bound := BindSessionIDToRefreshToken(random, 42)
|
||||
|
||||
part, sid, ok := SplitRefreshToken(bound)
|
||||
if !ok {
|
||||
t.Fatalf("bound token should parse: %s", bound)
|
||||
}
|
||||
if part != random {
|
||||
t.Fatalf("random part mismatch: %s vs %s", part, random)
|
||||
}
|
||||
if sid != 42 {
|
||||
t.Fatalf("session id mismatch: %d vs 42", sid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitRefreshToken_LegacyToken(t *testing.T) {
|
||||
// 存量令牌为纯 hex 随机段,无 ".",必须走兼容路径(ok=false)
|
||||
legacy, _ := GenerateRefreshToken()
|
||||
if _, _, ok := SplitRefreshToken(legacy); ok {
|
||||
t.Fatalf("legacy token without session id should not parse: %s", legacy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitRefreshToken_Malformed(t *testing.T) {
|
||||
cases := []string{
|
||||
"", // 空
|
||||
".42", // 空随机段
|
||||
"abc.", // 空会话 ID
|
||||
"abc.xid", // 非数字会话 ID
|
||||
"abc.0", // 非正会话 ID
|
||||
"abc.-1", // 负会话 ID
|
||||
"deadbeef.deadbeef", // 随机段误含类数字后缀(hex 段被当作 ID 也应能解析或安全拒绝)
|
||||
}
|
||||
for _, c := range cases {
|
||||
_, _, _ = SplitRefreshToken(c) // 只要不 panic 即可;具体 ok 值由格式决定
|
||||
}
|
||||
// 关键安全约束:解析出的会话 ID 必须为正数,否则视为不可解析
|
||||
if _, sid, ok := SplitRefreshToken("abc.7"); !ok || sid != 7 {
|
||||
t.Fatal("valid bound token failed to parse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashRefreshTokenForCompare(t *testing.T) {
|
||||
random, _ := GenerateRefreshToken()
|
||||
bound := BindSessionIDToRefreshToken(random, 7)
|
||||
|
||||
// 新格式:比对哈希只算随机段(库中存储口径)
|
||||
if got := HashRefreshTokenForCompare(bound); got != HashRefreshToken(random) {
|
||||
t.Fatal("bound token should hash random part only")
|
||||
}
|
||||
// 存量格式:哈希全文
|
||||
if got := HashRefreshTokenForCompare(random); got != HashRefreshToken(random) {
|
||||
t.Fatal("legacy token should hash full token")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GenerateTokenPair + ParseAccessToken round-trip ────────────────
|
||||
|
||||
func TestTokenPairRoundTrip(t *testing.T) {
|
||||
|
||||
@@ -99,36 +99,6 @@ func CreateSession(ctx context.Context, userType string, userID, tenantID int64,
|
||||
return sessionID, err
|
||||
}
|
||||
|
||||
// RefreshSession rotates a refresh token: invalidates the old one and creates a new session.
|
||||
func RefreshSession(ctx context.Context, sessionID int64, oldRefreshTokenHash, newRefreshTokenHash, ipAddress, deviceInfo string) error {
|
||||
refreshExpire := getRefreshExpire(ctx)
|
||||
expiresAt := gtime.New(time.Now().Add(refreshExpire))
|
||||
|
||||
result, err := dao.SysSessions.Ctx(ctx).
|
||||
Where("id", sessionID).
|
||||
Where("refresh_token_hash", oldRefreshTokenHash).
|
||||
Where("expires_at > NOW()").
|
||||
Data(do.SysSessions{
|
||||
RefreshTokenHash: newRefreshTokenHash,
|
||||
IpAddress: ipAddress,
|
||||
DeviceInfo: deviceInfo,
|
||||
ExpiresAt: expiresAt,
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "update session")
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "check rows affected")
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return NewUnauthorizedError("会话不存在或已过期")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeSession revokes a single session by ID (DB only; use MarkSessionRevoked for Redis).
|
||||
// userType 限定会话所属用户体系(admin/tenant),防止跨控制台按 ID 吊销他人会话。
|
||||
func RevokeSession(ctx context.Context, userType string, sessionID int64) error {
|
||||
@@ -225,6 +195,111 @@ func GetSessionByID(ctx context.Context, userType string, sessionID int64) (*ent
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// GetSessionByRefreshToken 按刷新令牌定位活跃会话,并检测"已轮换令牌重放"。
|
||||
//
|
||||
// 新格式令牌内嵌会话 ID(random.sessionID):按 ID 定位会话后与随机段哈希比对,
|
||||
// 命中即正常返回;会话仍在但哈希不匹配 = 该令牌已被轮换过 = 重放(典型场景:
|
||||
// 令牌被盗、攻击者抢先刷新,受害者手里成了旧令牌)。重放直接吊销整个会话
|
||||
// (攻击者手中的新令牌一并作废,迫使用户重新登录)并写登录历史留痕。
|
||||
// 存量旧格式令牌(无会话 ID)退化为按哈希直查,行为与历史版本一致。
|
||||
//
|
||||
// 返回 (session, replayed, err):replayed=true 时会话已在内部吊销,调用方返回 401 即可。
|
||||
func GetSessionByRefreshToken(ctx context.Context, refreshToken string) (*entity.SysSessions, bool, error) {
|
||||
if randomPart, sessionID, ok := SplitRefreshToken(refreshToken); ok {
|
||||
var sess *entity.SysSessions
|
||||
err := dao.SysSessions.Ctx(ctx).
|
||||
Where("id", sessionID).
|
||||
Where("expires_at > NOW()").
|
||||
Scan(&sess)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if sess == nil {
|
||||
// 会话已过期/被清理,按不存在拒绝,无重放可言
|
||||
return nil, false, nil
|
||||
}
|
||||
if sess.RefreshTokenHash == HashRefreshToken(randomPart) {
|
||||
return sess, false, nil
|
||||
}
|
||||
|
||||
// 哈希不匹配 ≠ 重放:只有随机段哈希命中上一代令牌(轮换时记入 Redis)
|
||||
// 才判定为重放。若仅凭"会话存在但哈希不匹配"就吊销,会话 ID 是顺序
|
||||
// bigint,任何人拼 {垃圾串}.{受害者会话ID} 即可恶意吊销他人会话(DoS)。
|
||||
if prev, rErr := g.Redis().Do(ctx, "GET", refreshPrevKey(sessionID)); rErr == nil && !prev.IsNil() && prev.String() == HashRefreshToken(randomPart) {
|
||||
// 重放:吊销会话(Redis jti 黑名单 + 删会话行),留痕后拒绝
|
||||
MarkSessionRevoked(ctx, sess.Jti)
|
||||
if _, delErr := dao.SysSessions.Ctx(ctx).Where("id", sessionID).Delete(); delErr != nil {
|
||||
g.Log().Errorf(ctx, "revoke replayed session %d: %v", sessionID, delErr)
|
||||
}
|
||||
ip, ua := "", ""
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
ip = r.GetClientIp()
|
||||
ua = r.GetHeader("User-Agent")
|
||||
}
|
||||
_ = RecordLoginHistory(ctx, sess.UserType, sess.UserId, sess.TenantId, "refresh", ip, ua, "", false, "刷新令牌重放,会话已吊销")
|
||||
g.Log().Warningf(ctx, "refresh token replay detected: user_type=%s user_id=%d session=%d", sess.UserType, sess.UserId, sessionID)
|
||||
return nil, true, nil
|
||||
}
|
||||
|
||||
// 既不是当前令牌也不是上一代:普通无效令牌,拒绝但不吊销
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// 存量旧格式令牌:按哈希直查(无法定位轮换来源,退化为普通拒绝)
|
||||
sess, err := GetSessionByRefreshHash(ctx, HashRefreshTokenForCompare(refreshToken))
|
||||
return sess, false, err
|
||||
}
|
||||
|
||||
// refreshPrevKey 是上一代刷新令牌哈希的 Redis key(重放检测用,见 RotateSessionRefreshToken)。
|
||||
func refreshPrevKey(sessionID int64) string {
|
||||
return fmt.Sprintf("session:refresh_prev:%d", sessionID)
|
||||
}
|
||||
|
||||
// RotateSessionRefreshToken 轮换会话刷新令牌:生成新随机段并原子替换库中哈希。
|
||||
// 返回绑定会话 ID 后的新令牌(返回给客户端的最终形态)。
|
||||
// UPDATE 同时校验旧哈希,并发下同一旧令牌只有一个请求能成功。
|
||||
func RotateSessionRefreshToken(ctx context.Context, sessionID int64, oldRefreshToken, ipAddress, deviceInfo string) (string, error) {
|
||||
newRandom, err := GenerateRefreshToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
oldHash := HashRefreshTokenForCompare(oldRefreshToken)
|
||||
|
||||
refreshExpire := getRefreshExpire(ctx)
|
||||
expiresAt := gtime.New(time.Now().Add(refreshExpire))
|
||||
|
||||
result, err := dao.SysSessions.Ctx(ctx).
|
||||
Where("id", sessionID).
|
||||
Where("refresh_token_hash", oldHash).
|
||||
Where("expires_at > NOW()").
|
||||
Data(do.SysSessions{
|
||||
RefreshTokenHash: HashRefreshToken(newRandom),
|
||||
IpAddress: ipAddress,
|
||||
DeviceInfo: deviceInfo,
|
||||
ExpiresAt: expiresAt,
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return "", gerror.Wrapf(err, "update session")
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return "", gerror.Wrapf(err, "check rows affected")
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return "", NewUnauthorizedError("会话不存在或已过期")
|
||||
}
|
||||
|
||||
// 记录上一代令牌哈希(TTL 对齐刷新周期):重放检测必须能证明"这个随机段
|
||||
// 曾经属于该会话",否则顺序会话 ID 会被用来恶意吊销他人会话。Redis 不可用
|
||||
// 时仅损失检测能力(降级为普通拒绝),不产生误吊销。
|
||||
if _, rErr := g.Redis().Do(ctx, "SETEX", refreshPrevKey(sessionID), 7*24*3600, oldHash); rErr != nil {
|
||||
g.Log().Warningf(ctx, "record prev refresh hash for session %d: %v", sessionID, rErr)
|
||||
}
|
||||
|
||||
return BindSessionIDToRefreshToken(newRandom, sessionID), nil
|
||||
}
|
||||
|
||||
// GetSessionByRefreshHash retrieves an active session by refresh token hash.
|
||||
func GetSessionByRefreshHash(ctx context.Context, refreshTokenHash string) (*entity.SysSessions, error) {
|
||||
var session *entity.SysSessions
|
||||
|
||||
@@ -659,6 +659,9 @@ func (s *sTenant) ApiKeyReveal(ctx context.Context, req *v1.TenantApiKeyRevealRe
|
||||
return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "密钥解密失败")
|
||||
}
|
||||
|
||||
// 查看明文属于敏感读取(Key 可长期调用计费接口),落敏感访问日志留痕
|
||||
lcommon.LogSensitiveAccess(ctx, userID, "tenant", "api_key", req.Id, "reveal", "查看 API Key 明文")
|
||||
|
||||
return &v1.TenantApiKeyRevealRes{
|
||||
Key: plainKey,
|
||||
KeyPrefix: info.KeyPrefix,
|
||||
|
||||
@@ -244,6 +244,7 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "create session")
|
||||
}
|
||||
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
|
||||
|
||||
tokenPair, err := common.GenerateTokenPair(ctx, ownerUserID, "tenant", "owner", tenantID, sessionID, jti)
|
||||
if err != nil {
|
||||
@@ -490,6 +491,7 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "create session")
|
||||
}
|
||||
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
|
||||
|
||||
tokenPair, err := common.GenerateTokenPair(ctx, user.Id, "tenant", user.Role, tenant.Id, sessionID, jti)
|
||||
if err != nil {
|
||||
@@ -565,12 +567,14 @@ func (s *sTenant) Logout(ctx context.Context, req *v1.TenantLogoutReq) (*v1.Tena
|
||||
|
||||
// Refresh handles token refresh for tenant users.
|
||||
func (s *sTenant) Refresh(ctx context.Context, req *v1.TenantRefreshReq) (*v1.TenantRefreshRes, error) {
|
||||
refreshTokenHash := common.HashRefreshToken(req.RefreshToken)
|
||||
|
||||
session, err := common.GetSessionByRefreshHash(ctx, refreshTokenHash)
|
||||
// 定位会话并做重放检测:已轮换的旧令牌再次出现时,会话在内部被整体吊销
|
||||
session, replayed, err := common.GetSessionByRefreshToken(ctx, req.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, common.NewUnauthorizedError("会话不存在")
|
||||
}
|
||||
if replayed {
|
||||
return nil, common.NewUnauthorizedError("登录状态已失效,请重新登录")
|
||||
}
|
||||
if session == nil {
|
||||
return nil, common.NewUnauthorizedError("会话已过期或不存在")
|
||||
}
|
||||
@@ -592,15 +596,10 @@ func (s *sTenant) Refresh(ctx context.Context, req *v1.TenantRefreshReq) (*v1.Te
|
||||
return nil, common.NewUnauthorizedError(consts.MsgUnauthorized)
|
||||
}
|
||||
|
||||
newRefreshToken, err := common.GenerateRefreshToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newRefreshTokenHash := common.HashRefreshToken(newRefreshToken)
|
||||
|
||||
// 轮换令牌(新令牌内嵌会话 ID,供下次重放检测定位)
|
||||
ipAddress := g.RequestFromCtx(ctx).GetClientIp()
|
||||
deviceInfo := common.ExtractDeviceInfo(ctx)
|
||||
err = common.RefreshSession(ctx, session.Id, refreshTokenHash, newRefreshTokenHash, ipAddress, deviceInfo)
|
||||
newRefreshToken, err := common.RotateSessionRefreshToken(ctx, session.Id, req.RefreshToken, ipAddress, deviceInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -16,6 +16,14 @@ import (
|
||||
|
||||
// InvitationList returns a paginated list of invitation records for the tenant.
|
||||
func (s *sTenant) InvitationList(ctx context.Context, req *v1.TenantInvitationListReq) (*v1.TenantInvitationListRes, error) {
|
||||
// 邀请码可直接兑换账号,active 邀请的完整 code 会随 invite_url 返回,
|
||||
// member 拿到即可自助注册出邀请角色(可为 admin)的账号 —— 必须与
|
||||
// 邀请创建/撤销同门槛,限 owner/admin。
|
||||
role := middleware.GetUserRole(ctx)
|
||||
if role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
||||
|
||||
|
||||
@@ -11,6 +11,12 @@ import (
|
||||
|
||||
// GetLevelBenefits returns all level configurations and the tenant's current level info.
|
||||
func (s *sTenant) GetLevelBenefits(ctx context.Context, req *v1.TenantLevelBenefitsReq) (*v1.TenantLevelBenefitsRes, error) {
|
||||
// 返回值含组织累计充值额(bil_wallets.cumulative_recharge,财务数据),
|
||||
// member 角色不应看到;组织页面前端本就限 owner,此处与组织管理同门槛
|
||||
if role := middleware.GetUserRole(ctx); role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
// Query all level configs ordered by level
|
||||
|
||||
@@ -428,6 +428,7 @@ func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "创建会话失败")
|
||||
}
|
||||
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
|
||||
|
||||
tokenPair, err := common.GenerateTokenPair(ctx, userID, "tenant", invitation.Role, tenantID, sessionID, jti)
|
||||
if err != nil {
|
||||
@@ -917,6 +918,12 @@ func (s *sTenant) UnlockMember(ctx context.Context, req *v1.TenantMemberUnlockRe
|
||||
|
||||
// GetMember returns a single member's detail.
|
||||
func (s *sTenant) GetMember(ctx context.Context, req *v1.TenantMemberGetReq) (*v1.TenantMemberGetRes, error) {
|
||||
// 成员详情含邮箱等 PII,属管理视角数据:member 不得窥视他人信息,
|
||||
// 自身资料走个人设置(GetProfile)
|
||||
if role := middleware.GetUserRole(ctx); role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
var user *struct {
|
||||
@@ -956,6 +963,12 @@ func (s *sTenant) GetMember(ctx context.Context, req *v1.TenantMemberGetReq) (*v
|
||||
|
||||
// GetMemberUsage returns usage statistics for a single member.
|
||||
func (s *sTenant) GetMemberUsage(ctx context.Context, req *v1.TenantMemberUsageReq) (*v1.TenantMemberUsageRes, error) {
|
||||
// 成员用量属于成员管理视角的数据:member 角色不得窥视他人消费明细,
|
||||
// 自身用量走个人工作台/用量日志(已强制过滤本人)
|
||||
if role := middleware.GetUserRole(ctx); role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
// Verify member exists in tenant
|
||||
@@ -1009,6 +1022,12 @@ func (s *sTenant) GetMemberUsage(ctx context.Context, req *v1.TenantMemberUsageR
|
||||
|
||||
// ListMemberApiKeys returns a paginated list of API keys belonging to a specific member.
|
||||
func (s *sTenant) ListMemberApiKeys(ctx context.Context, req *v1.TenantMemberApiKeysReq) (*v1.TenantMemberApiKeysRes, error) {
|
||||
// 同 GetMemberUsage:他人 Key 清单(名称/额度/状态)是管理视角数据,
|
||||
// member 只管理自己的 Key(个人 Key 列表已强制过滤本人),明文另有属主校验
|
||||
if role := middleware.GetUserRole(ctx); role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
||||
|
||||
@@ -32,14 +32,22 @@ func (s *sTenant) ModelComparison(ctx context.Context, req *v1.ModelComparisonRe
|
||||
|
||||
since := gtime.Now().AddDate(0, 0, -req.Days).Format("Y-m-d")
|
||||
|
||||
// Build args for parameterized query
|
||||
// 聚合与趋势两段查询共用同一份 WHERE 和参数(占位符顺序必须一致)。
|
||||
// member 角色只能对比自己的用量,避免普通成员看到组织级成本聚合
|
||||
// —— 与用量日志的 member 过滤口径一致。
|
||||
whereClause := "tenant_id = ? AND created_at >= ?"
|
||||
args := []any{tenantID, since}
|
||||
if middleware.GetUserRole(ctx) == "member" {
|
||||
whereClause += " AND user_id = ?"
|
||||
args = append(args, middleware.GetUserID(ctx))
|
||||
}
|
||||
for _, m := range models {
|
||||
args = append(args, m)
|
||||
}
|
||||
|
||||
// Aggregate per-model stats (parameterized IN clause, no fmt.Sprintf)
|
||||
inClause := strings.Repeat("?,", len(models)-1) + "?"
|
||||
whereClause += " AND model_name IN (" + inClause + ")"
|
||||
aggQuery := `
|
||||
SELECT
|
||||
model_name,
|
||||
@@ -51,7 +59,7 @@ func (s *sTenant) ModelComparison(ctx context.Context, req *v1.ModelComparisonRe
|
||||
COALESCE(SUM(input_tokens), 0) as input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as output_tokens
|
||||
FROM bil_usage_logs
|
||||
WHERE tenant_id = ? AND created_at >= ? AND model_name IN (` + inClause + `)
|
||||
WHERE ` + whereClause + `
|
||||
GROUP BY model_name
|
||||
`
|
||||
|
||||
@@ -132,8 +140,8 @@ func (s *sTenant) ModelComparison(ctx context.Context, req *v1.ModelComparisonRe
|
||||
items[i].IsRecommended = items[i].ModelName == recommended
|
||||
}
|
||||
|
||||
// Daily trends
|
||||
trends := fetchTrends(ctx, tenantID, since, models, args)
|
||||
// Daily trends(与主查询共用 WHERE/参数,member 过滤口径一致)
|
||||
trends := fetchTrends(whereClause, args)
|
||||
|
||||
return &v1.ModelComparisonRes{
|
||||
Summary: v1.ModelComparisonSummary{
|
||||
@@ -209,8 +217,7 @@ func recommendBest(items []v1.ModelComparisonItem) (string, string) {
|
||||
return best, fmt.Sprintf("综合评分 %.1f(费用40%% + 延迟30%% + 成功率30%%)", bestScore)
|
||||
}
|
||||
|
||||
func fetchTrends(ctx context.Context, tenantID int64, since string, models []string, baseArgs []any) []v1.ModelTrendDay {
|
||||
inClause := strings.Repeat("?,", len(models)-1) + "?"
|
||||
func fetchTrends(whereClause string, args []any) []v1.ModelTrendDay {
|
||||
trendQuery := `
|
||||
SELECT
|
||||
DATE(created_at) as day,
|
||||
@@ -219,12 +226,12 @@ func fetchTrends(ctx context.Context, tenantID int64, since string, models []str
|
||||
COALESCE(SUM(total_cost), 0) as cost,
|
||||
COALESCE(AVG(latency_ms) FILTER (WHERE status = 'success'), 0) as latency
|
||||
FROM bil_usage_logs
|
||||
WHERE tenant_id = ? AND created_at >= ? AND model_name IN (` + inClause + `)
|
||||
WHERE ` + whereClause + `
|
||||
GROUP BY DATE(created_at), model_name
|
||||
ORDER BY day
|
||||
`
|
||||
|
||||
records, err := g.DB().Raw(trendQuery, baseArgs...).All()
|
||||
records, err := g.DB().Raw(trendQuery, args...).All()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -336,6 +336,7 @@ func (s *sTenant) OAuthCallback(ctx context.Context, req *v1.OAuthCallbackReq) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
|
||||
|
||||
// 生成 JWT token pair
|
||||
tokenPair, err := common.GenerateTokenPair(ctx, userID, "tenant", user.Role, tenantID, sessionID, jti)
|
||||
|
||||
@@ -67,6 +67,7 @@ func (s *sTenant) Verify2FA(ctx context.Context, req *v1.Tenant2FAVerifyReq) (*v
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "create session")
|
||||
}
|
||||
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
|
||||
|
||||
tokenPair, err := common.GenerateTokenPair(ctx, user.Id, "tenant", user.Role, user.TenantId, sessionID, jti)
|
||||
if err != nil {
|
||||
|
||||
@@ -90,12 +90,19 @@ func OperationLog(r *ghttp.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get auth info
|
||||
// Get auth info;开放平台请求没有用户身份,以应用身份(open_app + 应用ID)留痕,
|
||||
// 否则 /api/open 的建成员/建删 Key 等写操作在三张审计表里都不存在
|
||||
userID := GetUserID(r.Context())
|
||||
userType := GetUserType(r.Context())
|
||||
|
||||
tenantID := GetTenantID(r.Context())
|
||||
if userID == 0 {
|
||||
return
|
||||
if appID := GetOpenAppID(r.Context()); appID > 0 {
|
||||
userID = appID
|
||||
userType = "open_app"
|
||||
tenantID = GetOpenTenantID(r.Context())
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Parse resource info from URL path
|
||||
@@ -134,7 +141,6 @@ func OperationLog(r *ghttp.Request) {
|
||||
logData.ResourceId = resourceID
|
||||
}
|
||||
|
||||
tenantID := GetTenantID(r.Context())
|
||||
if tenantID > 0 {
|
||||
logData.TenantId = tenantID
|
||||
}
|
||||
@@ -169,9 +175,9 @@ func parseResourceFromPath(path string) (resourceType string, resourceID int64)
|
||||
path = strings.TrimPrefix(path, "/api/")
|
||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
|
||||
// Skip prefix like "admin" or "tenant"
|
||||
// Skip prefix like "admin", "tenant" or "open"
|
||||
idx := 0
|
||||
if len(parts) > 0 && (parts[0] == "admin" || parts[0] == "tenant") {
|
||||
if len(parts) > 0 && (parts[0] == "admin" || parts[0] == "tenant" || parts[0] == "open") {
|
||||
idx = 1
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user