Class: StandardId::Passwordless::VerificationService
- Inherits:
-
Object
- Object
- StandardId::Passwordless::VerificationService
- 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
-
.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.
Instance Method Summary collapse
-
#initialize(email: nil, phone: nil, code:, request:, allow_registration: true, realm: DEFAULT_REALM, resolve_account: true) ⇒ VerificationService
constructor
A new instance of VerificationService.
- #verify ⇒ Object
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_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.
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: resolve_account).verify end |
Instance Method Details
#verify ⇒ Object
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. account = nil if @resolve_account strategy = strategy_for(@channel) account = resolve_account(strategy) unless account 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, account, challenge] result = success(account: 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..to_sentence}", error_code: :server_error) end |