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 }