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) ⇒ 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 }, 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.
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 |
# 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? # 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
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.
376 377 378 379 380 |
# File 'lib/parse/two_factor_auth/user_extension.rb', line 376 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.
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 368 369 370 |
# File 'lib/parse/two_factor_auth/user_extension.rb', line 331 def disable_mfa_master_key!(authorized_by:, admin_role: nil) 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 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 }, 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.
393 394 395 396 397 398 399 400 401 402 403 404 |
# File 'lib/parse/two_factor_auth/user_extension.rb', line 393 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.
418 419 420 421 |
# File 'lib/parse/two_factor_auth/user_extension.rb', line 418 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.
434 435 436 437 |
# File 'lib/parse/two_factor_auth/user_extension.rb', line 434 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 }, 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.
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 }, opts: { session_token: session_token }) if response.error? raise Parse::Client::ResponseError, response end true end |