refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)

* refactor(auth): replace dashboard sessions with stateless tokens

* feat(auth): harden session issuance and distributed enforcement

* fix(proxy): preserve trusted proxy compatibility defaults

* refactor: address dashboard auth review feedback

* refactor: remove classic frontend and flatten web app
This commit is contained in:
Calcium-Ion
2026-07-20 16:48:43 +08:00
committed by GitHub
parent 5a6c53d496
commit 31d70fca39
1605 changed files with 17511 additions and 147913 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ import (
)
// auditContentTemplates 将稳定的操作标识 action 映射为英文兜底模板,渲染后写入
// Log.Content(供导出 / 经典前端等非本地化消费者使用)。占位符为 ${name},由该
// Log.Content(供导出等非本地化消费者使用)。占位符为 ${name},由该
// action 的 params 填充。本地化展示文案在前端 i18n 模板中维护,本表是语言中立的
// 英文基线——调用方因此无需在每个埋点处手写句子(避免与 params 重复书写同一份值)。
var auditContentTemplates = map[string]string{
+224
View File
@@ -0,0 +1,224 @@
package controller
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type authFlowTestOAuthProvider struct {
exchangeErr error
userInfoErr error
exchangeCalls int
userInfoCalls int
}
func (*authFlowTestOAuthProvider) GetName() string { return "Auth Flow Test" }
func (*authFlowTestOAuthProvider) IsEnabled() bool { return true }
func (provider *authFlowTestOAuthProvider) ExchangeToken(context.Context, string, *gin.Context) (*oauth.OAuthToken, error) {
provider.exchangeCalls++
if provider.exchangeErr != nil {
return nil, provider.exchangeErr
}
return &oauth.OAuthToken{}, nil
}
func (provider *authFlowTestOAuthProvider) GetUserInfo(context.Context, *oauth.OAuthToken) (*oauth.OAuthUser, error) {
provider.userInfoCalls++
if provider.userInfoErr != nil {
return nil, provider.userInfoErr
}
return &oauth.OAuthUser{ProviderUserID: "external-user"}, nil
}
func (*authFlowTestOAuthProvider) IsUserIDTaken(string) bool { return false }
func (*authFlowTestOAuthProvider) FillUserByProviderID(*model.User, string) error { return nil }
func (*authFlowTestOAuthProvider) SetProviderUserID(*model.User, string) {}
func (*authFlowTestOAuthProvider) GetProviderPrefix() string { return "flow_" }
func setupAuthFlowControllerTest(t *testing.T) *authFlowTestOAuthProvider {
t.Helper()
previousDB := model.DB
previousType := common.MainDatabaseType()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.AuthFlow{}))
model.DB = db
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
provider := &authFlowTestOAuthProvider{}
oauth.Register("auth-flow-test", provider)
t.Cleanup(func() {
oauth.Unregister("auth-flow-test")
model.DB = previousDB
common.SetMainDatabaseType(previousType)
})
return provider
}
func TestGenerateOAuthCodeCarriesAffiliateInLoginFlow(t *testing.T) {
setupAuthFlowControllerTest(t)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/oauth/state", strings.NewReader(`{"provider":"auth-flow-test","intent":"login","aff":"invite-code"}`))
c.Request.Header.Set("Content-Type", "application/json")
GenerateOAuthCode(c)
require.Equal(t, http.StatusOK, recorder.Code)
var response struct {
Success bool `json:"success"`
Data struct {
FlowToken string `json:"flow_token"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
flow, err := model.GetAuthFlow(response.Data.FlowToken, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
})
require.NoError(t, err)
var payload oauthFlowPayload
require.NoError(t, common.UnmarshalJsonStr(flow.Payload, &payload))
assert.Equal(t, "invite-code", payload.AffiliateCode)
assert.Zero(t, flow.UserId)
assert.Empty(t, flow.SessionId)
}
func TestGenerateOAuthCodeBindsFlowToAuthenticatedSession(t *testing.T) {
setupAuthFlowControllerTest(t)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/oauth/state", strings.NewReader(`{"provider":"auth-flow-test","intent":"bind"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Set("id", 42)
c.Set("session_id", "session-42")
c.Set("auth_version", int64(3))
c.Set("session_version", int64(2))
GenerateOAuthCode(c)
require.Equal(t, http.StatusOK, recorder.Code)
var response struct {
Success bool `json:"success"`
Data struct {
FlowToken string `json:"flow_token"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
flow, err := model.GetAuthFlow(response.Data.FlowToken, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentBind,
UserId: 42, SessionId: "session-42",
})
require.NoError(t, err)
assert.Equal(t, 42, flow.UserId)
assert.Equal(t, "session-42", flow.SessionId)
}
func TestOAuthLoginConsumesFlowOnlyAfterProviderIdentity(t *testing.T) {
provider := setupAuthFlowControllerTest(t)
tests := []struct {
name string
exchangeErr error
userInfoErr error
}{
{name: "exchange failure", exchangeErr: errors.New("exchange failed")},
{name: "user info failure", userInfoErr: errors.New("user info failed")},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
provider.exchangeErr = test.exchangeErr
provider.userInfoErr = test.userInfoErr
token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
router := gin.New()
router.GET("/api/oauth/:provider", HandleOAuth)
request := httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+token+"&code=test", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
flow, err := model.GetAuthFlow(token, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
})
require.NoError(t, err)
assert.Nil(t, flow.ConsumedAt)
})
}
}
func TestOAuthLoginConsumesFlowAfterProviderIdentityAndOnProviderError(t *testing.T) {
provider := setupAuthFlowControllerTest(t)
provider.exchangeErr = nil
provider.userInfoErr = nil
successToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
Payload: `{invalid`, ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
router := gin.New()
router.GET("/api/oauth/:provider", HandleOAuth)
request := httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+successToken+"&code=test", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
_, err = model.GetAuthFlow(successToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth})
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
assert.Equal(t, 1, provider.exchangeCalls)
assert.Equal(t, 1, provider.userInfoCalls)
providerErrorToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
request = httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+providerErrorToken+"&error=access_denied", nil)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
_, err = model.GetAuthFlow(providerErrorToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth})
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
assert.Equal(t, 1, provider.exchangeCalls)
assert.Equal(t, 1, provider.userInfoCalls)
}
func TestOAuthBindProviderErrorConsumesSessionBoundFlow(t *testing.T) {
provider := setupAuthFlowControllerTest(t)
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentBind,
UserId: 42, SessionId: "session-42", Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("id", 42)
c.Set("session_id", "session-42")
c.Set("auth_version", int64(1))
c.Set("session_version", int64(1))
c.Next()
})
router.GET("/api/oauth/:provider", HandleOAuth)
request := httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+flowToken+"&error=access_denied&error_description=cancelled", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
assert.Equal(t, http.StatusOK, response.Code)
_, err = model.GetAuthFlow(flowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth})
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
assert.Zero(t, provider.exchangeCalls)
assert.Zero(t, provider.userInfoCalls)
}
+189
View File
@@ -0,0 +1,189 @@
package controller
import (
"errors"
"net/http"
"strings"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func RefreshAuth(c *gin.Context) {
setAuthNoStore(c)
rawRefreshToken, err := c.Cookie(service.RefreshCookieName)
if err != nil || rawRefreshToken == "" {
service.ClearRefreshCookie(c)
writeAuthSessionError(c, service.ErrRefreshTokenInvalid)
return
}
bundle, user, err := service.RefreshLoginSession(rawRefreshToken, c.GetHeader("X-Auth-Session"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
if errors.Is(err, service.ErrRefreshTokenInvalid) || errors.Is(err, service.ErrLoginSessionRevoked) {
service.ClearRefreshCookie(c)
}
writeAuthSessionError(c, err)
return
}
service.WriteRefreshCookie(c, bundle.RefreshToken)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"access_token": bundle.AccessToken,
"token_type": bundle.TokenType,
"access_expires_at": bundle.AccessExpiresAt,
"user": buildSelfUserData(user),
"session": bundle.Session,
},
})
}
func AuthLogout(c *gin.Context) {
setAuthNoStore(c)
expectedSID := strings.TrimSpace(c.GetHeader("X-Auth-Session"))
rawRefreshToken, cookieErr := c.Cookie(service.RefreshCookieName)
cookieSID, hasCookieSID := service.RefreshTokenSID(rawRefreshToken)
if expectedSID != "" && cookieErr == nil && hasCookieSID && cookieSID != expectedSID {
writeAuthSessionError(c, service.ErrLoginSessionMismatch)
return
}
if rawAccessToken, ok := dashboardBearer(c.GetHeader("Authorization")); ok {
if identity, err := service.ParseAccessToken(rawAccessToken); err == nil {
if expectedSID != "" && expectedSID != identity.SessionID {
writeAuthSessionError(c, service.ErrLoginSessionMismatch)
return
}
if _, err := model.RevokeUserSession(identity.UserID, identity.SessionID, "logout"); err != nil {
writeAuthSessionError(c, err)
return
}
cookieCleared := false
if cookieErr == nil && hasCookieSID && cookieSID == identity.SessionID {
if err := service.RevokeByRefreshToken(rawRefreshToken, identity.SessionID, "logout"); err != nil {
writeAuthSessionError(c, err)
return
}
service.ClearRefreshCookie(c)
cookieCleared = true
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{"revoked_sid": identity.SessionID, "cookie_cleared": cookieCleared},
})
return
}
}
if cookieErr != nil || rawRefreshToken == "" {
service.ClearRefreshCookie(c)
c.JSON(http.StatusOK, gin.H{"success": true, "message": ""})
return
}
if err := service.RevokeByRefreshToken(rawRefreshToken, expectedSID, "logout"); err != nil {
writeAuthSessionError(c, err)
return
}
service.ClearRefreshCookie(c)
c.JSON(http.StatusOK, gin.H{"success": true, "message": ""})
}
func GetLoginSessions(c *gin.Context) {
identity, ok := requireBrowserSession(c)
if !ok {
return
}
sessions, err := service.ListLoginSessions(identity.UserID, identity.SessionID)
if err != nil {
writeAuthSessionError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": sessions})
}
func DeleteLoginSession(c *gin.Context) {
identity, ok := requireBrowserSession(c)
if !ok {
return
}
sid := strings.TrimSpace(c.Param("sid"))
if sid == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "code": "AUTH_SESSION_ID_REQUIRED", "message": "session id is required"})
return
}
revoked, err := model.RevokeUserSession(identity.UserID, sid, "user_revoked")
if err != nil {
writeAuthSessionError(c, err)
return
}
if !revoked {
c.JSON(http.StatusNotFound, gin.H{"success": false, "code": "AUTH_SESSION_NOT_FOUND", "message": "session not found"})
return
}
if rawRefreshToken, cookieErr := c.Cookie(service.RefreshCookieName); cookieErr == nil {
cookieSID, ok := service.RefreshTokenSID(rawRefreshToken)
if ok && cookieSID == sid {
service.ClearRefreshCookie(c)
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"revoked_sid": sid, "current": sid == identity.SessionID}})
}
func RevokeOtherLoginSessions(c *gin.Context) {
identity, ok := requireBrowserSession(c)
if !ok {
return
}
count, err := model.RevokeOtherUserSessions(identity.UserID, identity.SessionID, "user_revoked_others")
if err != nil {
writeAuthSessionError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"revoked_count": count}})
}
func requireBrowserSession(c *gin.Context) (service.AuthIdentity, bool) {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"code": "AUTH_SESSION_REQUIRED",
"message": "a dashboard login session is required",
})
return service.AuthIdentity{}, false
}
return identity, true
}
func writeAuthSessionError(c *gin.Context, err error) {
status, code := service.AuthSessionErrorCode(err)
if errors.Is(err, gorm.ErrRecordNotFound) {
status, code = http.StatusUnauthorized, "AUTH_UNAUTHORIZED"
}
c.JSON(status, gin.H{"success": false, "code": code, "message": http.StatusText(status)})
}
func setAuthNoStore(c *gin.Context) {
c.Header("Cache-Control", "no-store")
}
func authRotationData(bundle *service.AuthBundle) gin.H {
return gin.H{
"access_token": bundle.AccessToken,
"token_type": bundle.TokenType,
"access_expires_at": bundle.AccessExpiresAt,
"session": bundle.Session,
}
}
func dashboardBearer(header string) (string, bool) {
parts := strings.Fields(header)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" {
return "", false
}
return parts[1], true
}
+155
View File
@@ -0,0 +1,155 @@
package controller
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestAuthLogoutRejectsRefreshCookieSessionMismatch(t *testing.T) {
previousDB := model.DB
previousRedis := common.RedisEnabled
previousSecret := common.SessionSecret
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}))
model.DB = db
common.RedisEnabled = false
common.SessionSecret = "auth-logout-mismatch-test-secret"
t.Cleanup(func() {
model.DB = previousDB
common.RedisEnabled = previousRedis
common.SessionSecret = previousSecret
})
user := &model.User{
Username: "logout-mismatch-user", Password: "unused", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1,
}
require.NoError(t, db.Create(user).Error)
sessionA, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "agent-a")
require.NoError(t, err)
sessionB, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "agent-b")
require.NoError(t, err)
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/user/auth/logout", nil)
c.Request.Header.Set("Authorization", "Bearer "+sessionA.AccessToken)
c.Request.Header.Set("X-Auth-Session", sessionA.Session.SID)
c.Request.AddCookie(&http.Cookie{Name: service.RefreshCookieName, Value: sessionB.RefreshToken})
AuthLogout(c)
assert.Equal(t, http.StatusConflict, recorder.Code)
var response struct {
Success bool `json:"success"`
Code string `json:"code"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.False(t, response.Success)
assert.Equal(t, "AUTH_SESSION_MISMATCH", response.Code)
for _, sid := range []string{sessionA.Session.SID, sessionB.Session.SID} {
stored, err := model.GetUserSessionBySID(sid)
require.NoError(t, err)
assert.Equal(t, model.UserSessionStatusActive, stored.Status)
}
}
func TestWriteAuthSessionErrorMapsSessionGrowthLimits(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
err error
expectedStatus int
expectedCode string
}{
{
name: "active session limit",
err: model.ErrUserSessionLimit,
expectedStatus: http.StatusConflict,
expectedCode: "AUTH_SESSION_LIMIT",
},
{
name: "issuance limit",
err: model.ErrUserSessionIssuanceLimit,
expectedStatus: http.StatusTooManyRequests,
expectedCode: "AUTH_SESSION_ISSUANCE_LIMIT",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
writeAuthSessionError(c, test.err)
assert.Equal(t, test.expectedStatus, recorder.Code)
var response struct {
Success bool `json:"success"`
Code string `json:"code"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.False(t, response.Success)
assert.Equal(t, test.expectedCode, response.Code)
})
}
}
func TestSessionLimitDoesNotRecordRejectedLoginAsSuccessful(t *testing.T) {
previousDB := model.DB
previousRedis := common.RedisEnabled
previousActiveLimit := common.UserSessionActiveLimit
previousIssuanceLimit := common.UserSessionIssuanceLimit
previousIssuanceWindow := common.UserSessionIssuanceWindowSeconds
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}))
model.DB = db
common.RedisEnabled = false
common.UserSessionActiveLimit = 1
common.UserSessionIssuanceLimit = 100
common.UserSessionIssuanceWindowSeconds = int64(common.DefaultUserSessionIssuanceWindowSeconds)
t.Cleanup(func() {
model.DB = previousDB
common.RedisEnabled = previousRedis
common.UserSessionActiveLimit = previousActiveLimit
common.UserSessionIssuanceLimit = previousIssuanceLimit
common.UserSessionIssuanceWindowSeconds = previousIssuanceWindow
})
const previousLastLoginAt = int64(123)
user := &model.User{
Username: "rejected-login-audit-user", Password: "unused", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, LastLoginAt: previousLastLoginAt,
}
require.NoError(t, db.Create(user).Error)
now := time.Now().Unix()
require.NoError(t, db.Create(&model.UserSession{
SID: "existing-active-session", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion,
Status: model.UserSessionStatusActive, RefreshHash: "hash", LoginMethod: "password",
CreatedAt: now, LastActiveAt: now, ExpiresAt: now + 3600,
}).Error)
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/user/login", nil)
setupLogin(user, c)
assert.Equal(t, http.StatusConflict, recorder.Code)
var stored model.User
require.NoError(t, db.First(&stored, user.Id).Error)
assert.Equal(t, previousLastLoginAt, stored.LastLoginAt)
}
-106
View File
@@ -1,106 +0,0 @@
// 用于迁移检测的旧键,该文件下个版本会删除
package controller
import (
"encoding/json"
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
// MigrateConsoleSetting 迁移旧的控制台相关配置到 console_setting.*
func MigrateConsoleSetting(c *gin.Context) {
// 读取全部 option
opts, err := model.AllOption()
if err != nil {
common.SysError("failed to get all options: " + err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "获取配置失败,请稍后重试"})
return
}
// 建立 map
valMap := map[string]string{}
for _, o := range opts {
valMap[o.Key] = o.Value
}
// 处理 APIInfo
if v := valMap["ApiInfo"]; v != "" {
var arr []map[string]interface{}
if err := json.Unmarshal([]byte(v), &arr); err == nil {
if len(arr) > 50 {
arr = arr[:50]
}
bytes, _ := json.Marshal(arr)
model.UpdateOption("console_setting.api_info", string(bytes))
}
model.UpdateOption("ApiInfo", "")
}
// Announcements 直接搬
if v := valMap["Announcements"]; v != "" {
model.UpdateOption("console_setting.announcements", v)
model.UpdateOption("Announcements", "")
}
// FAQ 转换
if v := valMap["FAQ"]; v != "" {
var arr []map[string]interface{}
if err := json.Unmarshal([]byte(v), &arr); err == nil {
out := []map[string]interface{}{}
for _, item := range arr {
q, _ := item["question"].(string)
if q == "" {
q, _ = item["title"].(string)
}
a, _ := item["answer"].(string)
if a == "" {
a, _ = item["content"].(string)
}
if q != "" && a != "" {
out = append(out, map[string]interface{}{"question": q, "answer": a})
}
}
if len(out) > 50 {
out = out[:50]
}
bytes, _ := json.Marshal(out)
model.UpdateOption("console_setting.faq", string(bytes))
}
model.UpdateOption("FAQ", "")
}
// Uptime Kuma 迁移到新的 groups 结构(console_setting.uptime_kuma_groups
url := valMap["UptimeKumaUrl"]
slug := valMap["UptimeKumaSlug"]
if url != "" && slug != "" {
// 仅当同时存在 URL 与 Slug 时才进行迁移
groups := []map[string]interface{}{
{
"id": 1,
"categoryName": "old",
"url": url,
"slug": slug,
"description": "",
},
}
bytes, _ := json.Marshal(groups)
model.UpdateOption("console_setting.uptime_kuma_groups", string(bytes))
}
// 清空旧键内容
if url != "" {
model.UpdateOption("UptimeKumaUrl", "")
}
if slug != "" {
model.UpdateOption("UptimeKumaSlug", "")
}
// 删除旧键记录
oldKeys := []string{"ApiInfo", "Announcements", "FAQ", "UptimeKumaUrl", "UptimeKumaSlug"}
model.DB.Where("key IN ?", oldKeys).Delete(&model.Option{})
// 重新加载 OptionMap
model.InitOptionMap()
common.SysLog("console setting migrated")
c.JSON(http.StatusOK, gin.H{"success": true, "message": "migrated"})
}
-26
View File
@@ -149,29 +149,3 @@ func GetLogsSelfStat(c *gin.Context) {
})
return
}
// DeleteHistoryLogs is the legacy synchronous log cleanup endpoint (DELETE /api/log/).
// It deletes directly instead of going through the async system task. It is kept only
// for the classic frontend; the default frontend uses POST /api/system-task/log-cleanup.
// TODO: remove this handler (and its route) once the classic frontend is removed.
func DeleteHistoryLogs(c *gin.Context) {
targetTimestamp, _ := strconv.ParseInt(c.Query("target_timestamp"), 10, 64)
if targetTimestamp == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "target timestamp is required",
})
return
}
count, err := model.DeleteOldLog(c.Request.Context(), targetTimestamp, 100)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": count,
})
return
}
+1 -1
View File
@@ -63,7 +63,7 @@ func GetStatus(c *gin.Context) {
"linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel,
"telegram_oauth": common.TelegramOAuthEnabled,
"telegram_bot_name": common.TelegramBotName,
"theme": system_setting.GetThemeSettings().Frontend,
"theme": "default",
"system_name": common.SystemName,
"logo": common.Logo,
"footer_html": common.Footer,
+1 -5
View File
@@ -14,8 +14,6 @@ import (
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/config"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
@@ -417,7 +415,7 @@ func TestCheckUpdatePasswordRejectsHistoricalEmptyPassword(t *testing.T) {
func TestSetupLoginDoesNotTouchPasswordWhenPasswordFieldOmitted(t *testing.T) {
db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.Log{}))
require.NoError(t, db.AutoMigrate(&model.Log{}, &model.UserSession{}))
hashedPassword, err := common.Password2Hash("CurrentPassword123")
require.NoError(t, err)
@@ -431,8 +429,6 @@ func TestSetupLoginDoesNotTouchPasswordWhenPasswordFieldOmitted(t *testing.T) {
require.NoError(t, db.Create(user).Error)
router := gin.New()
store := cookie.NewStore([]byte("test-session-secret"))
router.Use(sessions.Sessions("session", store))
router.GET("/", func(c *gin.Context) {
setupLogin(&model.User{
Id: user.Id,
+121 -31
View File
@@ -5,16 +5,30 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const oauthAuthFlowTTL = 10 * time.Minute
type oauthStateRequest struct {
Provider string `json:"provider"`
Intent string `json:"intent"`
Aff string `json:"aff,omitempty"`
}
type oauthFlowPayload struct {
AffiliateCode string `json:"affiliate_code,omitempty"`
}
// providerParams returns map with Provider key for i18n templates
func providerParams(name string) map[string]any {
return map[string]any{"Provider": name}
@@ -22,14 +36,47 @@ func providerParams(name string) map[string]any {
// GenerateOAuthCode generates a state code for OAuth CSRF protection
func GenerateOAuthCode(c *gin.Context) {
session := sessions.Default(c)
state := common.GetRandomString(12)
affCode := c.Query("aff")
if affCode != "" {
session.Set("aff", affCode)
var request oauthStateRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
session.Set("oauth_state", state)
err := session.Save()
request.Provider = strings.TrimSpace(request.Provider)
request.Intent = strings.TrimSpace(request.Intent)
request.Aff = strings.TrimSpace(request.Aff)
if oauth.GetProvider(request.Provider) == nil ||
(request.Intent != model.AuthFlowIntentLogin && request.Intent != model.AuthFlowIntentBind) ||
len(request.Aff) > 32 ||
(request.Intent == model.AuthFlowIntentBind && request.Aff != "") {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
userID := 0
sessionID := ""
if request.Intent == model.AuthFlowIntentBind {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "绑定操作需要登录"})
return
}
userID = identity.UserID
sessionID = identity.SessionID
}
payload, err := common.Marshal(oauthFlowPayload{AffiliateCode: request.Aff})
if err != nil {
common.ApiError(c, err)
return
}
expiresAt := time.Now().Add(oauthAuthFlowTTL)
state, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeOAuth,
Provider: request.Provider,
Intent: request.Intent,
UserId: userID,
SessionId: sessionID,
Payload: string(payload),
ExpiresAt: expiresAt,
})
if err != nil {
common.ApiError(c, err)
return
@@ -37,7 +84,10 @@ func GenerateOAuthCode(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": state,
"data": gin.H{
"flow_token": state,
"expires_at": expiresAt.Unix(),
},
})
}
@@ -53,11 +103,13 @@ func HandleOAuth(c *gin.Context) {
return
}
session := sessions.Default(c)
// 1. Validate state (CSRF protection)
state := c.Query("state")
if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
pendingFlow, err := model.GetAuthFlow(state, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth,
Provider: providerName,
})
if err != nil {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": i18n.T(c, i18n.MsgOAuthStateInvalid),
@@ -65,10 +117,25 @@ func HandleOAuth(c *gin.Context) {
return
}
// 2. Check if user is already logged in (bind flow)
username := session.Get("username")
if username != nil {
handleOAuthBind(c, provider)
consumeMatch := model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth,
Provider: providerName,
Intent: pendingFlow.Intent,
}
// 2. Bind flows are bound to the live dashboard Session that created them.
if pendingFlow.Intent == model.AuthFlowIntentBind {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok || identity.UserID != pendingFlow.UserId || identity.SessionID != pendingFlow.SessionId {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": i18n.T(c, i18n.MsgOAuthStateInvalid),
})
return
}
consumeMatch.UserId = identity.UserID
consumeMatch.SessionId = identity.SessionID
} else if pendingFlow.Intent != model.AuthFlowIntentLogin {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
@@ -81,13 +148,24 @@ func HandleOAuth(c *gin.Context) {
// 4. Handle error from provider
errorCode := c.Query("error")
if errorCode != "" {
if _, err := model.ConsumeAuthFlow(state, consumeMatch); err != nil {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)})
return
}
errorDescription := c.Query("error_description")
if errorDescription == "" {
errorDescription = errorCode
}
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": errorDescription,
})
return
}
if pendingFlow.Intent == model.AuthFlowIntentBind {
handleOAuthBind(c, provider, pendingFlow, state)
return
}
// 5. Exchange code for token
code := c.Query("code")
@@ -103,9 +181,19 @@ func HandleOAuth(c *gin.Context) {
handleOAuthError(c, err)
return
}
flow, err := model.ConsumeAuthFlow(state, consumeMatch)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)})
return
}
// 7. Find or create user
user, err := findOrCreateOAuthUser(c, provider, oauthUser, session)
var payload oauthFlowPayload
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
common.ApiError(c, err)
return
}
user, err := findOrCreateOAuthUser(c, provider, oauthUser, payload.AffiliateCode)
if err != nil {
if errors.Is(err, model.ErrEmailAlreadyTaken) {
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
@@ -135,12 +223,7 @@ func HandleOAuth(c *gin.Context) {
}
// handleOAuthBind handles binding OAuth account to existing user
func handleOAuthBind(c *gin.Context, provider oauth.Provider) {
if !provider.IsEnabled() {
common.ApiErrorI18n(c, i18n.MsgOAuthNotEnabled, providerParams(provider.GetName()))
return
}
func handleOAuthBind(c *gin.Context, provider oauth.Provider, pendingFlow *model.AuthFlow, flowToken string) {
// Exchange code for token
code := c.Query("code")
token, err := provider.ExchangeToken(c.Request.Context(), code, c)
@@ -169,10 +252,18 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider) {
}
}
// Get current user from session
session := sessions.Default(c)
id := session.Get("id")
user := model.User{Id: id.(int)}
if _, err := model.ConsumeAuthFlow(flowToken, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth,
Provider: pendingFlow.Provider,
Intent: model.AuthFlowIntentBind,
UserId: pendingFlow.UserId,
SessionId: pendingFlow.SessionId,
}); err != nil {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)})
return
}
user := model.User{Id: pendingFlow.UserId}
err = user.FillUserById()
if err != nil {
common.ApiError(c, err)
@@ -203,7 +294,7 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider) {
}
// findOrCreateOAuthUser finds existing user or creates new user
func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, session sessions.Session) (*model.User, error) {
func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, affiliateCode string) (*model.User, error) {
user := &model.User{}
// Check if user already exists with new ID
@@ -276,10 +367,9 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
user.Status = common.UserStatusEnabled
// Handle affiliate code
affCode := session.Get("aff")
inviterId := 0
if affCode != nil {
inviterId, _ = model.GetUserIdByAffCode(affCode.(string))
if affiliateCode != "" {
inviterId, _ = model.GetUserIdByAffCode(affiliateCode)
}
// Use transaction to ensure user creation and OAuth binding are atomic
+5 -2
View File
@@ -80,6 +80,9 @@ func GetOptions(c *gin.Context) {
optionValues := make(map[string]string)
common.OptionMapRWMutex.Lock()
for k, v := range common.OptionMap {
if k == "theme.frontend" {
continue
}
value := common.Interface2String(v)
isSensitiveKey := strings.HasSuffix(k, "Token") ||
strings.HasSuffix(k, "Secret") ||
@@ -216,10 +219,10 @@ func UpdateOption(c *gin.Context) {
return
}
case "theme.frontend":
if option.Value != "default" && option.Value != "classic" {
if option.Value != "default" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的主题值,可选值:default(新版前端)、classic(经典前端)",
"message": "Classic 前端已移除,主题只能设置为 default",
})
return
}
+192 -73
View File
@@ -1,6 +1,7 @@
package controller
import (
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -8,16 +9,43 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
passkeysvc "github.com/QuantumNous/new-api/service/passkey"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
webauthnlib "github.com/go-webauthn/webauthn/webauthn"
)
const (
securityProofScopeChannelKeyRead = "channel.key.read"
securityProofScopePasskeyRegister = "passkey.register"
securityProofScopePasskeyDelete = "passkey.delete"
)
type passkeyFinishRequest struct {
FlowToken string `json:"flow_token"`
Credential json.RawMessage `json:"credential"`
}
type passkeyVerifyBeginRequest struct {
Scope string `json:"scope"`
}
func parsePasskeyFinishRequest(c *gin.Context) (*passkeyFinishRequest, error) {
var request passkeyFinishRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
return nil, err
}
if request.FlowToken == "" || len(request.Credential) == 0 {
return nil, errors.New("Passkey 流程参数不完整")
}
return &request, nil
}
func PasskeyRegisterBegin(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
@@ -27,7 +55,7 @@ func PasskeyRegisterBegin(c *gin.Context) {
return
}
user, err := getSessionUser(c)
user, err := getAuthenticatedUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
@@ -68,7 +96,19 @@ func PasskeyRegisterBegin(c *gin.Context) {
return
}
if err := passkeysvc.SaveSessionData(c, passkeysvc.RegistrationSessionKey, sessionData); err != nil {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
return
}
flowToken, expiresAt, err := passkeysvc.CreateSessionDataFlow(
model.AuthFlowPurposePasskeyRegister,
user.Id,
identity.SessionID,
securityProofScopePasskeyRegister,
sessionData,
)
if err != nil {
common.ApiError(c, err)
return
}
@@ -77,7 +117,9 @@ func PasskeyRegisterBegin(c *gin.Context) {
"success": true,
"message": "",
"data": gin.H{
"options": creation,
"options": creation,
"flow_token": flowToken,
"expires_at": expiresAt,
},
})
}
@@ -91,7 +133,7 @@ func PasskeyRegisterFinish(c *gin.Context) {
return
}
user, err := getSessionUser(c)
user, err := getAuthenticatedUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
@@ -99,11 +141,21 @@ func PasskeyRegisterFinish(c *gin.Context) {
})
return
}
if !requirePasskeyRegistrationVerification(c, user.Id) {
return
}
request, err := parsePasskeyFinishRequest(c)
if err != nil {
common.ApiError(c, err)
return
}
parsedCredential, err := protocol.ParseCredentialCreationResponseBytes(request.Credential)
if err != nil {
common.ApiError(c, err)
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
@@ -119,14 +171,24 @@ func PasskeyRegisterFinish(c *gin.Context) {
credentialRecord = nil
}
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.RegistrationSessionKey)
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
return
}
sessionData, _, err := passkeysvc.PopSessionDataFlow(
request.FlowToken,
model.AuthFlowPurposePasskeyRegister,
user.Id,
identity.SessionID,
)
if err != nil {
common.ApiError(c, err)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credentialRecord)
credential, err := wa.FinishRegistration(waUser, *sessionData, c.Request)
credential, err := wa.CreateCredential(waUser, *sessionData, parsedCredential)
if err != nil {
common.ApiError(c, err)
return
@@ -138,7 +200,12 @@ func PasskeyRegisterFinish(c *gin.Context) {
return
}
if err := model.UpsertPasskeyCredential(passkeyCredential); err != nil {
if err := model.UpsertPasskeyCredentialWithAuthVersion(passkeyCredential); err != nil {
common.ApiError(c, err)
return
}
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "passkey_registered")
if err != nil {
common.ApiError(c, err)
return
}
@@ -147,11 +214,12 @@ func PasskeyRegisterFinish(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 注册成功",
"data": authRotationData(bundle),
})
}
func PasskeyDelete(c *gin.Context) {
user, err := getSessionUser(c)
user, err := getAuthenticatedUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
@@ -164,7 +232,17 @@ func PasskeyDelete(c *gin.Context) {
return
}
if err := model.DeletePasskeyByUserID(user.Id); err != nil {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
return
}
if err := model.DeletePasskeyByUserIDWithAuthVersion(user.Id); err != nil {
common.ApiError(c, err)
return
}
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "passkey_deleted")
if err != nil {
common.ApiError(c, err)
return
}
@@ -173,11 +251,12 @@ func PasskeyDelete(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 已解绑",
"data": authRotationData(bundle),
})
}
func PasskeyStatus(c *gin.Context) {
user, err := getSessionUser(c)
user, err := getAuthenticatedUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
@@ -235,7 +314,14 @@ func PasskeyLoginBegin(c *gin.Context) {
return
}
if err := passkeysvc.SaveSessionData(c, passkeysvc.LoginSessionKey, sessionData); err != nil {
flowToken, expiresAt, err := passkeysvc.CreateSessionDataFlow(
model.AuthFlowPurposePasskeyLogin,
0,
"",
"",
sessionData,
)
if err != nil {
common.ApiError(c, err)
return
}
@@ -244,7 +330,9 @@ func PasskeyLoginBegin(c *gin.Context) {
"success": true,
"message": "",
"data": gin.H{
"options": assertion,
"options": assertion,
"flow_token": flowToken,
"expires_at": expiresAt,
},
})
}
@@ -258,13 +346,29 @@ func PasskeyLoginFinish(c *gin.Context) {
return
}
request, err := parsePasskeyFinishRequest(c)
if err != nil {
common.ApiError(c, err)
return
}
parsedCredential, err := protocol.ParseCredentialRequestResponseBytes(request.Credential)
if err != nil {
common.ApiError(c, err)
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
return
}
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.LoginSessionKey)
sessionData, _, err := passkeysvc.PopSessionDataFlow(
request.FlowToken,
model.AuthFlowPurposePasskeyLogin,
0,
"",
)
if err != nil {
common.ApiError(c, err)
return
@@ -300,7 +404,7 @@ func PasskeyLoginFinish(c *gin.Context) {
return passkeysvc.NewWebAuthnUser(user, credential), nil
}
waUser, credential, err := wa.FinishPasskeyLogin(handler, *sessionData, c.Request)
waUser, credential, err := wa.ValidatePasskeyLogin(handler, *sessionData, parsedCredential)
if err != nil {
common.ApiError(c, err)
return
@@ -323,15 +427,7 @@ func PasskeyLoginFinish(c *gin.Context) {
return
}
// 更新凭证信息
updatedCredential := model.NewPasskeyCredentialFromWebAuthn(modelUser.Id, credential)
if updatedCredential == nil {
common.ApiErrorMsg(c, "Passkey 凭证更新失败")
return
}
now := time.Now()
updatedCredential.LastUsedAt = &now
if err := model.UpsertPasskeyCredential(updatedCredential); err != nil {
if err := model.UpdatePasskeyAssertionState(modelUser.Id, credential, time.Now()); err != nil {
common.ApiError(c, err)
return
}
@@ -369,7 +465,11 @@ func AdminResetPasskey(c *gin.Context) {
return
}
if err := model.DeletePasskeyByUserID(user.Id); err != nil {
if err := model.DeletePasskeyByUserIDWithAuthVersion(user.Id); err != nil {
common.ApiError(c, err)
return
}
if _, err := model.RevokeAllUserSessions(user.Id, "admin_passkey_reset"); err != nil {
common.ApiError(c, err)
return
}
@@ -393,7 +493,7 @@ func PasskeyVerifyBegin(c *gin.Context) {
return
}
user, err := getSessionUser(c)
user, err := getAuthenticatedUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
@@ -401,6 +501,15 @@ func PasskeyVerifyBegin(c *gin.Context) {
})
return
}
var request passkeyVerifyBeginRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
common.ApiError(c, errors.New("无效的 Passkey 验证请求"))
return
}
if !isAllowedSecurityProofScope(request.Scope) {
common.ApiError(c, errors.New("不支持的安全验证范围"))
return
}
credential, err := model.GetPasskeyByUserID(user.Id)
if err != nil {
@@ -424,7 +533,19 @@ func PasskeyVerifyBegin(c *gin.Context) {
return
}
if err := passkeysvc.SaveSessionData(c, passkeysvc.VerifySessionKey, sessionData); err != nil {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
return
}
flowToken, expiresAt, err := passkeysvc.CreateSessionDataFlow(
model.AuthFlowPurposePasskeyStepUp,
user.Id,
identity.SessionID,
request.Scope,
sessionData,
)
if err != nil {
common.ApiError(c, err)
return
}
@@ -433,7 +554,9 @@ func PasskeyVerifyBegin(c *gin.Context) {
"success": true,
"message": "",
"data": gin.H{
"options": assertion,
"options": assertion,
"flow_token": flowToken,
"expires_at": expiresAt,
},
})
}
@@ -447,7 +570,7 @@ func PasskeyVerifyFinish(c *gin.Context) {
return
}
user, err := getSessionUser(c)
user, err := getAuthenticatedUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
@@ -456,6 +579,17 @@ func PasskeyVerifyFinish(c *gin.Context) {
return
}
request, err := parsePasskeyFinishRequest(c)
if err != nil {
common.ApiError(c, err)
return
}
parsedCredential, err := protocol.ParseCredentialRequestResponseBytes(request.Credential)
if err != nil {
common.ApiError(c, err)
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
@@ -471,53 +605,57 @@ func PasskeyVerifyFinish(c *gin.Context) {
return
}
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey)
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
return
}
sessionData, scope, err := passkeysvc.PopSessionDataFlow(
request.FlowToken,
model.AuthFlowPurposePasskeyStepUp,
user.Id,
identity.SessionID,
)
if err != nil {
common.ApiError(c, err)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credential)
_, err = wa.FinishLogin(waUser, *sessionData, c.Request)
validatedCredential, err := wa.ValidateLogin(waUser, *sessionData, parsedCredential)
if err != nil {
common.ApiError(c, err)
return
}
// 更新凭证的最后使用时间
now := time.Now()
credential.LastUsedAt = &now
if err := model.UpsertPasskeyCredential(credential); err != nil {
if err := model.UpdatePasskeyAssertionState(user.Id, validatedCredential, time.Now()); err != nil {
common.ApiError(c, err)
return
}
session := sessions.Default(c)
// Mark passkey as ready; /api/verify will convert this into the final secure verification session.
session.Set(PasskeyReadySessionKey, time.Now().Unix())
session.Delete(SecureVerificationSessionKey)
session.Delete(secureVerificationMethodSessionKey)
if err := session.Save(); err != nil {
common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
proofToken, proofExpiresAt, err := service.IssueSecurityProof(identity, secureVerificationMethodPasskey, []string{scope})
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 验证成功",
"data": gin.H{
"proof_token": proofToken,
"expires_at": proofExpiresAt,
"method": secureVerificationMethodPasskey,
"scope": scope,
},
})
}
func getSessionUser(c *gin.Context) (*model.User, error) {
session := sessions.Default(c)
idRaw := session.Get("id")
if idRaw == nil {
func getAuthenticatedUser(c *gin.Context) (*model.User, error) {
id := c.GetInt("id")
if id == 0 {
return nil, errors.New("未登录")
}
id, ok := idRaw.(int)
if !ok {
return nil, errors.New("无效的会话信息")
}
user := &model.User{Id: id}
if err := user.FillUserById(); err != nil {
return nil, err
@@ -537,7 +675,7 @@ func requirePasskeyRegistrationVerification(c *gin.Context, userID int) bool {
if twoFA == nil || !twoFA.IsEnabled {
return true
}
return requireSecureVerificationMethod(c, secureVerificationMethod2FA)
return middleware.RequireSecurityProof(c, securityProofScopePasskeyRegister, []string{secureVerificationMethod2FA})
}
func requirePasskeyDeleteVerification(c *gin.Context, userID int) bool {
@@ -547,7 +685,7 @@ func requirePasskeyDeleteVerification(c *gin.Context, userID int) bool {
return false
}
if twoFA != nil && twoFA.IsEnabled {
return requireSecureVerificationMethod(c, secureVerificationMethod2FA)
return middleware.RequireSecurityProof(c, securityProofScopePasskeyDelete, []string{secureVerificationMethod2FA})
}
_, err = model.GetPasskeyByUserID(userID)
@@ -563,24 +701,5 @@ func requirePasskeyDeleteVerification(c *gin.Context, userID int) bool {
return false
}
return requireSecureVerificationMethod(c, secureVerificationMethodPasskey)
}
func requireSecureVerificationMethod(c *gin.Context, method string) bool {
session := sessions.Default(c)
verifiedAt, ok := session.Get(SecureVerificationSessionKey).(int64)
if !ok || time.Now().Unix()-verifiedAt >= SecureVerificationTimeout {
session.Delete(SecureVerificationSessionKey)
session.Delete(secureVerificationMethodSessionKey)
_ = session.Save()
common.ApiErrorMsg(c, "请先完成安全验证")
return false
}
if verifiedMethod, ok := session.Get(secureVerificationMethodSessionKey).(string); !ok || verifiedMethod != method {
common.ApiErrorMsg(c, "请先完成对应的安全验证")
return false
}
return true
return middleware.RequireSecurityProof(c, securityProofScopePasskeyDelete, []string{secureVerificationMethodPasskey})
}
+130
View File
@@ -0,0 +1,130 @@
package controller
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type passkeyTestBody struct {
*strings.Reader
}
func (*passkeyTestBody) Close() error { return nil }
func TestParsePasskeyFinishRequestDoesNotRewriteRequestBody(t *testing.T) {
gin.SetMode(gin.TestMode)
bodyText := `{"flow_token":"flow-1","credential":{"id":"credential-1"}}`
body := &passkeyTestBody{Reader: strings.NewReader(bodyText)}
request := httptest.NewRequest(http.MethodPost, "/api/user/passkey/register/finish", nil)
request.Body = body
request.ContentLength = int64(len(bodyText))
context, _ := gin.CreateTestContext(httptest.NewRecorder())
context.Request = request
parsed, err := parsePasskeyFinishRequest(context)
require.NoError(t, err)
assert.Equal(t, "flow-1", parsed.FlowToken)
assert.JSONEq(t, `{"id":"credential-1"}`, string(parsed.Credential))
assert.Same(t, body, context.Request.Body)
assert.Equal(t, int64(len(bodyText)), context.Request.ContentLength)
}
func TestPasskeyRegisterFinishRejectsMissingOrWrongProofWithoutConsumingFlow(t *testing.T) {
previousDB := model.DB
previousType := common.MainDatabaseType()
previousRedis := common.RedisEnabled
previousSecret := common.SessionSecret
settings := system_setting.GetPasskeySettings()
previousSettings := *settings
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.TwoFA{}, &model.AuthFlow{}))
model.DB = db
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
common.RedisEnabled = false
common.SessionSecret = "passkey-register-proof-test-secret"
*settings = system_setting.PasskeySettings{Enabled: true}
t.Cleanup(func() {
model.DB = previousDB
common.SetMainDatabaseType(previousType)
common.RedisEnabled = previousRedis
common.SessionSecret = previousSecret
*settings = previousSettings
sqlDB, dbErr := db.DB()
if dbErr == nil {
_ = sqlDB.Close()
}
})
user := &model.User{
Username: "passkey-proof-user", Password: "password-placeholder", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1,
}
require.NoError(t, db.Create(user).Error)
require.NoError(t, db.Create(&model.TwoFA{UserId: user.Id, Secret: "totp-secret", IsEnabled: true}).Error)
identity := service.AuthIdentity{
UserID: user.Id, SessionID: "passkey-proof-session", UserAuthVersion: 1, SessionVersion: 1,
}
wrongScopeProof, _, err := service.IssueSecurityProof(identity, secureVerificationMethod2FA, []string{securityProofScopePasskeyDelete})
require.NoError(t, err)
tests := []struct {
name string
proof string
expectedCode string
}{
{name: "missing proof", expectedCode: "SECURITY_PROOF_REQUIRED"},
{name: "wrong scope proof", proof: wrongScopeProof, expectedCode: "SECURITY_PROOF_SCOPE_MISMATCH"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposePasskeyRegister, UserId: user.Id, SessionId: identity.SessionID,
Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
body := fmt.Sprintf(`{"flow_token":%q,"credential":{}}`, flowToken)
request := httptest.NewRequest(http.MethodPost, "/api/user/passkey/register/finish", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
if test.proof != "" {
request.Header.Set("X-Security-Proof", test.proof)
}
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = request
context.Set("id", identity.UserID)
context.Set("session_id", identity.SessionID)
context.Set("auth_version", identity.UserAuthVersion)
context.Set("session_version", identity.SessionVersion)
PasskeyRegisterFinish(context)
assert.Equal(t, http.StatusForbidden, response.Code)
var responseBody struct {
Code string `json:"code"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &responseBody))
assert.Equal(t, test.expectedCode, responseBody.Code)
flow, err := model.GetAuthFlow(flowToken, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposePasskeyRegister, UserId: user.Id, SessionId: identity.SessionID,
})
require.NoError(t, err)
assert.Nil(t, flow.ConsumedAt)
})
}
}
+1 -2
View File
@@ -3,11 +3,10 @@ package controller
import (
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/system_setting"
)
func paymentReturnPath(suffix string) string {
base := strings.TrimRight(system_setting.ServerAddress, "/")
return base + common.ThemeAwarePath(suffix)
return base + suffix
}
+25
View File
@@ -0,0 +1,25 @@
package controller
import (
"testing"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/stretchr/testify/assert"
)
func TestPaymentReturnPathUsesDefaultDashboardRoutes(t *testing.T) {
previousAddress := system_setting.ServerAddress
system_setting.ServerAddress = "https://dashboard.example.com/"
t.Cleanup(func() { system_setting.ServerAddress = previousAddress })
assert.Equal(
t,
"https://dashboard.example.com/wallet?pay=success",
paymentReturnPath("/wallet?pay=success"),
)
assert.Equal(
t,
"https://dashboard.example.com/usage-logs",
paymentReturnPath("/usage-logs"),
)
}
+45 -136
View File
@@ -1,179 +1,88 @@
package controller
import (
"errors"
"fmt"
"net/http"
"time"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/gin-contrib/sessions"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
const (
// SecureVerificationSessionKey means the user has fully passed secure verification.
SecureVerificationSessionKey = "secure_verified_at"
secureVerificationMethodSessionKey = "secure_verified_method"
secureVerificationMethod2FA = "2fa"
secureVerificationMethodPasskey = "passkey"
// PasskeyReadySessionKey means WebAuthn finished and /api/verify can finalize step-up verification.
PasskeyReadySessionKey = "secure_passkey_ready_at"
// SecureVerificationTimeout 验证有效期(秒)
SecureVerificationTimeout = 300 // 5分钟
// PasskeyReadyTimeout passkey ready 标记有效期(秒)
PasskeyReadyTimeout = 60
secureVerificationMethod2FA = "2fa"
secureVerificationMethodPasskey = "passkey"
)
type UniversalVerifyRequest struct {
Method string `json:"method"` // "2fa" 或 "passkey"
Method string `json:"method"`
Code string `json:"code,omitempty"`
Scope string `json:"scope"`
}
type VerificationStatusResponse struct {
Verified bool `json:"verified"`
ExpiresAt int64 `json:"expires_at,omitempty"`
}
// UniversalVerify 通用验证接口
// 支持 2FA 和 Passkey 验证,验证成功后在 session 中记录时间戳
func UniversalVerify(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "当前认证方式不支持安全验证"})
return
}
var req UniversalVerifyRequest
if err := c.ShouldBindJSON(&req); err != nil {
var request UniversalVerifyRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
common.ApiError(c, fmt.Errorf("参数错误: %v", err))
return
}
// 获取用户信息
user := &model.User{Id: userId}
if err := user.FillUserById(); err != nil {
common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err))
if request.Method != secureVerificationMethod2FA {
common.ApiError(c, errors.New("Passkey 验证必须使用 Passkey verify 流程"))
return
}
if user.Status != common.UserStatusEnabled {
common.ApiError(c, fmt.Errorf("该用户已被禁用"))
if !isAllowedSecurityProofScope(request.Scope) {
common.ApiError(c, errors.New("不支持的安全验证范围"))
return
}
// 检查用户的验证方式
twoFA, _ := model.GetTwoFAByUserId(userId)
has2FA := twoFA != nil && twoFA.IsEnabled
passkey, passkeyErr := model.GetPasskeyByUserID(userId)
hasPasskey := passkeyErr == nil && passkey != nil
if !has2FA && !hasPasskey {
common.ApiError(c, fmt.Errorf("用户未启用2FA或Passkey"))
if strings.TrimSpace(request.Code) == "" {
common.ApiError(c, errors.New("验证码不能为空"))
return
}
// 根据验证方式进行验证
var verified bool
var verifyMethod string
var err error
switch req.Method {
case "2fa":
if !has2FA {
common.ApiError(c, fmt.Errorf("用户未启用2FA"))
return
}
if req.Code == "" {
common.ApiError(c, fmt.Errorf("验证码不能为空"))
return
}
verified = validateTwoFactorAuth(twoFA, req.Code)
verifyMethod = "2FA"
case "passkey":
if !hasPasskey {
common.ApiError(c, fmt.Errorf("用户未启用Passkey"))
return
}
// Passkey branch only trusts the short-lived marker written by PasskeyVerifyFinish.
verified, err = consumePasskeyReady(c)
if err != nil {
common.ApiError(c, fmt.Errorf("Passkey 验证状态异常: %v", err))
return
}
if !verified {
common.ApiError(c, fmt.Errorf("请先完成 Passkey 验证"))
return
}
verifyMethod = "Passkey"
default:
common.ApiError(c, fmt.Errorf("不支持的验证方式: %s", req.Method))
return
}
if !verified {
common.ApiError(c, fmt.Errorf("验证失败,请检查验证码"))
return
}
// 验证成功,在 session 中记录时间戳
now, err := setSecureVerificationSession(c, req.Method)
twoFA, err := model.GetTwoFAByUserId(identity.UserID)
if err != nil {
common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
common.ApiError(c, err)
return
}
// 记录日志
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("通用安全验证成功 (验证方式: %s)", verifyMethod))
if twoFA == nil || !twoFA.IsEnabled {
common.ApiError(c, errors.New("用户未启用2FA"))
return
}
if !validateTwoFactorAuth(twoFA, request.Code) {
common.ApiError(c, errors.New("验证失败,请检查验证码"))
return
}
proofToken, expiresAt, err := service.IssueSecurityProof(identity, request.Method, []string{request.Scope})
if err != nil {
common.ApiError(c, err)
return
}
model.RecordLog(identity.UserID, model.LogTypeSystem, "通用安全验证成功 (验证方式: 2FA)")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "验证成功",
"data": gin.H{
"verified": true,
"expires_at": now + SecureVerificationTimeout,
"proof_token": proofToken,
"expires_at": expiresAt,
"method": request.Method,
"scope": request.Scope,
},
})
}
func setSecureVerificationSession(c *gin.Context, method string) (int64, error) {
session := sessions.Default(c)
session.Delete(PasskeyReadySessionKey)
now := time.Now().Unix()
session.Set(SecureVerificationSessionKey, now)
session.Set(secureVerificationMethodSessionKey, method)
if err := session.Save(); err != nil {
return 0, err
func isAllowedSecurityProofScope(scope string) bool {
switch scope {
case securityProofScopeChannelKeyRead, securityProofScopePasskeyRegister, securityProofScopePasskeyDelete:
return true
default:
return false
}
return now, nil
}
func consumePasskeyReady(c *gin.Context) (bool, error) {
session := sessions.Default(c)
readyAtRaw := session.Get(PasskeyReadySessionKey)
if readyAtRaw == nil {
return false, nil
}
readyAt, ok := readyAtRaw.(int64)
if !ok {
session.Delete(PasskeyReadySessionKey)
_ = session.Save()
return false, fmt.Errorf("无效的 Passkey 验证状态")
}
session.Delete(PasskeyReadySessionKey)
if err := session.Save(); err != nil {
return false, err
}
// Expired ready markers cannot be reused.
if time.Now().Unix()-readyAt >= PasskeyReadyTimeout {
return false, nil
}
return true, nil
}
+7 -7
View File
@@ -176,7 +176,7 @@ func SubscriptionEpayReturn(c *gin.Context) {
if c.Request.Method == "POST" {
// POST 请求:从 POST body 解析参数
if err := c.Request.ParseForm(); err != nil {
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=fail"))
return
}
params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
@@ -192,29 +192,29 @@ func SubscriptionEpayReturn(c *gin.Context) {
}
if len(params) == 0 {
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=fail"))
return
}
client := GetEpayClient()
if client == nil {
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=fail"))
return
}
verifyInfo, err := client.Verify(params)
if err != nil || !verifyInfo.VerifyStatus {
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=fail"))
return
}
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
LockOrder(verifyInfo.ServiceTradeNo)
defer UnlockOrder(verifyInfo.ServiceTradeNo)
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo), model.PaymentProviderEpay, verifyInfo.Type); err != nil {
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=fail"))
return
}
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=success"))
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=success"))
return
}
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=pending"))
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=pending"))
}
+2 -2
View File
@@ -114,8 +114,8 @@ func genStripeSubscriptionLink(referenceId string, customerId string, email stri
params := &stripe.CheckoutSessionParams{
ClientReferenceID: stripe.String(referenceId),
SuccessURL: stripe.String(paymentReturnPath("/console/topup")),
CancelURL: stripe.String(paymentReturnPath("/console/topup")),
SuccessURL: stripe.String(paymentReturnPath("/wallet")),
CancelURL: stripe.String(paymentReturnPath("/wallet")),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(priceId),
+222 -38
View File
@@ -13,10 +13,12 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const (
@@ -24,61 +26,211 @@ const (
// so captured callbacks cannot be reused indefinitely.
telegramAuthorizationMaxAge = 5 * time.Minute
telegramAuthorizationFutureSkew = 2 * time.Minute
telegramBindFlowTTL = 5 * time.Minute
telegramBindErrorDisabled = "TELEGRAM_BIND_DISABLED"
telegramBindErrorInvalidRequest = "TELEGRAM_BIND_INVALID_REQUEST"
telegramBindErrorFlowInvalid = "TELEGRAM_BIND_FLOW_INVALID"
telegramBindErrorSessionInvalid = "TELEGRAM_BIND_SESSION_INVALID"
telegramBindErrorAlreadyBound = "TELEGRAM_BIND_ALREADY_BOUND"
telegramBindErrorUserDeleted = "TELEGRAM_BIND_USER_DELETED"
telegramBindErrorUserDisabled = "TELEGRAM_BIND_USER_DISABLED"
telegramBindErrorInternal = "TELEGRAM_BIND_INTERNAL_ERROR"
)
func TelegramBind(c *gin.Context) {
var (
errTelegramAccountAlreadyBound = errors.New("telegram account is already bound")
errTelegramBindAssertionInvalid = errors.New("telegram bind assertion is invalid")
errTelegramBindUserDeleted = errors.New("telegram bind user was deleted")
errTelegramBindUserDisabled = errors.New("telegram bind user is disabled")
)
func TelegramBindStart(c *gin.Context) {
if !common.TelegramOAuthEnabled {
c.JSON(200, gin.H{
c.JSON(http.StatusOK, gin.H{
"message": "管理员未开启通过 Telegram 登录以及注册",
"success": false,
})
return
}
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
expiresAt := time.Now().Add(telegramBindFlowTTL)
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeTelegramBind,
UserId: identity.UserID,
SessionId: identity.SessionID,
ExpiresAt: expiresAt,
})
if err != nil {
common.ApiError(c, err)
return
}
callbackURL := "/api/oauth/telegram/bind/" + flowToken
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"flow_token": flowToken,
"callback_url": callbackURL,
"expires_at": expiresAt.Unix(),
},
})
}
func TelegramBind(c *gin.Context) {
if !common.TelegramOAuthEnabled {
telegramBindFailure(c, telegramBindErrorDisabled)
return
}
params := c.Request.URL.Query()
telegramId, err := verifyTelegramAuthorization(params, common.TelegramBotToken, time.Now())
if err != nil {
common.SysLog("TelegramBind authorization failed: " + err.Error())
c.JSON(200, gin.H{
"message": "无效的请求",
"success": false,
})
telegramBindFailure(c, telegramBindErrorInvalidRequest)
return
}
if model.IsTelegramIdAlreadyTaken(telegramId) {
c.JSON(200, gin.H{
"message": "该 Telegram 账户已被绑定",
"success": false,
})
pendingFlow, err := model.GetAuthFlow(c.Param("flow_token"), model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeTelegramBind,
})
if err != nil {
if !errors.Is(err, model.ErrAuthFlowInvalid) &&
!errors.Is(err, model.ErrAuthFlowExpired) &&
!errors.Is(err, model.ErrAuthFlowConsumed) {
common.SysError("TelegramBind flow lookup failed: " + err.Error())
telegramBindFailure(c, telegramBindErrorInternal)
return
}
telegramBindFailure(c, telegramBindErrorFlowInvalid)
return
}
if _, err := service.ValidateSessionReference(pendingFlow.UserId, pendingFlow.SessionId); err != nil {
if !errors.Is(err, service.ErrLoginSessionInvalid) &&
!errors.Is(err, service.ErrLoginSessionRevoked) &&
!errors.Is(err, model.ErrUserSessionInactive) &&
!errors.Is(err, gorm.ErrRecordNotFound) {
common.SysError("TelegramBind session validation failed: " + err.Error())
telegramBindFailure(c, telegramBindErrorInternal)
return
}
var user model.User
userErr := model.DB.First(&user, pendingFlow.UserId).Error
switch {
case errors.Is(userErr, gorm.ErrRecordNotFound):
telegramBindFailure(c, telegramBindErrorUserDeleted)
case userErr != nil:
common.SysError("TelegramBind user status lookup failed: " + userErr.Error())
telegramBindFailure(c, telegramBindErrorInternal)
case user.Status != common.UserStatusEnabled:
telegramBindFailure(c, telegramBindErrorUserDisabled)
default:
telegramBindFailure(c, telegramBindErrorSessionInvalid)
}
return
}
assertion, assertionExpiresAt, err := telegramAuthorizationClaim(params, time.Now())
if err != nil {
common.SysLog("TelegramBind authorization claim failed: " + err.Error())
telegramBindFailure(c, telegramBindErrorInvalidRequest)
return
}
_, err = model.ConsumeAuthFlowWithAction(c.Param("flow_token"), model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeTelegramBind,
UserId: pendingFlow.UserId,
SessionId: pendingFlow.SessionId,
}, func(tx *gorm.DB, flow *model.AuthFlow) error {
if err := model.ClaimExternalAuthAssertionWithTx(tx, model.AuthFlowPurposeTelegramAssertion, assertion, assertionExpiresAt); err != nil {
if errors.Is(err, model.ErrAuthFlowInvalid) || errors.Is(err, model.ErrAuthFlowConsumed) {
return errors.Join(errTelegramBindAssertionInvalid, err)
}
return err
}
var user model.User
if err := tx.First(&user, flow.UserId).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errTelegramBindUserDeleted
}
return err
}
if user.Status != common.UserStatusEnabled {
return errTelegramBindUserDisabled
}
var session model.UserSession
if err := tx.Where("sid = ? AND user_id = ?", flow.SessionId, flow.UserId).First(&session).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return service.ErrLoginSessionRevoked
}
return err
}
if session.Status != model.UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= time.Now().Unix() {
return service.ErrLoginSessionRevoked
}
if session.UserAuthVersion != user.AuthVersion {
return service.ErrLoginSessionRevoked
}
if user.TelegramId != "" {
return errTelegramAccountAlreadyBound
}
if err := model.ClaimExternalIdentityWithTx(
tx,
model.ExternalIdentityProviderTelegram,
telegramId,
user.Id,
); err != nil {
if errors.Is(err, model.ErrExternalIdentityAlreadyClaimed) {
return errTelegramAccountAlreadyBound
}
return err
}
result := tx.Model(&model.User{}).
Where("id = ? AND status = ? AND auth_version = ? AND telegram_id = ?", user.Id, common.UserStatusEnabled, user.AuthVersion, "").
Update("telegram_id", telegramId)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return errTelegramAccountAlreadyBound
}
return nil
})
if err != nil {
switch {
case errors.Is(err, errTelegramBindAssertionInvalid):
telegramBindFailure(c, telegramBindErrorInvalidRequest)
case errors.Is(err, errTelegramAccountAlreadyBound):
telegramBindFailure(c, telegramBindErrorAlreadyBound)
case errors.Is(err, errTelegramBindUserDeleted):
telegramBindFailure(c, telegramBindErrorUserDeleted)
case errors.Is(err, errTelegramBindUserDisabled):
telegramBindFailure(c, telegramBindErrorUserDisabled)
case errors.Is(err, service.ErrLoginSessionRevoked):
telegramBindFailure(c, telegramBindErrorSessionInvalid)
case errors.Is(err, model.ErrAuthFlowInvalid), errors.Is(err, model.ErrAuthFlowExpired), errors.Is(err, model.ErrAuthFlowConsumed):
telegramBindFailure(c, telegramBindErrorFlowInvalid)
default:
common.SysError("TelegramBind failed: " + err.Error())
telegramBindFailure(c, telegramBindErrorInternal)
}
return
}
session := sessions.Default(c)
id := session.Get("id")
user := model.User{Id: id.(int)}
if err := user.FillUserById(); err != nil {
c.JSON(200, gin.H{
"message": err.Error(),
"success": false,
})
return
}
if user.Id == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户已注销",
})
return
}
user.TelegramId = telegramId
if err := user.Update(false); err != nil {
c.JSON(200, gin.H{
"message": err.Error(),
"success": false,
})
return
}
callback := "/oauth/telegram?telegram_bind=success&flow_token=" + url.QueryEscape(c.Param("flow_token"))
c.Redirect(http.StatusFound, callback)
}
c.Redirect(302, common.ThemeAwarePath("/console/personal"))
func telegramBindFailure(c *gin.Context, errorCode string) {
query := url.Values{
"telegram_bind": {"error"},
"flow_token": {c.Param("flow_token")},
"error_code": {errorCode},
}
c.Redirect(http.StatusFound, "/oauth/telegram?"+query.Encode())
}
func TelegramLogin(c *gin.Context) {
@@ -108,9 +260,41 @@ func TelegramLogin(c *gin.Context) {
})
return
}
if err := claimTelegramAuthorization(params, time.Now()); err != nil {
common.SysLog("TelegramLogin assertion replay rejected: " + err.Error())
c.JSON(http.StatusForbidden, gin.H{
"message": "该登录凭据已被使用",
"success": false,
})
return
}
setupLogin(&user, c)
}
func claimTelegramAuthorization(params url.Values, now time.Time) error {
assertion, expiresAt, err := telegramAuthorizationClaim(params, now)
if err != nil {
return err
}
return model.ClaimExternalAuthAssertion(model.AuthFlowPurposeTelegramAssertion, assertion, expiresAt)
}
func telegramAuthorizationClaim(params url.Values, now time.Time) (string, time.Time, error) {
authDate, err := strconv.ParseInt(params.Get("auth_date"), 10, 64)
if err != nil {
return "", time.Time{}, errors.New("telegram authorization date is invalid")
}
hashBytes, err := hex.DecodeString(params.Get("hash"))
if err != nil {
return "", time.Time{}, errors.New("telegram authorization signature is invalid")
}
expiresAt := time.Unix(authDate, 0).Add(telegramAuthorizationMaxAge)
if !expiresAt.After(now) {
return "", time.Time{}, errors.New("telegram authorization has expired")
}
return hex.EncodeToString(hashBytes), expiresAt, nil
}
func verifyTelegramAuthorization(params url.Values, token string, now time.Time) (string, error) {
if token == "" {
return "", errors.New("telegram bot token is empty")
+332 -1
View File
@@ -4,6 +4,9 @@ import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strconv"
@@ -11,8 +14,13 @@ import (
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestVerifyTelegramAuthorization(t *testing.T) {
@@ -31,6 +39,7 @@ func TestVerifyTelegramAuthorization(t *testing.T) {
{name: "expired", authDate: now.Add(-telegramAuthorizationMaxAge - time.Second), wantErr: "expired"},
{name: "too far in future", authDate: now.Add(telegramAuthorizationFutureSkew + time.Second), wantErr: "expired"},
{name: "invalid signature", authDate: now, mutate: func(values url.Values) { values.Set("hash", "00") }, wantErr: "signature"},
{name: "unsigned flow token query is rejected", authDate: now, mutate: func(values url.Values) { values.Set("flow_token", "must-be-in-path") }, wantErr: "signature"},
{name: "duplicate parameter", authDate: now, mutate: func(values url.Values) { values["id"] = append(values["id"], "654321") }, wantErr: "duplicate"},
}
@@ -61,8 +70,16 @@ func signedTelegramAuthorization(token string, authDate time.Time) url.Values {
"first_name": {"Test"},
"id": {"123456"},
}
signTelegramAuthorization(token, params)
return params
}
func signTelegramAuthorization(token string, params url.Values) {
keys := make([]string, 0, len(params))
for key := range params {
if key == "hash" {
continue
}
keys = append(keys, key)
}
sort.Strings(keys)
@@ -74,5 +91,319 @@ func signedTelegramAuthorization(token string, authDate time.Time) url.Values {
mac := hmac.New(sha256.New, secret[:])
_, _ = mac.Write([]byte(strings.Join(dataCheck, "\n")))
params.Set("hash", hex.EncodeToString(mac.Sum(nil)))
return params
}
func createTelegramBindTestFlow(t *testing.T, db *gorm.DB, name string, status int, now time.Time) (*model.User, string) {
t.Helper()
user := &model.User{
Username: name, Password: "password-placeholder", Role: common.RoleCommonUser,
Status: status, Group: "default", AuthVersion: 1, AffCode: name,
}
require.NoError(t, db.Create(user).Error)
session := &model.UserSession{
SID: name + "-session", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion,
Status: model.UserSessionStatusActive, RefreshHash: name + "-refresh-hash", LoginMethod: "password",
CreatedAt: now.Unix(), LastActiveAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(),
}
require.NoError(t, model.CreateUserSession(session))
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeTelegramBind, UserId: user.Id, SessionId: session.SID,
ExpiresAt: now.Add(time.Minute),
})
require.NoError(t, err)
return user, flowToken
}
func assertTelegramBindRedirect(t *testing.T, response *httptest.ResponseRecorder, flowToken, errorCode string) {
t.Helper()
require.Equal(t, http.StatusFound, response.Code)
location, err := url.Parse(response.Header().Get("Location"))
require.NoError(t, err)
assert.Equal(t, "/oauth/telegram", location.Path)
assert.Equal(t, "error", location.Query().Get("telegram_bind"))
assert.Equal(t, flowToken, location.Query().Get("flow_token"))
assert.Equal(t, errorCode, location.Query().Get("error_code"))
assert.Empty(t, location.Query().Get("error_description"))
assert.Empty(t, location.Query().Get("message"))
}
func TestTelegramBindFailureResponseContract(t *testing.T) {
failures := []struct {
name string
errorCode string
}{
{name: "disabled", errorCode: telegramBindErrorDisabled},
{name: "invalid request", errorCode: telegramBindErrorInvalidRequest},
{name: "invalid flow", errorCode: telegramBindErrorFlowInvalid},
{name: "invalid session", errorCode: telegramBindErrorSessionInvalid},
{name: "already bound", errorCode: telegramBindErrorAlreadyBound},
{name: "deleted user", errorCode: telegramBindErrorUserDeleted},
{name: "disabled user", errorCode: telegramBindErrorUserDisabled},
{name: "internal error", errorCode: telegramBindErrorInternal},
}
for _, failure := range failures {
t.Run(failure.name, func(t *testing.T) {
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Params = gin.Params{{Key: "flow_token", Value: "flow token"}}
context.Request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/flow-token", nil)
telegramBindFailure(context, failure.errorCode)
assertTelegramBindRedirect(t, response, "flow token", failure.errorCode)
})
}
}
func TestTelegramBindCommitsFlowAssertionAndBindingAtomically(t *testing.T) {
previousDB := model.DB
previousType := common.MainDatabaseType()
previousRedis := common.RedisEnabled
previousEnabled := common.TelegramOAuthEnabled
previousToken := common.TelegramBotToken
previousSecret := common.SessionSecret
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&model.User{},
&model.UserSession{},
&model.AuthFlow{},
&model.ExternalIdentityClaim{},
))
model.DB = db
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
common.RedisEnabled = false
common.TelegramOAuthEnabled = true
common.TelegramBotToken = "telegram-bind-test-token"
common.SessionSecret = "telegram-bind-session-secret"
t.Cleanup(func() {
model.DB = previousDB
common.SetMainDatabaseType(previousType)
common.RedisEnabled = previousRedis
common.TelegramOAuthEnabled = previousEnabled
common.TelegramBotToken = previousToken
common.SessionSecret = previousSecret
})
user := &model.User{
Username: "telegram-bind-user", Password: "password-placeholder", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "telegram-bind-user",
}
require.NoError(t, db.Create(user).Error)
now := time.Now()
session := &model.UserSession{
SID: "telegram-bind-session", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion,
Status: model.UserSessionStatusActive, RefreshHash: "refresh-hash", LoginMethod: "password",
CreatedAt: now.Unix(), LastActiveAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(),
}
require.NoError(t, model.CreateUserSession(session))
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeTelegramBind, UserId: user.Id, SessionId: session.SID,
ExpiresAt: now.Add(time.Minute),
})
require.NoError(t, err)
params := signedTelegramAuthorization(common.TelegramBotToken, now)
router := gin.New()
router.GET("/api/oauth/telegram/bind/:flow_token", TelegramBind)
common.TelegramOAuthEnabled = false
request := httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/disabled-flow", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
assertTelegramBindRedirect(t, response, "disabled-flow", telegramBindErrorDisabled)
common.TelegramOAuthEnabled = true
request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/invalid-request", nil)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
assertTelegramBindRedirect(t, response, "invalid-request", telegramBindErrorInvalidRequest)
request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/missing-flow?"+params.Encode(), nil)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
assertTelegramBindRedirect(t, response, "missing-flow", telegramBindErrorFlowInvalid)
invalidSessionFlowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeTelegramBind, UserId: user.Id, SessionId: "missing-session",
ExpiresAt: now.Add(time.Minute),
})
require.NoError(t, err)
request = httptest.NewRequest(
http.MethodGet,
"/api/oauth/telegram/bind/"+invalidSessionFlowToken+"?"+params.Encode(),
nil,
)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
assertTelegramBindRedirect(t, response, invalidSessionFlowToken, telegramBindErrorSessionInvalid)
invalidSessionFlow, err := model.GetAuthFlow(invalidSessionFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
require.NoError(t, err)
assert.Nil(t, invalidSessionFlow.ConsumedAt)
request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/"+flowToken+"?"+params.Encode(), nil)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
assert.Equal(t, http.StatusFound, response.Code)
assert.Equal(t, "/oauth/telegram?telegram_bind=success&flow_token="+url.QueryEscape(flowToken), response.Header().Get("Location"))
var storedUser model.User
require.NoError(t, db.First(&storedUser, user.Id).Error)
assert.Equal(t, "123456", storedUser.TelegramId)
var identityClaim model.ExternalIdentityClaim
require.NoError(t, db.Where("provider = ? AND subject = ?", model.ExternalIdentityProviderTelegram, "123456").
First(&identityClaim).Error)
assert.Equal(t, user.Id, identityClaim.UserId)
_, err = model.GetAuthFlow(flowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
replayFlowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeTelegramBind, UserId: user.Id, SessionId: session.SID,
ExpiresAt: now.Add(time.Minute),
})
require.NoError(t, err)
request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/"+replayFlowToken+"?"+params.Encode(), nil)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
assertTelegramBindRedirect(t, response, replayFlowToken, telegramBindErrorInvalidRequest)
replayFlow, err := model.GetAuthFlow(replayFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
require.NoError(t, err)
assert.Nil(t, replayFlow.ConsumedAt)
competingUser := &model.User{
Username: "telegram-bind-competing-user", Password: "password-placeholder", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "telegram-bind-competing-user",
}
require.NoError(t, db.Create(competingUser).Error)
competingSession := &model.UserSession{
SID: "telegram-bind-competing-session", UserID: competingUser.Id, Version: 1,
UserAuthVersion: competingUser.AuthVersion, Status: model.UserSessionStatusActive,
RefreshHash: "competing-refresh-hash", LoginMethod: "password",
CreatedAt: now.Unix(), LastActiveAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(),
}
require.NoError(t, model.CreateUserSession(competingSession))
competingFlowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeTelegramBind, UserId: competingUser.Id, SessionId: competingSession.SID,
ExpiresAt: now.Add(time.Minute),
})
require.NoError(t, err)
competingParams := signedTelegramAuthorization(common.TelegramBotToken, now)
competingParams.Set("first_name", "Competing")
signTelegramAuthorization(common.TelegramBotToken, competingParams)
request = httptest.NewRequest(
http.MethodGet,
"/api/oauth/telegram/bind/"+competingFlowToken+"?"+competingParams.Encode(),
nil,
)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
assertTelegramBindRedirect(t, response, competingFlowToken, telegramBindErrorAlreadyBound)
require.NoError(t, db.First(competingUser, competingUser.Id).Error)
assert.Empty(t, competingUser.TelegramId)
competingFlow, err := model.GetAuthFlow(competingFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
require.NoError(t, err)
assert.Nil(t, competingFlow.ConsumedAt)
competingAssertion, competingAssertionExpiry, err := telegramAuthorizationClaim(competingParams, time.Now())
require.NoError(t, err)
require.NoError(t, model.ClaimExternalAuthAssertion(
model.AuthFlowPurposeTelegramAssertion,
competingAssertion,
competingAssertionExpiry,
))
disabledUser, disabledFlowToken := createTelegramBindTestFlow(
t, db, "telegram-bind-disabled-user", common.UserStatusDisabled, now,
)
disabledParams := signedTelegramAuthorization(common.TelegramBotToken, now)
disabledParams.Set("id", "disabled-telegram-id")
disabledParams.Set("first_name", "Disabled")
signTelegramAuthorization(common.TelegramBotToken, disabledParams)
request = httptest.NewRequest(
http.MethodGet,
"/api/oauth/telegram/bind/"+disabledFlowToken+"?"+disabledParams.Encode(),
nil,
)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
assertTelegramBindRedirect(t, response, disabledFlowToken, telegramBindErrorUserDisabled)
var storedDisabledUser model.User
require.NoError(t, db.First(&storedDisabledUser, disabledUser.Id).Error)
assert.Empty(t, storedDisabledUser.TelegramId)
disabledFlow, err := model.GetAuthFlow(disabledFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
require.NoError(t, err)
assert.Nil(t, disabledFlow.ConsumedAt)
disabledAssertion, disabledAssertionExpiry, err := telegramAuthorizationClaim(disabledParams, time.Now())
require.NoError(t, err)
require.NoError(t, model.ClaimExternalAuthAssertion(
model.AuthFlowPurposeTelegramAssertion,
disabledAssertion,
disabledAssertionExpiry,
))
deletedUser, deletedFlowToken := createTelegramBindTestFlow(
t, db, "telegram-bind-deleted-user", common.UserStatusEnabled, now,
)
require.NoError(t, db.Delete(deletedUser).Error)
deletedParams := signedTelegramAuthorization(common.TelegramBotToken, now)
deletedParams.Set("id", "deleted-telegram-id")
deletedParams.Set("first_name", "Deleted")
signTelegramAuthorization(common.TelegramBotToken, deletedParams)
request = httptest.NewRequest(
http.MethodGet,
"/api/oauth/telegram/bind/"+deletedFlowToken+"?"+deletedParams.Encode(),
nil,
)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
assertTelegramBindRedirect(t, response, deletedFlowToken, telegramBindErrorUserDeleted)
deletedFlow, err := model.GetAuthFlow(deletedFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
require.NoError(t, err)
assert.Nil(t, deletedFlow.ConsumedAt)
deletedAssertion, deletedAssertionExpiry, err := telegramAuthorizationClaim(deletedParams, time.Now())
require.NoError(t, err)
require.NoError(t, model.ClaimExternalAuthAssertion(
model.AuthFlowPurposeTelegramAssertion,
deletedAssertion,
deletedAssertionExpiry,
))
_, internalFlowToken := createTelegramBindTestFlow(
t, db, "telegram-bind-internal-error", common.UserStatusEnabled, now,
)
internalParams := signedTelegramAuthorization(common.TelegramBotToken, now)
internalParams.Set("id", "internal-error-telegram-id")
internalParams.Set("first_name", "Internal")
signTelegramAuthorization(common.TelegramBotToken, internalParams)
forcedError := errors.New("forced telegram session query failure")
const callbackName = "test:telegram-bind-session-query-failure"
require.NoError(t, db.Callback().Query().Before("gorm:query").Register(callbackName, func(tx *gorm.DB) {
if tx.Statement.Table != "user_sessions" {
return
}
if _, inTransaction := tx.Statement.ConnPool.(gorm.TxCommitter); inTransaction {
tx.AddError(forcedError)
}
}))
request = httptest.NewRequest(
http.MethodGet,
"/api/oauth/telegram/bind/"+internalFlowToken+"?"+internalParams.Encode(),
nil,
)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
db.Callback().Query().Remove(callbackName)
assertTelegramBindRedirect(t, response, internalFlowToken, telegramBindErrorInternal)
assert.NotContains(t, response.Header().Get("Location"), forcedError.Error())
internalFlow, err := model.GetAuthFlow(internalFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
require.NoError(t, err)
assert.Nil(t, internalFlow.ConsumedAt)
internalAssertion, internalAssertionExpiry, err := telegramAuthorizationClaim(internalParams, time.Now())
require.NoError(t, err)
require.NoError(t, model.ClaimExternalAuthAssertion(
model.AuthFlowPurposeTelegramAssertion,
internalAssertion,
internalAssertionExpiry,
))
}
+47
View File
@@ -0,0 +1,47 @@
package controller
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUpdateOptionRejectsRetiredFrontendTheme(t *testing.T) {
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = httptest.NewRequest(
http.MethodPut,
"/api/option/",
strings.NewReader(`{"key":"theme.frontend","value":"classic"}`),
)
UpdateOption(context)
assert.Equal(t, http.StatusOK, response.Code)
assert.JSONEq(t, `{"success":false,"message":"Classic 前端已移除,主题只能设置为 default"}`, response.Body.String())
}
func TestGetStatusAdvertisesDefaultDashboard(t *testing.T) {
previousMap := common.OptionMap
common.OptionMap = map[string]string{}
t.Cleanup(func() { common.OptionMap = previousMap })
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = httptest.NewRequest(http.MethodGet, "/api/status", nil)
GetStatus(context)
var payload struct {
Success bool `json:"success"`
Data map[string]any `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &payload))
assert.True(t, payload.Success)
assert.Equal(t, "default", payload.Data["theme"])
}
+5 -5
View File
@@ -45,14 +45,14 @@ func GetTopUpInfo(c *gin.Context) {
stripeMethod := map[string]string{
"name": "Stripe",
"type": "stripe",
"color": "rgba(var(--semi-purple-5), 1)",
"color": "#635BFF",
"min_topup": strconv.Itoa(setting.StripeMinTopUp),
}
payMethods = append(payMethods, stripeMethod)
}
}
// Waffo Pancake displayed above the legacy Waffo gateway.
// Waffo Pancake is displayed above the standard Waffo gateway.
enableWaffoPancake := isWaffoPancakeTopUpEnabled()
if enableWaffoPancake {
hasWaffoPancake := false
@@ -67,7 +67,7 @@ func GetTopUpInfo(c *gin.Context) {
payMethods = append(payMethods, map[string]string{
"name": "Waffo Pancake",
"type": model.PaymentMethodWaffoPancake,
"color": "rgba(var(--semi-orange-5), 1)",
"color": "#F97316",
"min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp),
})
}
@@ -88,7 +88,7 @@ func GetTopUpInfo(c *gin.Context) {
waffoMethod := map[string]string{
"name": "Waffo (Global Payment)",
"type": model.PaymentMethodWaffo,
"color": "rgba(var(--semi-blue-5), 1)",
"color": "#3B82F6",
"min_topup": strconv.Itoa(setting.WaffoMinTopUp),
}
payMethods = append(payMethods, waffoMethod)
@@ -216,7 +216,7 @@ func RequestEpay(c *gin.Context) {
}
callBackAddress := service.GetCallbackAddress()
returnUrl, _ := url.Parse(paymentReturnPath("/console/log"))
returnUrl, _ := url.Parse(paymentReturnPath("/usage-logs"))
notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify")
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
+2 -2
View File
@@ -347,10 +347,10 @@ func genStripeLink(referenceId string, customerId string, email string, amount i
// Use custom URLs if provided, otherwise use defaults
if successURL == "" {
successURL = paymentReturnPath("/console/log")
successURL = paymentReturnPath("/usage-logs")
}
if cancelURL == "" {
cancelURL = paymentReturnPath("/console/topup")
cancelURL = paymentReturnPath("/wallet")
}
params := &stripe.CheckoutSessionParams{
+1 -1
View File
@@ -248,7 +248,7 @@ func RequestWaffoPay(c *gin.Context) {
if setting.WaffoNotifyUrl != "" {
notifyUrl = setting.WaffoNotifyUrl
}
returnUrl := paymentReturnPath("/console/topup?show_history=true")
returnUrl := paymentReturnPath("/wallet?show_history=true")
if setting.WaffoReturnUrl != "" {
returnUrl = setting.WaffoReturnUrl
}
+86 -42
View File
@@ -6,9 +6,10 @@ import (
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
@@ -19,7 +20,12 @@ type Setup2FARequest struct {
// Verify2FARequest 验证2FA请求结构
type Verify2FARequest struct {
Code string `json:"code" binding:"required"`
Code string `json:"code" binding:"required"`
FlowToken string `json:"flow_token,omitempty"`
}
type twoFALoginFlowPayload struct {
AuthVersion int64 `json:"auth_version"`
}
// Setup2FAResponse 设置2FA响应结构
@@ -49,7 +55,7 @@ func Setup2FA(c *gin.Context) {
// 如果存在已禁用的2FA记录,先删除它
if existing != nil && !existing.IsEnabled {
if err := existing.Delete(); err != nil {
if err := existing.DeletePendingTwoFASetup(); err != nil {
common.ApiError(c, err)
return
}
@@ -95,22 +101,13 @@ func Setup2FA(c *gin.Context) {
IsEnabled: false,
}
if existing != nil {
// 更新现有记录
twoFA.Id = existing.Id
err = twoFA.Update()
} else {
// 创建新记录
err = twoFA.Create()
}
if err != nil {
if err := twoFA.CreatePendingTwoFASetup(); err != nil {
common.ApiError(c, err)
return
}
// 创建备用码记录
if err := model.CreateBackupCodes(userId, backupCodes); err != nil {
if err := model.CreatePendingTwoFASetupBackupCodes(userId, backupCodes); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "保存备用码失败",
@@ -185,8 +182,18 @@ func Enable2FA(c *gin.Context) {
return
}
// 启用2FA
if err := twoFA.Enable(); err != nil {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
return
}
// 启用2FA并原子推进用户鉴权版本
if err := twoFA.EnableWithAuthVersion(); err != nil {
common.ApiError(c, err)
return
}
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "twofa_enabled")
if err != nil {
common.ApiError(c, err)
return
}
@@ -197,6 +204,7 @@ func Enable2FA(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "两步验证启用成功",
"data": authRotationData(bundle),
})
}
@@ -257,8 +265,18 @@ func Disable2FA(c *gin.Context) {
return
}
// 禁用2FA
if err := model.DisableTwoFA(userId); err != nil {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
return
}
// 禁用2FA并原子推进用户鉴权版本
if err := model.DisableTwoFAWithAuthVersion(userId); err != nil {
common.ApiError(c, err)
return
}
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "twofa_disabled")
if err != nil {
common.ApiError(c, err)
return
}
@@ -269,6 +287,7 @@ func Disable2FA(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "两步验证已禁用",
"data": authRotationData(bundle),
})
}
@@ -372,8 +391,13 @@ func RegenerateBackupCodes(c *gin.Context) {
return
}
// 保存新的备用码
if err := model.CreateBackupCodes(userId, backupCodes); err != nil {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
return
}
// 保存新的备用码并原子推进用户鉴权版本
if err := model.ReplaceBackupCodesWithAuthVersion(userId, backupCodes); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "保存备用码失败",
@@ -381,16 +405,21 @@ func RegenerateBackupCodes(c *gin.Context) {
common.SysLog("保存备用码失败: " + err.Error())
return
}
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "twofa_backup_codes_regenerated")
if err != nil {
common.ApiError(c, err)
return
}
// 记录操作日志
model.RecordLog(userId, model.LogTypeSystem, "重新生成两步验证备用码")
data := authRotationData(bundle)
data["backup_codes"] = backupCodes
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "备用码重新生成成功",
"data": map[string]interface{}{
"backup_codes": backupCodes,
},
"data": data,
})
}
@@ -405,26 +434,16 @@ func Verify2FALogin(c *gin.Context) {
return
}
// 从会话中获取pending用户信息
session := sessions.Default(c)
pendingUserId := session.Get("pending_user_id")
if pendingUserId == nil {
flow, err := model.GetAuthFlow(req.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTwoFALogin})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "会话已过期,请重新登录",
})
return
}
userId, ok := pendingUserId.(int)
if !ok {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "会话数据无效,请重新登录",
})
return
}
// 获取用户信息
user, err := model.GetUserById(userId, false)
user, err := model.GetUserById(flow.UserId, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -432,6 +451,21 @@ func Verify2FALogin(c *gin.Context) {
})
return
}
if user.Status != common.UserStatusEnabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户已被禁用",
})
return
}
var flowPayload twoFALoginFlowPayload
if err := common.UnmarshalJsonStr(flow.Payload, &flowPayload); err != nil || flowPayload.AuthVersion <= 0 || flowPayload.AuthVersion != user.AuthVersion {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "会话已过期,请重新登录",
})
return
}
// 获取2FA记录
twoFA, err := model.GetTwoFAByUserId(user.Id)
@@ -477,12 +511,18 @@ func Verify2FALogin(c *gin.Context) {
return
}
// 2FA验证成功,清理pending会话信息并完成登录
session.Delete("pending_username")
session.Delete("pending_user_id")
session.Save()
if _, err := model.ConsumeAuthFlow(req.FlowToken, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeTwoFALogin,
UserId: user.Id,
}); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "会话已过期,请重新登录",
})
return
}
setupLogin(user, c)
setupLoginAtAuthVersion(user, flowPayload.AuthVersion, c)
}
// Admin2FAStats 管理员获取2FA统计信息
@@ -529,7 +569,7 @@ func AdminDisable2FA(c *gin.Context) {
}
// 禁用2FA
if err := model.DisableTwoFA(userId); err != nil {
if err := model.DisableTwoFAWithAuthVersion(userId); err != nil {
if errors.Is(err, model.ErrTwoFANotEnabled) {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -540,6 +580,10 @@ func AdminDisable2FA(c *gin.Context) {
common.ApiError(c, err)
return
}
if _, err := model.RevokeAllUserSessions(userId, "admin_twofa_disabled"); err != nil {
common.ApiError(c, err)
return
}
recordManageAuditFor(c, userId, "user.2fa_disable", nil)
+166 -93
View File
@@ -1,7 +1,6 @@
package controller
import (
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -9,11 +8,13 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/service/authz"
@@ -22,7 +23,6 @@ import (
"github.com/QuantumNous/new-api/constant"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
@@ -43,7 +43,7 @@ func Login(c *gin.Context) {
return
}
var loginRequest LoginRequest
err := json.NewDecoder(c.Request.Body).Decode(&loginRequest)
err := common.DecodeJson(c.Request.Body, &loginRequest)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
@@ -80,13 +80,20 @@ func Login(c *gin.Context) {
return
}
if twoFAEnabled {
// 设置pending session,等待2FA验证
session := sessions.Default(c)
session.Set("pending_username", user.Username)
session.Set("pending_user_id", user.Id)
err := session.Save()
expiresAt := time.Now().Add(5 * time.Minute)
payload, err := common.Marshal(twoFALoginFlowPayload{AuthVersion: user.AuthVersion})
if err != nil {
common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed)
common.ApiError(c, err)
return
}
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeTwoFALogin,
UserId: user.Id,
Payload: string(payload),
ExpiresAt: expiresAt,
})
if err != nil {
common.ApiError(c, err)
return
}
@@ -95,6 +102,8 @@ func Login(c *gin.Context) {
"success": true,
"data": map[string]interface{}{
"require_2fa": true,
"flow_token": flowToken,
"expires_at": expiresAt.Unix(),
},
})
return
@@ -140,52 +149,60 @@ func recordLoginAudit(user *model.User, c *gin.Context) {
}, extra)
}
// setup session & cookies and then return user info
// setupLogin creates a server-controlled login Session and returns the shared
// authentication bundle used by every login method.
func setupLogin(user *model.User, c *gin.Context) {
model.UpdateUserLastLoginAt(user.Id)
session := sessions.Default(c)
session.Set("id", user.Id)
session.Set("username", user.Username)
session.Set("role", user.Role)
session.Set("status", user.Status)
session.Set("group", user.Group)
err := session.Save()
if err != nil {
common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed)
setupLoginAtAuthVersion(user, 0, c)
}
func setupLoginAtAuthVersion(user *model.User, expectedAuthVersion int64, c *gin.Context) {
if user == nil || user.Id <= 0 || user.Status != common.UserStatusEnabled {
common.ApiErrorI18n(c, i18n.MsgAuthUserBanned)
return
}
currentUser, err := model.GetUserById(user.Id, false)
if err != nil {
common.ApiError(c, err)
return
}
var bundle *service.AuthBundle
if expectedAuthVersion > 0 {
bundle, err = service.CreateLoginSessionAtAuthVersion(
user.Id,
expectedAuthVersion,
loginMethodFromContext(c),
c.ClientIP(),
c.Request.UserAgent(),
)
} else {
bundle, err = service.CreateLoginSession(
user.Id,
loginMethodFromContext(c),
c.ClientIP(),
c.Request.UserAgent(),
)
}
if err != nil {
writeAuthSessionError(c, err)
return
}
model.UpdateUserLastLoginAt(user.Id)
service.WriteRefreshCookie(c, bundle.RefreshToken)
setAuthNoStore(c)
recordLoginAudit(user, c)
c.JSON(http.StatusOK, gin.H{
"message": "",
"success": true,
"data": map[string]any{
"id": user.Id,
"username": user.Username,
"display_name": user.DisplayName,
"role": user.Role,
"status": user.Status,
"group": user.Group,
"data": gin.H{
"access_token": bundle.AccessToken,
"token_type": bundle.TokenType,
"access_expires_at": bundle.AccessExpiresAt,
"session": bundle.Session,
"user": buildSelfUserData(currentUser),
},
})
}
func Logout(c *gin.Context) {
session := sessions.Default(c)
session.Clear()
err := session.Save()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"message": err.Error(),
"success": false,
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "",
"success": true,
})
}
func Register(c *gin.Context) {
if !common.RegisterEnabled {
common.ApiErrorI18n(c, i18n.MsgUserRegisterDisabled)
@@ -196,7 +213,7 @@ func Register(c *gin.Context) {
return
}
var user model.User
err := json.NewDecoder(c.Request.Body).Decode(&user)
err := common.DecodeJson(c.Request.Body, &user)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
@@ -476,18 +493,30 @@ func GetSelf(c *gin.Context) {
common.ApiError(c, err)
return
}
// Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users
user.Remark = ""
// 计算用户权限信息
responseData := buildSelfUserData(user)
// The authenticated role is loaded from GetUserCache. It should equal the
// row role, but use it for capabilities so GetSelf and login/refresh remain
// consistent with the authorization decision made for this request.
permissions := calculateUserPermissions(userRole)
permissions["admin_permissions"] = authz.Capabilities(id, userRole)
responseData["permissions"] = permissions
// 获取用户设置并提取sidebar_modules
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": responseData,
})
return
}
// buildSelfUserData is the single safe dashboard-user DTO used by GetSelf,
// login and refresh. It intentionally excludes password, management PAT and
// administrator-only remarks.
func buildSelfUserData(user *model.User) map[string]interface{} {
userSetting := user.GetSetting()
// 构建响应数据,包含用户信息和权限
responseData := map[string]interface{}{
permissions := calculateUserPermissions(user.Role)
permissions["admin_permissions"] = authz.Capabilities(user.Id, user.Role)
return map[string]interface{}{
"id": user.Id,
"username": user.Username,
"display_name": user.DisplayName,
@@ -512,15 +541,8 @@ func GetSelf(c *gin.Context) {
"setting": user.Setting,
"stripe_customer": user.StripeCustomer,
"sidebar_modules": userSetting.SidebarModules, // 正确提取sidebar_modules字段
"permissions": permissions, // 新增权限字段
"permissions": permissions,
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": responseData,
})
return
}
// 计算用户权限的辅助函数
@@ -604,7 +626,7 @@ func generateDefaultSidebarConfig(userRole int) string {
// 普通用户不包含admin区域
// 转换为JSON字符串
configBytes, err := json.Marshal(defaultConfig)
configBytes, err := common.Marshal(defaultConfig)
if err != nil {
common.SysLog("生成默认边栏配置失败: " + err.Error())
return ""
@@ -661,7 +683,7 @@ func GetUserModels(c *gin.Context) {
func UpdateUser(c *gin.Context) {
var updatedUser model.User
err := json.NewDecoder(c.Request.Body).Decode(&updatedUser)
err := common.DecodeJson(c.Request.Body, &updatedUser)
if err != nil || updatedUser.Id == 0 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
@@ -715,8 +737,15 @@ func UpdateUser(c *gin.Context) {
return
}
}
if err := model.InvalidateUserCache(updatedUser.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate user cache for user %d: %s", updatedUser.Id, err.Error()))
if updatedUser.AuthVersion > originUser.AuthVersion {
if _, err := model.RevokeAllUserSessions(updatedUser.Id, "admin_user_update"); err != nil {
common.ApiError(c, err)
return
}
}
if err := model.PublishUserAuthCache(updatedUser.Id); err != nil {
common.ApiError(c, err)
return
}
recordManageAuditFor(c, updatedUser.Id, "user.update", map[string]interface{}{
"username": originUser.Username,
@@ -872,15 +901,45 @@ func UpdateSelf(c *gin.Context) {
common.ApiError(c, err)
return
}
if err := cleanUser.Update(updatePassword); err != nil {
if updatePassword {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
return
}
if err := model.DB.Transaction(func(tx *gorm.DB) error {
return cleanUser.UpdateWithTx(tx, true)
}); err != nil {
common.ApiError(c, err)
return
}
if err := model.PublishUserAuthCache(cleanUser.Id); err != nil {
common.ApiError(c, err)
return
}
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "password_changed")
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"access_token": bundle.AccessToken,
"token_type": bundle.TokenType,
"access_expires_at": bundle.AccessExpiresAt,
"session": bundle.Session,
},
})
return
}
if err := cleanUser.Update(false); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
c.JSON(http.StatusOK, gin.H{"success": true, "message": ""})
return
}
@@ -962,7 +1021,7 @@ func DeleteSelf(c *gin.Context) {
func CreateUser(c *gin.Context) {
var user model.User
err := json.NewDecoder(c.Request.Body).Decode(&user)
err := common.DecodeJson(c.Request.Body, &user)
user.Username = strings.TrimSpace(user.Username)
if err != nil || user.Username == "" || user.Password == "" {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
@@ -1044,7 +1103,7 @@ type ManageRequest struct {
// ManageUser Only admin user can do this
func ManageUser(c *gin.Context) {
var req ManageRequest
err := json.NewDecoder(c.Request.Body).Decode(&req)
err := common.DecodeJson(c.Request.Body, &req)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
@@ -1090,6 +1149,16 @@ func ManageUser(c *gin.Context) {
if err := model.InvalidateUserTokensCache(user.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
}
recordManageAuditFor(c, user.Id, "user.manage", map[string]interface{}{
"action": req.Action,
"username": user.Username,
"id": user.Id,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
case "promote":
if myRole != common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserAdminCannotPromote)
@@ -1155,25 +1224,32 @@ func ManageUser(c *gin.Context) {
"message": "",
})
return
default:
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
authzTouched := false
if req.Action == "demote" {
if err := model.DB.Transaction(func(tx *gorm.DB) error {
if err := user.UpdateWithTx(tx, false); err != nil {
return err
}
authzTouched = true
return authz.ClearUserAuthorizationInTx(tx, user.Id)
}); err != nil {
common.ApiError(c, err)
return
}
if authzTouched {
if err := authz.ReloadPolicy(); err != nil {
common.ApiError(c, err)
return
}
if err := authz.ReloadPolicy(); err != nil {
common.ApiError(c, err)
return
}
if err := model.PublishUserAuthCache(user.Id); err != nil {
common.ApiError(c, err)
return
}
if _, err := model.RevokeAllUserSessions(user.Id, "admin_demote"); err != nil {
common.ApiError(c, err)
return
}
} else {
if err := user.Update(false); err != nil {
@@ -1181,17 +1257,12 @@ func ManageUser(c *gin.Context) {
return
}
}
// 禁用 / 角色调整后,强制失效用户缓存与其全部令牌缓存,
// 避免在 Redis TTL 过期前仍使用旧状态(尤其是禁用后仍可发起请求的问题)。
// InvalidateUserCache 会让下一次 GetUserCache 从数据库重新加载,
// InvalidateUserTokensCache 则确保令牌侧的缓存也同步刷新。
if req.Action == "disable" || req.Action == "promote" || req.Action == "demote" {
if err := model.InvalidateUserCache(user.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate user cache for user %d: %s", user.Id, err.Error()))
}
if err := model.InvalidateUserTokensCache(user.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
}
// Update/UpdateWithTx has already published the new user hash and revoked
// browser sessions exactly once. Only PAT/relay token caches still need an
// explicit invalidation; deleting the user hash here would discard the
// freshly published auth-version floor.
if err := model.InvalidateUserTokensCache(user.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
}
recordManageAuditFor(c, user.Id, "user.manage", map[string]interface{}{
"action": req.Action,
@@ -1228,10 +1299,12 @@ func EmailBind(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError)
return
}
session := sessions.Default(c)
id := session.Get("id")
user := model.User{
Id: id.(int),
Id: c.GetInt("id"),
}
if user.Id == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "not authenticated"})
return
}
err := user.FillUserById()
if err != nil {
+161
View File
@@ -0,0 +1,161 @@
package controller
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service/authz"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupManageUserTestDB(t *testing.T) *gorm.DB {
t.Helper()
previousDB, previousLogDB := model.DB, model.LOG_DB
previousRedisEnabled := common.RedisEnabled
previousMainDatabaseType, previousLogDatabaseType := common.MainDatabaseType(), common.LogDatabaseType()
common.RedisEnabled = false
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
model.DB, model.LOG_DB = db, db
require.NoError(t, db.AutoMigrate(
&model.User{}, &model.UserSession{}, &model.Log{}, &model.CasbinRule{}, &model.AuthzRole{},
))
t.Cleanup(func() {
model.DB, model.LOG_DB = previousDB, previousLogDB
common.RedisEnabled = previousRedisEnabled
common.SetDatabaseTypes(previousMainDatabaseType, previousLogDatabaseType)
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
return db
}
func performManageUserRequest(t *testing.T, body string) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/user/manage", strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Set("id", 9999)
c.Set("role", common.RoleRootUser)
c.Set("username", "root-operator")
ManageUser(c)
return recorder
}
func TestManageUserDisableAdvancesAuthVersionOnceAndRevokesSession(t *testing.T) {
db := setupManageUserTestDB(t)
now := time.Now().Unix()
user := model.User{
Username: "managed-disable-user", Password: "password", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1,
}
require.NoError(t, db.Create(&user).Error)
require.NoError(t, db.Create(&model.UserSession{
SID: "managed-disable-session", UserID: user.Id, Version: 1, UserAuthVersion: 1,
Status: model.UserSessionStatusActive, RefreshHash: "refresh-hash", LoginMethod: "password",
LastActiveAt: now, ExpiresAt: now + 3600,
}).Error)
recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"disable"}`, user.Id))
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), `"success":true`)
var updated model.User
require.NoError(t, db.First(&updated, user.Id).Error)
assert.Equal(t, common.UserStatusDisabled, updated.Status)
assert.EqualValues(t, 2, updated.AuthVersion)
var session model.UserSession
require.NoError(t, db.First(&session, "sid = ?", "managed-disable-session").Error)
assert.Equal(t, model.UserSessionStatusRevoked, session.Status)
}
func TestManageUserDemoteAdvancesAuthVersionAndRevokesSessionsOnce(t *testing.T) {
db := setupManageUserTestDB(t)
previousMaster := common.IsMasterNode
common.IsMasterNode = false
t.Cleanup(func() { common.IsMasterNode = previousMaster })
require.NoError(t, authz.Init(db))
now := time.Now().Unix()
user := model.User{
Username: "managed-demote-user", Password: "password", Role: common.RoleAdminUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1,
}
require.NoError(t, db.Create(&user).Error)
for _, sid := range []string{"managed-demote-session-one", "managed-demote-session-two"} {
require.NoError(t, db.Create(&model.UserSession{
SID: sid, UserID: user.Id, Version: 1, UserAuthVersion: 1,
Status: model.UserSessionStatusActive, RefreshHash: "refresh-" + sid, LoginMethod: "password",
LastActiveAt: now, ExpiresAt: now + 3600,
}).Error)
}
sessionUpdateCount := 0
require.NoError(t, db.Callback().Update().Before("gorm:update").Register("test:count_demote_session_updates", func(tx *gorm.DB) {
if tx.Statement != nil && tx.Statement.Table == "user_sessions" {
sessionUpdateCount++
}
}))
recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"demote"}`, user.Id))
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), `"success":true`)
var updated model.User
require.NoError(t, db.First(&updated, user.Id).Error)
assert.Equal(t, common.RoleCommonUser, updated.Role)
assert.EqualValues(t, 2, updated.AuthVersion)
var sessions []model.UserSession
require.NoError(t, db.Where("user_id = ?", user.Id).Order("sid asc").Find(&sessions).Error)
require.Len(t, sessions, 2)
for _, session := range sessions {
assert.Equal(t, model.UserSessionStatusRevoked, session.Status)
assert.Equal(t, "admin_demote", session.RevokedReason)
}
assert.Equal(t, 1, sessionUpdateCount)
}
func TestManageUserDeleteReturnsImmediatelyAndUnknownActionFails(t *testing.T) {
db := setupManageUserTestDB(t)
deleted := model.User{
Username: "managed-delete-user", Password: "password", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "delete-aff",
}
require.NoError(t, db.Create(&deleted).Error)
recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"delete"}`, deleted.Id))
assert.Contains(t, recorder.Body.String(), `"success":true`)
var deletedCount int64
require.NoError(t, db.Unscoped().Model(&model.User{}).Where("id = ? AND deleted_at IS NOT NULL", deleted.Id).Count(&deletedCount).Error)
assert.EqualValues(t, 1, deletedCount)
unchanged := model.User{
Username: "managed-unknown-user", Password: "password", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "unknown-aff",
}
require.NoError(t, db.Create(&unchanged).Error)
recorder = performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"unknown"}`, unchanged.Id))
assert.Contains(t, recorder.Body.String(), `"success":false`)
require.NoError(t, db.First(&unchanged, unchanged.Id).Error)
assert.EqualValues(t, 1, unchanged.AuthVersion)
assert.Equal(t, common.UserStatusEnabled, unchanged.Status)
}
+6 -6
View File
@@ -1,7 +1,6 @@
package controller
import (
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -12,7 +11,6 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
@@ -40,7 +38,7 @@ func getWeChatIdByCode(code string) (string, error) {
}
defer httpResponse.Body.Close()
var res wechatLoginResponse
err = json.NewDecoder(httpResponse.Body).Decode(&res)
err = common.DecodeJson(httpResponse.Body, &res)
if err != nil {
return "", err
}
@@ -158,10 +156,12 @@ func WeChatBind(c *gin.Context) {
})
return
}
session := sessions.Default(c)
id := session.Get("id")
user := model.User{
Id: id.(int),
Id: c.GetInt("id"),
}
if user.Id == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
err = user.FillUserById()
if err != nil {