Web shell allows access to the ubi command line program without installing it. All features of ubi are available in the web shell, except for the program execution features. To avoid the need to create a personal access token to use the web shell, the internal cli request sets clover.web_cli_session_id in the request environment, and the internal request uses that as the session id if that exists. So web cli requests are only checked against the account's permissions, they are not checked against a token's permissions. I think this approach is better than forcing a user to create a personal access token to use the web shell. For ease of use, this uses the autofocus attribute, so add that as a boolean attribute.
1040 lines
38 KiB
Ruby
1040 lines
38 KiB
Ruby
# 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"
|
||
use Rack::RewindableInput::Middleware
|
||
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 :plain_hash_response_headers
|
||
plugin :typecast_params_sized_integers, sizes: [64], default_size: 64
|
||
plugin :typecast_params do
|
||
invalid_value_message(:pos_int64, "Value must be an integer greater than 0 for parameter")
|
||
|
||
handle_type(:ubid_uuid, invalid_value_message: "Value provided not a valid id for parameter") do
|
||
if it.is_a?(String) && it.bytesize == 26
|
||
UBID.to_uuid(it)
|
||
end
|
||
end
|
||
end
|
||
|
||
plugin :symbol_matchers
|
||
symbol_matcher(:ubid_uuid, /([a-tv-z0-9]{26})/) do |s|
|
||
UBID.to_uuid(s)
|
||
end
|
||
|
||
plugin :response_content_type,
|
||
mime_types: {
|
||
json: "application/json",
|
||
pdf: "application/pdf",
|
||
pem: "application/x-pem-file",
|
||
text: "text/plain"
|
||
}
|
||
|
||
# :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|
|
||
response.status = 400
|
||
@page_title = "Invalid Security Token"
|
||
view(content: "An invalid security token was submitted, please click back, refresh, and try again.")
|
||
end
|
||
|
||
plugin :content_security_policy do |csp|
|
||
csp.default_src :none
|
||
csp.style_src :self, "https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css"
|
||
csp.img_src :self, "data: image/svg+xml", "https://github.com", "https://avatars.githubusercontent.com"
|
||
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/jquery@3.7.0/dist/jquery.min.js", "https://cdn.jsdelivr.net/npm/dompurify@3.0.5/dist/purify.min.js", "https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js", "https://challenges.cloudflare.com/turnstile/v0/api.js", "https://cdn.jsdelivr.net/npm/marked@15.0.5/marked.min.js", "https://cdn.jsdelivr.net/npm/echarts@5.6.0/dist/echarts.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.accepts_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, Roda::RodaPlugins::TypecastParams::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::InvalidPattern
|
||
pattern = e.original_error.instance_variable_get(:@pattern)
|
||
value = e.original_error.instance_variable_get(:@value)
|
||
message = "Parameter #{value.inspect} does not match pattern #{pattern}"
|
||
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 Sequel::SerializationFailure
|
||
code = 500
|
||
type = "InternalServerError"
|
||
message = "There was a temporary error attempting to make this change, please try again."
|
||
Clog.emit("route exception") { Util.exception_to_hash(e) }
|
||
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.accepts_json? || !%w[GET POST].include?(request.request_method)
|
||
{error:}
|
||
else
|
||
@error = error
|
||
|
||
if e.is_a?(Sequel::ValidationFailed) || e.is_a?(DependencyError) || e.is_a?(Roda::RodaPlugins::TypecastParams::Error)
|
||
flash["error"] = message
|
||
redirect_back_with_inputs
|
||
elsif e.is_a?(Sequel::SerializationFailure)
|
||
flash["error"] = "There was a temporary error attempting to make this change, please try again."
|
||
redirect_back_with_inputs
|
||
elsif e.is_a?(CloverError) && !e.is_a?(Authorization::Unauthorized)
|
||
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_relative "rodauth/features/personal_access_token"
|
||
|
||
plugin :rodauth, name: :api do
|
||
enable :json, :personal_access_token
|
||
|
||
only_json? true
|
||
|
||
invalid_auth_error_body = {
|
||
"error" => {
|
||
"code" => 401,
|
||
"type" => "InvalidCredentials",
|
||
"message" => "invalid personal access token provided in Authorization header"
|
||
}
|
||
}.to_json.freeze
|
||
|
||
# The only response body that can be generated with this Rodauth configuration
|
||
# is when the provided personal access token is invalid. Generate the JSON
|
||
# error body up front and serve it, so it doesn't need to be generated per-request.
|
||
json_response_body do |_|
|
||
invalid_auth_error_body
|
||
end
|
||
|
||
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, :otp_unlock, :otp_lockout_email
|
||
|
||
title_instance_variable :@page_title
|
||
check_csrf? false
|
||
|
||
# :nocov:
|
||
unless Config.development?
|
||
enable :disallow_common_passwords, :verify_account
|
||
|
||
email_from Config.mail_from
|
||
|
||
before_verify_account do
|
||
if locked_domain_for(account[:email])
|
||
transaction do
|
||
DB[:account_verification_keys].where(id: account[:id]).delete
|
||
before_close_account
|
||
close_account
|
||
after_close_account
|
||
delete_account
|
||
end
|
||
check_locked_domain(account[:email], "Verifying accounts")
|
||
end
|
||
end
|
||
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 support@ubicloud.com."],
|
||
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 }
|
||
|
||
before_login do
|
||
email = account[:email]
|
||
if (locked_domain = locked_domain_for(email))
|
||
error = if !omniauth_provider
|
||
"Login via username and password"
|
||
elsif omniauth_provider.to_s != locked_domain.oidc_provider.ubid
|
||
"Login via #{scope.omniauth_provider_name(omniauth_provider)}"
|
||
end
|
||
|
||
if error
|
||
flash["error"] = "#{error} is not supported for the #{domain_for_email(email)} domain. You must authenticate using #{locked_domain.oidc_provider.display_name}."
|
||
redirect "/login"
|
||
end
|
||
end
|
||
end
|
||
|
||
after_login do
|
||
remember_login if scope.typecast_params.str("remember-me") == "on"
|
||
if omniauth_identity && omniauth_params["redirect_url"]
|
||
flash["notice"] = "You have successfully connected your account with #{scope.omniauth_provider_name(omniauth_provider)}."
|
||
# Don't trust the omniauth params, always redirect to the login methods page,
|
||
# as that is the only page that should be setting redirect_url
|
||
redirect "/account/login-method"
|
||
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 support@ubicloud.com."
|
||
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
|
||
scope.handle_validation_failure("auth/create_account")
|
||
check_locked_domain(account[:email], "Creating accounts with a password")
|
||
|
||
cf_response = scope.typecast_params.str("cf-turnstile-response").to_s if Config.cloudflare_turnstile_site_key
|
||
|
||
if cf_response&.empty?
|
||
Clog.emit("cloudflare turnstile parameter not submitted") { {user_agent: scope.env["HTTP_USER_AGENT"]} }
|
||
scope.flash["error"] = "Could not create account. Please ensure JavaScript is enabled and access to Cloudflare is not blocked, then try again."
|
||
request.redirect("/create-account")
|
||
end
|
||
|
||
Validation.validate_cloudflare_turnstile(cf_response)
|
||
scope.before_rodauth_create_account(account, param("name"))
|
||
end
|
||
after_create_account do
|
||
scope.after_rodauth_create_account(account_id)
|
||
end
|
||
|
||
use_multi_phase_login? true
|
||
need_password_notice_flash "Login recognized"
|
||
multi_phase_login_view { login_view }
|
||
|
||
# :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:
|
||
|
||
auth_class_eval do
|
||
def check_locked_domain(email, error_prefix, redirect: nil)
|
||
if (locked_domain = locked_domain_for(email))
|
||
flash["error"] = "#{error_prefix} is not supported for the #{domain_for_email(email)} domain.#{" You must authenticate using #{locked_domain.oidc_provider.display_name}." unless redirect}"
|
||
redirect(redirect || "/auth/#{locked_domain.oidc_provider.ubid}")
|
||
end
|
||
end
|
||
|
||
# If the route isn't already handled and matches a known provider,
|
||
# get the app specific to that provider, and then run it.
|
||
def route_omniauth!
|
||
super
|
||
if (match = %r{\A/auth/(0p[a-tv-z0-9]{24})(?:/callback)?\z}.match(request.path_info)) &&
|
||
(provider = OidcProvider[match[1]])
|
||
omniauth_run omniauth_app_for_provider(provider)
|
||
|
||
# :nocov:
|
||
# Not reached in testing due to omniauth_setup throw above.
|
||
handle_omniauth_callback
|
||
# :nocov:
|
||
end
|
||
nil
|
||
end
|
||
|
||
def domain_for_email(email)
|
||
email.split("@", 2)[1]
|
||
end
|
||
|
||
def locked_domain_for(email)
|
||
LockedDomain.with_pk(domain_for_email(email))
|
||
end
|
||
|
||
omniauth_apps = {}
|
||
omniauth_app_mutex = Mutex.new
|
||
builder_app = ->(env) { [404, {}, []] }
|
||
|
||
# Return OIDC-provider specific omniauth app. If there isn't an existing
|
||
# app for the provider in this process, build one.
|
||
define_method(:omniauth_app_for_provider) do |provider|
|
||
name = provider.ubid
|
||
if (app = omniauth_app_mutex.synchronize { omniauth_apps[name] })
|
||
return app
|
||
end
|
||
|
||
# Delay loading of omniauth_oidc until it is needed. Generally, this type of
|
||
# runtime require doesn't work with a frozen environment, but it does in this
|
||
# as the file does not modify any frozen constants. This is helpful so that
|
||
# users do not have to pay the cost of loading the file if they do not have
|
||
# any OidcProviders.
|
||
require_relative "vendor/omniauth_oidc"
|
||
|
||
# This part is copied from rodauth-omniauth's omniauth_app method in order
|
||
# to integrate with rodauth-omniauth.
|
||
builder = OmniAuth::Builder.new
|
||
builder.options(
|
||
path_prefix: omniauth_prefix,
|
||
setup: ->(env) { env["rodauth.omniauth.instance"].send(:omniauth_setup) }
|
||
)
|
||
builder.configure do |config|
|
||
[:request_validation_phase, :before_request_phase, :before_callback_phase, :on_failure].each do |hook|
|
||
config.send(:"#{hook}=", ->(env) { env["rodauth.omniauth.instance"].send(:"omniauth_#{hook}") })
|
||
end
|
||
end
|
||
|
||
# Only use the provider passed to the method. rodauth-omniauth uses all
|
||
# statically configured providers in omniauth_app
|
||
uri = URI(provider.url)
|
||
builder.provider :oidc,
|
||
name: name.to_sym,
|
||
issuer: provider.url,
|
||
client_options: {
|
||
port: uri.port,
|
||
scheme: uri.scheme,
|
||
host: uri.host,
|
||
identifier: provider.client_id,
|
||
secret: provider.client_secret,
|
||
redirect_uri: provider.callback_url,
|
||
authorization_endpoint: provider.authorization_endpoint,
|
||
token_endpoint: provider.token_endpoint,
|
||
userinfo_endpoint: provider.userinfo_endpoint
|
||
}
|
||
|
||
builder.run builder_app
|
||
app = builder.to_app
|
||
omniauth_app_mutex.synchronize { omniauth_apps[name] ||= app }
|
||
end
|
||
end
|
||
|
||
before_omniauth_create_account do
|
||
unless (email = account[:email])
|
||
flash["error"] = "Social login is only allowed if social login provider provides email"
|
||
redirect "/login"
|
||
end
|
||
|
||
if (locked_domain = locked_domain_for(email)) && omniauth_provider.to_s != locked_domain.oidc_provider.ubid
|
||
flash["error"] = "Creating an account via authentication through #{scope.omniauth_provider_name(omniauth_provider)} is not supported for the #{domain_for_email(email)} domain. You must authenticate using #{locked_domain.oidc_provider.display_name}."
|
||
redirect "/login"
|
||
end
|
||
|
||
scope.before_rodauth_create_account(account, omniauth_name || account[:email].split("@", 2)[0].gsub(/[^A-Za-z]+/, " ").capitalize)
|
||
end
|
||
|
||
after_omniauth_create_account do
|
||
scope.after_rodauth_create_account(account_id)
|
||
end
|
||
|
||
omniauth_on_failure do
|
||
Clog.emit("omniauth failure") { {omniauth_error:, omniauth_error_type:, omniauth_error_strategy:, backtrace: omniauth_error.backtrace} }
|
||
super()
|
||
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 #{scope.omniauth_provider_name(omniauth_provider)} account."
|
||
redirect "/account/login-method"
|
||
end
|
||
elsif account && account.identities_dataset.where(provider: omniauth_provider.to_s).empty?
|
||
provider_name = scope.omniauth_provider_name(omniauth_provider)
|
||
flash["error"] = "There is already an account with this email address, and it has not been linked to the #{provider_name} account.
|
||
Please login to the existing account normally, and then link it to the #{provider_name} account from your account settings.
|
||
Then you can login using the #{provider_name} account."
|
||
redirect "/login"
|
||
end
|
||
end
|
||
|
||
omniauth_create_account? { !authenticated? }
|
||
|
||
before_unlock_account { check_locked_domain(account[:email], "Unlocking accounts") }
|
||
before_unlock_account_request { check_locked_domain(account[:email], "Unlocking accounts") }
|
||
|
||
before_reset_password { check_locked_domain(account[:email], "Resetting passwords") }
|
||
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 support@ubicloud.com."],
|
||
button_title: "Reset Password",
|
||
button_link: reset_password_email_link)
|
||
end
|
||
|
||
before_reset_password_request do
|
||
check_locked_domain(account[:email], "Resetting passwords")
|
||
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 support@ubicloud.com"
|
||
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
|
||
|
||
before_change_password { check_locked_domain(account[:email], "Changing passwords", redirect: "/") }
|
||
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 support@ubicloud.com."])
|
||
end
|
||
|
||
before_change_login do
|
||
check_locked_domain(account[:email], "Changing email addresses", redirect: "/")
|
||
check_locked_domain(param("login"), "Changing email addresses", redirect: "/")
|
||
end
|
||
change_login_redirect "/account/change-login"
|
||
change_login_route "account/change-login"
|
||
change_login_view { view "account/change_login", "My Account" }
|
||
|
||
before_verify_login_change do
|
||
check_locked_domain(account[:email], "Changing email addresses", redirect: "/")
|
||
check_locked_domain(verify_login_change_new_login, "Changing email addresses", redirect: "/")
|
||
end
|
||
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 support@ubicloud.com."],
|
||
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
|
||
scope.handle_validation_failure(true) do
|
||
request.on do
|
||
close_account_view
|
||
end
|
||
end
|
||
account = Account[account_id]
|
||
# Do not allow to close account if the project has resources and
|
||
# the account is the only user
|
||
projects_dataset = Project
|
||
.where(id: DB[:access_tag]
|
||
.select_group(:project_id)
|
||
.where(project_id: account.projects_dataset.select(Sequel[:project][:id]))
|
||
.having(Sequel.function(:count).* => 1))
|
||
|
||
if (project = projects_dataset.first_project_with_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"
|
||
|
||
# OTP Unlock
|
||
otp_unlock_view { view "auth/otp_unlock", "One-Time Password Unlock" }
|
||
otp_unlock_not_available_view { view "auth/otp_unlock_not_available", "One-Time Password Unlock Not Available" }
|
||
otp_unlock_button "Authenticate Using One-Time Password to Unlock"
|
||
otp_unlocked_redirect "/otp-auth"
|
||
otp_unlocked_notice_flash "One-Time Password authentication unlocked"
|
||
otp_unlock_auth_success_notice_flash "One-Time Password successful authentication, more successful authentication needed to unlock"
|
||
otp_unlock_not_locked_out_error_flash "One-Time Password authentication is not currently locked out"
|
||
otp_unlock_auth_failure_error_flash "One-Time Password invalid authentication"
|
||
otp_unlock_auth_deadline_passed_error_flash "Deadline past for unlocking One-Time Password authentication"
|
||
otp_unlock_auth_not_yet_available_error_flash "One-Time Password unlock attempt not yet available"
|
||
|
||
# OTP Unlock Email
|
||
send_otp_locked_out_email do
|
||
user = Account[account_id]
|
||
Util.send_email(user.email, "Ubicloud Account One-Time Password Authentication Locked Out",
|
||
greeting: "Hello #{user.name},",
|
||
body: ["Due to repeated authentication failures, for the safety of your Ubicloud account, One-Time Password Authentication has been locked out. You can unlock it with three successful consecutive One-Time Password Authentications.",
|
||
"For any questions or assistance, reach out to our team at support@ubicloud.com."])
|
||
end
|
||
send_otp_unlocked_email do
|
||
user = Account[account_id]
|
||
Util.send_email(user.email, "Ubicloud Account One-Time Password Authentication Unlocked",
|
||
greeting: "Hello #{user.name},",
|
||
body: ["Since your Ubicloud account had three successful consecutive One-Time Password Authentications, One-Time Password Authentication is now unlocked for your account.",
|
||
"For any questions or assistance, reach out to our team at support@ubicloud.com."])
|
||
end
|
||
|
||
# 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: scope.typecast_params.nonempty_str!("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
|
||
no_authorization_needed
|
||
redirect_default_project_dashboard
|
||
end
|
||
end
|
||
|
||
# :nocov:
|
||
if Config.test?
|
||
# :nocov:
|
||
hash_branch(:webhook_prefix, "test-error") do |r|
|
||
raise(typecast_params.str("message") || "test error")
|
||
end
|
||
|
||
hash_branch(:webhook_prefix, "test-typecast-error-during-validation-failure") do |r|
|
||
r.POST["a"] = {}
|
||
handle_validation_failure(inline: "<%= typecast_body_params.str('a') %>")
|
||
typecast_body_params.str("a")
|
||
end
|
||
|
||
hash_branch(:webhook_prefix, "test-no-audit-logging") do |r|
|
||
r.post "test" do
|
||
@still_need_audit_logging = true
|
||
""
|
||
end
|
||
|
||
r.post "bad" do
|
||
audit_log(@project, "bad_action")
|
||
end
|
||
end
|
||
|
||
hash_branch("test-no-authorization-needed") do |r|
|
||
r.get "never" do
|
||
""
|
||
end
|
||
|
||
r.get "once" do
|
||
no_authorization_needed
|
||
""
|
||
end
|
||
|
||
r.get "twice" do
|
||
2.times { no_authorization_needed }
|
||
end
|
||
|
||
r.get "after-authorization" do
|
||
@project = current_account.projects.first
|
||
dataset_authorize(current_account.projects_dataset, "Project:edit")
|
||
no_authorization_needed
|
||
end
|
||
|
||
r.get "authorization-error" do
|
||
raise Authorization::Unauthorized
|
||
end
|
||
|
||
r.get "runtime-error" do
|
||
raise "foo"
|
||
end
|
||
end
|
||
|
||
hash_branch("clear-last-password-entry") do |r|
|
||
no_authorization_needed
|
||
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?
|
||
unless /\ABearer:?\s+pat-/i.match?(env["HTTP_AUTHORIZATION"].to_s)
|
||
if r.path_info == "/cli"
|
||
response.content_type = :text
|
||
response.status = 400
|
||
next "! Invalid request: No valid personal access token provided\n"
|
||
elsif (session_id = env["clover.web_cli_session_id"])
|
||
rodauth.instance_variable_set(:@session, rodauth.session_key.to_s => session_id)
|
||
else
|
||
response.json = true
|
||
fail CloverError.new(401, "MissingCredentials", "must include personal access token in Authorization header")
|
||
end
|
||
end
|
||
response.json = true
|
||
response.skip_content_security_policy!
|
||
else
|
||
r.on "runtime" do
|
||
response.json = true
|
||
response.skip_content_security_policy!
|
||
|
||
unless (jwt_payload = get_runtime_jwt_payload) && (@vm = Vm[id: UBID.to_uuid(jwt_payload["sub"])])
|
||
fail CloverError.new(400, "InvalidRequest", "invalid JWT format or claim in Authorization header")
|
||
end
|
||
|
||
before_main_hash_branches
|
||
r.hash_branches(:runtime_prefix)
|
||
end
|
||
|
||
r.public
|
||
r.assets
|
||
|
||
r.on "webhook" do
|
||
before_main_hash_branches
|
||
r.hash_branches(:webhook_prefix)
|
||
end
|
||
|
||
r.get "auth", :ubid_uuid do |id|
|
||
next unless (@oidc_provider = OidcProvider[id])
|
||
|
||
r.get do
|
||
uri = URI(@oidc_provider.url)
|
||
uri.path = ""
|
||
content_security_policy.add_form_action(uri.to_s)
|
||
view "auth/oidc_login"
|
||
end
|
||
end
|
||
|
||
check_csrf!
|
||
rodauth.load_memory
|
||
rodauth.check_active_session
|
||
|
||
r.root do
|
||
if current_account
|
||
redirect_default_project_dashboard
|
||
else
|
||
r.redirect rodauth.login_route
|
||
end
|
||
end
|
||
end
|
||
|
||
r.rodauth
|
||
rodauth.require_authentication
|
||
|
||
if api? && r.path_info != "/cli"
|
||
# 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
|
||
|
||
before_authenticated_hash_branches
|
||
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
|
||
|
||
# :nocov:
|
||
if Config.test? && ENV["CLOVER_FREEZE"] != "1"
|
||
# :nocov:
|
||
|
||
# This section is included when running non-frozen specs, and ensures that all routes
|
||
# either call an authorization method, or explicitly indicate that no additional authorization
|
||
# is needed by calling no_authorization_needed
|
||
|
||
after do |res|
|
||
if @still_need_authorization
|
||
if res
|
||
case res[0]
|
||
when 404, 501
|
||
next
|
||
end
|
||
else
|
||
case $!
|
||
when Authorization::Unauthorized
|
||
next
|
||
end
|
||
|
||
# Allow easier debugging of issues, by not raising a RuntimeError if there is a separate
|
||
# error being raised and you are explicitly requesting showing it.
|
||
next if ENV["SHOW_ERRORS"]
|
||
end
|
||
|
||
raise "no authorization check for #{request.request_method} #{request.path_info}"
|
||
end
|
||
|
||
if !request.get? && @still_need_audit_logging && res && res[0] < 400 && res[0] != 204 && (api? || !flash.next["error"])
|
||
raise "no audit logging for #{request.request_method} #{request.path_info}"
|
||
end
|
||
end
|
||
|
||
prepend(Module.new do
|
||
def before_authenticated_hash_branches
|
||
# Set the audit logging and authorization flags, which will be unset by
|
||
# the related methods, to ensure that all routes have some form of authorization,
|
||
# all add non-GET routes have some form of audit logging.
|
||
@still_need_audit_logging = true
|
||
@still_need_authorization = true
|
||
before_main_hash_branches
|
||
end
|
||
|
||
def before_main_hash_branches
|
||
# Disallow direct access of request.params in routes, only allow access
|
||
# through typecast_params
|
||
typecast_params
|
||
request.singleton_class.send(:undef_method, :params)
|
||
|
||
# Need to rewind body so webhook github requests work
|
||
request.body.rewind unless request.get?
|
||
end
|
||
|
||
def audit_log(...)
|
||
@still_need_audit_logging = false
|
||
super
|
||
end
|
||
|
||
def no_audit_log
|
||
@still_need_audit_logging = false
|
||
super
|
||
end
|
||
|
||
def authorize(actions, object_id)
|
||
@still_need_authorization = false
|
||
super
|
||
end
|
||
|
||
def has_permission?(actions, object_id)
|
||
@still_need_authorization = false
|
||
super
|
||
end
|
||
|
||
def dataset_authorize(ds, actions)
|
||
@still_need_authorization = false
|
||
super
|
||
end
|
||
|
||
def no_authorization_needed
|
||
raise "called no_authorization_needed when authorization already not needed: #{request.inspect}" unless @still_need_authorization
|
||
@still_need_authorization = false
|
||
super
|
||
end
|
||
end)
|
||
end
|
||
end
|