Class: StandardId::Passwordless::VerificationService

Inherits:
Object
  • Object
show all
Defined in:
lib/standard_id/passwordless/verification_service.rb

Defined Under Namespace

Classes: Result

Constant Summary collapse

STRATEGY_MAP =
{
  "email" => StandardId::Passwordless::EmailStrategy,
  "sms"   => StandardId::Passwordless::SmsStrategy
}.freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(email: nil, phone: nil, code:, request:) ⇒ VerificationService

Returns a new instance of VerificationService.



75
76
77
78
79
# File 'lib/standard_id/passwordless/verification_service.rb', line 75

def initialize(email: nil, phone: nil, code:, request:)
  @code = code.to_s.strip
  @request = request
  resolve_target_and_channel!(email, phone)
end

Class Method Details

.verify(email: nil, phone: nil, code:, request:, connection: nil, username: nil) ⇒ Result

Verify a passwordless OTP code and resolve the account.

OTP_VALIDATION_FAILED / PASSWORDLESS_CODE_FAILED events are only emitted when an active challenge exists but the code is wrong. Requests with no matching challenge (e.g. fabricated usernames) do not emit failure events — this avoids noise from speculative probes that never triggered a code. NOTE: This is a behavioral change from the pre-extraction API flow (PasswordlessOtpFlow), which emitted failure events unconditionally.

Examples:

Using connection/username (preferred for callers with channel info)

result = StandardId::Passwordless::VerificationService.verify(
  connection: "email",
  username: "user@example.com",
  code: "123456",
  request: request
)

Using email/phone directly

result = StandardId::Passwordless::VerificationService.verify(
  email: "user@example.com",
  code: "123456",
  request: request
)
if result.success?
  (result.)
else
  render_error(result.error)
end

Parameters:

  • email (String, nil) (defaults to: nil)

    The email address (mutually exclusive with phone)

  • phone (String, nil) (defaults to: nil)

    The phone number (mutually exclusive with email)

  • connection (String, nil) (defaults to: nil)

    Channel type (“email” or “sms”) — convenience alternative to email:/phone: (use with username:)

  • username (String, nil) (defaults to: nil)

    The identifier value — used with connection:

  • code (String)

    The OTP code to verify

  • request (ActionDispatch::Request)

    The current request (needed for strategy)

Returns:

  • (Result)

    A result object with success?, account, challenge, error, and attempts



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/standard_id/passwordless/verification_service.rb', line 57

def verify(email: nil, phone: nil, code:, request:, connection: nil, username: nil)
  # Allow callers to use connection:/username: instead of email:/phone:
  if connection.present?
    if username.blank?
      raise StandardId::InvalidRequestError, "username: is required when connection: is provided"
    end

    case connection.to_s
    when "email" then email = username
    when "sms"   then phone = username
    else raise StandardId::InvalidRequestError, "Unsupported connection type: #{connection}"
    end
  end

  new(email: email, phone: phone, code: code, request: request).verify
end

Instance Method Details

#verifyObject



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/standard_id/passwordless/verification_service.rb', line 81

def verify
  if @code.blank?
    return failure("Code is required")
  end

  challenge = find_active_challenge
  code_matches = challenge.present? && secure_compare(challenge.code, @code)
  attempts = record_failed_attempt(challenge, code_matches)

  unless code_matches
    emit_otp_validation_failed(attempts) if challenge.present?
    return failure("Invalid or expired verification code", attempts: attempts)
  end

  # Re-fetch with lock inside a transaction to prevent concurrent use.
  result = nil
  ActiveRecord::Base.transaction do
    locked_challenge = StandardId::CodeChallenge.lock.find(challenge.id)
    unless locked_challenge.active?
      # No OTP_VALIDATION_FAILED event here: the code was correct but the
      # challenge was consumed by a concurrent request — not an attacker
      # guessing codes. Emitting a failure event would be misleading.
      result = failure("Invalid or expired verification code", attempts: attempts)
      raise ActiveRecord::Rollback
    end

    strategy = strategy_for(@channel)
     = strategy.(@target)

    locked_challenge.use!

    result = success(account: , challenge: locked_challenge)
  end

  raise "BUG: transaction block failed to set result" if result.nil?

  # Emit events after the transaction commits so subscribers never see
  # events for rolled-back state.
  emit_otp_validated(result., result.challenge) if result.success?

  result
rescue ActiveRecord::RecordNotFound
  failure("Invalid or expired verification code")
rescue ActiveRecord::RecordInvalid => e
  failure("Unable to complete verification: #{e.record.errors.full_messages.to_sentence}")
end