parse-stack-next 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- metadata +377 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../two_factor_auth"
|
|
5
|
+
require_relative "../model/phone"
|
|
6
|
+
|
|
7
|
+
module Parse
|
|
8
|
+
module MFA
|
|
9
|
+
# User extension module that adds MFA capabilities to Parse::User.
|
|
10
|
+
#
|
|
11
|
+
# This module integrates with Parse Server's built-in MFA adapter,
|
|
12
|
+
# which stores MFA data in the user's authData.mfa field.
|
|
13
|
+
#
|
|
14
|
+
# == Parse Server Configuration Required
|
|
15
|
+
#
|
|
16
|
+
# Your Parse Server must have MFA enabled in the auth configuration:
|
|
17
|
+
#
|
|
18
|
+
# {
|
|
19
|
+
# auth: {
|
|
20
|
+
# mfa: {
|
|
21
|
+
# enabled: true,
|
|
22
|
+
# options: ["TOTP"],
|
|
23
|
+
# digits: 6,
|
|
24
|
+
# period: 30,
|
|
25
|
+
# algorithm: "SHA1"
|
|
26
|
+
# }
|
|
27
|
+
# }
|
|
28
|
+
# }
|
|
29
|
+
#
|
|
30
|
+
# @see Parse::MFA
|
|
31
|
+
module UserExtension
|
|
32
|
+
extend ActiveSupport::Concern
|
|
33
|
+
|
|
34
|
+
# Class methods added to Parse::User
|
|
35
|
+
module ClassMethods
|
|
36
|
+
# Login a user with username, password, and MFA token.
|
|
37
|
+
#
|
|
38
|
+
# This method handles the Parse Server MFA "additional" policy,
|
|
39
|
+
# which requires both standard credentials AND an MFA token.
|
|
40
|
+
#
|
|
41
|
+
# @param username [String] The username
|
|
42
|
+
# @param password [String] The password
|
|
43
|
+
# @param mfa_token [String] The TOTP code from authenticator app or recovery code
|
|
44
|
+
# @return [User, nil] The logged in user or nil if failed
|
|
45
|
+
# @raise [Parse::MFA::VerificationError] If MFA token is invalid
|
|
46
|
+
# @raise [Parse::MFA::RequiredError] If MFA is required but token not provided
|
|
47
|
+
#
|
|
48
|
+
# @example
|
|
49
|
+
# user = Parse::User.login_with_mfa("john", "password123", "123456")
|
|
50
|
+
def login_with_mfa(username, password, mfa_token)
|
|
51
|
+
raise MFA::RequiredError, "MFA token is required" if mfa_token.blank?
|
|
52
|
+
|
|
53
|
+
response = client.login_with_mfa(username, password, mfa_token)
|
|
54
|
+
return nil unless response.success?
|
|
55
|
+
|
|
56
|
+
Parse::User.build(response.result)
|
|
57
|
+
rescue Parse::Client::ResponseError => e
|
|
58
|
+
if e.message.include?("Invalid MFA token") || e.message.include?("Missing additional authData")
|
|
59
|
+
raise MFA::VerificationError, e.message
|
|
60
|
+
end
|
|
61
|
+
raise
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Check if a user requires MFA for login.
|
|
65
|
+
#
|
|
66
|
+
# This queries the user's authData.mfa status using the afterFind hook
|
|
67
|
+
# which returns { status: "enabled" } or { status: "disabled" }.
|
|
68
|
+
#
|
|
69
|
+
# @param username [String] The username to check
|
|
70
|
+
# @return [Boolean] True if MFA is required
|
|
71
|
+
#
|
|
72
|
+
# @example
|
|
73
|
+
# if Parse::User.mfa_required?("john")
|
|
74
|
+
# # Show MFA input field
|
|
75
|
+
# end
|
|
76
|
+
def mfa_required?(username)
|
|
77
|
+
user = where(username: username).first
|
|
78
|
+
return false unless user
|
|
79
|
+
|
|
80
|
+
user.mfa_enabled?
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Check if MFA is enabled for this user.
|
|
85
|
+
#
|
|
86
|
+
# @return [Boolean] True if MFA is enabled
|
|
87
|
+
def mfa_enabled?
|
|
88
|
+
return false unless auth_data.is_a?(Hash)
|
|
89
|
+
return false unless auth_data["mfa"].is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
# Parse Server's afterFind returns { status: "enabled" } for enabled MFA
|
|
92
|
+
mfa_data = auth_data["mfa"]
|
|
93
|
+
mfa_data["status"] == "enabled" || mfa_data["secret"].present? || mfa_data["mobile"].present?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Get the MFA status for this user.
|
|
97
|
+
#
|
|
98
|
+
# @return [Symbol] :enabled, :disabled, or :unknown
|
|
99
|
+
def mfa_status
|
|
100
|
+
return :unknown unless auth_data.is_a?(Hash)
|
|
101
|
+
return :disabled unless auth_data["mfa"].is_a?(Hash)
|
|
102
|
+
|
|
103
|
+
mfa_data = auth_data["mfa"]
|
|
104
|
+
if mfa_data["status"]
|
|
105
|
+
mfa_data["status"].to_sym
|
|
106
|
+
elsif mfa_data["secret"].present? || mfa_data["mobile"].present?
|
|
107
|
+
:enabled
|
|
108
|
+
else
|
|
109
|
+
:disabled
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Setup TOTP-based MFA for this user.
|
|
114
|
+
#
|
|
115
|
+
# This sends the secret and verification token to Parse Server,
|
|
116
|
+
# which validates the TOTP and stores the secret securely.
|
|
117
|
+
#
|
|
118
|
+
# @param secret [String] Base32-encoded TOTP secret (generate with MFA.generate_secret)
|
|
119
|
+
# @param token [String] Current TOTP code for verification (user enters from app)
|
|
120
|
+
# @return [String] Recovery codes (comma-separated) - SAVE THESE!
|
|
121
|
+
# @raise [Parse::MFA::VerificationError] If token is invalid
|
|
122
|
+
# @raise [Parse::MFA::AlreadyEnabledError] If MFA is already enabled
|
|
123
|
+
# @raise [ArgumentError] If secret or token is blank
|
|
124
|
+
#
|
|
125
|
+
# @example
|
|
126
|
+
# secret = Parse::MFA.generate_secret
|
|
127
|
+
# # Show QR code to user: Parse::MFA.qr_code(secret, user.email)
|
|
128
|
+
# # User scans and enters code from authenticator app
|
|
129
|
+
# recovery = user.setup_mfa!(secret: secret, token: "123456")
|
|
130
|
+
# puts "Save these recovery codes: #{recovery}"
|
|
131
|
+
def setup_mfa!(secret:, token:)
|
|
132
|
+
raise ArgumentError, "Secret is required" if secret.blank?
|
|
133
|
+
raise ArgumentError, "Token is required" if token.blank?
|
|
134
|
+
# Refresh authData from the server before gating on mfa_enabled?
|
|
135
|
+
# so a stale in-memory user does not bypass the local guard. This
|
|
136
|
+
# narrows the race window from "any time the user object is alive"
|
|
137
|
+
# to "one round-trip" — it does not eliminate TOCTOU. Full
|
|
138
|
+
# elimination requires the Parse Server MFA adapter to reject
|
|
139
|
+
# re-setup when authData.mfa.status == "enabled".
|
|
140
|
+
fetch if id.present?
|
|
141
|
+
raise MFA::AlreadyEnabledError if mfa_enabled?
|
|
142
|
+
|
|
143
|
+
# Validate secret length (Parse Server requires minimum 20 chars)
|
|
144
|
+
if secret.length < 20
|
|
145
|
+
raise ArgumentError, "Secret must be at least 20 characters (got #{secret.length})"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
auth_data_payload = {
|
|
149
|
+
mfa: {
|
|
150
|
+
secret: secret,
|
|
151
|
+
token: token,
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
response = client.update_user(id, { authData: auth_data_payload }, opts: { session_token: session_token })
|
|
156
|
+
|
|
157
|
+
if response.error?
|
|
158
|
+
if response.result.to_s.include?("Invalid MFA")
|
|
159
|
+
raise MFA::VerificationError, response.result.to_s
|
|
160
|
+
end
|
|
161
|
+
raise Parse::Client::ResponseError, response
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Parse Server returns recovery codes in the response
|
|
165
|
+
recovery = response.result["recovery"] || response.result["authDataResponse"]&.dig("mfa", "recovery")
|
|
166
|
+
|
|
167
|
+
# Refresh auth_data
|
|
168
|
+
fetch
|
|
169
|
+
|
|
170
|
+
recovery
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Setup SMS-based MFA for this user.
|
|
174
|
+
#
|
|
175
|
+
# This initiates SMS MFA setup by registering the mobile number.
|
|
176
|
+
# Parse Server will send an SMS with a verification code.
|
|
177
|
+
#
|
|
178
|
+
# @param mobile [String, Parse::Phone] Phone number in E.164 format (e.g., "+14155551234")
|
|
179
|
+
# @return [Boolean] True if SMS was sent
|
|
180
|
+
# @raise [ArgumentError] If mobile is blank or invalid format
|
|
181
|
+
#
|
|
182
|
+
# @example
|
|
183
|
+
# user.setup_sms_mfa!(mobile: "+14155551234")
|
|
184
|
+
# # User receives SMS, then call confirm_sms_mfa!
|
|
185
|
+
def setup_sms_mfa!(mobile:)
|
|
186
|
+
raise ArgumentError, "Mobile number is required" if mobile.blank?
|
|
187
|
+
|
|
188
|
+
# Use Parse::Phone for validation
|
|
189
|
+
phone = mobile.is_a?(Parse::Phone) ? mobile : Parse::Phone.new(mobile)
|
|
190
|
+
unless phone.valid?
|
|
191
|
+
raise ArgumentError, "Invalid mobile number format. Must be E.164 format: +[country code][number] (e.g., +14155551234)"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
mobile = phone.to_s # Use normalized E.164 format
|
|
195
|
+
|
|
196
|
+
# Same TOCTOU narrowing as #setup_mfa!: refresh authData before
|
|
197
|
+
# the guard so a stale in-memory user cannot bypass the check.
|
|
198
|
+
# See #setup_mfa! for the residual-risk caveat.
|
|
199
|
+
fetch if id.present?
|
|
200
|
+
raise MFA::AlreadyEnabledError if mfa_enabled?
|
|
201
|
+
|
|
202
|
+
auth_data_payload = {
|
|
203
|
+
mfa: {
|
|
204
|
+
mobile: mobile,
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
response = client.update_user(id, { authData: auth_data_payload }, opts: { session_token: session_token })
|
|
209
|
+
|
|
210
|
+
if response.error?
|
|
211
|
+
raise Parse::Client::ResponseError, response
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
true
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Confirm SMS MFA setup with the received code.
|
|
218
|
+
#
|
|
219
|
+
# @param mobile [String, Parse::Phone] The mobile number that was used in setup (E.164 format)
|
|
220
|
+
# @param token [String] The SMS code received
|
|
221
|
+
# @return [Boolean] True if confirmed successfully
|
|
222
|
+
# @raise [Parse::MFA::VerificationError] If token is invalid or expired
|
|
223
|
+
#
|
|
224
|
+
# @example
|
|
225
|
+
# user.confirm_sms_mfa!(mobile: "+14155551234", token: "123456")
|
|
226
|
+
def confirm_sms_mfa!(mobile:, token:)
|
|
227
|
+
raise ArgumentError, "Mobile number is required" if mobile.blank?
|
|
228
|
+
raise ArgumentError, "Token is required" if token.blank?
|
|
229
|
+
|
|
230
|
+
# Use Parse::Phone for validation
|
|
231
|
+
phone = mobile.is_a?(Parse::Phone) ? mobile : Parse::Phone.new(mobile)
|
|
232
|
+
unless phone.valid?
|
|
233
|
+
raise ArgumentError, "Invalid mobile number format. Must be E.164 format: +[country code][number] (e.g., +14155551234)"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
mobile = phone.to_s # Use normalized E.164 format
|
|
237
|
+
|
|
238
|
+
auth_data_payload = {
|
|
239
|
+
mfa: {
|
|
240
|
+
mobile: mobile,
|
|
241
|
+
token: token,
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
response = client.update_user(id, { authData: auth_data_payload }, opts: { session_token: session_token })
|
|
246
|
+
|
|
247
|
+
if response.error?
|
|
248
|
+
if response.result.to_s.include?("Invalid MFA token")
|
|
249
|
+
raise MFA::VerificationError, response.result.to_s
|
|
250
|
+
end
|
|
251
|
+
raise Parse::Client::ResponseError, response
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Refresh auth_data
|
|
255
|
+
fetch
|
|
256
|
+
|
|
257
|
+
true
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Disable MFA for this user.
|
|
261
|
+
#
|
|
262
|
+
# This requires a valid current MFA token (TOTP or recovery code)
|
|
263
|
+
# to verify the user's identity before disabling MFA.
|
|
264
|
+
#
|
|
265
|
+
# @param current_token [String] Current TOTP code or recovery code
|
|
266
|
+
# @return [Boolean] True if disabled successfully
|
|
267
|
+
# @raise [Parse::MFA::VerificationError] If token is invalid
|
|
268
|
+
# @raise [Parse::MFA::NotEnabledError] If MFA is not enabled
|
|
269
|
+
#
|
|
270
|
+
# @example
|
|
271
|
+
# user.disable_mfa!(current_token: "123456")
|
|
272
|
+
def disable_mfa!(current_token:)
|
|
273
|
+
raise MFA::NotEnabledError, "MFA is not enabled for this user" unless mfa_enabled?
|
|
274
|
+
raise ArgumentError, "Current token is required" if current_token.blank?
|
|
275
|
+
|
|
276
|
+
# To disable, we need to update authData.mfa with the old token for validation
|
|
277
|
+
# and then set it to null
|
|
278
|
+
auth_data_payload = {
|
|
279
|
+
mfa: {
|
|
280
|
+
old: current_token,
|
|
281
|
+
secret: nil, # Setting to nil disables TOTP
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
response = client.update_user(id, { authData: auth_data_payload }, opts: { session_token: session_token })
|
|
286
|
+
|
|
287
|
+
if response.error?
|
|
288
|
+
if response.result.to_s.include?("Invalid MFA token")
|
|
289
|
+
raise MFA::VerificationError, response.result.to_s
|
|
290
|
+
end
|
|
291
|
+
raise Parse::Client::ResponseError, response
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Refresh auth_data
|
|
295
|
+
fetch
|
|
296
|
+
|
|
297
|
+
true
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Disable MFA using the configured master key. This bypasses MFA
|
|
301
|
+
# verification entirely, so the caller must prove (out-of-band) that
|
|
302
|
+
# the operator initiating the disable is authorized to do so.
|
|
303
|
+
#
|
|
304
|
+
# The +authorized_by:+ keyword is required and must be a
|
|
305
|
+
# {Parse::User} (or {Parse::Pointer} to a User) representing the
|
|
306
|
+
# operator performing the override. The caller is responsible for
|
|
307
|
+
# verifying that operator's privileges (e.g. via a role check). An
|
|
308
|
+
# optional +admin_role:+ argument lets this method enforce a role
|
|
309
|
+
# membership check on the operator using the existing role-hierarchy
|
|
310
|
+
# support; when given, the operator must belong to the role (or any
|
|
311
|
+
# of its child roles) or +ForbiddenError+ is raised.
|
|
312
|
+
#
|
|
313
|
+
# @param authorized_by [Parse::User, Parse::Pointer] the operator
|
|
314
|
+
# performing the override. Required.
|
|
315
|
+
# @param admin_role [Parse::Role, String, nil] optional role (or role
|
|
316
|
+
# name) that +authorized_by+ must belong to.
|
|
317
|
+
# @return [Boolean] True if disabled successfully.
|
|
318
|
+
# @raise [ArgumentError] when +authorized_by:+ is missing or not a User.
|
|
319
|
+
# @raise [Parse::MFA::ForbiddenError] when +admin_role+ is supplied
|
|
320
|
+
# and the operator is not a member.
|
|
321
|
+
#
|
|
322
|
+
# @example Caller-verified authorization
|
|
323
|
+
# user.disable_mfa_master_key!(authorized_by: current_admin)
|
|
324
|
+
#
|
|
325
|
+
# @example Library-enforced role check
|
|
326
|
+
# user.disable_mfa_master_key!(authorized_by: current_admin,
|
|
327
|
+
# admin_role: "Admin")
|
|
328
|
+
def disable_mfa_master_key!(authorized_by:, admin_role: nil)
|
|
329
|
+
operator = authorized_by
|
|
330
|
+
unless operator.is_a?(Parse::User) ||
|
|
331
|
+
(operator.is_a?(Parse::Pointer) && operator.parse_class == Parse::User.parse_class)
|
|
332
|
+
raise ArgumentError,
|
|
333
|
+
"disable_mfa_master_key! requires authorized_by: to be a Parse::User " \
|
|
334
|
+
"or Parse::Pointer to a User (got #{operator.class})"
|
|
335
|
+
end
|
|
336
|
+
if operator.respond_to?(:id) && operator.id.blank?
|
|
337
|
+
raise ArgumentError, "authorized_by: User must be persisted (have an objectId)"
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
if admin_role
|
|
341
|
+
role = admin_role.is_a?(Parse::Role) ? admin_role : Parse::Role.find_by_name(admin_role.to_s)
|
|
342
|
+
if role.nil?
|
|
343
|
+
raise MFA::ForbiddenError,
|
|
344
|
+
"authorized_by user is not authorized: admin role " \
|
|
345
|
+
"#{admin_role.inspect} not found"
|
|
346
|
+
end
|
|
347
|
+
operator_id = operator.id
|
|
348
|
+
authorized = role.all_users.any? { |u| u.id == operator_id }
|
|
349
|
+
unless authorized
|
|
350
|
+
raise MFA::ForbiddenError,
|
|
351
|
+
"authorized_by user #{operator_id} is not a member of " \
|
|
352
|
+
"role #{role.name.inspect}"
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
auth_data_payload = { mfa: nil }
|
|
357
|
+
response = client.update_user(id, { authData: auth_data_payload }, opts: { use_master_key: true })
|
|
358
|
+
|
|
359
|
+
if response.error?
|
|
360
|
+
raise Parse::Client::ResponseError, response
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Refresh auth_data
|
|
364
|
+
fetch
|
|
365
|
+
|
|
366
|
+
true
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# @deprecated Use {#disable_mfa_master_key!} with an explicit
|
|
370
|
+
# +authorized_by:+ argument. The old name had no authorization gate
|
|
371
|
+
# and acted as a one-call IDOR primitive when invoked on an
|
|
372
|
+
# attacker-controlled user instance.
|
|
373
|
+
def disable_mfa_admin!(*args, **kwargs)
|
|
374
|
+
warn "[DEPRECATION] `disable_mfa_admin!` is deprecated; use " \
|
|
375
|
+
"`disable_mfa_master_key!(authorized_by: <admin user>)`."
|
|
376
|
+
disable_mfa_master_key!(*args, **kwargs)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Login this user instance with password and MFA token.
|
|
380
|
+
#
|
|
381
|
+
# @param password [String] The password
|
|
382
|
+
# @param mfa_token [String] The TOTP code or recovery code
|
|
383
|
+
# @return [Boolean] True if login successful
|
|
384
|
+
# @raise [Parse::MFA::RequiredError] If MFA required but not provided
|
|
385
|
+
# @raise [Parse::MFA::VerificationError] If MFA token is invalid
|
|
386
|
+
#
|
|
387
|
+
# @example
|
|
388
|
+
# user = Parse::User.first
|
|
389
|
+
# user.login_with_mfa!("password123", "123456")
|
|
390
|
+
def login_with_mfa!(password, mfa_token = nil)
|
|
391
|
+
response = client.login_with_mfa(username.to_s, password.to_s, mfa_token)
|
|
392
|
+
apply_attributes!(response.result)
|
|
393
|
+
session_token.present?
|
|
394
|
+
rescue Parse::Client::ResponseError => e
|
|
395
|
+
if e.message.include?("Missing additional authData")
|
|
396
|
+
raise MFA::RequiredError, "MFA token is required for this account"
|
|
397
|
+
elsif e.message.include?("Invalid MFA token")
|
|
398
|
+
raise MFA::VerificationError, e.message
|
|
399
|
+
end
|
|
400
|
+
raise
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Generate a provisioning URI for this user.
|
|
404
|
+
#
|
|
405
|
+
# Use this to create a QR code for the user to scan with their
|
|
406
|
+
# authenticator app.
|
|
407
|
+
#
|
|
408
|
+
# @param secret [String] The TOTP secret
|
|
409
|
+
# @param issuer [String] Optional custom issuer name
|
|
410
|
+
# @return [String] otpauth:// URI
|
|
411
|
+
#
|
|
412
|
+
# @example
|
|
413
|
+
# secret = Parse::MFA.generate_secret
|
|
414
|
+
# uri = user.mfa_provisioning_uri(secret, issuer: "MyApp")
|
|
415
|
+
def mfa_provisioning_uri(secret, issuer: nil)
|
|
416
|
+
account_name = email.presence || username.presence || id
|
|
417
|
+
MFA.provisioning_uri(secret, account_name, issuer: issuer)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Generate a QR code for MFA setup.
|
|
421
|
+
#
|
|
422
|
+
# @param secret [String] The TOTP secret
|
|
423
|
+
# @param issuer [String] Optional custom issuer name
|
|
424
|
+
# @param format [Symbol] Output format (:svg, :png, :ascii)
|
|
425
|
+
# @return [String] QR code in specified format
|
|
426
|
+
#
|
|
427
|
+
# @example
|
|
428
|
+
# secret = Parse::MFA.generate_secret
|
|
429
|
+
# qr_svg = user.mfa_qr_code(secret, issuer: "MyApp")
|
|
430
|
+
# # Render in HTML: <%= raw qr_svg %>
|
|
431
|
+
def mfa_qr_code(secret, issuer: nil, format: :svg)
|
|
432
|
+
account_name = email.presence || username.presence || id
|
|
433
|
+
MFA.qr_code(secret, account_name, issuer: issuer, format: format)
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Not enabled error
|
|
438
|
+
class NotEnabledError < Parse::Error
|
|
439
|
+
def initialize(message = "MFA is not enabled for this user")
|
|
440
|
+
super(message)
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Reopen User class to include MFA extension
|
|
446
|
+
class User
|
|
447
|
+
include MFA::UserExtension
|
|
448
|
+
end
|
|
449
|
+
end
|