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
# 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

  challenge = find_active_challenge

  unless challenge.present?
    # 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.
    fabricated_code = SecureRandom.random_number(10**@code.length.clamp(4, 10)).to_s.rjust(@code.length.clamp(4, 10), "0")
    secure_compare(fabricated_code, @code)
    return failure("Invalid or expired verification code", error_code: :not_found, attempts: 0)
  end

  code_matches = secure_compare(challenge.code, @code)
  attempts = record_failed_attempt(challenge, code_matches)

  unless code_matches
    emit_otp_validation_failed(attempts)

    if attempts >= StandardId.config.passwordless.max_attempts
      return failure("Too many failed attempts. Please request a new code.", error_code: :max_attempts, attempts: attempts)
    end

    return failure("Invalid or expired verification code", error_code: :invalid_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", error_code: :expired, attempts: attempts)
      raise ActiveRecord::Rollback
    end

     = 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

    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. Skip auth-oriented events when the
  # caller opted out of account resolution (non-auth realms).
  if result.success? && @resolve_account
    emit_otp_validated(result., result.challenge)
  end

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