mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 16:46:57 +08:00
Each user can now hold up to 20 named access tokens in the user_access_tokens table instead of a single token on the user row. Tokens are shown once at creation (nap_ prefix) and stored as a SHA-256 digest. They expire after 30 days by default; a token without expiry is allowed with a warning. A token only reaches the routes its scopes allow. Scopes are picked from the same permission matrix as admin permissions and are limited to what the owner can do; every management route declares the scope it needs, and routes without a declaration reject tokens. Token management itself always requires a browser session. Tokens can complete step-up verification, so verified actions such as changing a password or 2FA settings work with a token; such changes sign out all browser sessions. Disabling a user keeps their tokens, deleting the account removes them. The existing dashboard token keeps working for 30 days after the upgrade. It can be revoked but no longer regenerated, and afterwards it is rejected with ACCESS_TOKEN_LEGACY_RETIRED. The security page lists tokens with their access records, and audit details show scope names.
320 lines
11 KiB
Go
320 lines
11 KiB
Go
package middleware
|
||
|
||
import (
|
||
"bytes"
|
||
"strconv"
|
||
|
||
"github.com/QuantumNous/new-api/common"
|
||
"github.com/QuantumNous/new-api/constant"
|
||
"github.com/QuantumNous/new-api/model"
|
||
"github.com/QuantumNous/new-api/service"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// auditResponseWriter 包装 gin.ResponseWriter,捕获响应状态码并将响应体复制一份到
|
||
// 有限大小的缓冲区,用于判断业务是否成功(解析响应 JSON 的 success 字段)。
|
||
// 缓冲区有上限,避免大响应(如密钥导出)占用过多内存;超出上限则不再缓存,
|
||
// 此时仅依据 HTTP 状态码判断成败。
|
||
type auditResponseWriter struct {
|
||
gin.ResponseWriter
|
||
body *bytes.Buffer
|
||
maxSize int
|
||
}
|
||
|
||
func (w *auditResponseWriter) Write(b []byte) (int, error) {
|
||
if w.body.Len() < w.maxSize {
|
||
remain := w.maxSize - w.body.Len()
|
||
if remain >= len(b) {
|
||
w.body.Write(b)
|
||
} else {
|
||
w.body.Write(b[:remain])
|
||
}
|
||
}
|
||
return w.ResponseWriter.Write(b)
|
||
}
|
||
|
||
func (w *auditResponseWriter) WriteString(s string) (int, error) {
|
||
return w.Write([]byte(s))
|
||
}
|
||
|
||
// auditRouteActions 将「METHOD + 路由模板」映射为语言无关的操作标识 action。
|
||
// 这些是未被 handler 手动埋点的写操作,由中间件兜底记录;前端依据 action 用 i18n 本地化展示。
|
||
// 未命中的写操作回退为 action="generic",前端展示 "METHOD route"。
|
||
var auditRouteActions = map[string]string{
|
||
// 用户管理
|
||
"POST /api/user/topup/complete": "user.topup_complete",
|
||
"DELETE /api/user/:id/reset_passkey": "user.reset_passkey",
|
||
"DELETE /api/user/:id/oauth/bindings/:provider_id": "user.oauth_unbind",
|
||
|
||
// 系统设置(root)
|
||
"POST /api/option/payment_compliance": "option.payment_compliance",
|
||
"POST /api/option/rest_model_ratio": "option.reset_ratio",
|
||
"DELETE /api/option/channel_affinity_cache": "option.clear_affinity_cache",
|
||
|
||
// 自定义 OAuth(root)
|
||
"POST /api/custom-oauth-provider/": "custom_oauth.create",
|
||
"PUT /api/custom-oauth-provider/:id": "custom_oauth.update",
|
||
"DELETE /api/custom-oauth-provider/:id": "custom_oauth.delete",
|
||
|
||
// 性能/缓存(root)
|
||
"DELETE /api/performance/disk_cache": "performance.clear_disk_cache",
|
||
"POST /api/performance/gc": "performance.gc",
|
||
"DELETE /api/performance/logs": "performance.clear_logs",
|
||
|
||
// 兑换码
|
||
"PUT /api/redemption/": "redemption.update",
|
||
"POST /api/redemption/batch": "redemption.delete_batch",
|
||
"DELETE /api/redemption/:id": "redemption.delete",
|
||
"DELETE /api/redemption/invalid": "redemption.delete_invalid",
|
||
|
||
// 预填组
|
||
"POST /api/prefill_group/": "prefill_group.create",
|
||
"PUT /api/prefill_group/": "prefill_group.update",
|
||
"DELETE /api/prefill_group/:id": "prefill_group.delete",
|
||
|
||
// 供应商
|
||
"POST /api/vendors/": "vendor.create",
|
||
"PUT /api/vendors/": "vendor.update",
|
||
"DELETE /api/vendors/:id": "vendor.delete",
|
||
|
||
// 模型元数据
|
||
"POST /api/models/": "model.create",
|
||
"PUT /api/models/": "model.update",
|
||
"DELETE /api/models/:id": "model.delete",
|
||
"POST /api/models/sync_upstream": "model.sync_upstream",
|
||
|
||
// 部署
|
||
"POST /api/deployments/": "deployment.create",
|
||
"PUT /api/deployments/:id": "deployment.update",
|
||
"DELETE /api/deployments/:id": "deployment.delete",
|
||
|
||
// 订阅(管理员)
|
||
"POST /api/subscription/admin/plans": "subscription.plan_create",
|
||
"PUT /api/subscription/admin/plans/:id": "subscription.plan_update",
|
||
"POST /api/subscription/admin/bind": "subscription.bind",
|
||
|
||
// 日志
|
||
"POST /api/system-task/log-cleanup": "log.cleanup_start",
|
||
}
|
||
|
||
// beginAdminAudit 在管理/root 写操作进入 handler 前包装 ResponseWriter,
|
||
// 以便事后解析响应判断业务是否成功。仅对写方法(POST/PUT/PATCH/DELETE)生效;
|
||
// 只读请求返回 nil,调用方据此跳过事后兜底记录。
|
||
//
|
||
// 该函数由 authHelper 在鉴权通过、c.Next() 之前调用:因为任何管理/root 接口都
|
||
// 必然经过 AdminAuth/RootAuth,将审计兜底内聚到鉴权链路即可保证「新增接口自动留痕」,
|
||
// 无需在路由上再单独挂一层审计中间件(避免漏挂)。
|
||
func beginAdminAudit(c *gin.Context) *auditResponseWriter {
|
||
method := c.Request.Method
|
||
if method != "POST" && method != "PUT" && method != "PATCH" && method != "DELETE" {
|
||
return nil
|
||
}
|
||
writer := &auditResponseWriter{
|
||
ResponseWriter: c.Writer,
|
||
body: bytes.NewBuffer(nil),
|
||
maxSize: 64 * 1024,
|
||
}
|
||
c.Writer = writer
|
||
return writer
|
||
}
|
||
|
||
// finishAdminAudit 在 c.Next() 之后对管理/高危写操作做兜底审计记录。
|
||
// 若 handler 内已手动埋点(设置 ContextKeyAuditLogged),则跳过,避免重复。
|
||
func finishAdminAudit(c *gin.Context, writer *auditResponseWriter) {
|
||
if writer == nil {
|
||
return
|
||
}
|
||
method := c.Request.Method
|
||
|
||
// handler 已手动记录更精细的审计日志,跳过兜底。
|
||
if common.GetContextKeyBool(c, constant.ContextKeyAuditLogged) {
|
||
return
|
||
}
|
||
|
||
operatorId := c.GetInt("id")
|
||
operatorName := c.GetString("username")
|
||
operatorRole := c.GetInt("role")
|
||
ip := c.ClientIP()
|
||
status := writer.Status()
|
||
success := auditResponseSuccess(status, writer.body.Bytes())
|
||
|
||
route := c.FullPath()
|
||
action := auditRouteActions[method+" "+route]
|
||
if action == "" {
|
||
action = "generic"
|
||
}
|
||
|
||
routeParams := map[string]string{}
|
||
for _, p := range c.Params {
|
||
routeParams[p.Key] = p.Value
|
||
}
|
||
|
||
// op.params 为语言无关参数,供前端 i18n 渲染;generic 时携带 method/route。
|
||
opParams := map[string]any{}
|
||
if action == "generic" {
|
||
opParams["method"] = method
|
||
opParams["route"] = route
|
||
}
|
||
|
||
// content 为英文兜底文本(供导出等非本地化消费者使用)。
|
||
content := method + " " + route
|
||
|
||
adminInfo := &model.AuditAdminInfo{
|
||
AdminID: operatorId,
|
||
AdminUsername: operatorName,
|
||
AdminRole: operatorRole,
|
||
AuthMethod: auditAuthMethod(c),
|
||
}
|
||
auditInfo := &model.AuditRequestInfo{
|
||
Method: method,
|
||
Route: route,
|
||
Path: route,
|
||
Status: status,
|
||
Success: success,
|
||
Params: routeParams,
|
||
}
|
||
|
||
model.RecordOperationAuditLog(operatorId, operatorRole, content, ip, action, opParams, adminInfo, auditInfo, c)
|
||
}
|
||
|
||
func auditAuthMethod(c *gin.Context) string {
|
||
if c.GetBool("use_access_token") {
|
||
return "access_token"
|
||
}
|
||
return "session"
|
||
}
|
||
|
||
// auditResponseSuccess 依据 HTTP 状态码与响应体推断操作是否成功。
|
||
// 优先解析响应 JSON 中的 success 字段;无法解析时退回到状态码判断。
|
||
func auditResponseSuccess(status int, body []byte) bool {
|
||
if status >= 400 {
|
||
return false
|
||
}
|
||
trimmed := bytes.TrimSpace(body)
|
||
if len(trimmed) > 0 && trimmed[0] == '{' {
|
||
var resp struct {
|
||
Success *bool `json:"success"`
|
||
}
|
||
if err := common.Unmarshal(trimmed, &resp); err == nil && resp.Success != nil {
|
||
return *resp.Success
|
||
}
|
||
}
|
||
return status < 400
|
||
}
|
||
|
||
const accessTokenAuditContextKey = "access_token_request_audit"
|
||
|
||
// TokenOperationAudit runs after UserAuth and before endpoint rate limits.
|
||
// Handlers add only allowlisted metadata; neither bodies nor raw errors are persisted.
|
||
func TokenOperationAudit() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
var action, content string
|
||
switch c.Request.Method + " " + c.FullPath() {
|
||
case "POST /api/token/":
|
||
action, content = "token.create", "API token creation"
|
||
case "PUT /api/token/":
|
||
action, content = "token.update", "API token configuration update"
|
||
if c.Query("status_only") != "" {
|
||
action, content = "token.status_update", "API token status update"
|
||
}
|
||
case "DELETE /api/token/:id":
|
||
action, content = "token.delete", "API token deletion"
|
||
case "POST /api/token/batch":
|
||
action, content = "token.delete_batch", "API token batch deletion"
|
||
case "POST /api/token/:id/key":
|
||
action, content = "token.key_view", "API token key access"
|
||
case "POST /api/token/batch/keys":
|
||
action, content = "token.key_view_batch", "API token batch key access"
|
||
default:
|
||
c.Next()
|
||
return
|
||
}
|
||
|
||
params := model.AuditFields{}
|
||
if id, err := strconv.Atoi(c.Param("id")); err == nil && id > 0 {
|
||
params["id"] = id
|
||
}
|
||
common.SetContextKey(c, constant.ContextKeyTokenAuditParams, params)
|
||
entry := model.AuditLog{
|
||
UserId: c.GetInt("id"), Username: c.GetString("username"), ActorRole: c.GetInt("role"),
|
||
Category: model.AuditCategorySecurity, Action: action, Content: content,
|
||
Other: model.AuditOther{Op: &model.AuditOperation{Action: action, Params: params}},
|
||
}
|
||
writer := &auditResponseWriter{ResponseWriter: c.Writer, body: bytes.NewBuffer(nil), maxSize: 64 * 1024}
|
||
c.Writer = writer
|
||
c.Next()
|
||
entry.Status = writer.Status()
|
||
entry.Success = auditResponseSuccess(entry.Status, writer.body.Bytes())
|
||
if writer.body.Len() == writer.maxSize {
|
||
// JSON may be truncated before its success field. A completed handler
|
||
// supplies the result without retaining an unbounded response body.
|
||
entry.Success = entry.Status < 400 && common.GetContextKeyBool(c, constant.ContextKeyTokenAuditSucceeded)
|
||
}
|
||
model.RecordAuditLog(c, entry)
|
||
}
|
||
}
|
||
|
||
type accessTokenRequestAudit struct {
|
||
entry model.AuditLog
|
||
writer *auditResponseWriter
|
||
}
|
||
|
||
// AccessTokenAudit also captures public reads and rejections before route-level
|
||
// authentication (for example, rate limiting). It does not grant authentication.
|
||
func AccessTokenAudit() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
raw, present := authorizationToken(c.GetHeader("Authorization"))
|
||
if present {
|
||
_, internal, _ := service.ParseDashboardAccessToken(raw)
|
||
if !internal {
|
||
if lookup := lookupAccessToken(c, raw); lookup.user != nil {
|
||
beginAccessTokenAudit(c, lookup.user, lookup.ref)
|
||
defer finishAccessTokenAudit(c)
|
||
}
|
||
}
|
||
}
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
func beginAccessTokenAudit(c *gin.Context, user *model.UserBase, tokenRef string) {
|
||
if _, exists := c.Get(accessTokenAuditContextKey); exists {
|
||
return
|
||
}
|
||
writer := &auditResponseWriter{ResponseWriter: c.Writer, body: bytes.NewBuffer(nil), maxSize: 64 * 1024}
|
||
c.Writer = writer
|
||
c.Set(accessTokenAuditContextKey, &accessTokenRequestAudit{
|
||
entry: model.AuditLog{UserId: user.Id, Username: user.Username, ActorRole: user.Role, Category: model.AuditCategoryAccessToken, AuthMethod: "access_token", TokenRef: tokenRef, CreatedAt: common.GetTimestamp(), EventId: common.NewRequestId()},
|
||
writer: writer,
|
||
})
|
||
}
|
||
|
||
// setAccessTokenAuditParam adds non-secret context, such as why a token was
|
||
// rejected, to the current request's access audit entry.
|
||
func setAccessTokenAuditParam(c *gin.Context, key string, value any) {
|
||
stored, _ := c.Get(accessTokenAuditContextKey)
|
||
audit, ok := stored.(*accessTokenRequestAudit)
|
||
if !ok {
|
||
return
|
||
}
|
||
if audit.entry.Other.Op == nil {
|
||
audit.entry.Other.Op = &model.AuditOperation{Params: model.AuditFields{}}
|
||
}
|
||
audit.entry.Other.Op.Params[key] = value
|
||
}
|
||
|
||
func finishAccessTokenAudit(c *gin.Context) {
|
||
value, exists := c.Get(accessTokenAuditContextKey)
|
||
if !exists {
|
||
return
|
||
}
|
||
audit, ok := value.(*accessTokenRequestAudit)
|
||
if !ok {
|
||
return
|
||
}
|
||
audit.entry.Status = audit.writer.Status()
|
||
audit.entry.Success = auditResponseSuccess(audit.entry.Status, audit.writer.body.Bytes())
|
||
model.RecordAuditLog(c, audit.entry)
|
||
}
|