mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 01:17:09 +08:00
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.
207 lines
7.4 KiB
Go
207 lines
7.4 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"math/big"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/i18n"
|
|
"github.com/QuantumNous/new-api/model"
|
|
)
|
|
|
|
var (
|
|
ErrAccountEmailInvalid = errors.New("Please enter a valid email address")
|
|
ErrAccountEmailRestricted = errors.New("This email address is not allowed by the administrator's email policy.")
|
|
ErrEmailBindingDelivery = errors.New("Verification email could not be sent. Start email verification again.")
|
|
)
|
|
|
|
type EmailBindingData struct {
|
|
FlowToken string `json:"flow_token"`
|
|
Email string `json:"email"`
|
|
CurrentEmail string `json:"current_email,omitempty"`
|
|
OldEmailRequired bool `json:"old_email_required"`
|
|
ExpiresAt int64 `json:"expires_at"`
|
|
ResendAt int64 `json:"resend_at"`
|
|
NotificationWarning bool `json:"notification_warning"`
|
|
}
|
|
|
|
// ValidateAccountEmail is shared with registration mail delivery so binding
|
|
// cannot bypass the site's existing domain, alias and address-length rules.
|
|
func ValidateAccountEmail(email string) (string, error) {
|
|
email = model.NormalizeEmail(email)
|
|
if common.Validate.Var(email, "required,email,max=50") != nil {
|
|
return "", ErrAccountEmailInvalid
|
|
}
|
|
parts := strings.Split(email, "@")
|
|
if len(parts) != 2 {
|
|
return "", ErrAccountEmailInvalid
|
|
}
|
|
if common.EmailDomainRestrictionEnabled {
|
|
allowed := slices.Contains(common.EmailDomainWhitelist, parts[1])
|
|
if !allowed {
|
|
return "", ErrAccountEmailRestricted
|
|
}
|
|
}
|
|
if common.EmailAliasRestrictionEnabled && strings.ContainsAny(parts[0], "+.") {
|
|
return "", ErrAccountEmailRestricted
|
|
}
|
|
return email, nil
|
|
}
|
|
|
|
func StartEmailBinding(identity AuthIdentity, authorization *model.AuthFlowAuthorization, email string, lang string) (*EmailBindingData, error) {
|
|
email, err := ValidateAccountEmail(email)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
context, err := common.Marshal(AccountBindingContext{Provider: "email", Email: email})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ValidateFlowAuthorization(identity, VerificationOperation{Scope: VerificationScopeAccountBind, Context: context}, authorization); err != nil {
|
|
return nil, err
|
|
}
|
|
user, err := model.GetUserById(identity.UserID, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
state := model.EmailBindingState{Authorization: authorization, CurrentEmail: model.NormalizeEmail(user.Email), Email: email, ResendAt: time.Now().Add(model.EmailBindingResendDelay).Unix()}
|
|
requireOld := state.CurrentEmail != "" && authorization.Method != VerificationMethodTwoFA && authorization.Method != VerificationMethodPasskey
|
|
codes, err := generateEmailBindingCodes(requireOld)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
state.NewCodeHash, state.OldCodeHash = codes.NewHash, codes.OldHash
|
|
token, flow, err := model.CreateEmailBinding(identity, state)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := sendEmailBindingCodes(lang, state, codes); err != nil {
|
|
// A partially delivered pair must not leave a usable change request.
|
|
_, _ = model.ConsumeAuthFlow(token, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeEmailBinding, UserId: identity.UserID, SessionId: identity.SessionID})
|
|
return nil, ErrEmailBindingDelivery
|
|
}
|
|
data := emailBindingData(token, flow, &state)
|
|
if state.CurrentEmail != "" && !requireOld {
|
|
data.NotificationWarning = NotifyAccountSecurityChange(lang, state.CurrentEmail, i18n.MsgSecurityEventEmailChangeRequested) != nil
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func ResendAccountEmailBinding(identity AuthIdentity, token string, lang string) (*EmailBindingData, error) {
|
|
_, state, err := model.GetEmailBinding(identity, token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
context, err := common.Marshal(AccountBindingContext{Provider: "email", Email: state.Email})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ValidateFlowAuthorization(identity, VerificationOperation{Scope: VerificationScopeAccountBind, Context: context}, state.Authorization); err != nil {
|
|
return nil, err
|
|
}
|
|
if state.ResendAt > time.Now().Unix() {
|
|
return nil, model.ErrEmailBindingResendWait
|
|
}
|
|
if state.FailedAttempts >= model.EmailBindingMaxAttempts {
|
|
return nil, model.ErrEmailBindingLocked
|
|
}
|
|
codes, err := generateEmailBindingCodes(state.OldCodeHash != "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
flow, state, err := model.ResendEmailBinding(identity, token, codes.NewHash, codes.OldHash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := sendEmailBindingCodes(lang, *state, codes); err != nil {
|
|
_, _ = model.ConsumeAuthFlow(token, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeEmailBinding, UserId: identity.UserID, SessionId: identity.SessionID})
|
|
return nil, ErrEmailBindingDelivery
|
|
}
|
|
return emailBindingData(token, flow, state), nil
|
|
}
|
|
|
|
func FinishEmailBinding(identity AuthIdentity, token, newCode, oldCode string) (*model.EmailBindingState, error) {
|
|
_, state, err := model.GetEmailBinding(identity, token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
context, err := common.Marshal(AccountBindingContext{Provider: "email", Email: state.Email})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ValidateFlowAuthorization(identity, VerificationOperation{Scope: VerificationScopeAccountBind, Context: context}, state.Authorization); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := ValidateAccountEmail(state.Email); err != nil {
|
|
return nil, err
|
|
}
|
|
return model.CompleteEmailBinding(identity, token, state.Email, newCode, oldCode)
|
|
}
|
|
|
|
type emailBindingCodes struct {
|
|
New, Old string
|
|
NewHash, OldHash string
|
|
}
|
|
|
|
func generateEmailBindingCodes(requireOld bool) (emailBindingCodes, error) {
|
|
var codes emailBindingCodes
|
|
count := 1
|
|
if requireOld {
|
|
count = 2
|
|
}
|
|
for index := 0; index < count; {
|
|
number, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
|
|
if err != nil {
|
|
return codes, err
|
|
}
|
|
code := fmt.Sprintf("%06d", number.Int64())
|
|
if code == codes.New {
|
|
continue
|
|
}
|
|
hash, err := common.Password2Hash(code)
|
|
if err != nil {
|
|
return codes, err
|
|
}
|
|
if index == 0 {
|
|
codes.New, codes.NewHash = code, hash
|
|
} else {
|
|
codes.Old, codes.OldHash = code, hash
|
|
}
|
|
index++
|
|
}
|
|
return codes, nil
|
|
}
|
|
|
|
func sendEmailBindingCodes(lang string, state model.EmailBindingState, codes emailBindingCodes) error {
|
|
subject := i18n.Translate(lang, i18n.MsgEmailBindingSubject, map[string]any{"SystemName": common.SystemName})
|
|
minutes := map[string]any{"Minutes": int(model.EmailBindingTTL / time.Minute)}
|
|
content := fmt.Sprintf("<p>%s</p><p>%s</p><p>%s</p>",
|
|
i18n.Translate(lang, i18n.MsgEmailBindingNewIntro),
|
|
i18n.Translate(lang, i18n.MsgEmailBindingCode, map[string]any{"Code": html.EscapeString(codes.New)}),
|
|
i18n.Translate(lang, i18n.MsgEmailBindingNewValidity, minutes))
|
|
if err := common.SendEmail(subject, state.Email, content); err != nil {
|
|
return err
|
|
}
|
|
if codes.Old != "" {
|
|
content = fmt.Sprintf("<p>%s</p><p>%s</p><p>%s</p>",
|
|
i18n.Translate(lang, i18n.MsgEmailBindingOldIntro),
|
|
i18n.Translate(lang, i18n.MsgEmailBindingCode, map[string]any{"Code": html.EscapeString(codes.Old)}),
|
|
i18n.Translate(lang, i18n.MsgEmailBindingOldValidity, minutes))
|
|
return common.SendEmail(subject, state.CurrentEmail, content)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func emailBindingData(token string, flow *model.AuthFlow, state *model.EmailBindingState) *EmailBindingData {
|
|
return &EmailBindingData{
|
|
FlowToken: token, Email: state.Email, CurrentEmail: common.MaskEmail(state.CurrentEmail),
|
|
OldEmailRequired: state.OldCodeHash != "", ExpiresAt: flow.ExpiresAt.Unix(), ResendAt: state.ResendAt,
|
|
}
|
|
}
|