forked from ubicloud/ubicloud
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathclover.rb
691 lines (592 loc) · 24.2 KB
/
clover.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
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
172
173
174
175
176
177
178
179
180
181
182
183
184
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
216
217
218
219
220
221
222
223
224
225
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
259
260
261
262
263
264
265
266
267
268
269
270
271
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
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
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
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
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
# frozen_string_literal: true
require_relative "model"
require "committee"
require "roda"
require "tilt"
require "tilt/erubi"
class Clover < Roda
# :nocov:
linting = Config.test? && !defined?(SimpleCov)
use Rack::Lint if linting
if linting || Config.development? # Assume Rack::Lint added automatically in development
require "rack/rewindable_input"
# Switch to use Rack::RewindableInputMiddleware after Rack 3.2
class RewindableInputMiddleware
def initialize(app)
@app = app
end
def call(env)
if (input = env["rack.input"])
env["rack.input"] = Rack::RewindableInput.new(input)
end
@app.call(env)
end
end
use RewindableInputMiddleware
end
# :nocov:
OPENAPI = OpenAPIParser.load("openapi/openapi.yml", strict_reference_validation: true)
SCHEMA = Committee::Drivers::OpenAPI3::Driver.new.parse(OPENAPI)
SCHEMA_ROUTER = SCHEMA.build_router(schema: SCHEMA, strict: true)
opts[:check_dynamic_arity] = false
opts[:check_arity] = :warn
Unreloader.require("helpers") {}
Unreloader.record_split_class(__FILE__, "helpers")
# :nocov:
default_fixed_locals = if Config.production? || ENV["CLOVER_FREEZE"] == "1"
"()"
# :nocov:
else
"(_no_kw: nil)"
end
plugin :all_verbs
plugin :assets, js: "app.js", css: "app.css", css_opts: {style: :compressed, cache: false}, timestamp_paths: true
plugin :disallow_file_uploads
plugin :flash
plugin :h
plugin :hash_branches
plugin :hooks
plugin :Integer_matcher_max
plugin :json
plugin :invalid_request_body, :raise
plugin :json_parser, wrap: :unless_hash, error_handler: lambda { |r| raise Roda::RodaPlugins::InvalidRequestBody::Error, "invalid JSON uploaded" }
plugin :public
plugin :render, escape: true, layout: "./layouts/app", template_opts: {chain_appends: !defined?(SimpleCov), freeze: true, skip_compiled_encoding_detection: true, scope_class: self, default_fixed_locals:, extract_fixed_locals: true}, assume_fixed_locals: true
plugin :part
plugin :request_headers
plugin :typecast_params_sized_integers, sizes: [64], default_size: 64
# :nocov:
if Config.test? && defined?(SimpleCov)
plugin :render_coverage
end
# :nocov:
plugin :host_routing, scope_predicates: true do |hosts|
hosts.register :api, :web, :runtime
hosts.default :web do |host|
if host.start_with?("api.")
:api
elsif request.path_info.start_with?("/runtime")
:runtime
end
end
end
plugin :conditional_sessions,
key: "_Clover.session",
cookie_options: {secure: !(Config.development? || Config.test?)},
secret: Config.clover_session_secret do
scope.web?
end
plugin :custom_block_results
handle_block_result Integer do |status_code|
response.status = status_code
nil
end
plugin :route_csrf do |token|
flash["error"] = "An invalid security token submitted with this request, please try again"
response.status = 400
redirect_back_with_inputs
end
plugin :content_security_policy do |csp|
csp.default_src :none
csp.style_src :self, "https://cdn.jsdelivr.net/npm/[email protected]/dist/flatpickr.min.css"
csp.img_src :self, "data: image/svg+xml"
csp.form_action :self, "https://checkout.stripe.com", "https://github.com/login/oauth/authorize", "https://accounts.google.com/o/oauth2/auth"
csp.script_src :self, "https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js", "https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.min.js", "https://cdn.jsdelivr.net/npm/[email protected]/dist/flatpickr.min.js", "https://challenges.cloudflare.com/turnstile/v0/api.js", "https://cdn.jsdelivr.net/npm/[email protected]/marked.min.js"
csp.frame_src :self, "https://challenges.cloudflare.com"
csp.connect_src :self, "https://*.ubicloud.com"
csp.base_uri :none
csp.frame_ancestors :none
end
logger = if ENV["RACK_ENV"] == "test"
Class.new {
def write(_)
end
}.new
else
# :nocov:
$stderr
# :nocov:
end
plugin :common_logger, logger
plugin :not_found do
next if runtime?
@error = {
code: 404,
type: "ResourceNotFound",
message: "Sorry, we couldn’t find the resource you’re looking for."
}
if api? || request.headers["Accept"] == "application/json"
{error: @error}.to_json
else
view "/error"
end
end
if Config.development?
# :nocov:
plugin :exception_page
class RodaRequest
def assets
exception_page_assets
super
end
end
# :nocov:
end
plugin :error_handler do |e|
if Config.test? && ENV["SHOW_ERRORS"]
raise e
end
case e
when Sequel::ValidationFailed, Roda::RodaPlugins::InvalidRequestBody::Error
code = 400
type = "InvalidRequest"
message = e.to_s
when CloverError
code = e.code
type = e.type
message = e.message
details = e.details
when Committee::BadRequest, Committee::InvalidRequest
code = 400
type = "BadRequest"
message = e.message
case e.original_error
when JSON::ParserError
message = "Validation failed for following fields: body"
details = {"body" => "Request body isn't a valid JSON object."}
when OpenAPIParser::NotExistPropertyDefinition
keys = e.original_error.instance_variable_get(:@keys)
message = "Validation failed for following fields: body"
details = {"body" => "Request body contains unrecognized parameters: #{keys.join(", ")}"}
when OpenAPIParser::NotExistRequiredKey
keys = e.original_error.instance_variable_get(:@keys)
message = "Validation failed for following fields: body"
details = {"body" => "Request body must include required parameters: #{keys.join(", ")}"}
end
when Committee::InvalidResponse, Sequel::SerializationFailure
code = 500
type = "InternalServerError"
message = e.message
else
raise e if Config.test? && e.message != "test error"
Clog.emit("route exception") { Util.exception_to_hash(e) }
code = 500
type = "UnexceptedError"
message = "Sorry, we couldn’t process your request because of an unexpected error."
end
raise e if Config.test? && e.is_a?(Committee::Error)
response.status = code
next if code == 204
error = {code:, type:, message:, details:}
if runtime?
error
elsif api? || request.headers["Accept"] == "application/json"
{error:}
else
@error = error
case e
when Sequel::ValidationFailed, DependencyError
flash["error"] = message
redirect_back_with_inputs
when Sequel::SerializationFailure
flash["error"] = "There was a temporary error attempting to make this change, please try again."
redirect_back_with_inputs
when Validation::ValidationFailed
flash["error"] = message
flash["errors"] = (flash["errors"] || {}).merge(details).transform_keys(&:to_s)
redirect_back_with_inputs
end
# :nocov:
next exception_page(e, assets: true) if Config.development? && code == 500
# :nocov:
view "/error"
end
end
require "rodauth"
require_relative "rodauth/features/personal_access_token"
plugin :rodauth, name: :api do
enable :argon2, :json, :jwt, :active_sessions, :login, :personal_access_token
only_json? true
use_jwt? { !use_pat? }
# Converting rodauth error response to the common error format of the API
json_response_body do |hash|
# In case of an error, rodauth returns the error in the following format
# {
# (required) "error": "There was an error logging in"
# (optional) "field-error": [
# "password",
# "invalid password"
# ]
# }
if json_response_error?
error_message = hash["error"]
type, code = case error_message
when "There was an error logging in"
["InvalidCredentials", 401]
when "invalid JWT format or claim in Authorization header"
["InvalidRequest", 400]
when "Please login to continue"
["LoginRequired", 401]
else
# :nocov:
["AuthenticationError", 401]
# :nocov:
end
hash.clear
hash["error"] = {
"code" => code,
"type" => type,
"message" => error_message
}
end
hash.to_json
end
hmac_secret Config.clover_session_secret
jwt_secret Config.clover_session_secret
argon2_secret { Config.clover_session_secret }
require_bcrypt? false
end
plugin :rodauth do
enable :argon2, :change_login, :change_password, :close_account, :create_account,
:lockout, :login, :logout, :remember, :reset_password,
:disallow_password_reuse, :password_grace_period, :active_sessions,
:verify_login_change, :change_password_notify, :confirm_password,
:otp, :webauthn, :recovery_codes, :omniauth
title_instance_variable :@page_title
check_csrf? false
# :nocov:
unless Config.development?
enable :disallow_common_passwords, :verify_account
email_from Config.mail_from
verify_account_view { view "auth/verify_account", "Verify Account" }
resend_verify_account_view { view "auth/verify_account_resend", "Resend Verification" }
verify_account_email_sent_redirect { login_route }
verify_account_email_recently_sent_redirect { login_route }
verify_account_set_password? false
verify_account_resend_explanatory_text "You need to wait at least 5 minutes before sending another verification email. If you did not receive the email, please check your spam folder."
send_verify_account_email do
Util.send_email(email_to, "Welcome to Ubicloud: Please Verify Your Account",
greeting: "Welcome to Ubicloud,",
body: ["To complete your registration and activate your account, click the button below.",
"If you did not initiate this registration process, you may disregard this message.",
"We're excited to serve you. Should you require any assistance, our customer support team stands ready to help at [email protected]."],
button_title: "Verify Account",
button_link: verify_account_email_link)
end
# Password Requirements
password_minimum_length 8
password_maximum_bytes 72
password_meets_requirements? do |password|
password.match?(/[a-z]/) && password.match?(/[A-Z]/) && password.match?(/[0-9]/)
end
invalid_password_message = "Password must have 8 characters minimum and contain at least one lowercase letter, one uppercase letter, and one digit."
password_does_not_meet_requirements_message invalid_password_message
password_too_short_message invalid_password_message
end
# :nocov:
hmac_secret Config.clover_session_secret
login_view { view "auth/login", "Login" }
login_redirect { "/after-login" }
login_return_to_requested_location? true
login_label "Email Address"
two_factor_auth_return_to_requested_location? true
already_logged_in { redirect login_redirect }
after_login do
remember_login if request.params["remember-me"] == "on"
if omniauth_identity && (url = omniauth_params["redirect_url"])
flash["notice"] = "You have successfully connected your account with #{omniauth_provider.capitalize}."
redirect url
end
end
update_session do
if Account[account_session_value].suspended_at
flash["error"] = "Your account has been suspended. " \
"If you believe there's a mistake, or if you need further assistance, " \
"please reach out to our support team at [email protected]."
forget_login
redirect login_route
end
super()
end
create_account_view { view "auth/create_account", "Create Account" }
create_account_redirect { login_route }
create_account_set_password? true
password_confirm_label "Password Confirmation"
before_create_account do
Validation.validate_cloudflare_turnstile(param("cf-turnstile-response"))
scope.before_rodauth_create_account(account, param("name"))
end
after_create_account do
scope.after_rodauth_create_account(account_id)
end
# :nocov:
if Config.omniauth_github_id
require "omniauth-github"
omniauth_provider :github, Config.omniauth_github_id, Config.omniauth_github_secret, scope: "user:email"
end
if Config.omniauth_google_id
require "omniauth-google-oauth2"
omniauth_provider :google_oauth2, Config.omniauth_google_id, Config.omniauth_google_secret, name: :google
end
# :nocov:
before_omniauth_create_account do
scope.before_rodauth_create_account(account, omniauth_name)
end
after_omniauth_create_account do
scope.after_rodauth_create_account(account_id)
end
before_omniauth_callback_route do
account = Account[account_from_omniauth&.[](:id)]
if authenticated?
unless account && account.id == scope.current_account.id
flash["error"] = "Your account's email address is different from the email address associated with the #{omniauth_provider.capitalize} account."
redirect "/account/login-method"
end
elsif account && account.identities_dataset.where(provider: omniauth_provider.to_s).empty?
flash["error"] = "There is already an account with this email address, and it has not been linked to the #{omniauth_provider.capitalize} account.
Please login to the existing account normally, and then link it to the #{omniauth_provider.capitalize} account from your account settings.
Then you can can login using the #{omniauth_provider.capitalize} account."
redirect "/login"
end
end
omniauth_create_account? { !authenticated? }
reset_password_view { view "auth/reset_password", "Request Password" }
reset_password_request_view { view "auth/reset_password_request", "Request Password Reset" }
reset_password_redirect { login_route }
reset_password_email_sent_redirect { login_route }
reset_password_email_recently_sent_redirect { reset_password_request_route }
send_reset_password_email do
user = Account[account_id]
Util.send_email(user.email, "Reset Ubicloud Account Password",
greeting: "Hello #{user.name},",
body: ["We received a request to reset your account password. To reset your password, click the button below.",
"If you did not initiate this request, no action is needed. Your account remains secure.",
"For any questions or assistance, reach out to our team at [email protected]."],
button_title: "Reset Password",
button_link: reset_password_email_link)
end
before_reset_password_request do
unless has_password?
flash["error"] = "Login with password is not enabled for this account. Please use other login methods. For any questions or assistance, reach out to our team at [email protected]"
redirect login_route
end
end
reset_password_explanatory_text "If you have forgotten your password, you can request a password reset:"
after_reset_password do
remove_all_active_sessions_except_current
end
change_password_redirect "/account/change-password"
change_password_route "account/change-password"
change_password_view { view "account/change_password", "My Account" }
after_change_password do
remove_all_active_sessions_except_current
end
send_password_changed_email do
user = Account[account_id]
Util.send_email(email_to, "Ubicloud Account Password Changed",
greeting: "Hello #{user.name},",
body: ["Someone has changed the password for the account associated to this email address.",
"If you did not initiate this request or for any questions, reach out to our team at [email protected]."])
end
change_login_redirect "/account/change-login"
change_login_route "account/change-login"
change_login_view { view "account/change_login", "My Account" }
verify_login_change_view { view "auth/verify_login_change", "Verify Email Change" }
send_verify_login_change_email do |new_login|
user = Account[account_id]
Util.send_email(email_to, "Please Verify New Email Address for Ubicloud",
greeting: "Hello #{user.name},",
body: ["We received a request to change your account email to '#{new_login}'. To verify new email, click the button below.",
"If you did not initiate this request, no action is needed. Current email address can be used to login your account.",
"For any questions or assistance, reach out to our team at [email protected]."],
button_title: "Verify Email",
button_link: verify_login_change_email_link)
end
after_verify_login_change do
remove_all_active_sessions_except_current
end
close_account_redirect "/login"
close_account_route "account/close-account"
close_account_view { view "account/close_account", "My Account" }
before_close_account do
account = Account[account_id]
# Do not allow to close account if the project has resources and
# the account is the only user
if (project = account.projects.find { _1.accounts.count == 1 && _1.has_resources })
fail DependencyError.new("'#{project.name}' project has some resources. Delete all related resources first.")
end
end
delete_account_on_close? true
delete_account do
Account[account_id].destroy
end
argon2_secret { Config.clover_session_secret }
require_bcrypt? false
# Multifactor Manage
two_factor_manage_route "account/multifactor-manage"
two_factor_manage_view { view "account/two_factor_manage", "My Account" }
# Multifactor Auth
two_factor_auth_view { view "auth/two_factor_auth", "Two-factor Authentication" }
two_factor_auth_notice_flash { login_notice_flash }
# don't show error message when redirected after login
# :nocov:
two_factor_need_authentication_error_flash { (flash["notice"] == login_notice_flash) ? nil : super() }
# :nocov:
# If the single multifactor auth method is setup, redirect to it
before_two_factor_auth_route do
redirect otp_auth_path if otp_exists? && !webauthn_setup?
redirect webauthn_auth_path if webauthn_setup? && !otp_exists?
end
# OTP Setup
otp_setup_route "account/multifactor/otp-setup"
otp_setup_view { view "account/multifactor/otp_setup", "My Account" }
otp_setup_link_text "Enable"
otp_setup_button "Enable One-Time Password Authentication"
otp_setup_notice_flash "One-time password authentication is now setup, please make note of your recovery codes"
otp_setup_error_flash "Error setting up one-time password authentication"
# :nocov:
after_otp_setup do
flash["notice"] = otp_setup_notice_flash
redirect "/" + recovery_codes_route
end
# :nocov:
# OTP Disable
otp_disable_route "account/multifactor/otp-disable"
otp_disable_view { view "account/multifactor/otp_disable", "My Account" }
otp_disable_link_text "Disable"
otp_disable_button "Disable One-Time Password Authentication"
otp_disable_notice_flash "One-time password authentication has been disabled"
otp_disable_error_flash "Error disabling one-time password authentication"
otp_disable_redirect { "/" + two_factor_manage_route }
# OTP Auth
otp_auth_view { view "auth/otp_auth", "One-Time" }
otp_auth_button "Authenticate Using One-Time Password"
otp_auth_link_text "One-Time Password Generator"
# Webauthn Setup
webauthn_setup_route "account/multifactor/webauthn-setup"
webauthn_setup_view { view "account/multifactor/webauthn_setup", "My Account" }
webauthn_setup_link_text "Add"
webauthn_setup_button "Setup Security Key"
webauthn_setup_notice_flash "Security key is now setup, please make note of your recovery codes"
webauthn_setup_error_flash "Error setting up security key"
webauthn_key_insert_hash { |credential| super(credential).merge(name: request.params["name"]) }
# :nocov:
after_webauthn_setup do
flash["notice"] = webauthn_setup_notice_flash
redirect "/" + recovery_codes_route
end
# :nocov:
# Webauthn Remove
webauthn_remove_route "account/multifactor/webauthn-remove"
webauthn_remove_view { view "account/multifactor/webauthn_remove", "My Account" }
webauthn_remove_link_text "Remove"
webauthn_remove_button "Remove Security Key"
webauthn_remove_notice_flash "Security key has been removed"
webauthn_remove_error_flash "Error removing security key"
webauthn_invalid_remove_param_message "Invalid security key to remove"
webauthn_remove_redirect { "/" + two_factor_manage_route }
# Webauthn Auth
webauthn_auth_view { view "auth/webauthn_auth", "Security Keys" }
webauthn_auth_button "Authenticate Using Security Keys"
webauthn_auth_link_text "Security Keys"
# Recovery Codes
recovery_codes_route "account/multifactor/recovery-codes"
recovery_codes_view { view "account/multifactor/recovery_codes", "My Account" }
recovery_codes_link_text "View"
add_recovery_codes_view { view "account/multifactor/recovery_codes", "My Account" }
auto_add_recovery_codes? true
auto_remove_recovery_codes? true
add_recovery_codes_heading "Add Additional Recovery Codes"
recovery_auth_view { view "auth/recovery_auth", "Recovery Codes" }
end
hash_branch("after-login") do |r|
r.get web? do
if (project = current_account.projects_dataset.order(:created_at, :name).first)
r.redirect "#{project.path}/dashboard"
else
r.redirect "/project"
end
end
end
# :nocov:
if Config.test?
# :nocov:
hash_branch(:webhook_prefix, "test-error") do |r|
raise(r.params["message"] || "test error")
end
hash_branch("clear-last-password-entry") do |r|
session.delete("last_password_entry")
""
end
end
if Config.production? || ENV["FORCE_AUTOLOAD"] == "1"
Unreloader.require("routes")
# :nocov:
else
plugin :autoload_hash_branches
Dir["routes/**/*.rb"].each do |full_path|
parts = full_path.delete_prefix("routes/").split("/")
namespaces = parts[0...-1]
filename = parts.last
segment = File.basename(filename, ".rb").tr("_", "-")
namespace = if namespaces.empty?
""
else
:"#{namespaces.join("_")}_prefix"
end
autoload_hash_branch(namespace, segment, full_path)
end
Unreloader.autoload("routes", delete_hook: proc { |f| hash_branch(File.basename(f, ".rb").tr("_", "-")) }) {}
end
# :nocov:
route do |r|
if api?
response.json = true
response.skip_content_security_policy!
rodauth.check_active_session unless rodauth.use_pat?
else
r.on "runtime" do
response.json = true
response.skip_content_security_policy!
if (jwt_payload = get_runtime_jwt_payload).nil? || (@vm = Vm.from_ubid(jwt_payload["sub"])).nil?
fail CloverError.new(400, "InvalidRequest", "invalid JWT format or claim in Authorization header")
end
r.hash_branches(:runtime_prefix)
end
r.public
r.assets
r.on "webhook" do
r.hash_branches(:webhook_prefix)
end
check_csrf!
rodauth.load_memory
r.root do
r.redirect rodauth.login_route
end
rodauth.check_active_session
end
r.rodauth
rodauth.require_authentication
if api?
# Validate request against OpenAPI schema, after authenticating
# (which is thought to be cheaper)
begin
@schema_validator = SCHEMA_ROUTER.build_schema_validator(r)
@schema_validator.request_validate(r)
next unless @schema_validator.link_exist?
rescue JSON::ParserError => e
raise Committee::InvalidRequest.new("Request body wasn't valid JSON.", original_error: e)
end
end
r.hash_branches("")
end
# Validate response against OpenAPI schema
after do |res|
status, headers, body = res
next unless api? && status && headers && body
@schema_validator ||= SCHEMA_ROUTER.build_schema_validator(request)
@schema_validator.response_validate(status, headers, body, true) if @schema_validator.link_exist?
rescue JSON::ParserError => e
raise Committee::InvalidResponse.new("Response body wasn't valid JSON.", original_error: e)
end
end