mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-09 01:47:07 +08:00
Each user can now hold up to 20 named access tokens in the user_access_tokens table instead of a single token on the user row. Tokens are shown once at creation (nap_ prefix) and stored as a SHA-256 digest. They expire after 30 days by default; a token without expiry is allowed with a warning. A token only reaches the routes its scopes allow. Scopes are picked from the same permission matrix as admin permissions and are limited to what the owner can do; every management route declares the scope it needs, and routes without a declaration reject tokens. Token management itself always requires a browser session. Tokens can complete step-up verification, so verified actions such as changing a password or 2FA settings work with a token; such changes sign out all browser sessions. Disabling a user keeps their tokens, deleting the account removes them. The existing dashboard token keeps working for 30 days after the upgrade. It can be revoked but no longer regenerated, and afterwards it is rejected with ACCESS_TOKEN_LEGACY_RETIRED. The security page lists tokens with their access records, and audit details show scope names.
253 lines
6.3 KiB
Go
253 lines
6.3 KiB
Go
package controller
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"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/service"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type Verify2FARequest struct {
|
|
Code string `json:"code" binding:"required"`
|
|
FlowToken string `json:"flow_token,omitempty"`
|
|
}
|
|
|
|
func Setup2FA(c *gin.Context) {
|
|
authorization := middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeTwoFASetup})
|
|
if authorization == nil {
|
|
return
|
|
}
|
|
identity, _ := middleware.GetStepUpIdentity(c)
|
|
setup, err := service.StartTwoFASetup(identity, authorization)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
recordUserSecurityAudit(c, identity.UserID, "user.2fa_setup", nil)
|
|
common.ApiSuccess(c, setup)
|
|
}
|
|
|
|
func Enable2FA(c *gin.Context) {
|
|
identity, ok := middleware.GetStepUpIdentity(c)
|
|
if !ok {
|
|
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
|
|
return
|
|
}
|
|
var req Verify2FARequest
|
|
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
|
|
common.ApiErrorMsg(c, "参数错误")
|
|
return
|
|
}
|
|
if err := service.FinishTwoFASetup(identity, req.FlowToken, req.Code); err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "twofa_enabled")
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
recordUserSecurityAudit(c, identity.UserID, "user.2fa_enable", nil)
|
|
common.ApiSuccess(c, authRotationData(bundle))
|
|
}
|
|
|
|
// Disable2FA 禁用2FA
|
|
func Disable2FA(c *gin.Context) {
|
|
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeTwoFADisable}) == nil {
|
|
return
|
|
}
|
|
identity, _ := middleware.GetStepUpIdentity(c)
|
|
userId := identity.UserID
|
|
if err := model.DisableTwoFAForSession(identity); err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "twofa_disabled")
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
// 记录操作日志
|
|
recordUserSecurityAudit(c, userId, "user.2fa_disable_self", nil)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "两步验证已禁用",
|
|
"data": authRotationData(bundle),
|
|
})
|
|
}
|
|
|
|
// Get2FAStatus 获取用户2FA状态
|
|
func Get2FAStatus(c *gin.Context) {
|
|
userId := c.GetInt("id")
|
|
|
|
twoFA, err := model.GetTwoFAByUserId(userId)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
status := map[string]any{
|
|
"enabled": false,
|
|
"locked": false,
|
|
}
|
|
|
|
if twoFA != nil {
|
|
status["enabled"] = twoFA.IsEnabled
|
|
status["locked"] = twoFA.IsLocked()
|
|
if twoFA.IsEnabled {
|
|
// 获取剩余备用码数量
|
|
backupCount, err := model.GetUnusedBackupCodeCount(userId)
|
|
if err != nil {
|
|
common.SysLog("获取备用码数量失败: " + err.Error())
|
|
} else {
|
|
status["backup_codes_remaining"] = backupCount
|
|
}
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "",
|
|
"data": status,
|
|
})
|
|
}
|
|
|
|
// RegenerateBackupCodes 重新生成备用码
|
|
func RegenerateBackupCodes(c *gin.Context) {
|
|
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeTwoFABackupCodes}) == nil {
|
|
return
|
|
}
|
|
identity, _ := middleware.GetStepUpIdentity(c)
|
|
userId := identity.UserID
|
|
// 生成新的备用码
|
|
backupCodes, err := common.GenerateBackupCodes()
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": false,
|
|
"message": "生成备用码失败",
|
|
})
|
|
common.SysLog("生成备用码失败: " + err.Error())
|
|
return
|
|
}
|
|
|
|
// 保存新的备用码并原子推进用户鉴权版本
|
|
if err := model.ReplaceBackupCodesForSession(identity, backupCodes); err != nil {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": false,
|
|
"message": "保存备用码失败",
|
|
})
|
|
common.SysLog("保存备用码失败: " + err.Error())
|
|
return
|
|
}
|
|
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "twofa_backup_codes_regenerated")
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
// 记录操作日志
|
|
recordUserSecurityAudit(c, userId, "user.2fa_backup_codes", nil)
|
|
|
|
data := authRotationData(bundle)
|
|
data["backup_codes"] = backupCodes
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "备用码重新生成成功",
|
|
"data": data,
|
|
})
|
|
}
|
|
|
|
// Verify2FALogin 登录时验证2FA
|
|
func Verify2FALogin(c *gin.Context) {
|
|
VerifyLogin(c)
|
|
}
|
|
|
|
// Admin2FAStats 管理员获取2FA统计信息
|
|
func Admin2FAStats(c *gin.Context) {
|
|
stats, err := model.GetTwoFAStats()
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "",
|
|
"data": stats,
|
|
})
|
|
}
|
|
|
|
// AdminDisable2FA 管理员强制禁用用户2FA
|
|
func AdminDisable2FA(c *gin.Context) {
|
|
userIdStr := c.Param("id")
|
|
userId, err := strconv.Atoi(userIdStr)
|
|
if err != nil || userId <= 0 {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": false,
|
|
"message": "用户ID格式错误",
|
|
})
|
|
return
|
|
}
|
|
|
|
// 检查目标用户权限
|
|
targetUser, err := model.GetUserById(userId, false)
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
common.ApiErrorI18n(c, i18n.MsgUserNotExists)
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
myRole := c.GetInt("role")
|
|
if !canManageTargetRole(myRole, targetUser.Role) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": false,
|
|
"message": "无权操作同级或更高级用户的2FA设置",
|
|
})
|
|
return
|
|
}
|
|
authorization := requireAdminUserProof(c, service.VerificationScopeAdminUserTwoFADisable, service.AdminUserContext{UserID: userId})
|
|
if authorization == nil {
|
|
return
|
|
}
|
|
|
|
// 禁用2FA
|
|
if err := model.DisableTwoFAWithAuthVersion(userId); err != nil {
|
|
if errors.Is(err, model.ErrTwoFANotEnabled) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": false,
|
|
"message": "用户未启用2FA",
|
|
})
|
|
return
|
|
}
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
if _, err := model.RevokeAllUserSessions(userId, "admin_twofa_disabled"); err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
recordManageAuditFor(c, userId, "user.2fa_disable", map[string]any{
|
|
"username": targetUser.Username,
|
|
"verification_method": authorization.Method,
|
|
})
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "用户2FA已被强制禁用",
|
|
})
|
|
}
|