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) ⇒ Result
Verify a passwordless OTP code and resolve the account.
Instance Method Summary collapse
-
#initialize(email: nil, phone: nil, code:, request:, allow_registration: true) ⇒ VerificationService
constructor
A new instance of VerificationService.
- #verify ⇒ Object
Constructor Details
#initialize(email: nil, phone: nil, code:, request:, allow_registration: true) ⇒ VerificationService
Returns a new instance of VerificationService.
78 79 80 81 82 83 |
# File 'lib/standard_id/passwordless/verification_service.rb', line 78 def initialize(email: nil, phone: nil, code:, request:, allow_registration: true) @code = code.to_s.strip @request = request @allow_registration = allow_registration resolve_target_and_channel!(email, phone) end |
Class Method Details
.verify(email: nil, phone: nil, code:, request:, connection: nil, username: nil, allow_registration: 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.
60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
# File 'lib/standard_id/passwordless/verification_service.rb', line 60 def verify(email: nil, phone: nil, code:, request:, connection: nil, username: nil, allow_registration: 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).verify end |
Instance Method Details
#verify ⇒ Object
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 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 |
# File 'lib/standard_id/passwordless/verification_service.rb', line 85 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? 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 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 locked_challenge.use! result = success(account: 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.account, result.challenge) if result.success? 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..to_sentence}", error_code: :server_error) end |