Module: Parse::MFA::UserExtension

Extended by:
ActiveSupport::Concern
Included in:
User
Defined in:
lib/parse/two_factor_auth/user_extension.rb

Overview

User extension module that adds MFA capabilities to Parse::User.

This module integrates with Parse Server’s built-in MFA adapter, which stores MFA data in the user’s authData.mfa field.

Parse Server Configuration Required

Your Parse Server must have MFA enabled in the auth configuration:

{
  auth: {
    mfa: {
      enabled: true,
      options: ["TOTP"],
      digits: 6,
      period: 30,
      algorithm: "SHA1"
    }
  }
}

See Also:

Defined Under Namespace

Modules: ClassMethods

Instance Method Summary collapse

Instance Method Details

#confirm_sms_mfa!(mobile:, token:) ⇒ Boolean

Confirm SMS MFA setup with the received code.

Examples:

user.confirm_sms_mfa!(mobile: "+14155551234", token: "123456")

Parameters:

  • mobile (String, Parse::Phone)

    The mobile number that was used in setup (E.164 format)

  • token (String)

    The SMS code received

Returns:

  • (Boolean)

    True if confirmed successfully

Raises:



226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/parse/two_factor_auth/user_extension.rb', line 226

def confirm_sms_mfa!(mobile:, token:)
  raise ArgumentError, "Mobile number is required" if mobile.blank?
  raise ArgumentError, "Token is required" if token.blank?

  # Use Parse::Phone for validation
  phone = mobile.is_a?(Parse::Phone) ? mobile : Parse::Phone.new(mobile)
  unless phone.valid?
    raise ArgumentError, "Invalid mobile number format. Must be E.164 format: +[country code][number] (e.g., +14155551234)"
  end

  mobile = phone.to_s  # Use normalized E.164 format

  auth_data_payload = {
    mfa: {
      mobile: mobile,
      token: token,
    },
  }

  response = client.update_user(id, { authData: auth_data_payload }, opts: { session_token: session_token })

  if response.error?
    if response.result.to_s.include?("Invalid MFA token")
      raise MFA::VerificationError, response.result.to_s
    end
    raise Parse::Client::ResponseError, response
  end

  # Refresh auth_data
  fetch

  true
end

#disable_mfa!(current_token:) ⇒ Boolean

Disable MFA for this user.

This requires a valid current MFA token (TOTP or recovery code) to verify the user’s identity before disabling MFA.

Examples:

user.disable_mfa!(current_token: "123456")

Parameters:

  • current_token (String)

    Current TOTP code or recovery code

Returns:

  • (Boolean)

    True if disabled successfully

Raises:



272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
# File 'lib/parse/two_factor_auth/user_extension.rb', line 272

def disable_mfa!(current_token:)
  raise MFA::NotEnabledError, "MFA is not enabled for this user" unless mfa_enabled?
  raise ArgumentError, "Current token is required" if current_token.blank?

  # To disable, we need to update authData.mfa with the old token for validation
  # and then set it to null
  auth_data_payload = {
    mfa: {
      old: current_token,
      secret: nil,  # Setting to nil disables TOTP
    },
  }

  response = client.update_user(id, { authData: auth_data_payload }, opts: { session_token: session_token })

  if response.error?
    if response.result.to_s.include?("Invalid MFA token")
      raise MFA::VerificationError, response.result.to_s
    end
    raise Parse::Client::ResponseError, response
  end

  # Refresh auth_data
  fetch

  true
end

#disable_mfa_admin!(*args, **kwargs) ⇒ Object

Deprecated.

Use #disable_mfa_master_key! with an explicit authorized_by: argument. The old name had no authorization gate and acted as a one-call IDOR primitive when invoked on an attacker-controlled user instance.



373
374
375
376
377
# File 'lib/parse/two_factor_auth/user_extension.rb', line 373

def disable_mfa_admin!(*args, **kwargs)
  warn "[DEPRECATION] `disable_mfa_admin!` is deprecated; use " \
       "`disable_mfa_master_key!(authorized_by: <admin user>)`."
  disable_mfa_master_key!(*args, **kwargs)
end

#disable_mfa_master_key!(authorized_by:, admin_role: nil) ⇒ Boolean

Disable MFA using the configured master key. This bypasses MFA verification entirely, so the caller must prove (out-of-band) that the operator initiating the disable is authorized to do so.

The authorized_by: keyword is required and must be a User (or Pointer to a User) representing the operator performing the override. The caller is responsible for verifying that operator’s privileges (e.g. via a role check). An optional admin_role: argument lets this method enforce a role membership check on the operator using the existing role-hierarchy support; when given, the operator must belong to the role (or any of its child roles) or ForbiddenError is raised.

Examples:

Caller-verified authorization

user.disable_mfa_master_key!(authorized_by: current_admin)

Library-enforced role check

user.disable_mfa_master_key!(authorized_by: current_admin,
                             admin_role: "Admin")

Parameters:

  • authorized_by (Parse::User, Parse::Pointer)

    the operator performing the override. Required.

  • admin_role (Parse::Role, String, nil) (defaults to: nil)

    optional role (or role name) that authorized_by must belong to.

Returns:

  • (Boolean)

    True if disabled successfully.

Raises:

  • (ArgumentError)

    when authorized_by: is missing or not a User.

  • (Parse::MFA::ForbiddenError)

    when admin_role is supplied and the operator is not a member.



328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
# File 'lib/parse/two_factor_auth/user_extension.rb', line 328

def disable_mfa_master_key!(authorized_by:, admin_role: nil)
  operator = authorized_by
  unless operator.is_a?(Parse::User) ||
         (operator.is_a?(Parse::Pointer) && operator.parse_class == Parse::User.parse_class)
    raise ArgumentError,
          "disable_mfa_master_key! requires authorized_by: to be a Parse::User " \
          "or Parse::Pointer to a User (got #{operator.class})"
  end
  if operator.respond_to?(:id) && operator.id.blank?
    raise ArgumentError, "authorized_by: User must be persisted (have an objectId)"
  end

  if admin_role
    role = admin_role.is_a?(Parse::Role) ? admin_role : Parse::Role.find_by_name(admin_role.to_s)
    if role.nil?
      raise MFA::ForbiddenError,
            "authorized_by user is not authorized: admin role " \
            "#{admin_role.inspect} not found"
    end
    operator_id = operator.id
    authorized = role.all_users.any? { |u| u.id == operator_id }
    unless authorized
      raise MFA::ForbiddenError,
            "authorized_by user #{operator_id} is not a member of " \
            "role #{role.name.inspect}"
    end
  end

  auth_data_payload = { mfa: nil }
  response = client.update_user(id, { authData: auth_data_payload }, opts: { use_master_key: true })

  if response.error?
    raise Parse::Client::ResponseError, response
  end

  # Refresh auth_data
  fetch

  true
end

#login_with_mfa!(password, mfa_token = nil) ⇒ Boolean

Login this user instance with password and MFA token.

Examples:

user = Parse::User.first
user.("password123", "123456")

Parameters:

  • password (String)

    The password

  • mfa_token (String) (defaults to: nil)

    The TOTP code or recovery code

Returns:

  • (Boolean)

    True if login successful

Raises:



390
391
392
393
394
395
396
397
398
399
400
401
# File 'lib/parse/two_factor_auth/user_extension.rb', line 390

def (password, mfa_token = nil)
  response = client.(username.to_s, password.to_s, mfa_token)
  apply_attributes!(response.result)
  session_token.present?
rescue Parse::Client::ResponseError => e
  if e.message.include?("Missing additional authData")
    raise MFA::RequiredError, "MFA token is required for this account"
  elsif e.message.include?("Invalid MFA token")
    raise MFA::VerificationError, e.message
  end
  raise
end

#mfa_enabled?Boolean

Check if MFA is enabled for this user.

Returns:

  • (Boolean)

    True if MFA is enabled



87
88
89
90
91
92
93
94
# File 'lib/parse/two_factor_auth/user_extension.rb', line 87

def mfa_enabled?
  return false unless auth_data.is_a?(Hash)
  return false unless auth_data["mfa"].is_a?(Hash)

  # Parse Server's afterFind returns { status: "enabled" } for enabled MFA
  mfa_data = auth_data["mfa"]
  mfa_data["status"] == "enabled" || mfa_data["secret"].present? || mfa_data["mobile"].present?
end

#mfa_provisioning_uri(secret, issuer: nil) ⇒ String

Generate a provisioning URI for this user.

Use this to create a QR code for the user to scan with their authenticator app.

Examples:

secret = Parse::MFA.generate_secret
uri = user.mfa_provisioning_uri(secret, issuer: "MyApp")

Parameters:

  • secret (String)

    The TOTP secret

  • issuer (String) (defaults to: nil)

    Optional custom issuer name

Returns:



415
416
417
418
# File 'lib/parse/two_factor_auth/user_extension.rb', line 415

def mfa_provisioning_uri(secret, issuer: nil)
   = email.presence || username.presence || id
  MFA.provisioning_uri(secret, , issuer: issuer)
end

#mfa_qr_code(secret, issuer: nil, format: :svg) ⇒ String

Generate a QR code for MFA setup.

Examples:

secret = Parse::MFA.generate_secret
qr_svg = user.mfa_qr_code(secret, issuer: "MyApp")
# Render in HTML: <%= raw qr_svg %>

Parameters:

  • secret (String)

    The TOTP secret

  • issuer (String) (defaults to: nil)

    Optional custom issuer name

  • format (Symbol) (defaults to: :svg)

    Output format (:svg, :png, :ascii)

Returns:

  • (String)

    QR code in specified format



431
432
433
434
# File 'lib/parse/two_factor_auth/user_extension.rb', line 431

def mfa_qr_code(secret, issuer: nil, format: :svg)
   = email.presence || username.presence || id
  MFA.qr_code(secret, , issuer: issuer, format: format)
end

#mfa_statusSymbol

Get the MFA status for this user.

Returns:

  • (Symbol)

    :enabled, :disabled, or :unknown



99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/parse/two_factor_auth/user_extension.rb', line 99

def mfa_status
  return :unknown unless auth_data.is_a?(Hash)
  return :disabled unless auth_data["mfa"].is_a?(Hash)

  mfa_data = auth_data["mfa"]
  if mfa_data["status"]
    mfa_data["status"].to_sym
  elsif mfa_data["secret"].present? || mfa_data["mobile"].present?
    :enabled
  else
    :disabled
  end
end

#setup_mfa!(secret:, token:) ⇒ String

Setup TOTP-based MFA for this user.

This sends the secret and verification token to Parse Server, which validates the TOTP and stores the secret securely.

Examples:

secret = Parse::MFA.generate_secret
# Show QR code to user: Parse::MFA.qr_code(secret, user.email)
# User scans and enters code from authenticator app
recovery = user.setup_mfa!(secret: secret, token: "123456")
puts "Save these recovery codes: #{recovery}"

Parameters:

  • secret (String)

    Base32-encoded TOTP secret (generate with MFA.generate_secret)

  • token (String)

    Current TOTP code for verification (user enters from app)

Returns:

  • (String)

    Recovery codes (comma-separated) - SAVE THESE!

Raises:



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
# File 'lib/parse/two_factor_auth/user_extension.rb', line 131

def setup_mfa!(secret:, token:)
  raise ArgumentError, "Secret is required" if secret.blank?
  raise ArgumentError, "Token is required" if token.blank?
  # Refresh authData from the server before gating on mfa_enabled?
  # so a stale in-memory user does not bypass the local guard. This
  # narrows the race window from "any time the user object is alive"
  # to "one round-trip" — it does not eliminate TOCTOU. Full
  # elimination requires the Parse Server MFA adapter to reject
  # re-setup when authData.mfa.status == "enabled".
  fetch if id.present?
  raise MFA::AlreadyEnabledError if mfa_enabled?

  # Validate secret length (Parse Server requires minimum 20 chars)
  if secret.length < 20
    raise ArgumentError, "Secret must be at least 20 characters (got #{secret.length})"
  end

  auth_data_payload = {
    mfa: {
      secret: secret,
      token: token,
    },
  }

  response = client.update_user(id, { authData: auth_data_payload }, opts: { session_token: session_token })

  if response.error?
    if response.result.to_s.include?("Invalid MFA")
      raise MFA::VerificationError, response.result.to_s
    end
    raise Parse::Client::ResponseError, response
  end

  # Parse Server returns recovery codes in the response
  recovery = response.result["recovery"] || response.result["authDataResponse"]&.dig("mfa", "recovery")

  # Refresh auth_data
  fetch

  recovery
end

#setup_sms_mfa!(mobile:) ⇒ Boolean

Setup SMS-based MFA for this user.

This initiates SMS MFA setup by registering the mobile number. Parse Server will send an SMS with a verification code.

Examples:

user.setup_sms_mfa!(mobile: "+14155551234")
# User receives SMS, then call confirm_sms_mfa!

Parameters:

  • mobile (String, Parse::Phone)

    Phone number in E.164 format (e.g., “+14155551234”)

Returns:

  • (Boolean)

    True if SMS was sent

Raises:

  • (ArgumentError)

    If mobile is blank or invalid format



185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
# File 'lib/parse/two_factor_auth/user_extension.rb', line 185

def setup_sms_mfa!(mobile:)
  raise ArgumentError, "Mobile number is required" if mobile.blank?

  # Use Parse::Phone for validation
  phone = mobile.is_a?(Parse::Phone) ? mobile : Parse::Phone.new(mobile)
  unless phone.valid?
    raise ArgumentError, "Invalid mobile number format. Must be E.164 format: +[country code][number] (e.g., +14155551234)"
  end

  mobile = phone.to_s  # Use normalized E.164 format

  # Same TOCTOU narrowing as #setup_mfa!: refresh authData before
  # the guard so a stale in-memory user cannot bypass the check.
  # See #setup_mfa! for the residual-risk caveat.
  fetch if id.present?
  raise MFA::AlreadyEnabledError if mfa_enabled?

  auth_data_payload = {
    mfa: {
      mobile: mobile,
    },
  }

  response = client.update_user(id, { authData: auth_data_payload }, opts: { session_token: session_token })

  if response.error?
    raise Parse::Client::ResponseError, response
  end

  true
end