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:
+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 {
|
||||
|
||||
Reference in New Issue
Block a user