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

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

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

* fix(proxy): preserve trusted proxy compatibility defaults

* refactor: address dashboard auth review feedback

* refactor: remove classic frontend and flatten web app
This commit is contained in:
Calcium-Ion
2026-07-20 16:48:43 +08:00
committed by GitHub
parent 5a6c53d496
commit 31d70fca39
1605 changed files with 17511 additions and 147913 deletions
+121 -31
View File
@@ -5,16 +5,30 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const oauthAuthFlowTTL = 10 * time.Minute
type oauthStateRequest struct {
Provider string `json:"provider"`
Intent string `json:"intent"`
Aff string `json:"aff,omitempty"`
}
type oauthFlowPayload struct {
AffiliateCode string `json:"affiliate_code,omitempty"`
}
// providerParams returns map with Provider key for i18n templates
func providerParams(name string) map[string]any {
return map[string]any{"Provider": name}
@@ -22,14 +36,47 @@ func providerParams(name string) map[string]any {
// GenerateOAuthCode generates a state code for OAuth CSRF protection
func GenerateOAuthCode(c *gin.Context) {
session := sessions.Default(c)
state := common.GetRandomString(12)
affCode := c.Query("aff")
if affCode != "" {
session.Set("aff", affCode)
var request oauthStateRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
session.Set("oauth_state", state)
err := session.Save()
request.Provider = strings.TrimSpace(request.Provider)
request.Intent = strings.TrimSpace(request.Intent)
request.Aff = strings.TrimSpace(request.Aff)
if oauth.GetProvider(request.Provider) == nil ||
(request.Intent != model.AuthFlowIntentLogin && request.Intent != model.AuthFlowIntentBind) ||
len(request.Aff) > 32 ||
(request.Intent == model.AuthFlowIntentBind && request.Aff != "") {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
userID := 0
sessionID := ""
if request.Intent == model.AuthFlowIntentBind {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "绑定操作需要登录"})
return
}
userID = identity.UserID
sessionID = identity.SessionID
}
payload, err := common.Marshal(oauthFlowPayload{AffiliateCode: request.Aff})
if err != nil {
common.ApiError(c, err)
return
}
expiresAt := time.Now().Add(oauthAuthFlowTTL)
state, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeOAuth,
Provider: request.Provider,
Intent: request.Intent,
UserId: userID,
SessionId: sessionID,
Payload: string(payload),
ExpiresAt: expiresAt,
})
if err != nil {
common.ApiError(c, err)
return
@@ -37,7 +84,10 @@ func GenerateOAuthCode(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": state,
"data": gin.H{
"flow_token": state,
"expires_at": expiresAt.Unix(),
},
})
}
@@ -53,11 +103,13 @@ func HandleOAuth(c *gin.Context) {
return
}
session := sessions.Default(c)
// 1. Validate state (CSRF protection)
state := c.Query("state")
if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
pendingFlow, err := model.GetAuthFlow(state, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth,
Provider: providerName,
})
if err != nil {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": i18n.T(c, i18n.MsgOAuthStateInvalid),
@@ -65,10 +117,25 @@ func HandleOAuth(c *gin.Context) {
return
}
// 2. Check if user is already logged in (bind flow)
username := session.Get("username")
if username != nil {
handleOAuthBind(c, provider)
consumeMatch := model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth,
Provider: providerName,
Intent: pendingFlow.Intent,
}
// 2. Bind flows are bound to the live dashboard Session that created them.
if pendingFlow.Intent == model.AuthFlowIntentBind {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok || identity.UserID != pendingFlow.UserId || identity.SessionID != pendingFlow.SessionId {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": i18n.T(c, i18n.MsgOAuthStateInvalid),
})
return
}
consumeMatch.UserId = identity.UserID
consumeMatch.SessionId = identity.SessionID
} else if pendingFlow.Intent != model.AuthFlowIntentLogin {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
@@ -81,13 +148,24 @@ func HandleOAuth(c *gin.Context) {
// 4. Handle error from provider
errorCode := c.Query("error")
if errorCode != "" {
if _, err := model.ConsumeAuthFlow(state, consumeMatch); err != nil {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)})
return
}
errorDescription := c.Query("error_description")
if errorDescription == "" {
errorDescription = errorCode
}
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": errorDescription,
})
return
}
if pendingFlow.Intent == model.AuthFlowIntentBind {
handleOAuthBind(c, provider, pendingFlow, state)
return
}
// 5. Exchange code for token
code := c.Query("code")
@@ -103,9 +181,19 @@ func HandleOAuth(c *gin.Context) {
handleOAuthError(c, err)
return
}
flow, err := model.ConsumeAuthFlow(state, consumeMatch)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)})
return
}
// 7. Find or create user
user, err := findOrCreateOAuthUser(c, provider, oauthUser, session)
var payload oauthFlowPayload
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
common.ApiError(c, err)
return
}
user, err := findOrCreateOAuthUser(c, provider, oauthUser, payload.AffiliateCode)
if err != nil {
if errors.Is(err, model.ErrEmailAlreadyTaken) {
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
@@ -135,12 +223,7 @@ func HandleOAuth(c *gin.Context) {
}
// handleOAuthBind handles binding OAuth account to existing user
func handleOAuthBind(c *gin.Context, provider oauth.Provider) {
if !provider.IsEnabled() {
common.ApiErrorI18n(c, i18n.MsgOAuthNotEnabled, providerParams(provider.GetName()))
return
}
func handleOAuthBind(c *gin.Context, provider oauth.Provider, pendingFlow *model.AuthFlow, flowToken string) {
// Exchange code for token
code := c.Query("code")
token, err := provider.ExchangeToken(c.Request.Context(), code, c)
@@ -169,10 +252,18 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider) {
}
}
// Get current user from session
session := sessions.Default(c)
id := session.Get("id")
user := model.User{Id: id.(int)}
if _, err := model.ConsumeAuthFlow(flowToken, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth,
Provider: pendingFlow.Provider,
Intent: model.AuthFlowIntentBind,
UserId: pendingFlow.UserId,
SessionId: pendingFlow.SessionId,
}); err != nil {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)})
return
}
user := model.User{Id: pendingFlow.UserId}
err = user.FillUserById()
if err != nil {
common.ApiError(c, err)
@@ -203,7 +294,7 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider) {
}
// findOrCreateOAuthUser finds existing user or creates new user
func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, session sessions.Session) (*model.User, error) {
func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, affiliateCode string) (*model.User, error) {
user := &model.User{}
// Check if user already exists with new ID
@@ -276,10 +367,9 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
user.Status = common.UserStatusEnabled
// Handle affiliate code
affCode := session.Get("aff")
inviterId := 0
if affCode != nil {
inviterId, _ = model.GetUserIdByAffCode(affCode.(string))
if affiliateCode != "" {
inviterId, _ = model.GetUserIdByAffCode(affiliateCode)
}
// Use transaction to ensure user creation and OAuth binding are atomic