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:, allow_registration: true, realm: DEFAULT_REALM, resolve_account: true) ⇒ VerificationService

Returns a new instance of VerificationService.



82
83
84
85
86
87
88
89
# File 'lib/standard_id/passwordless/verification_service.rb', line 82

def initialize(email: nil, phone: nil, code:, request:, allow_registration: true, realm: DEFAULT_REALM, resolve_account: true)
  @code = code.to_s.strip
  @request = request
  @allow_registration = allow_registration
  @realm = realm.to_s
  @resolve_account = 
  resolve_target_and_channel!(email, phone)
end

Class Method Details

.verify(email: nil, phone: nil, code:, request:, connection: nil, username: nil, allow_registration: true, realm: DEFAULT_REALM, resolve_account: true) ⇒ 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



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/standard_id/passwordless/verification_service.rb', line 64

def verify(email: nil, phone: nil, code:, request:, connection: nil, username: nil, allow_registration: true, realm: DEFAULT_REALM, resolve_account: true)
  # 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, allow_registration: allow_registration, realm: realm, resolve_account: ).verify
end

Instance Method Details

#verifyObject



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
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/standard_id/passwordless/verification_service.rb', line 91

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

  bypass_result = try_bypass
  return bypass_result if bypass_result

  # Lookup -> lock -> verify -> record-attempt -> consume all happen
  # inside a single transaction with a pessimistic row lock on the
  # CodeChallenge. This closes two race windows that existed previously:
  #   1. Two concurrent verifications selecting the same active challenge
  #      before either had locked it.
  #   2. Concurrent updates to challenge.metadata["attempts"] losing
  #      increments (last-writer-wins) and letting attackers exceed the
  #      per-challenge ceiling.
  #
  # Events are captured inside the transaction but emitted only after
  # the transaction commits, so subscribers never observe rolled-back
  # state.
  result = nil
  pending_events = []

  ActiveRecord::Base.transaction do
    challenge = lock_active_challenge

    unless challenge
      # Constant-time compare even when no challenge exists to prevent
      # timing-based enumeration of valid target+realm pairs. We compare
      # the submitted code against a random value of the same digit
      # length so an observer cannot distinguish "no challenge" from
      # "wrong code" by response time.
      padded_length = @code.length.clamp(4, 10)
      fabricated_code = SecureRandom.random_number(10**padded_length).to_s.rjust(padded_length, "0")
      secure_compare(fabricated_code, @code)
      # No event — matches prior behavior: we do not publish failure
      # events for probes that never triggered a real challenge.
      result = failure("Invalid or expired verification code", error_code: :not_found, attempts: 0)
      next
    end

    code_matches = secure_compare(challenge.code, @code)

    unless code_matches
      attempts = record_failed_attempt!(challenge)
      pending_events << [:otp_validation_failed, attempts]

      if attempts >= StandardId::Passwordless.max_attempts_per_challenge
        # Ceiling hit: burn the challenge so further submissions fail
        # fast (including from different IPs). Protects against
        # distributed brute-force on the same challenge. The row lock
        # is held from lock_active_challenge above, so no other
        # transaction can flip `used?` between our reads and this call.
        challenge.use!
        result = failure("Too many failed attempts. Please request a new code.", error_code: :max_attempts, attempts: attempts)
      else
        result = failure("Invalid or expired verification code", error_code: :invalid_code, attempts: attempts)
      end

      next
    end

    # Correct code. Resolve the account under the still-held lock so
    # account-resolution failures don't leak the challenge to a racing
    # verification. When @resolve_account is false (non-auth realms via
    # Otp.verify) we skip account resolution entirely and just consume
    # the challenge.
     = nil
    if @resolve_account
      strategy = strategy_for(@channel)
       = (strategy)

      unless 
        label = @channel == "sms" ? "phone number" : "email address"
        result = failure("No account found for this #{label}", error_code: :account_not_found)
        raise ActiveRecord::Rollback
      end
    end

    challenge.use!

    pending_events << [:otp_validated, , challenge]
    result = success(account: , challenge: challenge)
  end

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

  # Emit events only after the transaction commits. Skip auth-oriented
  # OTP_VALIDATED payloads when the caller opted out of account
  # resolution (non-auth realms via Otp.verify).
  pending_events.each do |event|
    case event[0]
    when :otp_validation_failed
      emit_otp_validation_failed(event[1])
    when :otp_validated
      emit_otp_validated(event[1], event[2]) if @resolve_account
    end
  end

  result
rescue ActiveRecord::RecordInvalid => e
  failure("Unable to complete verification: #{e.record.errors.full_messages.to_sentence}", error_code: :server_error)
end