Files
openteam/server/internal/api/auth.go
T
Sakurasan b2e500ff51 feat: 金额 decimal 精确计费、failover 按失败类型封顶、渠道小时桶预聚合、分层守护测试
- 计费: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)
2026-11-09 00:41:48 +08:00

401 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}