- 计费:19 个金额字段 float64→decimal.Decimal,DB 列保持 numeric(20,8) 零迁移; API 金额仍为 JSON number(MarshalJSONWithoutQuotes),前端零改动 - failover:保留时间预算,新增 OT_PROXY_FAILOVER_CAPS_NET/SERVER/RATE/QUOTA 按失败类型封顶换渠道次数(429/配额类瞬时失败时间预算形同虚设) - 预聚合:新增 usage_channel_hourlies(schema v10),记账事务内同步 upsert, 长窗口渠道统计走桶不再扫明细,spent_last_30d 改扫 usage_daily,v10 一次性回填幂等 - 守护:internal/arch_test.go 用 go/parser 强制分层依赖(fail-closed)
401 lines
13 KiB
Go
401 lines
13 KiB
Go
// Package api 管理 API(/api/v1):认证、用户、密钥、用量、管理后台。
|
||
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/openteam/server/internal/api/middleware"
|
||
"github.com/openteam/server/internal/app"
|
||
"github.com/openteam/server/internal/passkey"
|
||
"github.com/openteam/server/internal/pkg/resp"
|
||
"github.com/openteam/server/internal/proxy"
|
||
"github.com/openteam/server/internal/store"
|
||
"github.com/shopspring/decimal"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// Handler 聚合所有管理 API。
|
||
type Handler struct {
|
||
a *app.App
|
||
gw *proxy.Gateway
|
||
passkeys *passkey.Service
|
||
// dummyHash 与配置同参数的 argon2id 哈希:用户不存在/被禁用时也跑一次完整校验,
|
||
// 抹平「存在→校验几十 ms / 不存在→立即 401」的时序差(用户名枚举面)。
|
||
dummyHash string
|
||
}
|
||
|
||
func NewHandler(a *app.App, gw *proxy.Gateway, pk *passkey.Service) *Handler {
|
||
h := &Handler{a: a, gw: gw, passkeys: pk}
|
||
if dummy, err := a.Hasher.HashPassword("openteam-timing-equalizer"); err == nil {
|
||
h.dummyHash = dummy
|
||
}
|
||
return h
|
||
}
|
||
|
||
// equalizeTiming 恒定耗时:登录失败路径同样消耗一次 argon2,时序上与命中用户不可区分。
|
||
func (h *Handler) equalizeTiming(password string) {
|
||
if h.dummyHash != "" {
|
||
_, _ = h.a.Hasher.VerifyPassword(h.dummyHash, password)
|
||
}
|
||
}
|
||
|
||
// authLimited 认证端点滑动窗口限速:超限返回 429 并带 Retry-After。
|
||
// limit<=0 或限速器缺席时放行。
|
||
func (h *Handler) authLimited(c *gin.Context, key string, limit int, window time.Duration) bool {
|
||
if h.a.Limit == nil || limit <= 0 {
|
||
return false
|
||
}
|
||
if h.a.Limit.AllowWindow(key, limit, window) {
|
||
return false
|
||
}
|
||
c.Header("Retry-After", strconv.Itoa(int(window.Seconds())))
|
||
resp.Fail(c, http.StatusTooManyRequests, "尝试过于频繁,请稍后再试")
|
||
return true
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 认证
|
||
|
||
type registerReq struct {
|
||
Username string `json:"username" binding:"required,min=3,max=32"`
|
||
Email string `json:"email" binding:"required,email"`
|
||
Password string `json:"password" binding:"required,min=8,max=72"`
|
||
InviteCode string `json:"invite_code"`
|
||
}
|
||
|
||
// Register POST /api/v1/auth/register
|
||
func (h *Handler) Register(c *gin.Context) {
|
||
// 先限速再解析/哈希:注册走 argon2,未限速时光靠建号请求就能把 CPU 打满
|
||
if h.authLimited(c, "register:ip:"+c.ClientIP(), h.a.Cfg.RateLimit.RegisterIPPerMin, time.Minute) {
|
||
return
|
||
}
|
||
var req registerReq
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
|
||
return
|
||
}
|
||
// 注册模式优先读系统配置(管理后台可改),缺省用环境配置
|
||
mode := h.a.Cfg.Auth.RegistrationMode
|
||
var modeCfgRaw string
|
||
h.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "registration_mode").Pluck("value", &modeCfgRaw)
|
||
var modeCfg string
|
||
_ = json.Unmarshal([]byte(modeCfgRaw), &modeCfg)
|
||
if modeCfg == "open" || modeCfg == "invite" {
|
||
mode = modeCfg
|
||
}
|
||
if mode == "invite" {
|
||
codes, err := loadInviteCodes(h.a.DB)
|
||
if err != nil {
|
||
resp.Fail(c, http.StatusInternalServerError, "failed to load invite codes")
|
||
return
|
||
}
|
||
// 精确匹配:子串匹配(Contains)会让任一真实邀请码的任意后缀都放行
|
||
matched := false
|
||
for _, code := range codes {
|
||
if req.InviteCode == code {
|
||
matched = true
|
||
break
|
||
}
|
||
}
|
||
if req.InviteCode == "" || !matched {
|
||
resp.Fail(c, http.StatusForbidden, "valid invite code required")
|
||
return
|
||
}
|
||
defer func() {
|
||
// 一码一用:注册成功(2xx,Created=201)才消费,避免失败请求作废邀请码
|
||
if s := c.Writer.Status(); s >= 200 && s < 300 {
|
||
if err := consumeInviteCode(h.a.DB, req.InviteCode); err != nil {
|
||
log.Printf("invite: failed to consume code after registration: %v", err)
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
email := strings.ToLower(req.Email)
|
||
var count int64
|
||
h.a.DB.Model(&store.User{}).Where("username = ? OR email = ?", req.Username, email).Count(&count)
|
||
if count > 0 {
|
||
resp.Fail(c, http.StatusConflict, "username or email already exists")
|
||
return
|
||
}
|
||
hash, err := h.a.Hasher.HashPassword(req.Password)
|
||
if err != nil {
|
||
resp.Fail(c, http.StatusInternalServerError, "failed to hash password")
|
||
return
|
||
}
|
||
u := store.User{
|
||
Username: req.Username,
|
||
Email: email,
|
||
PasswordHash: hash,
|
||
Role: store.RoleUser,
|
||
Balance: decimal.NewFromFloat(5.0), // 新用户赠送体验余额(可通过 admin 调整)
|
||
Status: store.UserStatusActive,
|
||
}
|
||
if req.InviteCode != "" {
|
||
ic := req.InviteCode
|
||
u.InviteCode = &ic
|
||
}
|
||
if err := h.a.DB.Create(&u).Error; err != nil {
|
||
// 并发注册撞唯一索引(前置 count 检查是 TOCTOU 的):回查区分冲突与真故障
|
||
var n int64
|
||
h.a.DB.Model(&store.User{}).Where("username = ? OR email = ?", req.Username, email).Count(&n)
|
||
if n > 0 {
|
||
resp.Fail(c, http.StatusConflict, "username or email already exists")
|
||
return
|
||
}
|
||
resp.Fail(c, http.StatusInternalServerError, "failed to create user")
|
||
return
|
||
}
|
||
resp.Created(c, gin.H{"id": u.ID, "username": u.Username})
|
||
}
|
||
|
||
type loginReq struct {
|
||
Username string `json:"username" binding:"required"`
|
||
Password string `json:"password" binding:"required"`
|
||
}
|
||
|
||
// Login POST /api/v1/auth/login — 返回 access token,refresh token 写入 HttpOnly Cookie。
|
||
func (h *Handler) Login(c *gin.Context) {
|
||
// 先限 IP 桶再解析 body(与 Register 同策略):登录是未认证面,不先给大请求做绑定
|
||
ip := c.ClientIP()
|
||
if h.authLimited(c, "login:ip:"+ip, h.a.Cfg.RateLimit.LoginIPPerMin, time.Minute) {
|
||
return
|
||
}
|
||
var req loginReq
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||
return
|
||
}
|
||
// 单 IP×账号桶:挡换 IP 定向爆破同一个账号
|
||
if h.authLimited(c, "login:u:"+ip+"|"+strings.ToLower(req.Username), h.a.Cfg.RateLimit.LoginUserPerMin, time.Minute) {
|
||
return
|
||
}
|
||
var u store.User
|
||
err := h.a.DB.Where("username = ? OR email = ?", req.Username, strings.ToLower(req.Username)).First(&u).Error
|
||
if err != nil || u.Status != store.UserStatusActive {
|
||
h.equalizeTiming(req.Password)
|
||
resp.Fail(c, http.StatusUnauthorized, "invalid credentials")
|
||
return
|
||
}
|
||
ok, err := h.a.Hasher.VerifyPassword(u.PasswordHash, req.Password)
|
||
if err != nil || !ok {
|
||
resp.Fail(c, http.StatusUnauthorized, "invalid credentials")
|
||
return
|
||
}
|
||
now := time.Now()
|
||
h.a.DB.Model(&u).Update("last_login_at", now)
|
||
|
||
access, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "access", u.TokenVersion)
|
||
if err != nil {
|
||
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
|
||
return
|
||
}
|
||
refresh, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "refresh", u.TokenVersion)
|
||
if err != nil {
|
||
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
|
||
return
|
||
}
|
||
h.setRefreshCookie(c, refresh)
|
||
resp.OK(c, gin.H{"access_token": access, "expires_in": int(h.a.JWT.AccessTTL().Seconds()), "user": h.publicUser(&u)})
|
||
}
|
||
|
||
// Refresh POST /api/v1/auth/refresh — 用 refresh cookie 换新 access token。
|
||
func (h *Handler) Refresh(c *gin.Context) {
|
||
tok, err := c.Cookie(h.a.Cfg.JWT.CookieName)
|
||
if err != nil || tok == "" {
|
||
resp.Fail(c, http.StatusUnauthorized, "refresh token missing")
|
||
return
|
||
}
|
||
claims, err := h.a.JWT.Parse(tok)
|
||
if err != nil || claims.Subject != "refresh" {
|
||
resp.Fail(c, http.StatusUnauthorized, "invalid refresh token")
|
||
return
|
||
}
|
||
var u store.User
|
||
if err := h.a.DB.First(&u, claims.UserID).Error; err != nil || u.Status != store.UserStatusActive {
|
||
resp.Fail(c, http.StatusUnauthorized, "user not found or disabled")
|
||
return
|
||
}
|
||
if claims.Ver != u.TokenVersion {
|
||
// 改密/登出已递增会话版本:全部旧 refresh 作废
|
||
resp.Fail(c, http.StatusUnauthorized, "session revoked, please sign in again")
|
||
return
|
||
}
|
||
access, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "access", u.TokenVersion)
|
||
if err != nil {
|
||
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
|
||
return
|
||
}
|
||
// 轮换 refresh cookie(滑动续期):单个 refresh token 的暴露窗口不超过一个刷新间隔
|
||
refresh, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "refresh", u.TokenVersion)
|
||
if err != nil {
|
||
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
|
||
return
|
||
}
|
||
h.setRefreshCookie(c, refresh)
|
||
resp.OK(c, gin.H{"access_token": access, "expires_in": int(h.a.JWT.AccessTTL().Seconds())})
|
||
}
|
||
|
||
// Logout POST /api/v1/auth/logout — 服务端撤销会话并清除 refresh cookie。
|
||
// TokenVersion 递增使该用户全部已发 access/refresh 失效(含其他设备);cookie 清理只是客户端侧补充。
|
||
func (h *Handler) Logout(c *gin.Context) {
|
||
if u, ok := userFromContext(c); ok {
|
||
if err := h.a.DB.Model(&store.User{}).Where("id = ?", u.ID).
|
||
Update("token_version", gorm.Expr("token_version + 1")).Error; err != nil {
|
||
log.Printf("auth: failed to bump token version on logout for user %d: %v", u.ID, err)
|
||
}
|
||
}
|
||
h.setRefreshCookie(c, "")
|
||
resp.OK(c, gin.H{"ok": true})
|
||
}
|
||
|
||
type changePasswordReq struct {
|
||
OldPassword string `json:"old_password" binding:"required"`
|
||
NewPassword string `json:"new_password" binding:"required,min=8,max=72"`
|
||
}
|
||
|
||
// ChangePassword POST /api/v1/auth/password — 修改密码。
|
||
func (h *Handler) ChangePassword(c *gin.Context) {
|
||
u := sessionUser(c)
|
||
var req changePasswordReq
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
|
||
return
|
||
}
|
||
ok, err := h.a.Hasher.VerifyPassword(u.PasswordHash, req.OldPassword)
|
||
if err != nil || !ok {
|
||
resp.Fail(c, http.StatusBadRequest, "旧密码不正确")
|
||
return
|
||
}
|
||
hash, err := h.a.Hasher.HashPassword(req.NewPassword)
|
||
if err != nil {
|
||
resp.Fail(c, http.StatusInternalServerError, "failed to hash password")
|
||
return
|
||
}
|
||
// 密码与会话版本同语句更新:改密后该用户全部已发 token 立即作废
|
||
if err := h.a.DB.Model(&store.User{}).Where("id = ?", u.ID).Updates(map[string]any{
|
||
"password_hash": hash,
|
||
"token_version": gorm.Expr("token_version + 1"),
|
||
}).Error; err != nil {
|
||
resp.Fail(c, http.StatusInternalServerError, "failed to update password")
|
||
return
|
||
}
|
||
resp.OK(c, gin.H{"ok": true})
|
||
}
|
||
|
||
// Me GET /api/v1/auth/me
|
||
func (h *Handler) Me(c *gin.Context) {
|
||
u := sessionUser(c)
|
||
resp.OK(c, gin.H{"user": h.publicUser(u)})
|
||
}
|
||
|
||
func (h *Handler) setRefreshCookie(c *gin.Context, value string) {
|
||
maxAge := int(h.a.JWT.RefreshTTL().Seconds())
|
||
if value == "" {
|
||
maxAge = -1
|
||
}
|
||
http.SetCookie(c.Writer, &http.Cookie{
|
||
Name: h.a.Cfg.JWT.CookieName,
|
||
Value: value,
|
||
Path: "/",
|
||
MaxAge: maxAge,
|
||
HttpOnly: true,
|
||
Secure: h.a.Cfg.JWT.CookieSecure,
|
||
SameSite: http.SameSiteLaxMode,
|
||
Domain: h.a.Cfg.JWT.CookieDomain,
|
||
})
|
||
}
|
||
|
||
func (h *Handler) publicUser(u *store.User) gin.H {
|
||
return gin.H{
|
||
"id": u.ID,
|
||
"username": u.Username,
|
||
"email": u.Email,
|
||
"role": u.Role,
|
||
"balance": u.Balance,
|
||
"status": u.Status,
|
||
"allowed_models": u.AllowedModels,
|
||
"denied_models": u.DeniedModels,
|
||
"created_at": u.CreatedAt,
|
||
}
|
||
}
|
||
|
||
func sessionUser(c *gin.Context) *store.User {
|
||
u, _ := c.Get(middleware.CtxSessionUser)
|
||
return u.(*store.User)
|
||
}
|
||
|
||
func userFromContext(c *gin.Context) (*store.User, bool) {
|
||
u, ok := c.Get(middleware.CtxSessionUser)
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
return u.(*store.User), true
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 邀请码
|
||
|
||
// loadInviteCodes 读取邀请码列表。兼容两种存储形态:历史版本存单个 JSON 字符串
|
||
// (逗号/分号/空白分隔),现在统一以 JSON 数组写回。
|
||
func loadInviteCodes(db *gorm.DB) ([]string, error) {
|
||
var raw string
|
||
if err := db.Model(&store.SystemConfig{}).Where("key = ?", "invite_codes").Pluck("value", &raw).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return nil, nil
|
||
}
|
||
var arr []string
|
||
if json.Unmarshal([]byte(raw), &arr) == nil {
|
||
return trimCodes(arr), nil
|
||
}
|
||
var s string
|
||
if json.Unmarshal([]byte(raw), &s) == nil {
|
||
return trimCodes(strings.FieldsFunc(s, func(r rune) bool {
|
||
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == ' ' || r == '\t'
|
||
})), nil
|
||
}
|
||
return nil, fmt.Errorf("invite_codes 配置格式无法解析: %s", raw)
|
||
}
|
||
|
||
// consumeInviteCode 把已使用的邀请码从列表移除(一码一用)。码不存在时静默成功。
|
||
// 写回保持「JSON 字符串 + 逗号分隔」的历史形态,管理后台的输入框按该格式展示编辑。
|
||
func consumeInviteCode(db *gorm.DB, code string) error {
|
||
codes, err := loadInviteCodes(db)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rest := make([]string, 0, len(codes))
|
||
found := false
|
||
for _, c := range codes {
|
||
if c == code && !found {
|
||
found = true // 同码重复录入只删一个
|
||
continue
|
||
}
|
||
rest = append(rest, c)
|
||
}
|
||
if !found {
|
||
return nil
|
||
}
|
||
raw, _ := json.Marshal(strings.Join(rest, ","))
|
||
return db.Save(&store.SystemConfig{Key: "invite_codes", Value: string(raw)}).Error
|
||
}
|
||
|
||
func trimCodes(in []string) []string {
|
||
out := make([]string, 0, len(in))
|
||
for _, s := range in {
|
||
if s = strings.TrimSpace(s); s != "" {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out
|
||
}
|