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" } } }
Defined Under Namespace
Modules: ClassMethods
Instance Method Summary collapse
-
#confirm_sms_mfa!(mobile:, token:) ⇒ Boolean
Confirm SMS MFA setup with the received code.
-
#disable_mfa!(current_token:) ⇒ Boolean
Disable MFA for this user.
-
#disable_mfa_admin!(*args, **kwargs) ⇒ Object
deprecated
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.
-
#disable_mfa_master_key!(authorized_by:, admin_role: nil, allow_unverified: false) ⇒ Boolean
Disable MFA using the configured master key.
-
#login_with_mfa!(password, mfa_token = nil) ⇒ Boolean
Login this user instance with password and MFA token.
-
#mfa_enabled? ⇒ Boolean
Check if MFA is enabled for this user.
-
#mfa_provisioning_uri(secret, issuer: nil) ⇒ String
Generate a provisioning URI for this user.
-
#mfa_qr_code(secret, issuer: nil, format: :svg) ⇒ String
Generate a QR code for MFA setup.
-
#mfa_status ⇒ Symbol
Get the MFA status for this user.
-
#setup_mfa!(secret:, token:) ⇒ String?
Setup TOTP-based MFA for this user.
-
#setup_sms_mfa!(mobile:) ⇒ Boolean
Setup SMS-based MFA for this user.
Instance Method Details
#confirm_sms_mfa!(mobile:, token:) ⇒ Boolean
Confirm SMS MFA setup with the received code.
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 259 260 261 |
# File 'lib/parse/two_factor_auth/user_extension.rb', line 229 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 }, 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.
275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 |
# File 'lib/parse/two_factor_auth/user_extension.rb', line 275 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? # Parse Server's TOTP adapter exposes no first-class "disable via authData # update" path — its validateUpdate always re-runs setup, so a partial # mfa payload is rejected outright. Disabling is therefore a two-step: # # 1. Prove possession of the current code by submitting it as # `{ mfa: { old: <token> } }`. In the *update* context (unlike a # fresh login) the adapter validates that code against the stored # secret. A WRONG code fails at validateLogin ("Invalid MFA token"); # a CORRECT code passes validateLogin and is then blocked by the # re-setup requirement ("Invalid MFA data") — which is precisely the # signal that the code was accepted. (This re-entry of the current # code is the deliberate confirmation gate for turning MFA off.) # 2. Disable MFA by unlinking the provider with `{ mfa: nil }`. # # This keeps self-disable gated on a valid current code even though the # server offers no dedicated TOTP self-disable endpoint. verify = client.update_user(id, { authData: { mfa: { old: current_token } } }, session_token: session_token) # Classify the two-step response POSITIVELY instead of treating # "anything that isn't success-or-one-magic-string" as a bad # token. The current code is ACCEPTED iff the server either # succeeds or rejects only the follow-on re-setup ("Invalid MFA # data") — that block fires AFTER validateLogin has already # accepted the code. A WRONG code fails earlier at validateLogin # ("Invalid MFA token"). Any OTHER error (transport, session, 5xx) # is a real fault surfaced as-is, not mislabeled a verification # failure. err = verify.error.to_s code_rejected = err.match?(/Invalid MFA token/i) code_accepted = verify.success? || err.match?(/Invalid MFA data/i) if code_rejected raise MFA::VerificationError, "Invalid MFA token" elsif !code_accepted raise Parse::Client::ResponseError, verify end response = client.update_user(id, { authData: { mfa: nil } }, session_token: session_token) raise Parse::Client::ResponseError, response if response.error? # CONFIRM the disable took effect from the SERVER's own view — a # positive post-condition rather than trusting the unlink response # alone. We must read the server directly here, NOT lean on the # in-memory #mfa_enabled? projection: Parse Server omits +authData+ # entirely for a user with no providers, so once MFA is unlinked an # ordinary fetch carries no +authData+ key at all and therefore can # never clear the +{ mfa: { status: "enabled" } }+ value pinned at # enrollment. An enabled account's own (session-token) read returns # +authData.mfa+; a disabled one omits it — so an absent/mfa-less # authData on this trusted self-read is the authoritative signal. if mfa_enabled_on_server? raise MFA::VerificationError, "MFA disable did not take effect (still enabled after unlink)" end clear_local_mfa_projection! true end |
#disable_mfa_admin!(*args, **kwargs) ⇒ Object
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.
432 433 434 435 436 |
# File 'lib/parse/two_factor_auth/user_extension.rb', line 432 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, allow_unverified: false) ⇒ 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.
370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 |
# File 'lib/parse/two_factor_auth/user_extension.rb', line 370 def disable_mfa_master_key!(authorized_by:, admin_role: nil, allow_unverified: false) operator = 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 # FAIL CLOSED: this method bypasses MFA verification entirely via # the master key, so it refuses to run without SOME authorization # signal. Either supply an `admin_role:` for the library to verify, # or pass `allow_unverified: true` to deliberately assert that the # caller has already authorized the operator out-of-band. if admin_role.nil? && !allow_unverified raise MFA::ForbiddenError, "disable_mfa_master_key! refuses to bypass MFA without an authorization " \ "check: pass admin_role: to enforce role membership, or " \ "allow_unverified: true to explicitly accept caller-side authorization." 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 = role.all_users.any? { |u| u.id == operator_id } unless 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 }, use_master_key: true) if response.error? raise Parse::Client::ResponseError, response end # Refresh auth_data, then drop the in-memory MFA projection. As in # #disable_mfa!, a disabled user's read omits +authData+, so the # +{ mfa: { status: "enabled" } }+ value pinned at enrollment won't # self-clear on fetch — clear it explicitly so #mfa_enabled? reports # the truth after a master-key disable. fetch clear_local_mfa_projection! true end |
#login_with_mfa!(password, mfa_token = nil) ⇒ Boolean
Login this user instance with password and MFA token.
449 450 451 452 453 454 455 456 457 458 459 460 |
# File 'lib/parse/two_factor_auth/user_extension.rb', line 449 def login_with_mfa!(password, mfa_token = nil) response = client.login_with_mfa(username.to_s, password.to_s, mfa_token) apply_attributes!(response.result) session_token.present? rescue Parse::Client::ResponseError => e if e..include?("Missing additional authData") raise MFA::RequiredError, "MFA token is required for this account" elsif e..include?("Invalid MFA token") raise MFA::VerificationError, e. end raise end |
#mfa_enabled? ⇒ Boolean
Check if MFA is enabled for this user.
89 90 91 92 93 94 95 96 |
# File 'lib/parse/two_factor_auth/user_extension.rb', line 89 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.
474 475 476 477 |
# File 'lib/parse/two_factor_auth/user_extension.rb', line 474 def mfa_provisioning_uri(secret, issuer: nil) account_name = email.presence || username.presence || id MFA.provisioning_uri(secret, account_name, issuer: issuer) end |
#mfa_qr_code(secret, issuer: nil, format: :svg) ⇒ String
Generate a QR code for MFA setup.
490 491 492 493 |
# File 'lib/parse/two_factor_auth/user_extension.rb', line 490 def mfa_qr_code(secret, issuer: nil, format: :svg) account_name = email.presence || username.presence || id MFA.qr_code(secret, account_name, issuer: issuer, format: format) end |
#mfa_status ⇒ Symbol
Get the MFA status for this user.
101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/parse/two_factor_auth/user_extension.rb', line 101 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.
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 |
# File 'lib/parse/two_factor_auth/user_extension.rb', line 134 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 }, 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.
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 216 217 218 |
# File 'lib/parse/two_factor_auth/user_extension.rb', line 188 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 }, session_token: session_token) if response.error? raise Parse::Client::ResponseError, response end true end |