Files
Calcium-Ion 6461f084ff feat(notify): turn each kind of notification on or off (#7868)
SMTP settings now only decide whether mail can be sent. Verification
codes, password reset, email binding and account security mails depend
on SMTP alone, and no notification switch affects them.

Admins get a site-wide low balance reminder switch, on by default, next
to the SMTP settings. The default threshold is entered as an amount.

Users choose which notifications they receive. Low balance reminders are
on for everyone by default. Admins can also turn channel notifications
and upstream model update notices on or off. The root user receives
channel notifications until it turns them off. Other admins receive them
after they turn them on.

A low balance reminder is sent once, when a charge takes the balance
below the threshold. A top-up above the threshold arms it again.

Channel notifications reach every admin who has them on, in that admin's
language. Email binding codes and account security mails follow the
account's language.

Saving notification settings no longer clears the language, the sidebar
layout or the billing preference.

Existing settings keep their behavior, and old clients that do not send
the new fields keep the stored switches. Webhook, Bark and Gotify
payloads are unchanged.
2026-10-17 22:17:59 +08:00

631 lines
22 KiB
Go

package controller
import (
"encoding/json"
"errors"
"net/http"
"slices"
"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/QuantumNous/new-api/service"
"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"`
Scope string `json:"scope,omitempty"`
Context json.RawMessage `json:"context,omitempty"`
}
type oauthFlowPayload struct {
AffiliateCode string `json:"affiliate_code,omitempty"`
Verification *service.OAuthVerificationFlow `json:"verification,omitempty"`
Telegram *oauth.TelegramOAuthFlow `json:"telegram,omitempty"`
SessionIdentity *service.AuthIdentity `json:"session_identity,omitempty"`
Authorization *model.AuthFlowAuthorization `json:"authorization,omitempty"`
}
// providerParams returns the params of messages that name the OAuth provider.
func providerParams(name string) map[string]any {
return map[string]any{"provider": name}
}
// GenerateOAuthCode generates a state code for OAuth CSRF protection
func GenerateOAuthCode(c *gin.Context) {
var request oauthStateRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
common.ApiErrorT(c, "Invalid parameters")
return
}
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 && request.Intent != model.AuthFlowIntentVerify) ||
len(request.Aff) > 32 ||
(request.Intent != model.AuthFlowIntentLogin && request.Aff != "") ||
(request.Intent != model.AuthFlowIntentVerify && (request.Scope != "" || len(request.Context) != 0)) {
common.ApiErrorT(c, "Invalid parameters")
return
}
userID := 0
sessionID := ""
flowPayload := oauthFlowPayload{AffiliateCode: request.Aff}
bindingStarted := false
if request.Provider == "telegram" {
telegramFlow, err := oauth.NewTelegramOAuthFlow()
if err != nil {
writeSecurityOperationError(c, err)
return
}
flowPayload.Telegram = telegramFlow
}
if request.Intent == model.AuthFlowIntentBind || request.Intent == model.AuthFlowIntentVerify {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
writeAccountStatusError(c, http.StatusUnauthorized, "Sign in to bind an account")
return
}
userID = identity.UserID
sessionID = identity.SessionID
if request.Intent == model.AuthFlowIntentBind {
defer func() {
recordUserSecurityAudit(c, userID, "user.binding_start", map[string]any{"provider": request.Provider, "success": bindingStarted})
}()
context, err := common.Marshal(service.AccountBindingContext{Provider: request.Provider})
if err != nil {
writeSecurityOperationError(c, err)
return
}
flowPayload.Authorization = middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context})
if flowPayload.Authorization == nil {
return
}
flowPayload.SessionIdentity = &identity
}
if flowPayload.Telegram != nil {
if _, _, err := service.ValidateLoginSession(identity); err != nil {
writeSecurityOperationError(c, err)
return
}
flowPayload.SessionIdentity = &identity
}
if request.Intent == model.AuthFlowIntentVerify {
verification, err := service.StartOAuthVerification(identity, service.VerificationOperation{Scope: request.Scope, Context: request.Context}, request.Provider)
if err != nil {
writeSecurityOperationError(c, err)
return
}
flowPayload.Verification = verification
}
}
payload, err := common.Marshal(flowPayload)
if err != nil {
writeSecurityOperationError(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 {
writeSecurityOperationError(c, err)
return
}
bindingStarted = request.Intent == model.AuthFlowIntentBind
data := gin.H{"flow_token": state, "expires_at": expiresAt.Unix()}
if flowPayload.Telegram != nil {
data["authorization_url"] = flowPayload.Telegram.AuthorizationURL(state)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": data,
})
}
// HandleOAuth handles OAuth callback for all standard OAuth providers
func HandleOAuth(c *gin.Context) {
providerName := c.Param("provider")
provider := oauth.GetProvider(providerName)
if provider == nil {
writeAccountStatusError(c, http.StatusBadRequest, "Unknown OAuth provider")
return
}
// 1. Validate state (CSRF protection)
state := c.Query("state")
pendingFlow, err := model.GetAuthFlow(state, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth,
Provider: providerName,
})
if err != nil {
writeAccountStatusError(c, http.StatusForbidden, "State parameter is empty or mismatched")
return
}
consumeMatch := model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth,
Provider: providerName,
Intent: pendingFlow.Intent,
}
bindSucceeded, notificationFailed := false, false
if pendingFlow.Intent == model.AuthFlowIntentBind {
defer func() {
recordUserSecurityAudit(c, pendingFlow.UserId, "user.binding_bind", map[string]any{"provider": providerName, "success": bindSucceeded, "notification_failed": notificationFailed})
}()
}
// Bind and verification callbacks must use the dashboard session that started them.
if pendingFlow.Intent == model.AuthFlowIntentBind || pendingFlow.Intent == model.AuthFlowIntentVerify {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok || identity.UserID != pendingFlow.UserId || identity.SessionID != pendingFlow.SessionId {
writeAccountStatusError(c, http.StatusForbidden, "State parameter is empty or mismatched")
return
}
consumeMatch.UserId = identity.UserID
consumeMatch.SessionId = identity.SessionID
if pendingFlow.Intent == model.AuthFlowIntentBind {
var payload oauthFlowPayload
if err := common.UnmarshalJsonStr(pendingFlow.Payload, &payload); err != nil {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
context, err := common.Marshal(service.AccountBindingContext{Provider: providerName})
if err != nil {
writeSecurityOperationError(c, err)
return
}
if err := service.ValidateFlowAuthorization(identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context}, payload.Authorization); err != nil {
writeSecurityOperationError(c, err)
return
}
}
} else if pendingFlow.Intent != model.AuthFlowIntentLogin {
common.ApiErrorT(c, "Invalid parameters")
return
}
// 3. Check if provider is enabled
var telegramPayload oauthFlowPayload
if providerName == "telegram" {
if err := oauth.TelegramConfigurationError(); err != nil {
writeSecurityOperationError(c, err)
return
}
if err := common.UnmarshalJsonStr(pendingFlow.Payload, &telegramPayload); err != nil || telegramPayload.Telegram == nil {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
if pendingFlow.Intent != model.AuthFlowIntentLogin {
identity, _ := middleware.GetSessionAuthIdentity(c)
if telegramPayload.SessionIdentity == nil || *telegramPayload.SessionIdentity != identity {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
if _, _, err := service.ValidateLoginSession(identity); err != nil {
writeSecurityOperationError(c, err)
return
}
}
c.Set(oauth.TelegramOAuthFlowContextKey, telegramPayload.Telegram)
}
if !provider.IsEnabled() {
common.ApiErrorT(c, "{{provider}} login and registration has not been enabled by administrator", providerParams(provider.GetName()))
return
}
// 4. Handle error from provider
errorCode := c.Query("error")
if errorCode != "" {
if _, err := model.ConsumeAuthFlow(state, consumeMatch); err != nil {
writeAccountStatusError(c, http.StatusForbidden, "State parameter is empty or mismatched")
return
}
errorDescription := c.Query("error_description")
if errorDescription == "" {
errorDescription = errorCode
}
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": errorDescription,
})
return
}
// 5. Exchange code for token
code := c.Query("code")
token, err := provider.ExchangeToken(c.Request.Context(), code, c)
if err != nil {
if providerName == "telegram" {
writeSecurityOperationError(c, err)
return
}
handleOAuthError(c, err)
return
}
// 6. Get user info
oauthUser, err := provider.GetUserInfo(c.Request.Context(), token)
if err != nil {
if providerName == "telegram" {
writeSecurityOperationError(c, err)
return
}
handleOAuthError(c, err)
return
}
if pendingFlow.Intent == model.AuthFlowIntentBind {
bindSucceeded, notificationFailed = handleOAuthBind(c, providerName, provider, oauthUser, pendingFlow, state, consumeMatch)
return
}
flow, err := model.ConsumeAuthFlow(state, consumeMatch)
if err != nil {
writeAccountStatusError(c, http.StatusForbidden, "State parameter is empty or mismatched")
return
}
switch flow.Intent {
case model.AuthFlowIntentLogin:
handleOAuthLogin(c, provider, oauthUser, token, flow)
case model.AuthFlowIntentVerify:
handleOAuthVerification(c, providerName, oauthUser, flow)
}
}
func handleOAuthVerification(c *gin.Context, provider string, oauthUser *oauth.OAuthUser, flow *model.AuthFlow) {
var payload oauthFlowPayload
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
writeSecurityOperationError(c, err)
return
}
identity, _ := middleware.GetSessionAuthIdentity(c)
proof, err := service.FinishOAuthVerification(identity, provider, oauthUser.ProviderUserID, payload.Verification)
if err != nil {
writeSecurityOperationError(c, err)
return
}
recordUserSecurityAudit(c, identity.UserID, "user.security_verify", map[string]any{"method": proof.Method, "scope": proof.Scope, "provider": provider})
common.ApiSuccess(c, proof)
}
func handleOAuthLogin(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, token *oauth.OAuthToken, flow *model.AuthFlow) {
// 7. Find or create user
var payload oauthFlowPayload
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
writeSecurityOperationError(c, err)
return
}
user, migration, err := findOrCreateOAuthUser(c, provider, oauthUser, token, payload.AffiliateCode)
if err != nil {
if errors.Is(err, model.ErrEmailAlreadyTaken) {
common.ApiErrorT(c, "Email address is already in use")
return
}
switch err.(type) {
case *OAuthUserDeletedError:
common.ApiErrorT(c, "User has been deleted")
case *OAuthRegistrationDisabledError:
common.ApiErrorT(c, "New user registration has been disabled by administrator")
case *OAuthEmailAlreadyTakenError:
common.ApiErrorT(c, "Email address is already in use")
case *OAuthLegacyBindingNotConfirmedError:
common.ApiErrorT(c, "This {{provider}} account cannot be linked to an existing account automatically, please sign in or register another way and then link {{provider}} in account settings", providerParams(provider.GetName()))
default:
writeSecurityOperationError(c, err)
}
return
}
// 8. Check user status
if user.Status != common.UserStatusEnabled {
common.ApiErrorT(c, "User has been banned")
return
}
// 9. Setup login
setupLogin(user, migration, c)
}
// handleOAuthBind handles binding OAuth account to existing user
func handleOAuthBind(c *gin.Context, providerName string, provider oauth.Provider, oauthUser *oauth.OAuthUser, flow *model.AuthFlow, state string, match model.AuthFlowMatch) (bool, bool) {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return false, false
}
var payload oauthFlowPayload
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return false, false
}
context, err := common.Marshal(service.AccountBindingContext{Provider: providerName})
if err != nil {
writeSecurityOperationError(c, err)
return false, false
}
// Recheck after the external provider round trip, then validate the session
// under the transaction's locks before consuming the flow and writing.
if err := service.ValidateFlowAuthorization(identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context}, payload.Authorization); err != nil {
writeSecurityOperationError(c, err)
return false, false
}
if provider.IsUserIDTaken(oauthUser.ProviderUserID) {
common.ApiErrorT(c, "This {{provider}} account has already been bound", providerParams(provider.GetName()))
return false, false
}
_, err = model.ConsumeAuthFlowWithAction(state, match, func(tx *gorm.DB, _ *model.AuthFlow) error {
if providerName == "telegram" {
return model.BindTelegramForSessionWithTx(tx, identity, oauthUser.ProviderUserID)
}
if custom, ok := provider.(*oauth.GenericOAuthProvider); ok {
return model.UpdateUserOAuthBindingForSessionWithTx(tx, identity, custom.GetProviderId(), oauthUser.ProviderUserID)
}
return model.UpdateUserBindColumnForSessionWithTx(tx, identity, provider.ProviderUserIDColumn(), oauthUser.ProviderUserID)
})
if err != nil {
writeSecurityOperationError(c, err)
return false, false
}
user, err := model.GetUserById(identity.UserID, false)
if err != nil {
writeSecurityOperationError(c, err)
return true, true
}
notificationFailed := service.NotifyAccountSecurityChange(service.AccountMailLanguage(c, user.Id), user.Email, i18n.MsgSecurityEventLoginLinked, map[string]any{"Provider": provider.GetName()}) != nil
common.ApiSuccessT(c, "Binding successful", gin.H{"action": "bind", "notification_warning": notificationFailed})
return true, notificationFailed
}
// findOrCreateOAuthUser finds the existing user or creates a new one. For a
// legacy GitHub binding that still waits for the login verification, it also
// returns the rewrite to carry into the challenge.
func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, token *oauth.OAuthToken, affiliateCode string) (*model.User, *service.LegacyGitHubMigration, error) {
user := &model.User{}
if provider.ProviderUserIDColumn() == "telegram_id" {
err := provider.FillUserByProviderID(user, oauthUser.ProviderUserID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, oauth.ErrTelegramAccountNotBound
}
return user, nil, err
}
// Check if user already exists with new ID
if provider.IsUserIDTaken(oauthUser.ProviderUserID) {
err := provider.FillUserByProviderID(user, oauthUser.ProviderUserID)
if err != nil {
return nil, nil, err
}
// Check if user has been deleted
if user.Id == 0 {
return nil, nil, &OAuthUserDeletedError{}
}
return user, nil, nil
}
// Legacy GitHub bindings stored the login name, which only points at a
// candidate account. Values that are all digits are already numeric account
// IDs and never take part in the comparison.
legacyID, _ := oauthUser.Extra["legacy_id"].(string)
if strings.ContainsFunc(legacyID, func(r rune) bool { return r < '0' || r > '9' }) && provider.IsUserIDTaken(legacyID) {
if err := provider.FillUserByProviderID(user, legacyID); err != nil {
return nil, nil, err
}
if user.Id != 0 {
state, err := model.GetUserVerificationState(user.Id)
if err != nil {
return nil, nil, err
}
if state.HasTwoFA || state.HasPasskey {
// The rewrite is written in the transaction that issues the session
// once the login verification completes.
return user, &service.LegacyGitHubMigration{GitHubID: oauthUser.ProviderUserID, LegacyID: legacyID}, nil
}
// Without a second factor, one of the addresses the provider has
// confirmed must match the account email. The list is fetched only here
// and never recorded.
reason, matched := "no_matching_evidence", false
if emailProvider, ok := provider.(oauth.VerifiedEmailProvider); ok && user.Email != "" {
emails, err := emailProvider.GetVerifiedEmails(c.Request.Context(), token)
if err != nil {
common.SysError(common.LogText("[OAuth] Failed to load verified emails for user %d: %s", user.Id, err.Error()))
reason = "verified_emails_unavailable"
}
accountEmail := model.NormalizeEmail(user.Email)
matched = slices.ContainsFunc(emails, func(email string) bool { return model.NormalizeEmail(email) == accountEmail })
}
if !matched {
recordLegacyGitHubBindingAudit(c, user, false, map[string]any{"legacy_id": legacyID, "provider_user_id": oauthUser.ProviderUserID, "reason": reason})
return nil, nil, &OAuthLegacyBindingNotConfirmedError{}
}
written := false
err = model.DB.Transaction(func(tx *gorm.DB) error {
var err error
written, err = model.MigrateLegacyGitHubBindingWithTx(tx, user.Id, legacyID, oauthUser.ProviderUserID)
return err
})
if err != nil {
return nil, nil, err
}
if written {
user.GitHubId = oauthUser.ProviderUserID
notificationFailed := service.NotifyAccountSecurityChange(service.AccountMailLanguage(c, user.Id), user.Email, i18n.MsgSecurityEventLoginLinked, map[string]any{"Provider": provider.GetName()}) != nil
recordLegacyGitHubBindingAudit(c, user, true, map[string]any{
"legacy_id": legacyID, "provider_user_id": oauthUser.ProviderUserID,
"verified_email_matched": true, "notification_failed": notificationFailed,
})
}
return user, nil, nil
}
}
// User doesn't exist, create new user if registration is enabled
if !common.RegisterEnabled {
return nil, nil, &OAuthRegistrationDisabledError{}
}
// Set up new user
user.Username = provider.GetProviderPrefix() + strconv.Itoa(model.GetMaxUserId()+1)
if oauthUser.Username != "" {
if exists, err := model.CheckUserExistOrDeleted(oauthUser.Username, ""); err == nil && !exists {
// 防止索引退化
if len(oauthUser.Username) <= model.UserNameMaxLength {
user.Username = oauthUser.Username
}
}
}
if oauthUser.DisplayName != "" {
user.DisplayName = oauthUser.DisplayName
} else if oauthUser.Username != "" {
user.DisplayName = oauthUser.Username
} else {
user.DisplayName = provider.GetName() + " User"
}
if oauthUser.Email != "" {
user.Email = model.NormalizeEmail(oauthUser.Email)
if err := model.EnsureEmailAvailable(user.Email, 0); err != nil {
if errors.Is(err, model.ErrEmailAlreadyTaken) {
return nil, nil, &OAuthEmailAlreadyTakenError{}
}
return nil, nil, err
}
}
user.Role = common.RoleCommonUser
user.Status = common.UserStatusEnabled
// Handle affiliate code
inviterId := 0
if affiliateCode != "" {
inviterId, _ = model.GetUserIdByAffCode(affiliateCode)
}
// Use transaction to ensure user creation and OAuth binding are atomic
if genericProvider, ok := provider.(*oauth.GenericOAuthProvider); ok {
// Custom provider: create user and binding in a transaction
err := model.DB.Transaction(func(tx *gorm.DB) error {
// Create user
if err := user.InsertWithTx(tx, inviterId); err != nil {
return err
}
// Create OAuth binding
binding := &model.UserOAuthBinding{
UserId: user.Id,
ProviderId: genericProvider.GetProviderId(),
ProviderUserId: oauthUser.ProviderUserID,
}
if err := model.CreateUserOAuthBindingWithTx(tx, binding); err != nil {
return err
}
return nil
})
if err != nil {
return nil, nil, err
}
// Perform post-transaction tasks (logs, sidebar config, inviter rewards)
user.FinalizeOAuthUserCreation(inviterId)
} else {
// Built-in provider: create user and update provider ID in a transaction
err := model.DB.Transaction(func(tx *gorm.DB) error {
// Create user
if err := user.InsertWithTx(tx, inviterId); err != nil {
return err
}
// Set the provider user ID on the user model and update
provider.SetProviderUserID(user, oauthUser.ProviderUserID)
if err := tx.Model(user).Updates(map[string]any{
"github_id": user.GitHubId,
"discord_id": user.DiscordId,
"oidc_id": user.OidcId,
"linux_do_id": user.LinuxDOId,
"wechat_id": user.WeChatId,
"telegram_id": user.TelegramId,
}).Error; err != nil {
return err
}
return nil
})
if err != nil {
return nil, nil, err
}
// Perform post-transaction tasks
user.FinalizeOAuthUserCreation(inviterId)
}
return user, nil, nil
}
// recordLegacyGitHubBindingAudit records the outcome of a legacy GitHub binding
// rewrite as an account binding event. No session exists yet on the login path,
// so the row carries the user's own role instead of the request context.
func recordLegacyGitHubBindingAudit(c *gin.Context, user *model.User, success bool, params map[string]any) {
params["provider"], params["legacy_migration"], params["success"] = "github", true, success
model.RecordOperationAuditLog(user.Id, user.Role, auditContentEN("user.binding_bind", params), c.ClientIP(), "user.binding_bind", params, nil, &model.AuditRequestInfo{
Method: c.Request.Method, Route: c.FullPath(), Path: c.FullPath(), Status: c.Writer.Status(), Success: success,
}, c)
}
// Error types for OAuth
type OAuthUserDeletedError struct{}
func (e *OAuthUserDeletedError) Error() string {
return "user has been deleted"
}
type OAuthRegistrationDisabledError struct{}
func (e *OAuthRegistrationDisabledError) Error() string {
return "registration is disabled"
}
type OAuthEmailAlreadyTakenError struct{}
func (e *OAuthEmailAlreadyTakenError) Error() string {
return "email is already in use"
}
// OAuthLegacyBindingNotConfirmedError reports a legacy GitHub binding match that
// neither a second factor nor a confirmed provider email backed.
type OAuthLegacyBindingNotConfirmedError struct{}
func (e *OAuthLegacyBindingNotConfirmedError) Error() string {
return "legacy binding was not confirmed"
}
// handleOAuthError handles OAuth errors and returns a web console message
func handleOAuthError(c *gin.Context, err error) {
switch e := err.(type) {
case *oauth.OAuthError:
common.ApiError(c, e.Message)
case *oauth.AccessDeniedError:
common.ApiErrorMsg(c, e.Message)
case *oauth.TrustLevelError:
common.ApiErrorT(c, "Linux DO trust level does not meet the minimum required by administrator")
default:
writeSecurityOperationError(c, err)
}
}