diff --git a/CLAUDE.md b/CLAUDE.md index 3066389..79ff840 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 = ?`)。新增查询漏写该条件即直接跨租户,评审时必须逐条核对。 ### 路由前缀 diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index df859d3..0eb76be 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -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()) }) diff --git a/internal/logic/admin/audit.go b/internal/logic/admin/audit.go index c6fffb1..d7a7db4 100644 --- a/internal/logic/admin/audit.go +++ b/internal/logic/admin/audit.go @@ -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) diff --git a/internal/logic/admin/auth.go b/internal/logic/admin/auth.go index e74330c..660083f 100644 --- a/internal/logic/admin/auth.go +++ b/internal/logic/admin/auth.go @@ -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 } diff --git a/internal/logic/admin/security.go b/internal/logic/admin/security.go index ed4c998..c5f00f0 100644 --- a/internal/logic/admin/security.go +++ b/internal/logic/admin/security.go @@ -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) diff --git a/internal/logic/common/audit.go b/internal/logic/common/audit.go index 96adcc0..721f72e 100644 --- a/internal/logic/common/audit.go +++ b/internal/logic/common/audit.go @@ -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。 diff --git a/internal/logic/common/jwt.go b/internal/logic/common/jwt.go index eee9f3a..c0b8948 100644 --- a/internal/logic/common/jwt.go +++ b/internal/logic/common/jwt.go @@ -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() diff --git a/internal/logic/common/jwt_test.go b/internal/logic/common/jwt_test.go index f7e1f27..8349022 100644 --- a/internal/logic/common/jwt_test.go +++ b/internal/logic/common/jwt_test.go @@ -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) { diff --git a/internal/logic/common/session.go b/internal/logic/common/session.go index 08e8b52..6866fb9 100644 --- a/internal/logic/common/session.go +++ b/internal/logic/common/session.go @@ -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 diff --git a/internal/logic/tenant/api_key.go b/internal/logic/tenant/api_key.go index 3fcbca4..04b9ba3 100644 --- a/internal/logic/tenant/api_key.go +++ b/internal/logic/tenant/api_key.go @@ -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, diff --git a/internal/logic/tenant/auth.go b/internal/logic/tenant/auth.go index 789ee7f..ed05efa 100644 --- a/internal/logic/tenant/auth.go +++ b/internal/logic/tenant/auth.go @@ -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 } diff --git a/internal/logic/tenant/invitation.go b/internal/logic/tenant/invitation.go index c164979..3c3e0e0 100644 --- a/internal/logic/tenant/invitation.go +++ b/internal/logic/tenant/invitation.go @@ -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) diff --git a/internal/logic/tenant/level_benefits.go b/internal/logic/tenant/level_benefits.go index 172ce9c..ce9b6ce 100644 --- a/internal/logic/tenant/level_benefits.go +++ b/internal/logic/tenant/level_benefits.go @@ -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 diff --git a/internal/logic/tenant/member.go b/internal/logic/tenant/member.go index 7b7e8bc..28a2b94 100644 --- a/internal/logic/tenant/member.go +++ b/internal/logic/tenant/member.go @@ -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) diff --git a/internal/logic/tenant/model_comparison.go b/internal/logic/tenant/model_comparison.go index 7605b26..96da3ef 100644 --- a/internal/logic/tenant/model_comparison.go +++ b/internal/logic/tenant/model_comparison.go @@ -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 } diff --git a/internal/logic/tenant/oauth.go b/internal/logic/tenant/oauth.go index 0016651..d0482c0 100644 --- a/internal/logic/tenant/oauth.go +++ b/internal/logic/tenant/oauth.go @@ -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) diff --git a/internal/logic/tenant/security.go b/internal/logic/tenant/security.go index 7074031..58f6fa0 100644 --- a/internal/logic/tenant/security.go +++ b/internal/logic/tenant/security.go @@ -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 { diff --git a/internal/middleware/operation_log.go b/internal/middleware/operation_log.go index 701d6f1..6499437 100644 --- a/internal/middleware/operation_log.go +++ b/internal/middleware/operation_log.go @@ -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 }