Files

206 lines
7.5 KiB
Go

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
}