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
+166 -93
View File
@@ -1,7 +1,6 @@
package controller
import (
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -9,11 +8,13 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/service/authz"
@@ -22,7 +23,6 @@ import (
"github.com/QuantumNous/new-api/constant"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
@@ -43,7 +43,7 @@ func Login(c *gin.Context) {
return
}
var loginRequest LoginRequest
err := json.NewDecoder(c.Request.Body).Decode(&loginRequest)
err := common.DecodeJson(c.Request.Body, &loginRequest)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
@@ -80,13 +80,20 @@ func Login(c *gin.Context) {
return
}
if twoFAEnabled {
// 设置pending session,等待2FA验证
session := sessions.Default(c)
session.Set("pending_username", user.Username)
session.Set("pending_user_id", user.Id)
err := session.Save()
expiresAt := time.Now().Add(5 * time.Minute)
payload, err := common.Marshal(twoFALoginFlowPayload{AuthVersion: user.AuthVersion})
if err != nil {
common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed)
common.ApiError(c, err)
return
}
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeTwoFALogin,
UserId: user.Id,
Payload: string(payload),
ExpiresAt: expiresAt,
})
if err != nil {
common.ApiError(c, err)
return
}
@@ -95,6 +102,8 @@ func Login(c *gin.Context) {
"success": true,
"data": map[string]interface{}{
"require_2fa": true,
"flow_token": flowToken,
"expires_at": expiresAt.Unix(),
},
})
return
@@ -140,52 +149,60 @@ func recordLoginAudit(user *model.User, c *gin.Context) {
}, extra)
}
// setup session & cookies and then return user info
// setupLogin creates a server-controlled login Session and returns the shared
// authentication bundle used by every login method.
func setupLogin(user *model.User, c *gin.Context) {
model.UpdateUserLastLoginAt(user.Id)
session := sessions.Default(c)
session.Set("id", user.Id)
session.Set("username", user.Username)
session.Set("role", user.Role)
session.Set("status", user.Status)
session.Set("group", user.Group)
err := session.Save()
if err != nil {
common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed)
setupLoginAtAuthVersion(user, 0, c)
}
func setupLoginAtAuthVersion(user *model.User, expectedAuthVersion int64, c *gin.Context) {
if user == nil || user.Id <= 0 || user.Status != common.UserStatusEnabled {
common.ApiErrorI18n(c, i18n.MsgAuthUserBanned)
return
}
currentUser, err := model.GetUserById(user.Id, false)
if err != nil {
common.ApiError(c, err)
return
}
var bundle *service.AuthBundle
if expectedAuthVersion > 0 {
bundle, err = service.CreateLoginSessionAtAuthVersion(
user.Id,
expectedAuthVersion,
loginMethodFromContext(c),
c.ClientIP(),
c.Request.UserAgent(),
)
} else {
bundle, err = service.CreateLoginSession(
user.Id,
loginMethodFromContext(c),
c.ClientIP(),
c.Request.UserAgent(),
)
}
if err != nil {
writeAuthSessionError(c, err)
return
}
model.UpdateUserLastLoginAt(user.Id)
service.WriteRefreshCookie(c, bundle.RefreshToken)
setAuthNoStore(c)
recordLoginAudit(user, c)
c.JSON(http.StatusOK, gin.H{
"message": "",
"success": true,
"data": map[string]any{
"id": user.Id,
"username": user.Username,
"display_name": user.DisplayName,
"role": user.Role,
"status": user.Status,
"group": user.Group,
"data": gin.H{
"access_token": bundle.AccessToken,
"token_type": bundle.TokenType,
"access_expires_at": bundle.AccessExpiresAt,
"session": bundle.Session,
"user": buildSelfUserData(currentUser),
},
})
}
func Logout(c *gin.Context) {
session := sessions.Default(c)
session.Clear()
err := session.Save()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"message": err.Error(),
"success": false,
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "",
"success": true,
})
}
func Register(c *gin.Context) {
if !common.RegisterEnabled {
common.ApiErrorI18n(c, i18n.MsgUserRegisterDisabled)
@@ -196,7 +213,7 @@ func Register(c *gin.Context) {
return
}
var user model.User
err := json.NewDecoder(c.Request.Body).Decode(&user)
err := common.DecodeJson(c.Request.Body, &user)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
@@ -476,18 +493,30 @@ func GetSelf(c *gin.Context) {
common.ApiError(c, err)
return
}
// Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users
user.Remark = ""
// 计算用户权限信息
responseData := buildSelfUserData(user)
// The authenticated role is loaded from GetUserCache. It should equal the
// row role, but use it for capabilities so GetSelf and login/refresh remain
// consistent with the authorization decision made for this request.
permissions := calculateUserPermissions(userRole)
permissions["admin_permissions"] = authz.Capabilities(id, userRole)
responseData["permissions"] = permissions
// 获取用户设置并提取sidebar_modules
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": responseData,
})
return
}
// buildSelfUserData is the single safe dashboard-user DTO used by GetSelf,
// login and refresh. It intentionally excludes password, management PAT and
// administrator-only remarks.
func buildSelfUserData(user *model.User) map[string]interface{} {
userSetting := user.GetSetting()
// 构建响应数据,包含用户信息和权限
responseData := map[string]interface{}{
permissions := calculateUserPermissions(user.Role)
permissions["admin_permissions"] = authz.Capabilities(user.Id, user.Role)
return map[string]interface{}{
"id": user.Id,
"username": user.Username,
"display_name": user.DisplayName,
@@ -512,15 +541,8 @@ func GetSelf(c *gin.Context) {
"setting": user.Setting,
"stripe_customer": user.StripeCustomer,
"sidebar_modules": userSetting.SidebarModules, // 正确提取sidebar_modules字段
"permissions": permissions, // 新增权限字段
"permissions": permissions,
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": responseData,
})
return
}
// 计算用户权限的辅助函数
@@ -604,7 +626,7 @@ func generateDefaultSidebarConfig(userRole int) string {
// 普通用户不包含admin区域
// 转换为JSON字符串
configBytes, err := json.Marshal(defaultConfig)
configBytes, err := common.Marshal(defaultConfig)
if err != nil {
common.SysLog("生成默认边栏配置失败: " + err.Error())
return ""
@@ -661,7 +683,7 @@ func GetUserModels(c *gin.Context) {
func UpdateUser(c *gin.Context) {
var updatedUser model.User
err := json.NewDecoder(c.Request.Body).Decode(&updatedUser)
err := common.DecodeJson(c.Request.Body, &updatedUser)
if err != nil || updatedUser.Id == 0 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
@@ -715,8 +737,15 @@ func UpdateUser(c *gin.Context) {
return
}
}
if err := model.InvalidateUserCache(updatedUser.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate user cache for user %d: %s", updatedUser.Id, err.Error()))
if updatedUser.AuthVersion > originUser.AuthVersion {
if _, err := model.RevokeAllUserSessions(updatedUser.Id, "admin_user_update"); err != nil {
common.ApiError(c, err)
return
}
}
if err := model.PublishUserAuthCache(updatedUser.Id); err != nil {
common.ApiError(c, err)
return
}
recordManageAuditFor(c, updatedUser.Id, "user.update", map[string]interface{}{
"username": originUser.Username,
@@ -872,15 +901,45 @@ func UpdateSelf(c *gin.Context) {
common.ApiError(c, err)
return
}
if err := cleanUser.Update(updatePassword); err != nil {
if updatePassword {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
return
}
if err := model.DB.Transaction(func(tx *gorm.DB) error {
return cleanUser.UpdateWithTx(tx, true)
}); err != nil {
common.ApiError(c, err)
return
}
if err := model.PublishUserAuthCache(cleanUser.Id); err != nil {
common.ApiError(c, err)
return
}
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "password_changed")
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"access_token": bundle.AccessToken,
"token_type": bundle.TokenType,
"access_expires_at": bundle.AccessExpiresAt,
"session": bundle.Session,
},
})
return
}
if err := cleanUser.Update(false); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
c.JSON(http.StatusOK, gin.H{"success": true, "message": ""})
return
}
@@ -962,7 +1021,7 @@ func DeleteSelf(c *gin.Context) {
func CreateUser(c *gin.Context) {
var user model.User
err := json.NewDecoder(c.Request.Body).Decode(&user)
err := common.DecodeJson(c.Request.Body, &user)
user.Username = strings.TrimSpace(user.Username)
if err != nil || user.Username == "" || user.Password == "" {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
@@ -1044,7 +1103,7 @@ type ManageRequest struct {
// ManageUser Only admin user can do this
func ManageUser(c *gin.Context) {
var req ManageRequest
err := json.NewDecoder(c.Request.Body).Decode(&req)
err := common.DecodeJson(c.Request.Body, &req)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
@@ -1090,6 +1149,16 @@ func ManageUser(c *gin.Context) {
if err := model.InvalidateUserTokensCache(user.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
}
recordManageAuditFor(c, user.Id, "user.manage", map[string]interface{}{
"action": req.Action,
"username": user.Username,
"id": user.Id,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
case "promote":
if myRole != common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserAdminCannotPromote)
@@ -1155,25 +1224,32 @@ func ManageUser(c *gin.Context) {
"message": "",
})
return
default:
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
authzTouched := false
if req.Action == "demote" {
if err := model.DB.Transaction(func(tx *gorm.DB) error {
if err := user.UpdateWithTx(tx, false); err != nil {
return err
}
authzTouched = true
return authz.ClearUserAuthorizationInTx(tx, user.Id)
}); err != nil {
common.ApiError(c, err)
return
}
if authzTouched {
if err := authz.ReloadPolicy(); err != nil {
common.ApiError(c, err)
return
}
if err := authz.ReloadPolicy(); err != nil {
common.ApiError(c, err)
return
}
if err := model.PublishUserAuthCache(user.Id); err != nil {
common.ApiError(c, err)
return
}
if _, err := model.RevokeAllUserSessions(user.Id, "admin_demote"); err != nil {
common.ApiError(c, err)
return
}
} else {
if err := user.Update(false); err != nil {
@@ -1181,17 +1257,12 @@ func ManageUser(c *gin.Context) {
return
}
}
// 禁用 / 角色调整后,强制失效用户缓存与其全部令牌缓存,
// 避免在 Redis TTL 过期前仍使用旧状态(尤其是禁用后仍可发起请求的问题)。
// InvalidateUserCache 会让下一次 GetUserCache 从数据库重新加载,
// InvalidateUserTokensCache 则确保令牌侧的缓存也同步刷新。
if req.Action == "disable" || req.Action == "promote" || req.Action == "demote" {
if err := model.InvalidateUserCache(user.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate user cache for user %d: %s", user.Id, err.Error()))
}
if err := model.InvalidateUserTokensCache(user.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
}
// Update/UpdateWithTx has already published the new user hash and revoked
// browser sessions exactly once. Only PAT/relay token caches still need an
// explicit invalidation; deleting the user hash here would discard the
// freshly published auth-version floor.
if err := model.InvalidateUserTokensCache(user.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
}
recordManageAuditFor(c, user.Id, "user.manage", map[string]interface{}{
"action": req.Action,
@@ -1228,10 +1299,12 @@ func EmailBind(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError)
return
}
session := sessions.Default(c)
id := session.Get("id")
user := model.User{
Id: id.(int),
Id: c.GetInt("id"),
}
if user.Id == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "not authenticated"})
return
}
err := user.FillUserById()
if err != nil {