Files
new-api/controller/email_binding.go
T
CaIon caca52f8de feat(auth): replace the dashboard access token with scoped access tokens
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.
2026-09-30 17:24:19 +08:00

113 lines
3.6 KiB
Go

package controller
import (
"net/http"
"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-gonic/gin"
)
type emailBindRequest struct {
Email string `json:"email"`
FlowToken string `json:"flow_token"`
NewCode string `json:"new_code"`
OldCode string `json:"old_code"`
}
func EmailBindStart(c *gin.Context) {
identity, ok := middleware.GetStepUpIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
}
succeeded, notificationFailed := false, false
defer func() {
recordUserSecurityAudit(c, identity.UserID, "user.binding_start", map[string]any{"provider": "email", "success": succeeded, "notification_failed": notificationFailed})
}()
var request emailBindRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
writeSecurityOperationError(c, service.ErrVerificationContextInvalid)
return
}
email, err := service.ValidateAccountEmail(request.Email)
if err != nil {
writeSecurityOperationError(c, err)
return
}
context, err := common.Marshal(service.AccountBindingContext{Provider: "email", Email: email})
if err != nil {
writeSecurityOperationError(c, err)
return
}
authorization := middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context})
if authorization == nil {
return
}
data, err := service.StartEmailBinding(identity, authorization, email)
if err != nil {
writeSecurityOperationError(c, err)
return
}
succeeded, notificationFailed = true, data.NotificationWarning
common.ApiSuccess(c, data)
}
func EmailBindResend(c *gin.Context) {
identity, ok := middleware.GetStepUpIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
}
succeeded := false
defer func() {
recordUserSecurityAudit(c, identity.UserID, "user.email_binding_resend", map[string]any{"success": succeeded})
}()
var request emailBindRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil || request.FlowToken == "" {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
data, err := service.ResendAccountEmailBinding(identity, request.FlowToken)
if err != nil {
writeSecurityOperationError(c, err)
return
}
succeeded = true
common.ApiSuccess(c, data)
}
func EmailBind(c *gin.Context) {
identity, ok := middleware.GetStepUpIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
}
succeeded, notificationFailed := false, false
defer func() {
recordUserSecurityAudit(c, identity.UserID, "user.binding_bind", map[string]any{"provider": "email", "success": succeeded, "notification_failed": notificationFailed})
}()
var request emailBindRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil || request.FlowToken == "" {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
state, err := service.FinishEmailBinding(identity, request.FlowToken, request.NewCode, request.OldCode)
if err != nil {
writeSecurityOperationError(c, err)
return
}
succeeded = true
notificationFailed = service.NotifyAccountSecurityChange(state.CurrentEmail, "Email address changed") != nil
if err := service.NotifyAccountSecurityChange(state.Email, "Email address confirmed"); err != nil {
notificationFailed = true
}
if err := model.PublishUserAuthCache(identity.UserID); err != nil {
writeSecurityOperationError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"notification_warning": notificationFailed}})
}