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) ⇒ 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.

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



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

#verifyObject



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)
     = (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

    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", error_code: :expired)
rescue ActiveRecord::RecordInvalid => e
  failure("Unable to complete verification: #{e.record.errors.full_messages.to_sentence}", error_code: :server_error)
end