Ctrl K
ComunidadGuías…

model/email_binding.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package model

import (
	"errors"
	"time"

	"github.com/QuantumNous/new-api/common"
	"gorm.io/gorm"
)

const (
	EmailBindingTTL         = 10 * time.Minute
	EmailBindingResendDelay = time.Minute
	EmailBindingMaxAttempts = 5
)

var (
	ErrEmailBindingCodeInvalid = errors.New("Email verification code is incorrect.")
	ErrEmailBindingLocked      = errors.New("Too many incorrect codes. Start email verification again.")
	ErrEmailBindingResendWait  = errors.New("Please wait before requesting another verification code.")
)

// EmailBindingState is server-owned payload. It deliberately contains only
// salted code hashes and an already-consumed authorization, never usable codes
// or proof tokens. Resending must retain the deadline and failure counter.
type EmailBindingState struct {
	Authorization  *AuthFlowAuthorization `json:"authorization"`
	CurrentEmail   string                 `json:"current_email"`
	Email          string                 `json:"email"`
	NewCodeHash    string                 `json:"new_code_hash"`
	OldCodeHash    string                 `json:"old_code_hash,omitempty"`
	FailedAttempts int                    `json:"failed_attempts"`
	ResendAt       int64                  `json:"resend_at"`
}

func CreateEmailBinding(identity AuthSessionIdentity, state EmailBindingState) (string, *AuthFlow, error) {
	var token string
	var flow *AuthFlow
	err := DB.Transaction(func(tx *gorm.DB) error {
		if err := validateEmailBindingAccountWithTx(tx, identity, &state); err != nil {
			return err
		}
		if err := ensureEmailAvailableWithTx(tx, state.Email, identity.UserID); err != nil {
			return err
		}
		payload, err := common.Marshal(state)
		if err != nil {
			return err
		}
		token, flow, err = createAuthFlowWithTx(tx, AuthFlowCreate{
			Purpose: AuthFlowPurposeEmailBinding, UserId: identity.UserID, SessionId: identity.SessionID,
			Payload: string(payload), ExpiresAt: time.Now().Add(EmailBindingTTL),
		})
		return err
	})
	return token, flow, err
}

func GetEmailBinding(identity AuthSessionIdentity, token string) (*AuthFlow, *EmailBindingState, error) {
	flow, err := GetAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeEmailBinding, UserId: identity.UserID, SessionId: identity.SessionID})
	if err != nil {
		return nil, nil, err
	}
	var state EmailBindingState
	if err := common.UnmarshalJsonStr(flow.Payload, &state); err != nil {
		return nil, nil, ErrAuthFlowInvalid
	}
	return flow, &state, nil
}

func ResendEmailBinding(identity AuthSessionIdentity, token, newCodeHash, oldCodeHash string) (*AuthFlow, *EmailBindingState, error) {
	var flow *AuthFlow
	var state *EmailBindingState
	err := DB.Transaction(func(tx *gorm.DB) error {
		var err error
		flow, state, err = lockEmailBindingWithTx(tx, identity, token)
		if err != nil {
			return err
		}
		now := time.Now()
		if state.ResendAt > now.Unix() {
			return ErrEmailBindingResendWait
		}
		if newCodeHash == "" || (state.OldCodeHash != "") != (oldCodeHash != "") {
			return ErrAuthFlowInvalid
		}
		state.NewCodeHash, state.OldCodeHash = newCodeHash, oldCodeHash
		state.ResendAt = now.Add(EmailBindingResendDelay).Unix()
		payload, err := common.Marshal(state)
		if err != nil {
			return err
		}
		return tx.Model(flow).Update("payload", string(payload)).Error
	})
	return flow, state, err
}

// CompleteEmailBinding commits failed attempts even though verification fails.
// Successful confirmation consumes the flow in the same transaction as the
// email update, preserving the existing cross-database email ownership lock.
func CompleteEmailBinding(identity AuthSessionIdentity, token, email, newCode, oldCode string) (*EmailBindingState, error) {
	var state *EmailBindingState
	var verificationError error
	err := DB.Transaction(func(tx *gorm.DB) error {
		// Acquire ownership protection before the first consistent read. Under
		// MySQL REPEATABLE READ, a snapshot created before this lock could hide
		// an address claimed by a concurrent transaction while we were waiting.
		if email == "" || email != NormalizeEmail(email) {
			return ErrAuthFlowInvalid
		}
		if err := lockNormalizedEmail(tx, email); err != nil {
			return err
		}
		flow, lockedState, err := lockEmailBindingWithTx(tx, identity, token)
		if err != nil {
			return err
		}
		state = lockedState
		if state.Email != email {
			return ErrAuthFlowInvalid
		}
		newCode, newCodeError := common.ValidateNumericCode(newCode)
		oldCode, oldCodeError := common.ValidateNumericCode(oldCode)
		newValid := newCodeError == nil && common.ValidatePasswordAndHash(newCode, state.NewCodeHash)
		oldValid := state.OldCodeHash == "" || oldCodeError == nil && common.ValidatePasswordAndHash(oldCode, state.OldCodeHash)
		if !newValid || !oldValid {
			state.FailedAttempts++
			verificationError = ErrEmailBindingCodeInvalid
			if state.FailedAttempts >= EmailBindingMaxAttempts {
				verificationError = ErrEmailBindingLocked
			}
			payload, err := common.Marshal(state)
			if err != nil {
				return err
			}
			return tx.Model(flow).Update("payload", string(payload)).Error
		}
		if err := ensureEmailAvailableWithTx(tx, state.Email, identity.UserID); err != nil {
			return err
		}
		if err := tx.Model(&User{}).Where("id = ?", identity.UserID).Update("email", state.Email).Error; err != nil {
			return err
		}
		return tx.Model(flow).Update("consumed_at", time.Now()).Error
	})
	if err != nil {
		return nil, err
	}
	return state, verificationError
}

func lockEmailBindingWithTx(tx *gorm.DB, identity AuthSessionIdentity, token string) (*AuthFlow, *EmailBindingState, error) {
	match := AuthFlowMatch{Purpose: AuthFlowPurposeEmailBinding, UserId: identity.UserID, SessionId: identity.SessionID}
	// Make the first statement a write so SQLite does not have to upgrade a
	// deferred read transaction. A no-op update also locks the row on MySQL and
	// PostgreSQL; do not interpret dialect-dependent RowsAffected as success.
	if err := applyAuthFlowMatch(tx.Model(&AuthFlow{}), token, match).
		Where("consumed_at IS NULL AND expires_at > ?", time.Now()).
		UpdateColumn("payload", gorm.Expr("payload")).Error; err != nil {
		return nil, nil, err
	}
	var flow AuthFlow
	if err := applyAuthFlowMatch(tx, token, match).First(&flow).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, nil, ErrAuthFlowInvalid
		}
		return nil, nil, err
	}
	if flow.ConsumedAt != nil {
		return nil, nil, ErrAuthFlowConsumed
	}
	if !flow.ExpiresAt.After(time.Now()) {
		return nil, nil, ErrAuthFlowExpired
	}
	var state EmailBindingState
	if err := common.UnmarshalJsonStr(flow.Payload, &state); err != nil {
		return nil, nil, ErrAuthFlowInvalid
	}
	if err := validateEmailBindingAccountWithTx(tx, identity, &state); err != nil {
		return nil, nil, err
	}
	if state.FailedAttempts >= EmailBindingMaxAttempts {
		return nil, nil, ErrEmailBindingLocked
	}
	return &flow, &state, nil
}

func validateEmailBindingAccountWithTx(tx *gorm.DB, identity AuthSessionIdentity, state *EmailBindingState) error {
	if state.Authorization == nil || state.Authorization.ProofID <= 0 || state.Authorization.AuthSessionIdentity != identity ||
		state.Authorization.Scope != "account.binding.bind" || state.Authorization.ContextHash == "" ||
		state.Email == "" || state.Email != NormalizeEmail(state.Email) || state.NewCodeHash == "" {
		return ErrAuthFlowInvalid
	}
	if err := ValidateAuthSessionWithTx(tx, identity); err != nil {
		return err
	}
	var user User
	if err := tx.Select("email").First(&user, identity.UserID).Error; err != nil {
		return err
	}
	if NormalizeEmail(user.Email) != state.CurrentEmail || state.CurrentEmail == state.Email {
		return ErrAccountBindingChanged
	}
	return nil
}