链路功能已经跑通,缺的是边界。这几处默认值都是仓库里公开已知的,忘配就等于没有认证。
- 认证端点限速:/auth/login、/auth/register 此前完全没接限速器(Limiter 只挂在代理
Auth 上),爆破与批量建号零成本。新增 AllowWindow 滑动窗口,登录按「单 IP」和
「单 IP×账号」两个桶(默认 30/min、10/min),注册按 IP(5/min)且在 argon2 之前拦,
避免光靠建号请求打满 CPU;超限返回 429 + Retry-After。
- 生产启动断言:env=production 时拒绝默认 OT_JWT_SECRET、缺失或短于 16 字节的
OT_MASTER_KEY(缺失时 NewEncryptor 会用 sha256("") 派生,渠道密文可被公开还原)、
默认管理员密码;一次列全所有问题,不再改一个试一个。development 不拦。
- 请求体上限:parseBody 原来全量 io.ReadAll 且全项目没有一处 MaxBytesReader,
合法密钥可把任意大 body 整份压进内存再转发。/v1 组挂 BodyLimit(默认 32MB,
OT_PROXY_MAX_BODY_MB),超限按客户端协议返回 413 request_too_large 并点名上限。
- CORS:不再无条件反射 Origin + Allow-Credentials: true——那唯一挡住跨站凭据读取的
是 refresh cookie 的 SameSite=Lax,改成 None 就立刻变成窃取面。改为显式白名单,
空白名单完全不发跨域头(同源部署与 vite proxy 本来就不需要),通配 * 不配凭据;
允许头补上 X-Api-Key / Anthropic-Version / OpenAI-*,否则浏览器里跑官方 SDK 预检会挂。
- 配套 OT_SERVER_TRUSTED_PROXIES:不配置就不信 X-Forwarded-For(防伪造头绕过 IP 限速)。
顺手删掉 .env.example 末尾重复的 OT_PROXY_LOG_RAW 段。
150 lines
5.2 KiB
Go
150 lines
5.2 KiB
Go
package api
|
||
|
||
import (
|
||
"net/http"
|
||
"os"
|
||
"strings"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/openteam/server/internal/api/middleware"
|
||
"github.com/openteam/server/internal/app"
|
||
"github.com/openteam/server/internal/proxy"
|
||
)
|
||
|
||
// NewRouter 装配所有路由:
|
||
// - /v1/* 代理端点(Bearer API Key,OpenAI 格式错误体)
|
||
// - /api/v1/* 管理 API(会话 JWT)
|
||
func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
|
||
if a.Cfg.Env == "production" {
|
||
gin.SetMode(gin.ReleaseMode)
|
||
}
|
||
r := gin.New()
|
||
// 只有显式配置了反代列表才信任 X-Forwarded-For;否则 ClientIP 退回 RemoteAddr,
|
||
// 避免伪造头把认证限速绕开。
|
||
if len(a.Cfg.TrustedProxies) > 0 {
|
||
if err := r.SetTrustedProxies(a.Cfg.TrustedProxies); err != nil {
|
||
panic("invalid trusted proxies: " + err.Error())
|
||
}
|
||
}
|
||
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS(a.Cfg.CORSAllowOrigins))
|
||
|
||
h := NewHandler(a, gw, a.Passkeys)
|
||
|
||
// --- 代理端点(对外)---
|
||
proxyGroup := r.Group("/v1", middleware.BodyLimit(int64(a.Cfg.Proxy.MaxBodyMB)*1024*1024))
|
||
{
|
||
proxyGroup.Any("/chat/completions", gw.Auth, gw.Handle)
|
||
proxyGroup.Any("/responses", gw.Auth, gw.Handle)
|
||
proxyGroup.Any("/messages", gw.Auth, gw.Handle)
|
||
proxyGroup.Any("/models", gw.Auth, gw.Handle)
|
||
}
|
||
// 静态资源(前端构建产物,存在时托管)
|
||
const dist = "web/dist"
|
||
if _, err := os.Stat(dist); err == nil {
|
||
// /assets 文件名含内容 hash,不可变:长缓存 + immutable,避免每次回源
|
||
assets := r.Group("/assets")
|
||
assets.Use(func(c *gin.Context) {
|
||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||
c.Next()
|
||
})
|
||
assets.Static("", dist+"/assets")
|
||
r.StaticFile("/favicon.svg", dist+"/favicon.svg")
|
||
}
|
||
|
||
// 未匹配路由:/v1/* 走代理鉴权;其余回退 SPA 或 404
|
||
r.NoRoute(func(c *gin.Context) {
|
||
if strings.HasPrefix(c.Request.URL.Path, "/v1") {
|
||
gw.Auth(c)
|
||
if !c.IsAborted() {
|
||
gw.Handle(c)
|
||
}
|
||
return
|
||
}
|
||
if _, err := os.Stat(dist); err == nil {
|
||
// SPA 入口:必须每次回源校验,否则浏览器启发式缓存会保留引用已删除 chunk 的旧 HTML → 黑屏
|
||
c.Header("Cache-Control", "no-cache")
|
||
c.File(dist + "/index.html")
|
||
return
|
||
}
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||
})
|
||
|
||
// --- 管理 API ---
|
||
api := r.Group("/api/v1")
|
||
{
|
||
auth := api.Group("/auth")
|
||
{
|
||
auth.POST("/register", h.Register)
|
||
auth.POST("/login", h.Login)
|
||
auth.POST("/refresh", h.Refresh)
|
||
auth.POST("/logout", h.Logout)
|
||
auth.POST("/password", middleware.SessionAuth(a), h.ChangePassword)
|
||
auth.GET("/me", middleware.SessionAuth(a), h.Me)
|
||
}
|
||
|
||
webauthn := api.Group("/webauthn")
|
||
{
|
||
webauthn.POST("/register/begin", middleware.SessionAuth(a), h.PasskeyRegisterBegin)
|
||
webauthn.POST("/register/complete", middleware.SessionAuth(a), h.PasskeyRegisterComplete)
|
||
webauthn.POST("/login/begin", h.PasskeyLoginBegin)
|
||
webauthn.POST("/login/complete", h.PasskeyLoginComplete)
|
||
webauthn.GET("/passkeys", middleware.SessionAuth(a), h.PasskeyList)
|
||
webauthn.DELETE("/passkeys/:id", middleware.SessionAuth(a), h.PasskeyDelete)
|
||
}
|
||
|
||
user := api.Group("", middleware.SessionAuth(a))
|
||
{
|
||
user.GET("/user/profile", h.UserProfile)
|
||
user.GET("/user/balance", h.UserBalance)
|
||
user.GET("/user/models", h.UserModels)
|
||
user.GET("/usage/summary", h.UsageSummary)
|
||
user.GET("/usage/stats", h.UsageStats)
|
||
user.GET("/usage/logs", h.UsageLogs)
|
||
user.POST("/keys", h.CreateKey)
|
||
user.GET("/keys", h.ListKeys)
|
||
user.PATCH("/keys/:id", h.PatchKey)
|
||
user.DELETE("/keys/:id", h.DeleteKey)
|
||
}
|
||
|
||
admin := api.Group("/admin", middleware.SessionAuth(a), middleware.AdminOnly)
|
||
{
|
||
// 用户
|
||
admin.GET("/users", h.AdminUsers)
|
||
admin.PATCH("/users/:id", h.AdminPatchUser)
|
||
admin.POST("/users/:id/balance", h.AdminAdjustBalance)
|
||
// 渠道
|
||
admin.GET("/channels", h.AdminChannels)
|
||
admin.POST("/channels", h.AdminCreateChannel)
|
||
admin.PUT("/channels/:id", h.AdminUpdateChannel)
|
||
admin.DELETE("/channels/:id", h.AdminDeleteChannel)
|
||
admin.POST("/channels/:id/test", h.AdminTestChannel)
|
||
admin.POST("/channels/:id/recover", h.AdminRecoverChannel)
|
||
admin.GET("/channels/:id/models/remote", h.AdminChannelRemoteModels)
|
||
admin.GET("/channels/:id/models", h.AdminChannelModels)
|
||
admin.POST("/channels/:id/models", h.AdminChannelAddModel)
|
||
admin.PATCH("/channels/:id/models/:bid", h.AdminChannelUpdateModel)
|
||
admin.DELETE("/channels/:id/models/:bid", h.AdminChannelDeleteModel)
|
||
// 模型与定价
|
||
admin.GET("/models", h.AdminModels)
|
||
admin.DELETE("/models/unused", h.AdminDeleteUnusedModels)
|
||
admin.POST("/models", h.AdminCreateModel)
|
||
admin.PUT("/models/:id", h.AdminUpdateModel)
|
||
admin.DELETE("/models/:id", h.AdminDeleteModel)
|
||
admin.POST("/models/:id/bindings", h.AdminCreateModelBinding)
|
||
admin.DELETE("/models/:id/bindings/:bid", h.AdminDeleteModelBinding)
|
||
// 统计与用量
|
||
admin.GET("/stats/overview", h.AdminStatsOverview)
|
||
admin.GET("/usage", h.AdminUsage)
|
||
// 配置
|
||
admin.GET("/config", h.AdminConfig)
|
||
admin.PUT("/config", h.AdminPutConfig)
|
||
// 充值审核(M6 预留)
|
||
}
|
||
}
|
||
|
||
r.GET("/healthz", func(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||
})
|
||
return r
|
||
}
|