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.
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.
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
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.
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 = 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.
390 391 392 393 394 395 396 397 398 399 400 401 |
# File 'lib/parse/two_factor_auth/user_extension.rb', line 390 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.
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.
415 416 417 418 |
# File 'lib/parse/two_factor_auth/user_extension.rb', line 415 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.
431 432 433 434 |
# File 'lib/parse/two_factor_auth/user_extension.rb', line 431 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.
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.
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.
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 |