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:
+1
-1
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
// auditContentTemplates 将稳定的操作标识 action 映射为英文兜底模板,渲染后写入
|
||||
// Log.Content(供导出 / 经典前端等非本地化消费者使用)。占位符为 ${name},由该
|
||||
// Log.Content(供导出等非本地化消费者使用)。占位符为 ${name},由该
|
||||
// action 的 params 填充。本地化展示文案在前端 i18n 模板中维护,本表是语言中立的
|
||||
// 英文基线——调用方因此无需在每个埋点处手写句子(避免与 params 重复书写同一份值)。
|
||||
var auditContentTemplates = map[string]string{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
))
|
||||
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user