Files

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,
}
}