From 45ac51259da0a46dc76d337a8718189b1e3c4910 Mon Sep 17 00:00:00 2001 From: Christopher Guess Date: Fri, 13 Jan 2023 15:39:39 -0500 Subject: [PATCH 01/10] Initial setup of webauthn works --- Gemfile | 3 + Gemfile.lock | 27 ++++++ app/controllers/accounts_controller.rb | 88 ++++++++++++++++++- .../controllers/webauthn_controller.js | 52 +++++++++++ app/models/user.rb | 3 + app/models/webauthn_credential.rb | 8 ++ app/views/accounts/new.html.erb | 2 +- app/views/accounts/setup_mfa.html.erb | 6 ++ config/importmap.rb | 3 + config/initializers/webauthn.rb | 5 ++ config/routes.rb | 6 ++ ...20230112162406_add_webauthn_id_to_users.rb | 6 ++ ...30112171823_create_webauthn_credentials.rb | 15 ++++ db/schema.rb | 18 +++- test/fixtures/webauthn_credentials.yml | 15 ++++ test/models/webauthn_credential_test.rb | 7 ++ 16 files changed, 259 insertions(+), 5 deletions(-) create mode 100644 app/javascript/controllers/webauthn_controller.js create mode 100644 app/models/webauthn_credential.rb create mode 100644 app/views/accounts/setup_mfa.html.erb create mode 100644 config/initializers/webauthn.rb create mode 100644 db/migrate/20230112162406_add_webauthn_id_to_users.rb create mode 100644 db/migrate/20230112171823_create_webauthn_credentials.rb create mode 100644 test/fixtures/webauthn_credentials.yml create mode 100644 test/models/webauthn_credential_test.rb diff --git a/Gemfile b/Gemfile index fb9cff15..273cbc87 100644 --- a/Gemfile +++ b/Gemfile @@ -186,3 +186,6 @@ gem "dartsass-rails", "~> 0.4.0" # Comma lets us generate CSV-formatted data from ActiveRecord models gem "comma", "~>4.7.0" + +# Webauthn Enabling +gem "webauthn" diff --git a/Gemfile.lock b/Gemfile.lock index 7280e31f..bb028609 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -95,10 +95,12 @@ GEM tzinfo (~> 2.0) addressable (2.8.1) public_suffix (>= 2.0.2, < 6.0) + android_key_attestation (0.3.0) apparition (0.6.0) capybara (~> 3.13, < 4) websocket-driver (>= 0.6.5) ast (2.4.2) + awrence (1.2.1) aws-eventstream (1.2.0) aws-partitions (1.637.0) aws-sdk-core (3.155.0) @@ -116,6 +118,7 @@ GEM aws-sigv4 (1.5.1) aws-eventstream (~> 1, >= 1.0.2) bcrypt (3.1.18) + bindata (2.4.14) bindex (0.8.1) blueprinter (0.25.3) bootsnap (1.13.0) @@ -134,6 +137,7 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + cbor (0.5.9.6) childprocess (4.1.0) climate_control (0.2.0) coderay (1.1.3) @@ -144,6 +148,9 @@ GEM concurrent-ruby (1.1.10) connection_pool (2.3.0) content_disposition (1.0.0) + cose (1.3.0) + cbor (~> 0.5.9) + openssl-signature_algorithm (~> 1.0) countries (5.1.2) sixarm_ruby_unaccent (~> 1.1) country_select (8.0.0) @@ -206,6 +213,7 @@ GEM hana (~> 1.3) regexp_parser (~> 2.0) uri_template (~> 0.7) + jwt (2.6.0) listen (3.7.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) @@ -246,6 +254,9 @@ GEM racc (~> 1.4) oauth (0.5.14) oj (3.13.21) + openssl (3.0.2) + openssl-signature_algorithm (1.2.1) + openssl (> 2.0, < 3.1) orm_adapter (0.5.0) os (1.1.4) pagy (5.10.1) @@ -364,6 +375,8 @@ GEM tty-screen (~> 0.8.1) rubyzip (2.3.2) safe_type (1.1.1) + safety_net_attestation (0.4.0) + jwt (~> 2.0) selenium-webdriver (4.4.0) childprocess (>= 0.5, < 5.0) rexml (~> 3.2, >= 3.2.5) @@ -429,6 +442,10 @@ GEM erubis (~> 2.6) thor (~> 1.2.1) xdg (~> 2.2, >= 2.2.5) + tpm-key_attestation (0.11.0) + bindata (~> 2.4) + openssl (> 2.0, < 3.1) + openssl-signature_algorithm (~> 1.0) tty-screen (0.8.1) turbo-rails (1.3.0) actionpack (>= 6.0.0) @@ -454,6 +471,15 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webauthn (2.5.2) + android_key_attestation (~> 0.3.0) + awrence (~> 1.1) + bindata (~> 2.4) + cbor (~> 0.5.9) + cose (~> 1.1) + openssl (>= 2.2, < 3.1) + safety_net_attestation (~> 0.4.0) + tpm-key_attestation (~> 0.11.0) webdrivers (5.1.0) nokogiri (~> 1.6) rubyzip (>= 1.3.0) @@ -538,6 +564,7 @@ DEPENDENCIES typhoeus validate_url web-console (>= 4.1.0) + webauthn webdrivers yard youtubearchiver! diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index d8184149..c77af27d 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -53,6 +53,11 @@ class ResetPasswordParams < T::Struct const :email, String end + class FinishWebauthnSetupParams < T::Struct + const :nickname, String + const :publicKeyCredential, Hash + end + sig { void } def new begin @@ -95,10 +100,87 @@ def create raise InvalidTokenError if @user.new_record? raise InvalidUpdatePasswordError if typed_params.password.blank? || @user.invalid? - @user.remove_role :new_user - sign_in @user - redirect_to after_sign_in_path_for(@user) + # From here branch to set up 2FA + redirect_to account_setup_mfa_path + end + + sig { void } + def setup_mfa + ###### + # TODO: Next steps: + # - Make sure the route above works to get to here + # do this via the console after creating an applicant in the UI + # Then `applicant.approve` and User.create_from_applicant(applicant) + # - From here refer to https://www.honeybadger.io/blog/multi-factor-2fa-authentication-rails-webauthn-devise/ + # and https://github.com/cedarcode/webauthn-ruby + # This could be used later too https://github.com/github/webauthn-json + current_user.remove_role :new_user + end + + sig { void } + def start_webauthn_setup + if !current_user.webauthn_id + current_user.update!(webauthn_id: WebAuthn.generate_user_id) + end + + options = WebAuthn::Credential.options_for_create( + user: { id: current_user.webauthn_id, display_name: current_user.email, name: current_user.email }, + rp: { name: current_user.email }, + exclude: current_user.webauthn_credentials.pluck(:external_id) + ) + + # Store the newly generated challenge somewhere so you can have it + # for the verification phase. + session[:webauthn_credential_register_challenge] = options.challenge + + # Send `options` back to the browser, so that they can be used + # to call `navigator.credentials.create({ "publicKey": options })` + # + # You can call `options.as_json` to get a ruby hash with a JSON representation if needed. + + # If inside a Rails controller, `render json: options` will just work. + # I.e. it will encode and convert the options to JSON automatically. + + # For your frontend code, you might find @github/webauthn-json npm package useful. + # Especially for handling the necessary decoding of the options, and sending the + # `PublicKeyCredential` object back to the server. + + options = { publicKey: options } + + # redirect_to after_sign_in_path_for(current_user) + respond_to do |format| + format.json { render json: options } + end + end + + sig { void } + def finish_webauthn_setup + typed_params = TypedParams[FinishWebauthnSetupParams].new.extract!(params) + + webauthn_credential = WebAuthn::Credential.from_create(typed_params.publicKeyCredential) + + begin + webauthn_credential.verify(session[:webauthn_credential_register_challenge]) + + # The validation would raise WebAuthn::Error so if we are here, the credentials are valid, and we can save it + credential = current_user.webauthn_credentials.new( + external_id: webauthn_credential.id, + public_key: webauthn_credential.public_key, + nickname: typed_params.nickname, + sign_count: webauthn_credential.sign_count + ) + + if credential.save + render json: { registration_status: "success" } + else + render json: { registration_status: "error", error: "Unknown processing error" } + end + rescue WebAuthn::Error => e + render json: { registration_status: "error", error: e } + ensure + session.delete(:webauthn_credential_register_challenge) + end end sig { void } diff --git a/app/javascript/controllers/webauthn_controller.js b/app/javascript/controllers/webauthn_controller.js new file mode 100644 index 00000000..5d57689e --- /dev/null +++ b/app/javascript/controllers/webauthn_controller.js @@ -0,0 +1,52 @@ +import { Controller } from '@hotwired/stimulus' +import { + create, + parseCreationOptionsFromJSON, +} from "@github/webauthn-json/browser-ponyfill"; +import { get, post } from "@rails/request.js" + + +export default class extends Controller { + static values = { input: String } + static targets = [ "output" ] + + async connect() { + if(navigator.credentials == undefined) { + console.log("Webauthn not available. Are you on HTTPS?") + return + // Webauthn isn't supported, so we'll do stuff here eventually + } + + const setup_response = await get("/setup_mfa/webauthn.json", { + contentType: "application/json", + responseKind: "json" + }) + + if (!setup_response.ok) { + console.log ("Something went horribly wrong") + return + } + + const setup_response_body = await setup_response.text + const optionsJson = JSON.parse(setup_response_body) + const options = parseCreationOptionsFromJSON(optionsJson) + + const createResponse = await create(options); + console.log(createResponse) + + const finishWebauthnResponse = await post("/setup_mfa/webauthn.json", { + body: { publicKeyCredential: createResponse, nickname: "stuffthings" }, + contentType: "application/json", + responseKind: "json" + }) + + const finishWebauthnResponseBody = await finishWebauthnResponse.text + const finishedBodyJson = JSON.parse(finishWebauthnResponseBody) + + if(finishedBodyJson["registration_status"] == success) { + this.outputTarget.textContent = "Success!" + } else { + this.outputTarget.textContent = "Failed" + } + } +} diff --git a/app/models/user.rb b/app/models/user.rb index 969e33a6..72a5526f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -6,6 +6,8 @@ class User < ApplicationRecord :recoverable, :rememberable, :validatable, :trackable, :lockable, :confirmable + has_many :webauthn_credentials, dependent: :destroy + has_many :api_keys, dependent: :delete_all has_many :archive_items, foreign_key: :submitter_id, dependent: :nullify @@ -21,6 +23,7 @@ class User < ApplicationRecord validates :name, presence: true validates :email, presence: true + validates :webauthn_id, uniqueness: true, allow_nil: true # `Devise::Recoverable#set_reset_password_token` is a protected method, which prevents us from # calling it directly. Since we need to be able to do that for tests and for duck-punching other diff --git a/app/models/webauthn_credential.rb b/app/models/webauthn_credential.rb new file mode 100644 index 00000000..57c75ba2 --- /dev/null +++ b/app/models/webauthn_credential.rb @@ -0,0 +1,8 @@ +class WebauthnCredential < ApplicationRecord + belongs_to :user + + validates :external_id, presence: true, uniqueness: true + validates :public_key, presence: true + validates :nickname, presence: true, uniqueness: { scope: :user_id } + validates :sign_count, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } +end diff --git a/app/views/accounts/new.html.erb b/app/views/accounts/new.html.erb index 4ed6cf24..9f18a026 100644 --- a/app/views/accounts/new.html.erb +++ b/app/views/accounts/new.html.erb @@ -11,7 +11,7 @@ Finish setting up your account
-

Your request to access the site has been approved! Please enter and confirm a password to finish setting up your account.

+

Your request to access the site has been approved! Please enter and confirm a password to continue setting up your account.

diff --git a/app/views/accounts/setup_mfa.html.erb b/app/views/accounts/setup_mfa.html.erb new file mode 100644 index 00000000..504b1fcc --- /dev/null +++ b/app/views/accounts/setup_mfa.html.erb @@ -0,0 +1,6 @@ +
+
For security purposes we require, in addition to a password, all accounts to use two-factor authentication.
+ +
Filler for OTP app authentication option
+
Here's the stuff for webauthn
+
diff --git a/config/importmap.rb b/config/importmap.rb index 8c126129..cebf0a31 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -6,6 +6,9 @@ pin "@rails/activestorage", to: "activestorage.esm.js" pin "dayjs" # @1.11.5 pin "dayjs/plugin/utc", to: "dayjs--plugin--utc.js" # @1.11.5 +pin "@github/webauthn-json", to: "https://ga.jspm.io/npm:@github/webauthn-json@2.0.2/dist/esm/webauthn-json.js" +pin "@github/webauthn-json/browser-ponyfill", to: "https://ga.jspm.io/npm:@github/webauthn-json@2.0.2/dist/esm/webauthn-json.browser-ponyfill.js" +pin "@rails/request.js", to: "https://ga.jspm.io/npm:@rails/request.js@0.0.8/src/index.js" # Our JavaScript pin "application", preload: true diff --git a/config/initializers/webauthn.rb b/config/initializers/webauthn.rb new file mode 100644 index 00000000..b95c2630 --- /dev/null +++ b/config/initializers/webauthn.rb @@ -0,0 +1,5 @@ +WebAuthn.configure do |config| + config.origin = ENV.fetch("APP_URL", "https://vault-factstream-reporterslab.pagekite.me") + config.rp_name = "Media Vault" + config.credential_options_timeout = 120_000 +end diff --git a/config/routes.rb b/config/routes.rb index 1971ac71..cd094cc5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -99,6 +99,12 @@ get "/account/setup/(:token)", to: "accounts#new", as: "new_account" post "/account/setup", to: "accounts#create", as: "create_account" + scope "setup_mfa" do + get "/", to: "accounts#setup_mfa", as: "account_setup_mfa" + get "/webauthn", to: "accounts#start_webauthn_setup", as: "account_start_webauthn_setup" + post "/webauthn", to: "accounts#finish_webauthn_setup", as: "account_finish_webauthn_setup" + end + get "/account/reset_password", to: "accounts#reset_password", as: "reset_password" post "/account/reset_password", to: "accounts#send_password_reset_email", as: "send_password_reset_email" end diff --git a/db/migrate/20230112162406_add_webauthn_id_to_users.rb b/db/migrate/20230112162406_add_webauthn_id_to_users.rb new file mode 100644 index 00000000..39445cf9 --- /dev/null +++ b/db/migrate/20230112162406_add_webauthn_id_to_users.rb @@ -0,0 +1,6 @@ +class AddWebauthnIdToUsers < ActiveRecord::Migration[7.0] + def change + add_column :users, :webauthn_id, :string + add_index :users, :webauthn_id, unique: true + end +end diff --git a/db/migrate/20230112171823_create_webauthn_credentials.rb b/db/migrate/20230112171823_create_webauthn_credentials.rb new file mode 100644 index 00000000..6956c1fc --- /dev/null +++ b/db/migrate/20230112171823_create_webauthn_credentials.rb @@ -0,0 +1,15 @@ +class CreateWebauthnCredentials < ActiveRecord::Migration[7.0] + def change + create_table :webauthn_credentials, id: :uuid do |t| + t.references :user, null: false, foreign_key: true, type: :uuid + t.string :external_id, null: false + t.string :public_key, null: false + t.string :nickname, null: false + t.integer :sign_count, null: false, default: 0 + + t.timestamps + end + add_index :webauthn_credentials, :external_id, unique: true + add_index :webauthn_credentials, %i[nickname user_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 4551d635..b21b1263 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2022_11_09_152113) do +ActiveRecord::Schema[7.0].define(version: 2023_01_12_171823) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -319,10 +319,12 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "name", null: false + t.string "webauthn_id" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true + t.index ["webauthn_id"], name: "index_users_on_webauthn_id", unique: true end create_table "users_roles", id: false, force: :cascade do |t| @@ -333,6 +335,19 @@ t.index ["user_id"], name: "index_users_roles_on_user_id" end + create_table "webauthn_credentials", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "user_id", null: false + t.string "external_id", null: false + t.string "public_key", null: false + t.string "nickname", null: false + t.integer "sign_count", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["external_id"], name: "index_webauthn_credentials_on_external_id", unique: true + t.index ["nickname", "user_id"], name: "index_webauthn_credentials_on_nickname_and_user_id", unique: true + t.index ["user_id"], name: "index_webauthn_credentials_on_user_id" + end + create_table "youtube_channels", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "title", null: false t.string "youtube_id", null: false @@ -383,5 +398,6 @@ add_foreign_key "text_searches", "users" add_foreign_key "twitter_images", "tweets" add_foreign_key "twitter_videos", "tweets" + add_foreign_key "webauthn_credentials", "users" add_foreign_key "youtube_videos", "youtube_posts" end diff --git a/test/fixtures/webauthn_credentials.yml b/test/fixtures/webauthn_credentials.yml new file mode 100644 index 00000000..175763cc --- /dev/null +++ b/test/fixtures/webauthn_credentials.yml @@ -0,0 +1,15 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + user: one + external_id: MyString + public_key: MyString + nickname: MyString + sign_count: 1 + +two: + user: two + external_id: MyString + public_key: MyString + nickname: MyString + sign_count: 1 diff --git a/test/models/webauthn_credential_test.rb b/test/models/webauthn_credential_test.rb new file mode 100644 index 00000000..6b78b695 --- /dev/null +++ b/test/models/webauthn_credential_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class WebauthnCredentialTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end From e90831473534b95baadb40782fc2ab178ed12263 Mon Sep 17 00:00:00 2001 From: Christopher Guess Date: Wed, 25 Jan 2023 01:02:56 -0500 Subject: [PATCH 02/10] Add Webauthn Authentication as second factor This adds webauthn authentication as a required second factor authentication. To do so the following are here: - After setting the password when setting up an account the user is prompted to use MFA to validate their account. - To do the previous we also add `authenticate_user_and_setup!` to replace `authenticate_user!` specfically so that it validates MFA is set up as well everywhere. - At login the user is not logged in until after MFA is validated Before this is merged into master there will be branches off of it to do the following: 1. Add recovery codes 2. Add TOTP for Firefox users since Firefox only implements Webauthn for hardware keys, not the passkey standard. This is absurd, but oh well. 3. Add the ability to register multiple keys To Test: - Go through the entire account request, confirm, authorize system and then create a user, set a password and make sure you can register your MFA. Do this on vault, not insights, to facilitate later testing - Log into your account with MFA - Log out, swtich to insights, try to log in again. Should work just fine - Try it in a few different browsers with a hardware key, that should work. Passkey is going to be browser locked. --- Gemfile | 4 +- Gemfile.lock | 30 +++-- app/assets/stylesheets/application.scss | 1 + app/assets/stylesheets/pages/accounts.scss | 14 ++ app/controllers/accounts_controller.rb | 80 ++++++----- app/controllers/application_controller.rb | 29 +++- .../media_vault/archive_controller.rb | 2 +- .../media_vault/ingest_controller.rb | 2 +- app/controllers/media_vault_controller.rb | 2 +- app/controllers/users/sessions_controller.rb | 125 +++++++++++++++++- .../controllers/webauthn_controller.js | 69 ++++++++-- .../controllers/webauthn_login_controller.js | 81 ++++++++++++ app/models/webauthn_credential.rb | 1 + app/views/accounts/_setup_mfa_error.html.erb | 23 ++++ app/views/accounts/setup_mfa.html.erb | 33 ++++- app/views/layouts/application.html.erb | 1 + .../sessions/_validate_mfa_error.html.erb | 23 ++++ .../users/sessions/mfa_validation.html.erb | 21 +++ app/views/users/sessions/new.html.erb | 3 +- config/application.rb | 2 + config/environments/development.rb | 2 + config/importmap.rb | 1 + config/initializers/figaro.rb | 1 + config/initializers/webauthn.rb | 4 +- config/routes.rb | 12 +- ...223143_add_type_to_webauthn_credentials.rb | 5 + db/schema.rb | 3 +- public/lock-cpu-cyber-security.json | 1 + 28 files changed, 504 insertions(+), 71 deletions(-) create mode 100644 app/assets/stylesheets/pages/accounts.scss create mode 100644 app/javascript/controllers/webauthn_login_controller.js create mode 100644 app/views/accounts/_setup_mfa_error.html.erb create mode 100644 app/views/users/sessions/_validate_mfa_error.html.erb create mode 100644 app/views/users/sessions/mfa_validation.html.erb create mode 100644 db/migrate/20230123223143_add_type_to_webauthn_credentials.rb create mode 100644 public/lock-cpu-cyber-security.json diff --git a/Gemfile b/Gemfile index 273cbc87..e9c4d5ce 100644 --- a/Gemfile +++ b/Gemfile @@ -48,6 +48,8 @@ group :development, :test do gem "rubocop-sorbet", require: false # Check Sorbet gem "rubocop-minitest", require: false # For checking tests gem "minitest-hooks" # Used to apply stubs to each test + + gem "hotwire-livereload" # Live reload for JS, HTML and CSS devlopment end group :development do @@ -188,4 +190,4 @@ gem "dartsass-rails", "~> 0.4.0" gem "comma", "~>4.7.0" # Webauthn Enabling -gem "webauthn" +gem "webauthn", git: "https://github.com/cedarcode/webauthn-ruby", tag: "v3.0.0.alpha2" diff --git a/Gemfile.lock b/Gemfile.lock index bb028609..d1980130 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -17,6 +17,21 @@ GIT selenium-webdriver typhoeus +GIT + remote: https://github.com/cedarcode/webauthn-ruby + revision: d7022a9ed5f3050e908ff846fe6ae8143a16fbbe + tag: v3.0.0.alpha2 + specs: + webauthn (3.0.0.alpha2) + android_key_attestation (~> 0.3.0) + awrence (~> 1.1) + bindata (~> 2.4) + cbor (~> 0.5.9) + cose (~> 1.1) + openssl (>= 2.2, < 3.1) + safety_net_attestation (~> 0.4.0) + tpm-key_attestation (~> 0.11.0) + GIT remote: https://github.com/cguess/birdsong revision: a0363b1a917e2c4ec9083ac71c7ac800cfb8ade1 @@ -195,6 +210,9 @@ GEM activesupport (>= 5.0) hana (1.3.7) highline (2.0.3) + hotwire-livereload (1.2.3) + listen (>= 3.0.0) + rails (>= 6.0.0) http-accept (1.7.0) http-cookie (1.0.5) domain_name (~> 0.5) @@ -471,15 +489,6 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - webauthn (2.5.2) - android_key_attestation (~> 0.3.0) - awrence (~> 1.1) - bindata (~> 2.4) - cbor (~> 0.5.9) - cose (~> 1.1) - openssl (>= 2.2, < 3.1) - safety_net_attestation (~> 0.4.0) - tpm-key_attestation (~> 0.11.0) webdrivers (5.1.0) nokogiri (~> 1.6) rubyzip (>= 1.3.0) @@ -520,6 +529,7 @@ DEPENDENCIES ferrum (~> 0.11) figaro forki! + hotwire-livereload importmap-rails jbuilder (~> 2.7) json_schemer @@ -564,7 +574,7 @@ DEPENDENCIES typhoeus validate_url web-console (>= 4.1.0) - webauthn + webauthn! webdrivers yard youtubearchiver! diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 77432eed..74b1e29d 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -34,6 +34,7 @@ @use "pages/fact_check_insights_guide"; @use "pages/fact_check_insights_highlights"; @use "pages/media_vault"; +@use "pages/accounts"; #site-wrapper { display: flex; diff --git a/app/assets/stylesheets/pages/accounts.scss b/app/assets/stylesheets/pages/accounts.scss new file mode 100644 index 00000000..3695f558 --- /dev/null +++ b/app/assets/stylesheets/pages/accounts.scss @@ -0,0 +1,14 @@ +@use "shared"; + +#page--accounts--setup_mfa { + background-color: shared.$color--brand--fact_check_insights; + + .setup_mfa--lock { + height: 400px; + } + + .setup_mfa--webauthn_button { + top: -75px; + position: relative; + } +} diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index c77af27d..ec41fef5 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -9,11 +9,20 @@ class InvalidUpdatePasswordError < StandardError; end rescue_from InvalidTokenError, with: :invalid_token_error rescue_from InvalidUpdatePasswordError, with: :invalid_update_password_error - before_action :authenticate_user!, except: [ + before_action :authenticate_user!, only: [ + :setup_mfa, + :start_webauthn_setup, + :finish_webauthn_setup, + ] + + before_action :authenticate_user_and_setup!, except: [ :new, :create, :reset_password, :send_password_reset_email, + :setup_mfa, + :start_webauthn_setup, + :finish_webauthn_setup, ] before_action :must_be_logged_out, only: [ @@ -107,14 +116,6 @@ def create sig { void } def setup_mfa - ###### - # TODO: Next steps: - # - Make sure the route above works to get to here - # do this via the console after creating an applicant in the UI - # Then `applicant.approve` and User.create_from_applicant(applicant) - # - From here refer to https://www.honeybadger.io/blog/multi-factor-2fa-authentication-rails-webauthn-devise/ - # and https://github.com/cedarcode/webauthn-ruby - # This could be used later too https://github.com/github/webauthn-json current_user.remove_role :new_user end @@ -124,28 +125,14 @@ def start_webauthn_setup current_user.update!(webauthn_id: WebAuthn.generate_user_id) end - options = WebAuthn::Credential.options_for_create( - user: { id: current_user.webauthn_id, display_name: current_user.email, name: current_user.email }, - rp: { name: current_user.email }, + options = relying_party(request.referer).options_for_registration( + user: { id: current_user.webauthn_id, display_name: current_user.name, name: current_user.email }, exclude: current_user.webauthn_credentials.pluck(:external_id) ) # Store the newly generated challenge somewhere so you can have it # for the verification phase. session[:webauthn_credential_register_challenge] = options.challenge - - # Send `options` back to the browser, so that they can be used - # to call `navigator.credentials.create({ "publicKey": options })` - # - # You can call `options.as_json` to get a ruby hash with a JSON representation if needed. - - # If inside a Rails controller, `render json: options` will just work. - # I.e. it will encode and convert the options to JSON automatically. - - # For your frontend code, you might find @github/webauthn-json npm package useful. - # Especially for handling the necessary decoding of the options, and sending the - # `PublicKeyCredential` object back to the server. - options = { publicKey: options } # redirect_to after_sign_in_path_for(current_user) @@ -158,26 +145,55 @@ def start_webauthn_setup def finish_webauthn_setup typed_params = TypedParams[FinishWebauthnSetupParams].new.extract!(params) - webauthn_credential = WebAuthn::Credential.from_create(typed_params.publicKeyCredential) - begin - webauthn_credential.verify(session[:webauthn_credential_register_challenge]) + begin + webauthn_credential = relying_party(request.referer).verify_registration(typed_params.publicKeyCredential, session[:webauthn_credential_register_challenge]) + rescue StandardError + render json: { + errorPartial: + render_to_string( + partial: "accounts/setup_mfa_error", + formats: :html, + layout: false, + locals: { error: "Error validating credentials, please contact us if you continue to have problems." } + ) + } + return + end # The validation would raise WebAuthn::Error so if we are here, the credentials are valid, and we can save it + # TODO: Add "type" to this credential = current_user.webauthn_credentials.new( external_id: webauthn_credential.id, public_key: webauthn_credential.public_key, nickname: typed_params.nickname, - sign_count: webauthn_credential.sign_count + sign_count: webauthn_credential.sign_count, + key_type: webauthn_credential.type, ) if credential.save render json: { registration_status: "success" } else - render json: { registration_status: "error", error: "Unknown processing error" } + render json: { + errorPartial: + render_to_string( + partial: "accounts/setup_mfa_error", + formats: :html, + layout: false, + locals: {} + ) + } end rescue WebAuthn::Error => e - render json: { registration_status: "error", error: e } + render json: { + errorPartial: + render_to_string( + partial: "accounts/setup_mfa_error", + formats: :html, + layout: false, + locals: { error: e } + ) + } ensure session.delete(:webauthn_credential_register_challenge) end @@ -189,7 +205,7 @@ def send_password_reset_email email = typed_params.email @user = User.find_by(email: email) @user.send_reset_password_instructions unless @user.nil? - redirect_to "/users/sign_in", notice: "A recovery email has been sent to the provided email addres " + redirect_to "/users/sign_in", notice: "A recovery email has been sent to the provided email address" end sig { void } diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 088903ed..cd3b1aae 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -102,6 +102,19 @@ def must_be_logged_out end end + sig { void } + def must_have_mfa_setup + return if current_user.nil? || current_user.webauthn_credentials.count.positive? + redirect_to account_setup_mfa_path, allow_other_host: false, flash: { error: "You must setup two-factor authentication before continuing." } + end + + # We require users to have MFA enabled, so this stops them from accessing the site unless they do + sig { void } + def authenticate_user_and_setup! + return false unless authenticate_user! + must_have_mfa_setup + end + sig { void } def authenticate_user_from_api_key! if params[:api_key].blank? @@ -139,7 +152,7 @@ def authenticate_super_user sig { void } def authenticate_super_user! # First we make sure they're logged in at all, this also sets the current user so we can check it - authenticate_user! + authenticate_user_and_setup! unless current_user.is_admin? redirect_back_or_to "/", allow_other_host: false, flash: { error: "You don’t have permission to access that page." } @@ -153,4 +166,18 @@ def render_unauthorized content_type: "text/html", status: :unauthorized end + + sig { params(origin: String).returns(WebAuthn::RelyingParty) } + def relying_party(origin) + uri = URI(origin) + unless uri.host == Figaro.env.FACT_CHECK_INSIGHTS_HOST || uri.host == Figaro.env.MEDIA_VAULT_HOST + raise "Invalid origin host #{uri.scheme}://#{uri.host}" + end + + WebAuthn::RelyingParty.new( + id: Figaro.env.AUTH_BASE_HOST, # This lets us use the same creds for subdomains + origin: "#{uri.scheme}://#{uri.host}", # Make sure that the url is the url we're on + name: "FactCheck Insights/MediaVault" + ) + end end diff --git a/app/controllers/media_vault/archive_controller.rb b/app/controllers/media_vault/archive_controller.rb index 6680e6a8..95bbf7d7 100644 --- a/app/controllers/media_vault/archive_controller.rb +++ b/app/controllers/media_vault/archive_controller.rb @@ -6,7 +6,7 @@ class MediaVault::ArchiveController < MediaVaultController :submit_url, ] - skip_before_action :authenticate_user!, only: :scrape_result_callback + skip_before_action :authenticate_user_and_setup!, only: :scrape_result_callback skip_before_action :must_be_media_vault_user, only: :scrape_result_callback ARCHIVE_ITEMS_PER_PAGE = 15 diff --git a/app/controllers/media_vault/ingest_controller.rb b/app/controllers/media_vault/ingest_controller.rb index 129f5d3e..a84c12d2 100644 --- a/app/controllers/media_vault/ingest_controller.rb +++ b/app/controllers/media_vault/ingest_controller.rb @@ -1,7 +1,7 @@ # typed: ignore class MediaVault::IngestController < MediaVaultController - skip_before_action :authenticate_user! + skip_before_action :authenticate_user_and_setup! skip_before_action :must_be_media_vault_user before_action :authenticate_user_from_api_key! diff --git a/app/controllers/media_vault_controller.rb b/app/controllers/media_vault_controller.rb index 62921658..7bd786ed 100644 --- a/app/controllers/media_vault_controller.rb +++ b/app/controllers/media_vault_controller.rb @@ -1,7 +1,7 @@ # typed: strict class MediaVaultController < ApplicationController - before_action :authenticate_user!, except: [ + before_action :authenticate_user_and_setup!, except: [ :terms, :privacy, :optout, diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 2963164c..9ea5b23a 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -2,7 +2,14 @@ class Users::SessionsController < Devise::SessionsController before_action :must_be_logged_out, only: [:new] + before_action :authenticate_user_and_setup!, except: [:create] + # An error for use when validating MFA tokens for login + class MFAValidationError < StandardError; end + + class FinishWebauthnValidationParams < T::Struct + const :publicKeyCredential, Hash + end # before_action :configure_sign_in_params, only: [:create] # GET /resource/sign_in @@ -11,9 +18,121 @@ class Users::SessionsController < Devise::SessionsController # end # POST /resource/sign_in - # def create - # super - # end + # We patch this so that instead of logging the user in we check that they exist and then move to the 2FA page + def create + user = User.find_for_authentication(email: params["user"][:email]) + + # This will redirect back to the login page and handle flashes and such if it's invalid + warden.authenticate!(auth_options) if user.nil? || !user.valid_password?(params["user"][:password]) + + # Set the user to the session for just the next step + session[:mfa_validate_user] = user.id + + redirect_to mfa_validation_path + # super + end + + # GET /resource/sign_in/mfa + def mfa_validation + # Get the user from the session or go bye bye + user_id = session[:mfa_validate_user] + raise MFAValidationError if user_id.nil? + + user = User.find(user_id) + raise MFAValidationError if user.nil? + rescue MFAValidationError + flash[:notice] = "You do not have access to the previous page" + redirect_to new_user_session_path + end + + # GET /resource/sign_in/mfa/webauthn + def begin_mfa_webauthn_validation + # Get the user from the session or go bye bye + user_id = session[:mfa_validate_user] + raise MFAValidationError if user_id.nil? + + user = User.find(user_id) + raise MFAValidationError if user.nil? + + # TODO: add types and transports + options = relying_party(request.referer).options_for_authentication( + allow: user.webauthn_credentials.map { |c| c.external_id } + ) + # options = { publicKey: options } # Something broken, but the JS side requires this key + + session[:authentication_challenge] = options.challenge + + respond_to do |format| + format.json { render json: { publicKey: options } } + end + rescue MFAValidationError + flash[:notice] = "You do not have access to the previous page" + respond_to do |format| + format.json { render json: { error: "There's been an error validating credentials. Please log out and try again." } } + end + end + + def finish_mfa_webauthn_validation + typed_params = TypedParams[FinishWebauthnValidationParams].new.extract!(params) + + webauthn_credential = WebAuthn::Credential.from_get(typed_params.publicKeyCredential) + + user_id = session[:mfa_validate_user] + raise MFAValidationError if user_id.nil? + + user = User.find(user_id) + raise MFAValidationError if user.nil? + + credential_index = user.webauthn_credentials.find_index { |cred| cred.external_id == webauthn_credential.id } + raise MFAValidationError if credential_index.nil? + + stored_credential = user.webauthn_credentials[credential_index] + + begin + relying_party(request.referer).verify_authentication( + typed_params.publicKeyCredential, + session[:authentication_challenge], + public_key: stored_credential.public_key, + sign_count: stored_credential.sign_count, + ) + + # Update the stored credential sign count with the value from `webauthn_credential.sign_count` + stored_credential.update!(sign_count: webauthn_credential.sign_count) + + # Continue with successful sign in or 2FA verification... + sign_in(user) + respond_to do |format| + format.json { render json: { authentication_status: "success" } } + end + rescue WebAuthn::SignCountVerificationError => e + # Cryptographic verification of the authenticator data succeeded, but the signature counter was less then or equal + # to the stored value. This can have several reasons and depending on your risk tolerance you can choose to fail or + # pass authentication. For more information see https://www.w3.org/TR/webauthn/#sign-counter + respond_to do |format| + render json: { + errorPartial: + render_to_string( + partial: "accounts/setup_mfa_error", + formats: :html, + layout: false, + locals: { error: e } + ) + } + end + rescue WebAuthn::Error => e + respond_to do |format| + render json: { + errorPartial: + render_to_string( + partial: "accounts/setup_mfa_error", + formats: :html, + layout: false, + locals: { error: e } + ) + } + end + end + end # DELETE /resource/sign_out # def destroy diff --git a/app/javascript/controllers/webauthn_controller.js b/app/javascript/controllers/webauthn_controller.js index 5d57689e..ac3ce5e7 100644 --- a/app/javascript/controllers/webauthn_controller.js +++ b/app/javascript/controllers/webauthn_controller.js @@ -1,20 +1,56 @@ -import { Controller } from '@hotwired/stimulus' +import { Controller } from "@hotwired/stimulus" import { create, parseCreationOptionsFromJSON, -} from "@github/webauthn-json/browser-ponyfill"; +} from "@github/webauthn-json/browser-ponyfill" import { get, post } from "@rails/request.js" - +import Lottie from "lottie-web" export default class extends Controller { static values = { input: String } - static targets = [ "output" ] + static targets = [ "lock", "webauthnSetup", "totpSetup" ] async connect() { + // Check if we're actually encrypting, if not, don't allow setup to continue. + if(window.location.protocol != 'https:') { + this.outputTarget.textContent = "Somehow you've visited an unencrypted version of this page, please contact us immediately to report this." + + this.webauthnSetupTarget.remove() + this.totpSetupTarget.remove() + return + } + + if(navigator.credentials == undefined) { + this.outputTarget.textContent = 'You are using an outdated or non-standard browser that does not support Webauthn, please click "Being App-Based Setup" below to continue.' + + this.webauthnSetupTarget.remove() + return + } + + if(navigator.userAgent.toLowerCase().indexOf('firefox') > -1){ + this.outputTarget.textContent = + 'Unfortunately FireFox does not yet have full support for Webauthn, the technology we use for our two-factor authentication.\n' + + 'If you have a hardware key please click the "Begin Hardware Key Setup" button below, otherwise please click "Begin App-Based Setup" to continue' + return + } + + this.lockAnimation = Lottie.loadAnimation({ + container: this.lockTarget, // the dom element that will contain the animation + renderer: 'svg', + autoplay: false, + loop: false, + path: '/lock-cpu-cyber-security.json' // the path to the animation json + }); + + // What we want to do here: + // Make sure there's a back button? + // Same with TOTP + } + + async beginWebauthnSetup() { if(navigator.credentials == undefined) { - console.log("Webauthn not available. Are you on HTTPS?") + alert("Webauthn is not available. We only support modern browsers, please use Firefox, Safari, Chrome, or something similar") return - // Webauthn isn't supported, so we'll do stuff here eventually } const setup_response = await get("/setup_mfa/webauthn.json", { @@ -23,16 +59,15 @@ export default class extends Controller { }) if (!setup_response.ok) { - console.log ("Something went horribly wrong") + alert("500 Error, please reload the page and try again") return } const setup_response_body = await setup_response.text const optionsJson = JSON.parse(setup_response_body) - const options = parseCreationOptionsFromJSON(optionsJson) + const options = parseCreationOptionsFromJSON(optionsJson) const createResponse = await create(options); - console.log(createResponse) const finishWebauthnResponse = await post("/setup_mfa/webauthn.json", { body: { publicKeyCredential: createResponse, nickname: "stuffthings" }, @@ -43,10 +78,20 @@ export default class extends Controller { const finishWebauthnResponseBody = await finishWebauthnResponse.text const finishedBodyJson = JSON.parse(finishWebauthnResponseBody) - if(finishedBodyJson["registration_status"] == success) { - this.outputTarget.textContent = "Success!" + this.webauthnSetupTarget.classList.add("transition-opacity", "duration-150", "ease-out", "opacity-0") + this.webauthnSetupTarget.remove() + + if(finishedBodyJson["registration_status"] == "success") { + this.lockAnimation.play() + await new Promise(r => setTimeout(r, 2000)) + window.location = "/" } else { - this.outputTarget.textContent = "Failed" + this.lockTarget.classList.add("transition-opacity", "duration-150", "ease-out", "opacity-0") + await new Promise(r => setTimeout(r, 500)) + + this.lockAnimation.destroy() + this.lockTarget.innerHTML = finishedBodyJson["errorPartial"] + this.lockTarget.classList.replace("opacity-0", "opacity-100") } } } diff --git a/app/javascript/controllers/webauthn_login_controller.js b/app/javascript/controllers/webauthn_login_controller.js new file mode 100644 index 00000000..24b5d9fd --- /dev/null +++ b/app/javascript/controllers/webauthn_login_controller.js @@ -0,0 +1,81 @@ +import { Controller } from '@hotwired/stimulus' +import { get as webauthnGet, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill' +import { get, post } from "@rails/request.js" +import Lottie from "lottie-web" + +export default class extends Controller { + static values = {} + static targets = [ "output", "lock", "authenicateButton" ] + + async connect() { + // Check if we're actually encrypting, if not, don't allow setup to continue. + if(window.location.protocol != 'https:') { + this.outputTarget.textContent = "Somehow you've visited an unencrypted version of this page, please contact us immediately to report this." + + this.webauthnSetupTarget.remove() + this.totpSetupTarget.remove() + return + } + + if(navigator.credentials == undefined) { + this.outputTarget.textContent = 'You are using an outdated or non-standard browser that does not support Webauthn, please click "Being App-Based Setup" below to continue.' + + this.webauthnSetupTarget.remove() + return + } + + this.lockAnimation = Lottie.loadAnimation({ + container: this.lockTarget, // the dom element that will contain the animation + renderer: 'svg', + autoplay: false, + loop: false, + path: '/lock-cpu-cyber-security.json' // the path to the animation json + }); + this.lockAnimation.setDirection(-1) + this.lockAnimation.goToAndStop(83, true) + } + + async authenticateWebauthn() { + if(navigator.credentials == undefined) { + alert("Webauthn is not available. We only support modern browsers, please use Firefox, Safari, Chrome, or something similar") + return + } + + const setup_response = await get("/users/sign_in/mfa/webauthn.json", { + contentType: "application/json", + responseKind: "json" + }) + + if (!setup_response.ok) { + alert("500 Error, please reload the page and try again") + return + } + + const setup_response_body = await setup_response.text + const optionsJson = JSON.parse(setup_response_body) + const options = parseRequestOptionsFromJSON(optionsJson) + + const getResponse = await webauthnGet(options); + + const finishWebauthnResponse = await post("/users/sign_in/mfa/webauthn.json", { + body: { publicKeyCredential: getResponse, nickname: "stuffthings" }, + contentType: "application/json", + responseKind: "json" + }) + + const finishWebauthnResponseBody = await finishWebauthnResponse.text + const finishedBodyJson = JSON.parse(finishWebauthnResponseBody) + + if(finishedBodyJson["authentication_status"] == "success") { + this.authenicateButtonTarget.textContent = "Logging In..." + this.lockAnimation.play() + await new Promise(r => setTimeout(r, 2000)) + + window.location = "/" + } else { + this.lockTarget.innerHTML = finishedBodyJson["errorPartial"] + this.lockTarget.innerHTML + this.lockTarget.firstChild.classList.add("transition-opacity", "duration-500", "ease-out") + this.lockTarget.firstChild.classList.replace("opacity-0", "opacity-100") + } + } +} diff --git a/app/models/webauthn_credential.rb b/app/models/webauthn_credential.rb index 57c75ba2..092ac4d0 100644 --- a/app/models/webauthn_credential.rb +++ b/app/models/webauthn_credential.rb @@ -5,4 +5,5 @@ class WebauthnCredential < ApplicationRecord validates :public_key, presence: true validates :nickname, presence: true, uniqueness: { scope: :user_id } validates :sign_count, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :key_type, presence: true end diff --git a/app/views/accounts/_setup_mfa_error.html.erb b/app/views/accounts/_setup_mfa_error.html.erb new file mode 100644 index 00000000..17ac4463 --- /dev/null +++ b/app/views/accounts/_setup_mfa_error.html.erb @@ -0,0 +1,23 @@ + diff --git a/app/views/accounts/setup_mfa.html.erb b/app/views/accounts/setup_mfa.html.erb index 504b1fcc..402e60bb 100644 --- a/app/views/accounts/setup_mfa.html.erb +++ b/app/views/accounts/setup_mfa.html.erb @@ -1,6 +1,31 @@ -
-
For security purposes we require, in addition to a password, all accounts to use two-factor authentication.
+<% @page_id = "mfa-setup" %> -
Filler for OTP app authentication option
-
Here's the stuff for webauthn
+
+
+

Setting up Two-Factor Authentication

+

+ For security purposes we require two-factor authentication on all accounts. This ensures that + even if your password is compromised your data is secure from attackers trying to log into your + account. +

+

+ We support both Webauthn/hardware and app-based TOTP authentication. If possible, please use the Webauthn/hardware + key authentication. After setup you will be able to add multiple keys on the account settings page for multiple devices. +

+

+ If you do not have the ability to use Webauthn you can also use app-based two-factor + authentication via a code generator such as Google + Authenticator or Authy though we discourage this unless there's no other option. We do not support SMS-based two-factor authentication as it is inherently unencrypted and insecure. +

+
+
+
+
+ +
+ +
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 7ca38330..3610efad 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -17,6 +17,7 @@ <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> + <%= hotwire_livereload_tags if Rails.env.development? %> diff --git a/app/views/users/sessions/_validate_mfa_error.html.erb b/app/views/users/sessions/_validate_mfa_error.html.erb new file mode 100644 index 00000000..72b1945b --- /dev/null +++ b/app/views/users/sessions/_validate_mfa_error.html.erb @@ -0,0 +1,23 @@ + diff --git a/app/views/users/sessions/mfa_validation.html.erb b/app/views/users/sessions/mfa_validation.html.erb new file mode 100644 index 00000000..6f34b746 --- /dev/null +++ b/app/views/users/sessions/mfa_validation.html.erb @@ -0,0 +1,21 @@ +<% @title_tag = "Log in" %> + +
+ +

+ To continue your log in, authenticate with a previously registered two factor authentication key. +

+
+
+ +
+

+ If you've lost your key or are on a different device, you can + <%= link_to "use a backup code here", reset_password_path %> +

+
diff --git a/app/views/users/sessions/new.html.erb b/app/views/users/sessions/new.html.erb index 64c4b359..c3f63745 100644 --- a/app/views/users/sessions/new.html.erb +++ b/app/views/users/sessions/new.html.erb @@ -1,8 +1,9 @@ <% @title_tag = "Log in" %>
+

- To access Zenodotus, please log in below. + To access the system, please log in below. Don’t have an account? <%= link_to "Request one here", new_applicant_path %>

diff --git a/config/application.rb b/config/application.rb index de481ccf..bb412f4f 100644 --- a/config/application.rb +++ b/config/application.rb @@ -34,5 +34,7 @@ class Application < Rails::Application api_key: Figaro.env.MAILGUN_API_KEY, domain: Figaro.env.MAIL_DOMAIN, } + + config.force_ssl = true end end diff --git a/config/environments/development.rb b/config/environments/development.rb index b24e5a99..347652ca 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -70,6 +70,8 @@ # Prefix job queues names to avoid collisions config.active_job.queue_name_prefix = "zenodotus_development" + config.importmap.sweep_cache = true + config.hosts << Figaro.env.FACT_CHECK_INSIGHTS_HOST config.hosts << Figaro.env.MEDIA_VAULT_HOST end diff --git a/config/importmap.rb b/config/importmap.rb index cebf0a31..d972e584 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -15,3 +15,4 @@ pin "utilities", preload: true pin_all_from "app/javascript/channels", under: "channels" pin_all_from "app/javascript/controllers", under: "controllers" +pin "lottie-web", to: "https://ga.jspm.io/npm:lottie-web@5.10.2/build/player/lottie.js" diff --git a/config/initializers/figaro.rb b/config/initializers/figaro.rb index 9c520c99..398d7bf4 100644 --- a/config/initializers/figaro.rb +++ b/config/initializers/figaro.rb @@ -11,6 +11,7 @@ # The host names for the apps, used for routing requests to the appropriate app Figaro.require_keys("FACT_CHECK_INSIGHTS_HOST") Figaro.require_keys("MEDIA_VAULT_HOST") +Figaro.require_keys("AUTH_BASE_HOST") # This is used by MFA as the site id # Settings for sending email Figaro.require_keys("MAIL_DOMAIN") diff --git a/config/initializers/webauthn.rb b/config/initializers/webauthn.rb index b95c2630..6e85520e 100644 --- a/config/initializers/webauthn.rb +++ b/config/initializers/webauthn.rb @@ -1,5 +1,5 @@ WebAuthn.configure do |config| - config.origin = ENV.fetch("APP_URL", "https://vault-factstream-reporterslab.pagekite.me") - config.rp_name = "Media Vault" + config.origin = ENV.fetch("AUTH_BASE_HOST") + config.rp_name = "FactCheck Insights/MediaVault" config.credential_options_timeout = 120_000 end diff --git a/config/routes.rb b/config/routes.rb index cd094cc5..186fc343 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,6 +5,7 @@ root "application#index" + # This generates only the session and confirmation-related Devise URLs. devise_for :users, skip: :all, @@ -16,7 +17,16 @@ controllers: { sessions: "users/sessions", confirmations: "users/confirmations", - } + } + + + scope "users/sign_in/mfa" do + devise_scope :user do + get "/", to: "users/sessions#mfa_validation", as: "mfa_validation" + get "/webauthn", to: "users/sessions#begin_mfa_webauthn_validation", as: "begin_mfa_webauthn_validation" + post "/webauthn", to: "users/sessions#finish_mfa_webauthn_validation", as: "finish_mfa_webauthn_validation" + end + end get "about", to: "application#about" get "contact", to: "application#contact" diff --git a/db/migrate/20230123223143_add_type_to_webauthn_credentials.rb b/db/migrate/20230123223143_add_type_to_webauthn_credentials.rb new file mode 100644 index 00000000..a8a14d78 --- /dev/null +++ b/db/migrate/20230123223143_add_type_to_webauthn_credentials.rb @@ -0,0 +1,5 @@ +class AddTypeToWebauthnCredentials < ActiveRecord::Migration[7.0] + def change + add_column :webauthn_credentials, :key_type, :string, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index b21b1263..ee118d21 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_01_12_171823) do +ActiveRecord::Schema[7.0].define(version: 2023_01_23_223143) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -343,6 +343,7 @@ t.integer "sign_count", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "key_type", null: false t.index ["external_id"], name: "index_webauthn_credentials_on_external_id", unique: true t.index ["nickname", "user_id"], name: "index_webauthn_credentials_on_nickname_and_user_id", unique: true t.index ["user_id"], name: "index_webauthn_credentials_on_user_id" diff --git a/public/lock-cpu-cyber-security.json b/public/lock-cpu-cyber-security.json new file mode 100644 index 00000000..363ae703 --- /dev/null +++ b/public/lock-cpu-cyber-security.json @@ -0,0 +1 @@ +{"v":"5.5.7","meta":{"g":"LottieFiles AE 0.1.20","a":"Ision Industries","k":"#Cybersecurity, #Internet, #Lock, #Secure, #Safe, #Data","d":"Internet Security - Lock CPU","tc":""},"fr":60,"ip":0,"op":84,"w":256,"h":256,"nm":"Lock CPU 2 - Animation - Staggered (Outline)","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"Resize Null","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[128,128,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[70,70,100],"ix":6}},"ao":0,"ip":0,"op":84,"st":10,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"CPU Circuit - Top Left","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[400,400,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-11.469,-3],[-16,-3],[-16,-5],[-16,-8.5]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"CPU Arm","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]},"t":37,"s":[0]},{"t":55,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-1.657],[-1.657,0],[0,1.657],[1.657,0]],"o":[[0,1.657],[1.657,0],[0,-1.657],[-1.657,0]],"v":[[-19,-9.5],[-16,-6.5],[-13,-9.5],[-16,-12.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-16.002,-9.501],"ix":2},"a":{"a":0,"k":[-16.002,-9.501],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.4,0.4],"y":[1,1]},"o":{"x":[0.6,0.6],"y":[0,0]},"t":48,"s":[0,0]},{"i":{"x":[0.4,0.4],"y":[1,1]},"o":{"x":[0.6,0.6],"y":[0,0]},"t":56,"s":[116,116]},{"t":66,"s":[100,100]}],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Dot","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":37,"op":84,"st":10,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"CPU Circuit - Middle Left","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-70,14,0],"ix":2},"a":{"a":0,"k":[-17.5,3.5,0],"ix":1},"s":{"a":0,"k":[400,400,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-13.094,3.5],[-20,3.5]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"CPU Arm","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]},"t":33,"s":[0]},{"t":51,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-1.657],[-1.657,0],[0,1.657],[1.657,0]],"o":[[0,1.657],[1.657,0],[0,-1.657],[-1.657,0]],"v":[[-23,3.5],[-20,6.5],[-17,3.5],[-20,0.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-20,3.499],"ix":2},"a":{"a":0,"k":[-20,3.499],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.4,0.4],"y":[1,1]},"o":{"x":[0.6,0.6],"y":[0,0]},"t":44,"s":[0,0]},{"i":{"x":[0.4,0.4],"y":[1,1]},"o":{"x":[0.6,0.6],"y":[0,0]},"t":52,"s":[116,116]},{"t":62,"s":[100,100]}],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Dot","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":33,"op":84,"st":10,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"CPU Circuit - Bottom Left","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-60,59,0],"ix":2},"a":{"a":0,"k":[-15,14.75,0],"ix":1},"s":{"a":0,"k":[400,400,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-11.625,10],[-16,10],[-16,12],[-16,15.5]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"CPU Arm","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]},"t":41,"s":[0]},{"t":59,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,1.657],[-1.657,0],[0,-1.657],[1.657,0]],"o":[[0,-1.657],[1.657,0],[0,1.657],[-1.657,0]],"v":[[-19,16.5],[-16,13.5],[-13,16.5],[-16,19.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-15.999,16.494],"ix":2},"a":{"a":0,"k":[-15.999,16.494],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.4,0.4],"y":[1,1]},"o":{"x":[0.6,0.6],"y":[0,0]},"t":52,"s":[0,0]},{"i":{"x":[0.4,0.4],"y":[1,1]},"o":{"x":[0.6,0.6],"y":[0,0]},"t":60,"s":[116,116]},{"t":70,"s":[100,100]}],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Dot","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":41,"op":84,"st":10,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"CPU Circuit - Bottom Right","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[60,59,0],"ix":2},"a":{"a":0,"k":[15,14.75,0],"ix":1},"s":{"a":0,"k":[400,400,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[11.43,10],[16,10],[16,12],[16,15.5]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"CPU Arm","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]},"t":35,"s":[0]},{"t":53,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,1.657],[1.657,0],[0,-1.657],[-1.657,0]],"o":[[0,-1.657],[-1.657,0],[0,1.657],[1.657,0]],"v":[[19,16.5],[16,13.5],[13,16.5],[16,19.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[15.998,16.501],"ix":2},"a":{"a":0,"k":[15.998,16.501],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.4,0.4],"y":[1,1]},"o":{"x":[0.6,0.6],"y":[0,0]},"t":46,"s":[0,0]},{"i":{"x":[0.4,0.4],"y":[1,1]},"o":{"x":[0.6,0.6],"y":[0,0]},"t":54,"s":[116,116]},{"t":64,"s":[100,100]}],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Dot","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":35,"op":84,"st":10,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"CPU Circuit - Middle Right","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[70,14,0],"ix":2},"a":{"a":0,"k":[17.5,3.5,0],"ix":1},"s":{"a":0,"k":[400,400,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[13.094,3.5],[20,3.5]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"CPU Arm","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]},"t":31,"s":[0]},{"t":49,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-1.657],[1.657,0],[0,1.657],[-1.657,0]],"o":[[0,1.657],[-1.657,0],[0,-1.657],[1.657,0]],"v":[[23,3.5],[20,6.5],[17,3.5],[20,0.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[19.993,3.5],"ix":2},"a":{"a":0,"k":[19.993,3.5],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.4,0.4],"y":[1,1]},"o":{"x":[0.6,0.6],"y":[0,0]},"t":42,"s":[0,0]},{"i":{"x":[0.4,0.4],"y":[1,1]},"o":{"x":[0.6,0.6],"y":[0,0]},"t":50,"s":[116,116]},{"t":60,"s":[100,100]}],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Dot","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":31,"op":84,"st":10,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"CPU Circuit - Top Right","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[60,-32,0],"ix":2},"a":{"a":0,"k":[15,-8,0],"ix":1},"s":{"a":0,"k":[400,400,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[11.625,-3],[16,-3],[16,-5],[16,-9]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"CPU Arm","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]},"t":39,"s":[0]},{"t":57,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-1.657],[1.657,0],[0,1.657],[-1.657,0]],"o":[[0,1.657],[-1.657,0],[0,-1.657],[1.657,0]],"v":[[19,-10],[16,-7],[13,-10],[16,-13]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[15.996,-9.5],"ix":2},"a":{"a":0,"k":[15.996,-9.5],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.4,0.4],"y":[1,1]},"o":{"x":[0.6,0.6],"y":[0,0]},"t":50,"s":[0,0]},{"i":{"x":[0.4,0.4],"y":[1,1]},"o":{"x":[0.6,0.6],"y":[0,0]},"t":58,"s":[116,116]},{"t":68,"s":[100,100]}],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Dot","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":39,"op":84,"st":10,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"Check Mark","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,14,0],"ix":2},"a":{"a":0,"k":[0,5.125,0],"ix":1},"s":{"a":0,"k":[400,400,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":33,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[0,5.152],[0,5.152],[0,5.152]],"c":false}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":45,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-3.439,4.561],[-1,7],[3.891,1.047]],"c":false}]},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-3,5],[-1,7],[3,2]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[-0.06,5.12],"ix":2},"a":{"a":0,"k":[-0.06,4.495],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Check Mark","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":33,"op":84,"st":35,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"Keyhole","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,16,0],"ix":2},"a":{"a":0,"k":[0,4,0],"ix":1},"s":{"a":0,"k":[400,400,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":19,"s":[{"i":[[0.552,0],[0,0],[0,0.552],[0,0],[-0.101,0],[0,0],[0,-0.101],[0,0]],"o":[[0,0],[-0.552,0],[0,0],[0,-0.101],[0,0],[0.101,0],[0,0],[0,0.552]],"v":[[0,8.5],[0,8.5],[-1,7.5],[-1,4.684],[-0.816,4.5],[0.816,4.5],[1,4.684],[1,7.5]],"c":true}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":29,"s":[{"i":[[0.552,0],[0,0],[0,0.552],[0,0],[-0.101,0],[0,0],[0,-0.101],[0,0]],"o":[[0,0],[-0.552,0],[0,0],[0,-0.101],[0,0],[0.101,0],[0,0],[0,0.552]],"v":[[0,4.625],[0,4.625],[-1,3.625],[-1,2.59],[-0.816,2.406],[0.816,2.406],[1,2.59],[1,3.625]],"c":true}]},{"t":35,"s":[{"i":[[0.031,0],[0,0],[0.062,0],[0,0],[-0.101,0],[0,0],[0,-0.101],[0,0]],"o":[[0,0],[-0.055,0],[0,0],[0,-0.101],[0,0],[0.101,0],[0,0],[0.031,0]],"v":[[-0.016,4.219],[-0.039,4.203],[-0.039,4.199],[-0.008,4.226],[-0.027,4.23],[-0.043,4.207],[-0.016,4.226],[-0.031,4.238]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-0.004,2.5],"ix":2},"a":{"a":0,"k":[-0.004,2.5],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":1,"k":[{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]},"t":37,"s":[100]},{"t":38,"s":[1]}],"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Key Line","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-1.657],[1.657,0],[0,1.657],[-1.657,0]],"o":[[0,1.657],[-1.657,0],[0,-1.657],[1.657,0]],"v":[[3,2.5],[0,5.5],[-3,2.5],[0,-0.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":21,"s":[0.001,2.501],"to":[0,0],"ti":[0,0]},{"t":40,"s":[0.001,4.401]}],"ix":2},"a":{"a":0,"k":[0.001,2.501],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.4,0.4],"y":[1,1]},"o":{"x":[0.6,0.6],"y":[0,0]},"t":21,"s":[100,100]},{"t":40,"s":[0,0]}],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Dot","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":84,"st":10,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"Lock Circle","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,14,0],"ix":2},"a":{"a":0,"k":[0,3.5,0],"ix":1},"s":{"a":0,"k":[400,400,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-7.18],[7.18,0],[0,7.18],[-7.18,0]],"o":[[0,7.18],[-7.18,0],[0,-7.18],[7.18,0]],"v":[[13,3.5],[0,16.5],[-13,3.5],[0,-9.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[-0.001,3.504],"ix":2},"a":{"a":0,"k":[-0.001,3.504],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.4,0.4],"y":[1,1]},"o":{"x":[0.6,0.6],"y":[0,0]},"t":17,"s":[100,100]},{"i":{"x":[0.4,0.4],"y":[1,1]},"o":{"x":[0.6,0.6],"y":[0,0]},"t":28,"s":[90,90]},{"t":38,"s":[100,100]}],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Circle","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":84,"st":10,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"Lock Arm","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[400,400,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":15,"s":[{"i":[[0,0],[0,0],[-3.866,0],[0,0],[0,-3.866],[0,0]],"o":[[0,0],[0,-3.866],[0,0],[3.866,0],[0,0],[0,0]],"v":[[-7,-15.438],[-7,-16.5],[0,-23.5],[0,-23.5],[7,-16.5],[7,-7.781]],"c":false}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":26,"s":[{"i":[[0,0],[0,0],[-3.866,0],[0,0],[0,-3.866],[0,0]],"o":[[0,0],[0,-3.866],[0,0],[3.866,0],[0,0],[0,0]],"v":[[-7,-6.344],[-7,-11],[0,-18],[0,-18],[7,-11],[7,-6.344]],"c":false}]},{"t":38,"s":[{"i":[[0,0],[0,0],[-3.866,0],[0,0],[0,-3.866],[0,0]],"o":[[0,0],[0,-3.866],[0,0],[3.866,0],[0,0],[0,0]],"v":[[-7,-7.781],[-7,-14],[0,-21],[0,-21],[7,-14],[7,-7.781]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Lock Arm","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":84,"st":10,"bm":0}],"markers":[{"tm":8,"cm":"2","dr":0},{"tm":23,"cm":"3","dr":0},{"tm":30,"cm":"1","dr":0},{"tm":75,"cm":"4","dr":0}]} \ No newline at end of file From 429174b466fd3fa7b3197948719290b9af64a91e Mon Sep 17 00:00:00 2001 From: Christopher Guess Date: Wed, 25 Jan 2023 23:17:34 -0500 Subject: [PATCH 03/10] Add Recovery Codes for User Account Enable recovery codes for if the MFA token is long. This just creates them and allows checking, but doesn't actually include the UI to check yet or the actual resetting of the credentials yet. --- ...sKILxRpg3hIP6sJ7fM7PqlPevWnsUnxg (1).woff2 | Bin 0 -> 19688 bytes ..._SiYsKILxRpg3hIP6sJ7fM7PqlPevWnsUnxg.woff2 | Bin 0 -> 19688 bytes app/assets/stylesheets/_variables.scss | 1 + app/assets/stylesheets/fonts.css | 42 +++++++++++++ app/assets/stylesheets/pages/accounts.scss | 4 ++ app/controllers/accounts_controller.rb | 17 ++++++ .../controllers/webauthn_controller.js | 56 ++++++++++++++---- .../controllers/webauthn_login_controller.js | 2 +- app/models/user.rb | 48 +++++++++++++++ .../accounts/_setup_recovery_codes.html.erb | 24 ++++++++ app/views/accounts/setup_mfa.html.erb | 4 +- config/importmap.rb | 2 +- config/routes.rb | 1 + ...230125061836_add_recovery_codes_to_user.rb | 5 ++ db/schema.rb | 3 +- test/fixtures/webauthn_credentials.yml | 10 ++-- test/models/user_test.rb | 50 ++++++++++++++++ 17 files changed, 249 insertions(+), 20 deletions(-) create mode 100644 app/assets/fonts/sourcecodepro/v22/HI_SiYsKILxRpg3hIP6sJ7fM7PqlPevWnsUnxg (1).woff2 create mode 100644 app/assets/fonts/sourcecodepro/v22/HI_SiYsKILxRpg3hIP6sJ7fM7PqlPevWnsUnxg.woff2 create mode 100644 app/views/accounts/_setup_recovery_codes.html.erb create mode 100644 db/migrate/20230125061836_add_recovery_codes_to_user.rb diff --git a/app/assets/fonts/sourcecodepro/v22/HI_SiYsKILxRpg3hIP6sJ7fM7PqlPevWnsUnxg (1).woff2 b/app/assets/fonts/sourcecodepro/v22/HI_SiYsKILxRpg3hIP6sJ7fM7PqlPevWnsUnxg (1).woff2 new file mode 100644 index 0000000000000000000000000000000000000000..abb53538271b488901f1f3e63dee2ef3b0e748f9 GIT binary patch literal 19688 zcmV(@K-Rx^Pew8T0RR9108Hor6951J0E|ok08D)V0RR9100000000000000000000 z0000QSQ~)^9D`IpNLE2oF9u*gQ&d4zfhs2|0E7-NbP)&&|3Ksw3xqNNF!NdgHUcCA zgfavm1&d4vAR8ll72}u{ZU>3bqt~&EBG@<(gLx@nB*JFRP3YwRZwYh^vFH|{=y#D+ z;~J}j&)Ck>#>%nFm1;uGT=$QAEmzOYyvGjrv5&W)|2c6wfl$@8aR?c|2^p%MnU%#7 z=c-s1j}iHVQrXI*LMQ_S2n_<2dIi0g0#UlP$41^&zU2&XCr2b+{849LPh!gs!krMk-2AtfeQa6}`WjUC*if`4DaMo~IA6!NGJ9 zS$BekRoGaFg$-D5WQ=vOsCm%MJ|ZB3B3es5yZt^ppIytpy|I>mtf~AtZ%nV#+je`N zrHV)~YQz9BLlN>W+=~pf3b7lU%_Cp@c<)h zXObqsM3W{A1iSOIr96TV}k}_zFs9&%1{awk< zo7v+VU_A%WV@V+>s!U3}APDIJ7EOTEN^OYnGajyGLeXq6?c2L^6stAN*_*=d7ov1Y&{@}BoU=EdX8*A zEGLX#u;uv{hcpq>qg;<6N|crbN7Nxim9- zrQz!cA%xHhPw>Vjmv3I}7;}X-er=zUln~(<0_sWqgq}TP-aUfB^6y zNEpWX7zKz++A1Z|NokByp<-T%AVIALsX-HOMUX(CL6T8OYpo;NtFod}F(^VQ6bioD zIzapgn}Ae^tT$ghh`lWynvc+ZDAN;qv<>&^r8^?{55is$R3O{A&Jch9KHzB9zYlmf zVE?l$iIC9VIru1n=76f^2FO#UP8$)G?QWhFXf0Zc*527#Oy@`M0BAe<`g?(ZJzVn~ z=^X+x-B~I^Ys$y3XB5%3G%>3JA~AJM7&2RNp{-8#833AQpzFm$>w{hWY!VBDx2gAs z$7n;kgy3V z9?OV4lzauIcuez};Wx|Y20fV#QPUBSW|u4jQcH@WputZdjVJ|Jt6*he1I_NF&Gy~d z-$db|8VA?#vB8NEy21qH8mOCcJle;QSY^{YQ_G`t+yWDT4^};Z8@6GnMP$hP#YP^= z}m;s30wY6g8t6|sCS>d1LT_ap5M+FfJ3KyE`dI1c-=ezj@;k6 z&)jeU6Zq*>qQGjoYfEH28V(g&NUR-Y2>JSqhLV2*NZK$cdEX*DlI{)aHs-`F<_*i6 zlW?d~#91xK#P;-XR0A5OVH+Jr*%+h_Q>SBtWABgo znC7P3XxajYlF%?~)NYgO3Iq_uCKcYB!+N5j82Y@l8{Gppf_?u6Z)$9f0?2rn+wAZ4}b;o(0gqOAfMn^2qh&W-CTx} zlLOlbl#&8K%fZx?`U}{Tma5A`C_Odl-w?`3L${`XBd4lmh3`ebjh}1DqY2Zrv^cD?X7b6UzL2WJ9ChFi0Oro^( zYExZL7sh4=27)nt=h%*zb0Zh^ij=%rS&R>xcDfN}tz`RL#a(yPUbSa`;UH}^3f2#<$;ANVMRhg-I+jqU}jJ)u=0+ z(_m`gavl{WJ3SA%I@znN9HS>^0nGpgGZ!%w!G@IH3kXORRim5T((ctNxL7=2FAX(h z#L^bnNJsnyP^H02YAKU$Ok3;E#2)(m(&NvFy@_uj(!!GH#cW=1uo#_a$^2EM%Y#q+ ze~iPoWd*yDK0$t@lVFaW5We9Veo>BKaaoyfS7N7RX9a~QI9Y-`gE_NN{dP)+Bo%x1 z=%BfKnO5BX0dgamct@iWG~QxGBZ_3WD_9a*;!jk;v#D1d^ifPxR^CI^7_aAreaE2p z(sw%v8K@W_jZcs%s?K1_%R{c=BtsVr(e)^4Y-cpY%6%@2vp!Yd#U_{jDQXdFiO;JL zXuveLgIRpGorAjz{eE`pUL!)}#TZn?e&sKbi>X|BBawY*F-PEmnPZZxoOmA&9rC5O<^>SO-A3s8b~SjVen`W z5}vxCjn$BBUGGJ_(d!+~r?%a_c=M_Np+{1ME)&kkHfpp&j-9+eOu1cZ979~Xg<0OdC$0Lc+dw21_Qd7)${Xca#AB$8dk8VLcFX}+_b+?o)yY6E;*?b@Ecl_F_V zGIk-smG6HDotHD5=e|XOjj0G(gBd;9LrtzgCF5J5rmKrX-eQ5e0 zQTqt}BuUtbv(&_gsl@udLs?}D%#jN^Kw8_j%NQw6>6g{70h;=Qp$k$Z`G2W0re3wP zNg^_LPvNkc=@pGNkV?jvx5nJ3yd@eAUA;VjU$XX8HX2%t2~24^cO02B2DF&C-x2GT za&iClD7qwYa#g(Otdb#fPx+#__szX~~+YO`RH&%CVe_(n* z+R;g3b}VhVa%@lt1gv51z2?YZ1QAq6oI7;y&M=?g{%RM0#b_Rrj8S2npB!xN+6_L?{H2OMNt?hIG9;NsZBR z;8k%n0-OcI;`)f$w#ZpnL6gqi`sY}sDAuw>Q40;D z!9#Q#qiH(U`S`Ht&RfEwvxXrzwAru*?wpBXGU;F>y6A4PekRJ#9F?+9FT+i?xajgjqMfOkhQVA+M*xQ?^2A?$an zj3!Zk)!)K0`5Jq&4+_u2WN z^iO)3DWQ)oR{&`9f*U95#;S$Adj#5aZIppaN)TpI70KW}0u=EFQ7-pPfBxN7^uzN{UyccCl5%Lh z#qXj1+a$bn+Yri&QIBW|>+tF?+C6wz(NA+FM@u!mk>ufF4CA zU4YqYU6b4-8Zgjc$_%i4a28nhp{uMJuP%@aLoX(HiDd>Y=Sy`nPam}c?}=_WEsV;r z@U5E_H1OXs5%~GLtYZ!<;j@5Ci46Q!z=1UgfDyRMK*PEaTAOZoVe4mxLD+>Hv2KZ= z)#|gU^8%7P@BNuDYhUPmE`Jfv{<^Ma2}ra-=5Ax|V6sQ%+D9|1f9ErBx=uESRA5Df zVi2BjO|)@xpLx2Jkk@gXtc(?;Nk#9%U9 zUe&t$fMG2rIDHmT|8ErA5eI$48aEdnR?G3d-b8*m#nR+>S-p;aTXAtt{ zmM2A*PiSQOJRFd|nS`j4wqcW(F&{!e{BOm(X_NgH#Du`2{C-=KCUGcaYTq*m_q_U` z-KdK8@ai`8&Y$0GwK$Wyj-kYgo}s#W?~to!1!yYKcSu}u)-r$2zGEz0ua1hh$e(pH z#lBvzW@BrsW^+%SM;_w3VNbIZr1Sags*qu_z1^^NRV2HBIFK9;DAqSND%K7Ak^^9M zeg%{&=0F@pHw5UrBgzy>(YEXWy|b95sif8|9nb97SE9SG%#bRlT{p z&MOW<>%ZF%y$=p2IUtdzvQjK+94nj(g8^tdF%2zR&3;G#8Y(&u@S(G}-moi|GwkRMJ3|mI zJp{XCi-TlrsgrGPZIx|o@fs0$y@z14|9S;u`DsrlP42fC<>5}QXEVdJq9^N#HfBvd zD|i+V_q8Pzq@+0hj(&PP+!E1A9(u$~5LR>y_0K#C#`3S2lQpnMuYvtZj(MA^eq*A| z?en)86C1b4Yu~rBfE@aURNc{dcdfC$!*3MGo;PHK-&>eJ)rzH#7+YmbiO$K-N0|zD zlTFQi=$VZfVZV*bHNjH3KP3Rr%^#rK&<~3MAF|!p*Jx?qKI*pS+H8O;5(l}ZZr(=T z=Tm~$bSofrPtule(vj0XgpZOIJU;-`Gm~I>f1Xr#m^(bdE8IA9yQ`5-kJG!Z++K`O z4-a!ahoSj-UfMHuYOHAN`sOwdhB{>)Plm{Y>@5#|bm&xSv) z;^962YFG~Rl<;2Z#UPGv^3~Ep5(S~iUHD)Rtd(ShEsw1z*~+=aWp2I4!4<-O8v&(rMMx+Po?wr&ai$^xRAX%_W{;jio;jv=0nx zocf^PEBQ|@rr4TMsnQmUvLmCkNyD6SbZrwK;9+uP#o{{SK&M_O@-s|@1sz|Ik9O$a z_HRuvUl5Z_qn--VIEqFsoH2BymH>B&ZBT3N_xmja^K}MO5|*{w=Tzcefu5nv&vu4n zEi!Gp!=>wJhc$4JggtXPLI=fUJuB)HXefV~U#;ZO1D2SRcIbWFgGGf9P`>iD(<`{p zp&SBxhm1Y{0wMzOB_f|+AYmT-4B9cmv~VRNub(f$l=LKCMlmnKOPd1{HAm;}6F2W= z&-8J*{kPfd+x=W_-;89Xqn+iW@=a9B;>rbx(+)_~Yyf>n$tMd|7QUa*Yhuxsb{?4( zwT69)sA@?QSap<4L)e9!!N|se%cSUUFqkSaIqgBg8_;RLCa%BN{HB6DG6 z9*28o_v?I)RAzT2$gxo`oif!&j%hutq7?KQZ>AOTOp{I&#fQdDjiEmfGz^p}V^t0W z1BU(xx72Z^w8LVNrsFELI;Mitv^cmUrUbob-y#$_3$5*9vx8y9>1oZ3U|~VWOOOAg zYH)z1`10&$1#N}MCRlFV6(si31BFV$TVMZV)E}w?=Lkg$3vWfl9U|7|a|RDwArTB?exG)-jvMHx@% z0lE7ub(6e^Y(0-SPqNND?c_6O!F?;w(!%C}zxxr(O9xU3uP!WI{s~Ei%+%#)c_HPh za?68=!E)Op0Xb;+;NA;Y8Ewli-IzfX5iYfk(_Xsz(LF}b`WYh%fhJ zUX?G?ccxyP@SG^c@5yKR?(yoVWj*6Pf2P-o7BB+S=Z(nj8u@por&6Pw_Q= z1F9hsS_pNiI^nv;M&bHY0HniyTwM`sx?9HLX=oaZ<*AD{r}}+PfA;W_dGewi5QT^= zgGi-F)ZZlNpE!~JD`K#vMCLT;B?|XaNw%2KUed#x=7?(f{1)AFXk&|Yj@obLEbxdx zLhXlZoKWCo(nk|2Y@WHn@~U8y2d=$IN-F1}$Wn;9G^^;+GR!<~TgXgFo76CURB$T! z8fg09E`&NaAiCYg4843O1P)cOo}zw<-}ot((Y7ShLEMtKHQfF{kULQR$B?Lf@v3-b zW!xXv315Sfs(C0f20|>&%DWUA=TJ|-f51pNDL5sHu>@u|Kk#OCb=(&RlO=o)@l*D_ zSDAXPnZqRaz1~Y^u+V3bG~phVIUH*#B@^_b#*hdW{H|rPapx+jWK8$#?PxxGvyBe; zP?uAfvM#f#Bd2nyGum6MeOam47L)U0uvXp_hZXjSk=12t7iY#+|Vzc zY!50&(#9u{FAMj+40_K#N4`+dT9{2qB=#n)aM15CtJg6b87ZcO+9G&m;&Mv%r7ebW^s_YNgao}2sL}m{P7D3zm8a11Le@!~^dmR}!tkiNZ(^OIfResI65;+uIz1F3lMg}LNJk*(0%B8P=`(iWYsz(fGQ zmq_b*(tuLPb6T`sg++OYRfFS+V2fDBA94=GvY!MUf?IFiatkN^O!Rmc3KlAPRMTTD zSZ0=TxfYAstF)-O7OseAbufA2kOVQMSzK)=qg;m7FS_G$z=!tMfHdDVHE^Ia*MDHD zt2Uns*oexoUAVrZL%23p&3%?T&CXXWBHpuPxJB7-CR3fz7 z#bU&$&J7F2_7DqhX-hgq+UT<4aKz&nm6SZ8}WdZ%+pJ(44H=k+3n%`YtBdhYe=aTUJl#J<@K$x{nzDbYeZZ1VM}k} zd)8V!Zb2RGT;%6Qp2U3WD#xmmAZP%={NZuk2>W;r%YYF3jVxt2&wBTv3fy@d{sOqS z1^cb2(0Ac$7lD*}!=unuSvx*8{xG=fHjZk=YugVH1g>&kiY zcMl(EZ?G<~z~SlnzVVCs{P?B$-r2pOo)fEA_Z;sHgSFFw6z)0BPAKYVd&e%>?riKL zxU)Thz}%4Bz<`$R?;S(}d@eU_hCR$8JHkdsr%f0m&bU2S859-aEOOC2&2U13(P>U4 znWEY0T5b^zfh*!FXE#Y1t*10PUdz*IPxUjTbDEUg8H3LZw{}ii#Oynzr6Nh93HHz? zpCTs}l`&xqID@ZDqv=JqB0JObUX=g~r%)YP+I@|H^z#OE1H4Tecnx}uDE3Xjv&#oY zrk`@IJ7`3FOi1d=)-}}TGc~aEAYe~WglMOuul^&GY-KPGStwqTg?T`zuo{lPnh)~W zk63DAQ~KjPdgdJA4024&owU&0w34(uhw$P`APb+(um|fUT z2GKvf{cJ z8WWjm!nBYwGOxDO&SR?`S_2Pu$HSxN2!Nd_@d|W<0yBfj|B1)-^87f@9U9#uB#fzQ z)h0TdzK%p?KKJ(3l{|*@2hjF(HB^0mr1Hs>9wz|rqu%#gqy$=qj(_7yD|V174NJ(2Z2FfE5&#!6 z&8)eD*NqhVbPe@>HBNStNce|=Ja)NuRu@^IVv|4m;>0U8^OA%-AKjq<1K@4 ze~kltD9)+|vEMRTXK0L1xfpSozG~|M=z+4fBMj+`l=_nrs3G+Mz0k@Dn(1t!|IbsO zlh9;8)v8v%!C@?-kl9?~ov&&-oMh!(Hs^Ivs=_o2LYlV;mr(CZ8jx`Y;6ud>jk0bU zk@yu)`<^F0R6~Y~5j)cqrcHu*_SrThwuMC-$3kD1?ypG^@Ws?u|B~QoprnLooOkL> z8JcXMSk>x{T*gA`38&0@YcJf_B-ANG=f(-r<`Q|%>6CXB)Bj|(>h-MTU*M|7{wlTF zY5kL4tZIfaa)1vjEJ&oK;@vwARCJwLauV9PKz?j?Szi^hy$m%*qVEA%FV1%Yd7pfs zgz!C|M%%%^#^R!A=6 zc#1Z`W}n`$a2NrDz8Pq3lSdn|IyzI2jmG3tU%A~zo+}z+*g7WNH%HgT@nuif*eRn( zwwc3nNhB<{nagIIZsx9^F1E|W0hxaE>6FM+7qZIOMLv3z;$zL`3VsRJ)SA4UAK}tp z)xJar)60F-@U(DBL}HmGN|y=!m}5`=xsAMyR7=j2L1VRps-CZ*S6lEV|8&z}k*9&J zHw1VaBO4rac`y`E(M0!G+c*|}{UeA@=y^6_LBS0OKX(MvK5Zycr`-ZDqe zWE$ir=vx;vwlHew1U!LI^Hl+B%Amg<-Gr(7-^-saM47&)kZw`@zI*wz#s06riT0Fvnc*MEcOlpyAm zz~fr~2Psh(v~OIXPQPL6$i!}7{&>86+{5r8f|AM8f(I_!}Lp{t0}zY+mn zmF)$C5SpDI>_C7;R?_&re=yJvoaMuCok1n_-YdgEyJkN~yp9HokB}DqHy2?4q7M@8 zW0ThZrvmIPc(8aMc(E1w6$1Pnm7_@1NM%(9h0KBz@d`!-@$RkW!TD2V)ZkFd$}3s? z@XC$i2l%r%QZ`%4;nE5p+8Re`pN=u!nUuJi%?+)98;1hrBOO%L>Z!q`2)!g zVy09|gCepWL`|ub21OJnI*U>VQBx|VQ92Y+r4&k~G$?vod8jjt3Mi#gE3Go1gnAQk z@(gq-lv1ftKz|+Rq?AgHf_KS;R>P$Z26=&f=JcNhANGmeMqj(ID;|V;8r|>kM!LxV{&*KHO7$mTlnnSPf$FTr7##_CL z>c@%zk>Z-z?Au#BfyY|jg<|J?&6fMY7n#FfQv-K?avUqKmBq&T;)K_7a+1r72t*

tVR_h(Q7F6mEyt7pL<3;=u!(B36lT+CojrdrnV1G}&Z z)vlrz%ylL6SY#|?P73NVmBpEMW2DBb@o@(J)B?{h`GcLQkKFl?lmn}BA?f+Pnn(YXrxv_ z&ZO}GUfty3=(>w`uJ}j&OZ5P4f~q(J?WaiX8EZ$}OXK?_s9*6@p}D@dO1$gx!WuOIPnUUG-%KFu;($NTy6P`%=}XOfeLvTc`(n-;BT|EuU6#J?C!Be5*&OicRiWet>1gmCYw(eSrT^tch}>U0SC8iA}`$#AU>7#M8vTnaKvs*WRX+&Z91+Zl&&_9;Tk8o~OP-eT(`5^)u=>)bBZe<@}qIwz8^5cj!atqv#Xp-_kE| z!`A)Qqt;W_3)XAa8@7+bVR#OH058J{IBlD>{m-_?cGY&n*7R`C^(st@`3a*p_pq0< zx3l-LkFw9OUt$Bzi}o`2F9FsP3)~j?QsA#9lP!B4o^pv_Q~6#~uVatn|Bgm0UdaWA ztM06Nm)V5bZ0mnpFShxy0Gb$Ji90^wLj-ZOq7(fX#THO75TF5>6|xLzMS77DWHT~@ zT!Q=@c@9;BYDKL=eTX`VI*Yo3x`p~3jX*Qd_2>+G4LXm05q%N;I|hq!VB(kuG4EoI zV{T&p!eX&xEE6lh!dMN~jCElH*amD1HjVAU4q+E#mt)sppT{1>72_P_!>M9uf|*O_4tMOW%w=lJ@{wv@8U1ub<)pua$Q}u)RekRT~8Pz+(x*E z@DSk%!gGXI2zBuv#D5dNC_X8EUpyxEb?)fAzFyKInxpj-?;^fR{DkDZ#1G47favdt z21vl(&lCWdFc!2%z48qsIL-;|{fbXo+0Zn>EXTWu#-bib%{tYKnT*hlVODZznpuZ+ zV}pdvqDi7^>-Xk_}FWI*(o!!fqXXKB^ZLY&UUtjSR>8gU@<34;l+M9uHWNfMEqg@5owD7;PUkB&C#ZYZq$ z4yb`+%F$^3yjg+EXA2KCz?bE&z^dx^CJ;+;3D#sppZjfO@R|H0P9vqojLQ)wgfVOO zj`cS6wqjE>H2lKV494`qjV680v{qh=NE`Ma0c_a7Vok2jI9jD1T2cNT6iMsdOXxu+ zYo#k=D%H2*3e@NT+sav4>xeEG{=uh(j;+40=KCsyvpR#kwGVi1JiCGH@essj+x9l^ z-8R9}TNZ&$;J1w$ef9NigI_p(GdpL2|3gdR_r{xghei0Wwr~LN5Q0|*cKVqpBje`1yt|DkNhe)MB&Z=|=iXlviNN?lA;i)3s}0p!AUe168CNJOCtk%C)Qra0 zJ{nC+3VY|qH9k7`K7XX$V%=3-1+qasS8AXWoC@WNWleqa9#lIlu4xm`Z{Y*FzEW9@ zi;@gTgG7SBH6nzF0u;7nDT58LO{Hv6)8G|UT7eUGLE6snB^%%ct6*jT>)p1hz=;GE zWE(&N2?t}HP;4(?J-(=$I2~b|Qo-QHY{~IrVF|w(4HFhuP9eLB>ejm(M<;^K@Oqob zW>O&&-mOmMo|BnY$9loim~6nF(RgD(C>G&!@+4t*UmC1_k|dvlfLWZr+vie5qb-SQ zHIDGuj&1$30WX?rtQt6n%dTz~hg$9N{mkNWLeUJnsb95bS^=8c9nPOir-Q~Jbos#G z0VxtHqS*#C9vc}00ue&*$PX$#v`t#LgKCwkqv-ZTu(9+Hafl|P(&Gz|D-vzi;>ThI zeaszTpK+M)Bq5>T0Uljky{~f=i=JB`zUiy_;(0KbQjmK4MRzp&lqTM$gls#in-MnX zd2=s58M+H5i0oCRI>CVT_W-|*l0BUsd$n%3XvjDe=&aquLWTp&C|>q;WJkMq+(B%x zHJ#NEwruXmM5G+tasMctGu*RE#N{v>)DX3?=ZYUC%6>vje)#^pM{f-3@AUUCT(}0= z--Ivn2l!(zU^G|05JC+|vcPb*-Px!n(=Z&TlS#i>+^MCRPu5H41cV^g)11<>uC8UJ zyfXp!=1bKPs`0ZtKIk#6r82859HkXRxr9{_zL*a19>(0oH6gomm-wZt}gyF)%|FS5#whx4Yndz?jx8WoXH|uo3 zH-|LB)sVp8v<6aV^H4&Xgv(kQ`e!Mc^h)!LJ0!$#@D?n3fUwXJ2}dl|{nk5RU5R%? z+yIX(tg&H*4q={CkwGYyPIG&iL9?&IS>UMwzhd8Ym5r+PV(zI4 ztMYsJYF$5~tjry_Omj+-oF5iDh+z_l4x9ODoFa)TJ=$f*s^_8>NqpgIP7vAz{~{sc z%aq+^=VH*ETVL-UQe-Iyaz*V(WT15aE&GFlIsvgiM+6T;@eH|kV(h|$48ki*07>P* z1eLq9@Wd#p%qwe6c50))Py+#qX?ap|meOR1Uu-1HUF#u_!afTbAT}CYVQ5EkzgyB= zYR5L6#pM>@TXiS+7Ea{n`FC`$Se%Dp<33p-mf;97PA&1$l@NuC!$Ot%+?u3#T;>QZ z%d@>P8&gPWFdP=cL9fZ-VO;TaeWT7@Y|V!W*jB|cWw2E77kG6;j3{-KB(8OmDWat+ z-nKPW>94DFNMIecD{VZvTp{B`1p_}RJ4gkof7jR(ZI z@|o(w@c2?8B{2L>VRiWBvZsuWVKc|KZ{nt_UmsRu3=??&b-+$=d62lmO-S9vpQAa!TIDxzb+QN3l}C144Qq* zPz=)_>2pf1{qLG6ekWf}m>kz8%YrELO3HD)GCP0@Sy7UvD`o2dxB$sor1A|f2cgk` zQH`|`EBVZ7_THDqe)uG~w^Vl83z>8>q()+P*cVVwv6W8$B|lkd^{n+zAb}I;df1nR zbfM6m0Ye=QmZG|VB_9j|TfYSfhWmX!W3eM=kju$&f^s>zZW(Y^Dml7gxeAb0JuK*f z6B^er%~L?Xe|Yy0gs@E-yL~a>Ndiyw)pM9|I-(gq0oRyuDF*!xfvLvVVrZo@YZFab z#=k2`cCY+i0363|^|}i*uqg7pF?ZjY9``w{R7Iy$6G_r>YxPC5TX7(I?_~^Qhs4FD zMsyv39HCrm3Q~~-H#O;ar8}eAs)CsewAHcNccFo)6U5^w$rSiPcv#1$mH`Fh-KB{P zj@p4dJNp7;cy7mBlr&4NrK;DId44>LwDDrlAjJQ>@Q504d$b)=t_lq<5{hu51V~Cf zk&`jo%9K)UFBIBRCh*}VezZ^FGX@Cj#UbOM`N;vXt9nTHAxVA#i$-tm!d6D7uue9R zl*S(AkfRC77OUw(Hn%fF`NtWw4e^9-bIr9}+eR4`FvUuzy;Nf-{;fnyC*w82s~U53 zb9WBPr2Y;*$SMhfaoIN;80G8NLPDnA`YBlzF3fA~%D@$)xj(-MfC(B!@yKf)AG8f% zg9OHQo;yAI5h9%6nZeFvvU6}~NDzJpzcZ3X>nDl=tCV1D>|CnkTE{%6PmKBjB{4o} zVCqPOtSs`>d?uO9ox-Yn2D?=kMUB!GKROU1R{*y_be@syJ+V@F`{y5d6zW{ZsIJwC{{(7rP zi=4}g*t_Y7DPcdr7P7Yi>ENfCY!-TTB&$v~8kQlpkav#alqEdZvOgMSH;xZT#YhNC z#Ef3>Er@?l4Db0t+STX4`M!1Dd$T_4eAyEdu<_6HnvN6-QM4-5Fi>$}z|+;Rl< zp$P#LW=A9PFGJ1Ir^)i4k`W2TuizRAV_M*Z`w_G$hC|4?~vJv0xL zG#nCdOkWP%WO8vcr<(B0M zrt(WjXaJ2-wBxo>vQew;35okk+cW|ZX@NC!C)#r@B8N@$yyXg$&M!$%r_azA+}+dK zvTJib0rD7bN`Rm;j=QjVJAg5$PpwF`?D8D>ROb2Q(1e;q8TKt8%XFq2Qh9kirpZax zh?_8qcE|OLqeVrW6|U;;GMSuPTZO~~#^af?*ZuM>$ZdVt=duhVI=|8eZz#NoF=E5?sQX5KL%UoN?W)6|K40+A=F5-Xti3r=jGXrm7XevIja=1>)`_pMl zekLY$;)7pVi`(V{nx1|yvEGy6d8$~_gzIPx58d9CN__*XIGg^i-1Jyl@Z$VDjNWV-6wWVTOx6vV8mG#Z zk}{Lka7Z;(Itv8aaZcH7u5WQZ!0*(p)$%eb9Y^7iis4B$_aYW3Ou=O;=B83ys*)4Z zvne{lG!Nl121NbOmj)E3>HLh(ZhF#uem~76LpMH* zjMly>AHFtK+YugPR9+njNZYtPKZ=@a9N*AKd5Clv z^m)8aSv>C`i8TEE?_uXBEm$|Q<&xDlA!%F-H?Y!~RmsG{3Pv_n)<~k0rOYMhoo2c( zTP1FHU-bdw`*@M8*EDs3@mve{QgxK5i6q_^ShfnG&{+yQwt1AakcY>T3Tl9?yFxBu z4rJm7W12Y6nLgB&kr3HCgEIs?!2=YVb(E>E+%}ykbl%qF)8>%1_#-M`%aJJ(1iKOx z8jHda8&$9h5roHvRFNRGD9MoZ$|S(N-2O~k;ADGLH9}hnn|q4;lM5|s3GF6S(TBQ% ze?(vif>(19(|wnU+K}F^beST3i9IW@UhBf?|Na}F4`AA*N(LBgR>@Fkpp+R!?scnF z(+}jbJkdg>x~#I&%HZdraeb}K?X`)PRC{{Dns)+Nd4bGulVWI)DBUzR)JNRKrU9>R zFH`0<+kTQw`UPy$y@gnCSqaZgjc1CAIAGc=JC$is`%W6MG(?+P`s-qyec-=Yjb_^vvcf6!v?s6> zj;4x2$Zj}um$7gSCqOBj3vN=!mN{He0@;OR0#4+`qr9g;J@w5K?snPtHo)WE#nO_1Ch8td1}Xk$e?$N7PH-bB$X0$%4WkPP_G3D3;T z4d3;=6S{sS<)2M|G>e)!JLtU}6&37aH(}2txbMblBUov%v}!Wux?*4+pF1P3J@ctj z1Jn2heJmY=6;S|?yeGf|D^+^S?VWwdFoWLsfXL46(e&>e209745{~E4RiLs~YjHZN zB~G(kb;a06=S{sj4hk@@E}su0OSR(A9SvR$1hPsg??R}o5y2fP+D(Vd3$94@s4ZB& zPdcc=`Af2j-X7(r$w2TCh=sYatkeiM>4)604yC&LO^=1}Ql2v&4i3Mxza`gYs&;@^ zbziURXxx?~iMvWOxMC+U(Fr8($-_pN*1J4q0iK&F__^gGV4z@;D`YB@Zs7eW z;pVPJJgiMtkOCx7KdUsbzUB%`!(Ck~k`^Ewqw^1Mpmy}50Wu?i@#Tv=)8ql&t; zDJ*a}*&vdDzE|j>#gx!n9V0FnVfLn6<<_g$<=~cwBv(if!H`?OvR{BD$|4egemuX9 zi2dKd#5W;#Z-?Cy6pq0O9+U7^2;`HrTRl$))dAs0T73W4>e z4bIPeloTylD3hdfI$O>$NOEJRXV7J9ixBM3njzmfI`bdbF(m%2GGLogSTK%~>dqLD z*tBiPIW$yYWiB}mAMn}vTdnso~3C{wxwiwy#a2b{ImJI zX!ncPz^@gK`K~W2+lcYMz;Vj3|QNVlfR=CN`$%({IsUWNyQCeIqosh~Prgv;*g@c*H+iv`DIo#OR zcDKq<>|6et*aCO_cGOky-PyMiY_GG=MN7)d%OI}n#XN-!A?Jqg>0=$;{89B?MI zSI0T2AT(W0-B?q=C=7Cf)#8liLPUZd*^P2E7DIEd5dul2z@A#{Ost~~w`WZ=+ul~v z(KLzas$v>s6(7um7Q|&xe5Hs{EsJ7uSX0!VegAz@kFXpm6!+M?#knzKiY{L zX~TFtpQ>&-?>|@qf6B*F%+|{VV$YbhemWVXQF3!qZ>_OsetM+K;Ao|mst6zw#=7&%rOi-uveKsahQ?$zRp9lJ%LSDK68DLf^ z;Wb@9zi!TmWP~+66)_f4IYjdmSW`L2FlA|Elw-Ooof0u`j%a5w8I2p=Hn_k@5u!qD zz2tukot(3HZ_Hq|Fe>G(oR@NjiBL#NKY)CKGFZ!NH0|>hDlxr)gS5K{QxMoP&yaQ z144ct=K1?{HBrPPdL=0yDldrU_X~nFZ7ch*F52j0bc=BaXBhkWc1hO}O&QZ;a!nhR zyRo}w5=?Lh{fCSUwj~onjGW+QqpWLV5-lRrDTUF4zUJ}xi5M0mV!=r~6J{!%MAn)P zd+<~&Qxs-qKF}EZ$fqK{rbRQ&mB`E_lB7OO6eokB&23536%clP1&UWB>UEDFT$r&q zr|a9pN-!C$v3htkQ-`5Kg1Ar{4+ zwj`}X$PnQSax02a;dxRPOL7&Qg>4-?<1sp21O?u1)}2^Eje>@%Cp3A`&azuuyOcWY zgx?y=m9r2k)QD7oB_~#NF2QxPxjL@+lCRA>l_^h%W1|L^tYqSZbd*-IYIsh~vAf%j z5U%51YPmp-f|JlfOqKR{fQL2pV+4|1nZOccF2Gu~(~RXCi+;zb%2SJ-ASD3whzt^j z!AW_3xS1*T^dQ8MBC}ra#Vs~@^#OP=TR(NxRRX$<5k-(4CDAdE9Lt8#szj~Xsqn#t zG7+nIvut=wtzvRi3V~!_bt%_3!TH74x_XUWYIu0^&lpPAW9|@sEHTnVs z_2Z%0U?_s5lL>)AKZ-)8BtGF8NqG@uadyZWk%%Ws&|JMJlG#;4D`u#V!v4F&^UJi` zRLV#|?G26|oXCB>ZqSAe?n2`UhDpRxq+{0mRz!)?=E5GR#2m+eW`7)Hne#6?BoPzd zil>!|?X?t&ZI?5d5>%X+P&W>$698fVuw5#kiO5>kV>pwi2eu~|Y{w+eNb?NiUZJro z!YH^uy)UR$+#~;m!&UGGrDy#F5E*KRtx;iV{sm=NLRJ|>E@utoVae0Uly=BQq(`4O z)jW!8Gsj~v+#TN;E>q(-ZtpXKT53?CKrl*irGc3$3M5+_j1} zfg}vmsk-sm#d+;!f3Tq;81N7A9p%Pd^)wN#sZEj=Iq(t90kpM~Jd#vtY<0SputXt7 zcu$nwqJ=hL!^AMc!-`^*8m%Pr7oxn8rcZCL%#ktMzdDSE+V(}vaWy%)!*JGcy3Tyk zyxopvvX|AiQ&`*Hh{kkyNj^HtY3=+n8i=B#sEME>En{-(16f3Cz2VO4+^zsB7`87oD?!dfZLDWOVrWfGM+C9&D1rz7@oiV6BfqX^_Lnwo|+6?f9@ zr3X+m6w`X#)}{a)``Y^>_0@#Ubngm^0>E#9SbLPJ!Iq5{c8ZbgJ(6il0l zAtL^X&r%-D>%E*%%GlS<>+?KdFww$h7%R$y3!CB9*|`_z=m0Gq5bXbZkTgCq{V)Rm zj6w~5c=p(8Bu|i;$+JnEt z+=56^y;Ft~qV1oK^}|Hz>G8JPk>;-MbDMhRel>w+(4^- z<3ye`YI0G;WvvOCi-}&8%?>#a$2XhQp z?uvz(S-u4-_=`E|KZnvs*Q2BJ+brbq;+$Gb-&3rI(g7S@1cBjsYCCMPgC&INCc1%jVc&0aj*J)khvT$>fp-dkm`^i&|X=QIG|i_^1@0RUgVHz}KI^8_jN#sT1<001E1fBMq6&JeRcKYHXVLUde} z2Y0$f3h?X}Vx9z*4jb#nTeXQdcF~?L6RAdKFhiwyuDn8noTT;hx;m1FYp4CnM9!o1 zq3x_RAX{ouHi1WK*FqR_8i479EzMUIe~uuTW5%K&20=BlMXwraoK z>x}gwKhz9~CjJ(oNXOGhNg>RyR_ih@O$G*X<DG}3MdtH&aA(ieGpcrDUPrRFNbZV;8oE)knqJd<4_bmj%jQT9apMjNl2 zegB8i`sj8QC+FP*4$WF52t#3jmjIzm5bYD0!W6$`4r(&j%jC&A;3X;RB03*%Q>=D( zBd!*uG%eduqmYw|=}Mjf9+mUv(z{?Ae#qNVuI%&_SckicT5^}l8S<8N z_s~pUu8_<(@-njdSFVySsoYDk(sE7c+9mgiU!TeS!Z#ofFlHRzL557l(*}}~dF{O; zl`w*&)n+_O9r&qz#0FX{mzSmP+!Q(uSq+sF?T+(Onj=?pc1b4^pJ-uks5V6rTFmA8 z0G9xjBgI%E?!tT5;?r2WYI9IhXl~H2ot!7JE1XtZV+5V2+VPku*2kSS30}a1GFPU8 z-Fhr(p@^7}lGNkE&a_Os$|F26!cRrZEDKj@Rj1v#6&3vF+fE@ws0J_PY_^(qxK<~P z9M-)O&n=uyBpF+~VLiiL#;Jl vyi`lvx~1{5dK(&d?$$J3c&OO66o=XwOy^@OE?d2wSt$;#ZT4l8Z5ILnSZLFd literal 0 HcmV?d00001 diff --git a/app/assets/fonts/sourcecodepro/v22/HI_SiYsKILxRpg3hIP6sJ7fM7PqlPevWnsUnxg.woff2 b/app/assets/fonts/sourcecodepro/v22/HI_SiYsKILxRpg3hIP6sJ7fM7PqlPevWnsUnxg.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..abb53538271b488901f1f3e63dee2ef3b0e748f9 GIT binary patch literal 19688 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 literal 0 HcmV?d00001 diff --git a/app/assets/stylesheets/_variables.scss b/app/assets/stylesheets/_variables.scss index cfe57f07..59fbae06 100644 --- a/app/assets/stylesheets/_variables.scss +++ b/app/assets/stylesheets/_variables.scss @@ -114,6 +114,7 @@ $line-height--base: 1.5; // Font Families $font-family--serif: "Source Serif Pro", serif; $font-family--sans-serif: "Source Sans Pro", sans-serif; +$font-family--monospace: "Source Code Pro", monospace; // Font Weights // Weight values based on OpenType weight classes: diff --git a/app/assets/stylesheets/fonts.css b/app/assets/stylesheets/fonts.css index a591c488..e1991986 100644 --- a/app/assets/stylesheets/fonts.css +++ b/app/assets/stylesheets/fonts.css @@ -482,3 +482,45 @@ need updating. src: url("sourceserifpro/v15/neIXzD-0qpwxpaWvjeD0X88SAOeasc8btSyqxKcsdrM.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } + +/* Note that for monospace we only have latin characters. However, should you need to add Cyrillic or + Greek or whatever you can access the rest at: + https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@300;400;600;700&family=Source+Code+Pro:wght@300;400;600;700&display=swap +*/ + +/* latin */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url(sourcecodepro/v22/HI_SiYsKILxRpg3hIP6sJ7fM7PqlPevWnsUnxg.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(sourcecodepro/v22/HI_SiYsKILxRpg3hIP6sJ7fM7PqlPevWnsUnxg.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(sourcecodepro/v22/HI_SiYsKILxRpg3hIP6sJ7fM7PqlPevWnsUnxg.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(sourcecodepro/v22/HI_SiYsKILxRpg3hIP6sJ7fM7PqlPevWnsUnxg.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/app/assets/stylesheets/pages/accounts.scss b/app/assets/stylesheets/pages/accounts.scss index 3695f558..cc9b5a3c 100644 --- a/app/assets/stylesheets/pages/accounts.scss +++ b/app/assets/stylesheets/pages/accounts.scss @@ -11,4 +11,8 @@ top: -75px; position: relative; } + + .setup_mfa--recovery_code { + font-family: shared.$font-family--monospace; + } } diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index ec41fef5..1c1676d4 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -173,6 +173,7 @@ def finish_webauthn_setup if credential.save render json: { registration_status: "success" } + else render json: { errorPartial: @@ -199,6 +200,22 @@ def finish_webauthn_setup end end + sig { void } + def setup_recovery_codes + raise "Recovery codes already enabled for user." unless current_user.hashed_recovery_codes.empty? + + recovery_codes = current_user.generate_recovery_codes + render json: { + recoveryCodePartial: + render_to_string( + partial: "accounts/setup_recovery_codes", + formats: :html, + layout: false, + locals: { recovery_codes: recovery_codes } + ) + } + end + sig { void } def send_password_reset_email typed_params = TypedParams[ResetPasswordParams].new.extract!(params) diff --git a/app/javascript/controllers/webauthn_controller.js b/app/javascript/controllers/webauthn_controller.js index ac3ce5e7..000aef50 100644 --- a/app/javascript/controllers/webauthn_controller.js +++ b/app/javascript/controllers/webauthn_controller.js @@ -13,7 +13,7 @@ export default class extends Controller { async connect() { // Check if we're actually encrypting, if not, don't allow setup to continue. if(window.location.protocol != 'https:') { - this.outputTarget.textContent = "Somehow you've visited an unencrypted version of this page, please contact us immediately to report this." + alert('Somehow you have visited an unencrypted version of this page, please contact us immediately to report this.') this.webauthnSetupTarget.remove() this.totpSetupTarget.remove() @@ -21,17 +21,16 @@ export default class extends Controller { } if(navigator.credentials == undefined) { - this.outputTarget.textContent = 'You are using an outdated or non-standard browser that does not support Webauthn, please click "Being App-Based Setup" below to continue.' + alert('You are using an outdated or non-standard browser that does not support Webauthn, please click "Being App-Based Setup" below to continue.') this.webauthnSetupTarget.remove() return } if(navigator.userAgent.toLowerCase().indexOf('firefox') > -1){ - this.outputTarget.textContent = + alert( 'Unfortunately FireFox does not yet have full support for Webauthn, the technology we use for our two-factor authentication.\n' + - 'If you have a hardware key please click the "Begin Hardware Key Setup" button below, otherwise please click "Begin App-Based Setup" to continue' - return + 'If you have a hardware key please click the "Begin Hardware Key Setup" button below, otherwise please click "Begin App-Based Setup" to continue') } this.lockAnimation = Lottie.loadAnimation({ @@ -41,10 +40,6 @@ export default class extends Controller { loop: false, path: '/lock-cpu-cyber-security.json' // the path to the animation json }); - - // What we want to do here: - // Make sure there's a back button? - // Same with TOTP } async beginWebauthnSetup() { @@ -67,7 +62,12 @@ export default class extends Controller { const optionsJson = JSON.parse(setup_response_body) const options = parseCreationOptionsFromJSON(optionsJson) - const createResponse = await create(options); + let createResponse + try { + createResponse = await create(options); + } catch(error) { + console.log(error) + } const finishWebauthnResponse = await post("/setup_mfa/webauthn.json", { body: { publicKeyCredential: createResponse, nickname: "stuffthings" }, @@ -84,7 +84,8 @@ export default class extends Controller { if(finishedBodyJson["registration_status"] == "success") { this.lockAnimation.play() await new Promise(r => setTimeout(r, 2000)) - window.location = "/" + + this.loadRecoveryCodes() } else { this.lockTarget.classList.add("transition-opacity", "duration-150", "ease-out", "opacity-0") await new Promise(r => setTimeout(r, 500)) @@ -94,4 +95,37 @@ export default class extends Controller { this.lockTarget.classList.replace("opacity-0", "opacity-100") } } + + async loadRecoveryCodes() { + const setup_response = await get("/setup_mfa/webauthn/setup_recovery_codes.json", { + contentType: "application/json", + responseKind: "json" + }) + + if (!setup_response.ok) { + alert("500 Error, please reload the page and try again") + return + } + + const setup_response_body = await setup_response.text + const setup_response_json = JSON.parse(setup_response_body) + + this.lockTarget.classList.add("transition-opacity", "duration-150", "ease-out", "opacity-0") + await new Promise(r => setTimeout(r, 500)) + + this.lockAnimation.destroy() + + this.lockTarget.innerHTML = setup_response_json["recoveryCodePartial"] + this.lockTarget.classList.remove("setup_mfa--lock") + this.lockTarget.classList.replace("opacity-0", "opacity-100") + } + + finishSetup() { + // We need to remove the codes from the DOM so the back button doesn't allow the codes to be + // retrieved later on + this.lockTarget.classList.remove("opacity-100") + this.lockTarget.classList.add("transition-opacity", "duration-150", "ease-out", "opacity-0") + this.lockTarget.destroy + Turbo.visit("/") + } } diff --git a/app/javascript/controllers/webauthn_login_controller.js b/app/javascript/controllers/webauthn_login_controller.js index 24b5d9fd..d6b94edb 100644 --- a/app/javascript/controllers/webauthn_login_controller.js +++ b/app/javascript/controllers/webauthn_login_controller.js @@ -71,7 +71,7 @@ export default class extends Controller { this.lockAnimation.play() await new Promise(r => setTimeout(r, 2000)) - window.location = "/" + Turbo.visit("/", { action: "replace" }) } else { this.lockTarget.innerHTML = finishedBodyJson["errorPartial"] + this.lockTarget.innerHTML this.lockTarget.firstChild.classList.add("transition-opacity", "duration-500", "ease-out") diff --git a/app/models/user.rb b/app/models/user.rb index 72a5526f..2c462094 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,5 +1,7 @@ # typed: strict +require "bcrypt" + class User < ApplicationRecord rolify devise :database_authenticatable, :registerable, @@ -94,6 +96,52 @@ def assign_applicant_roles(applicant) self.add_role :media_vault_user if applicant.source_site == SiteDefinitions::MEDIA_VAULT[:shortname] end + sig { returns(T::Array[String]) } + def generate_recovery_codes + raise "Recovery codes already generated for user" unless self.hashed_recovery_codes.empty? + # For ease the format is a 24 hex. When we show it to the user we'll do it like XXXXX-XXXXXX-XXXXXX-XXXXXX + # This above still needs to happen + # We'll have to remember to remove the dashes before checking, but not bad. + # + # There are ten codes when generated + keys = 10.times.map { SecureRandom.hex(24) } + + # Get the BCrypt (it's slow and long, that's good here, and included in Rails already) version of each + # which is what we actually save + hashed_keys = keys.map { |key| BCrypt::Password.create(key) } + + # Save the hashed keys, and return the plaintext ones + self.update!(hashed_recovery_codes: hashed_keys) + keys + end + + sig { params(recovery_code: String, invalidate_after_confirm: T::Boolean).returns(T::Boolean) } + def validate_recovery_code(recovery_code, invalidate_after_confirm: true) + timing_attack_key = BCrypt::Password.create(SecureRandom.hex(24)) + + self.hashed_recovery_codes.each do |hashed_recovery_code| + # If it works, we remove it if we're invalidating and return true + if BCrypt::Password.new(hashed_recovery_code) == recovery_code + if invalidate_after_confirm + self.hashed_recovery_codes.delete(hashed_recovery_code) + self.update!( + hashed_recovery_codes: self.hashed_recovery_codes + ) + end + + return true + end + end + + # To prevent timing attacks we want to make sure we're always checking ten strings + (10 - self.hashed_recovery_codes.count).times do + # Just do a compare to kill time but ignore the results + recovery_code == timing_attack_key + end + + false # Return false since if we're here there's no comparison + end + sig { returns(T::Boolean) } def can_access_fact_check_insights? self.is_admin? || self.is_fact_check_insights_user? diff --git a/app/views/accounts/_setup_recovery_codes.html.erb b/app/views/accounts/_setup_recovery_codes.html.erb new file mode 100644 index 00000000..1a019841 --- /dev/null +++ b/app/views/accounts/_setup_recovery_codes.html.erb @@ -0,0 +1,24 @@ +

+ +
+ <% recovery_codes.each do |code| %> +
+ <%= code %> +
+ <% end %> +
+ +
+
+ +
+
diff --git a/app/views/accounts/setup_mfa.html.erb b/app/views/accounts/setup_mfa.html.erb index 402e60bb..9482027a 100644 --- a/app/views/accounts/setup_mfa.html.erb +++ b/app/views/accounts/setup_mfa.html.erb @@ -1,6 +1,6 @@ <% @page_id = "mfa-setup" %> -
+

Setting up Two-Factor Authentication

@@ -18,7 +18,7 @@ Authenticator or Authy though we discourage this unless there's no other option. We do not support SMS-based two-factor authentication as it is inherently unencrypted and insecure.

-
+
diff --git a/config/importmap.rb b/config/importmap.rb index d972e584..2d2b7e53 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -9,10 +9,10 @@ pin "@github/webauthn-json", to: "https://ga.jspm.io/npm:@github/webauthn-json@2.0.2/dist/esm/webauthn-json.js" pin "@github/webauthn-json/browser-ponyfill", to: "https://ga.jspm.io/npm:@github/webauthn-json@2.0.2/dist/esm/webauthn-json.browser-ponyfill.js" pin "@rails/request.js", to: "https://ga.jspm.io/npm:@rails/request.js@0.0.8/src/index.js" +pin "lottie-web", to: "https://ga.jspm.io/npm:lottie-web@5.10.2/build/player/lottie.js" # Our JavaScript pin "application", preload: true pin "utilities", preload: true pin_all_from "app/javascript/channels", under: "channels" pin_all_from "app/javascript/controllers", under: "controllers" -pin "lottie-web", to: "https://ga.jspm.io/npm:lottie-web@5.10.2/build/player/lottie.js" diff --git a/config/routes.rb b/config/routes.rb index 186fc343..dd86edef 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -113,6 +113,7 @@ get "/", to: "accounts#setup_mfa", as: "account_setup_mfa" get "/webauthn", to: "accounts#start_webauthn_setup", as: "account_start_webauthn_setup" post "/webauthn", to: "accounts#finish_webauthn_setup", as: "account_finish_webauthn_setup" + get "/webauthn/setup_recovery_codes", to: "accounts#setup_recovery_codes", as: "account_setup_recovery_codes" end get "/account/reset_password", to: "accounts#reset_password", as: "reset_password" diff --git a/db/migrate/20230125061836_add_recovery_codes_to_user.rb b/db/migrate/20230125061836_add_recovery_codes_to_user.rb new file mode 100644 index 00000000..4bcd666b --- /dev/null +++ b/db/migrate/20230125061836_add_recovery_codes_to_user.rb @@ -0,0 +1,5 @@ +class AddRecoveryCodesToUser < ActiveRecord::Migration[7.0] + def change + add_column :users, :hashed_recovery_codes, :string, array: true, null: false, default: [] + end +end diff --git a/db/schema.rb b/db/schema.rb index ee118d21..24955d79 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_01_23_223143) do +ActiveRecord::Schema[7.0].define(version: 2023_01_25_061836) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -320,6 +320,7 @@ t.datetime "updated_at", null: false t.string "name", null: false t.string "webauthn_id" + t.string "hashed_recovery_codes", default: [], null: false, array: true t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true diff --git a/test/fixtures/webauthn_credentials.yml b/test/fixtures/webauthn_credentials.yml index 175763cc..c9745f4e 100644 --- a/test/fixtures/webauthn_credentials.yml +++ b/test/fixtures/webauthn_credentials.yml @@ -1,15 +1,17 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html one: - user: one - external_id: MyString + user: user + external_id: "oixuvoimnfmwonwfw0p" public_key: MyString nickname: MyString sign_count: 1 + key_type: "public-key" two: - user: two - external_id: MyString + user: new_user + external_id: "lkdfjkhsdhgfkjdsfhksjdfhiu" public_key: MyString nickname: MyString sign_count: 1 + key_type: "public-key" diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 12433716..1aa75772 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -117,4 +117,54 @@ class UserTest < ActiveSupport::TestCase user.send_setup_instructions end end + + test "can generate recovery codes" do + user = users(:fact_check_insights_user) + + assert user.hashed_recovery_codes.empty? + recovery_codes = user.generate_recovery_codes + assert_not user.hashed_recovery_codes.empty? + + assert_equal 10, recovery_codes.count + assert_equal 10, user.hashed_recovery_codes.count + end + + test "can validate a recovery code" do + user = users(:fact_check_insights_user) + recovery_codes = user.generate_recovery_codes + + assert user.validate_recovery_code(recovery_codes.first) + assert_equal 9, user.hashed_recovery_codes.count + end + + test "fails on a invalid recovery code" do + user = users(:fact_check_insights_user) + user.generate_recovery_codes + + assert_not user.validate_recovery_code("sldfkjsoifnwonwonwf") + assert_equal 10, user.hashed_recovery_codes.count + end + + test "asserting an invalid code runs in the same time no matter how many have been removed" do + user = users(:fact_check_insights_user) + recovery_codes = user.generate_recovery_codes + + start_time = Time.now + user.validate_recovery_code("llkfjoifjwoiknwoifnwffosnfosifnsoifns") + first_validate_time = Time.now - start_time + + user.validate_recovery_code(recovery_codes.first) + user.validate_recovery_code(recovery_codes.first) + user.validate_recovery_code(recovery_codes.first) + user.validate_recovery_code(recovery_codes.first) + user.validate_recovery_code(recovery_codes.first) + user.validate_recovery_code(recovery_codes.first) + + start_time = Time.now + user.validate_recovery_code("llkfjoifjwoiknwoifnwffosnfosifnsoifns") + second_validate_time = Time.now - start_time + + # Should run similarly + assert second_validate_time - first_validate_time < 0.4 + end end From 65898b0b4d0971c4759672726c3f8fcd4573982c Mon Sep 17 00:00:00 2001 From: Christopher Guess Date: Thu, 26 Jan 2023 11:58:24 -0500 Subject: [PATCH 04/10] intermezzo --- app/controllers/application_controller.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index cd3b1aae..a9ff5e2d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -115,6 +115,21 @@ def authenticate_user_and_setup! must_have_mfa_setup end + # TODO: Implement the following before landing, the debugger is there to remind you + sig { void } + def authenticate_and_require_mfa! + # debugger + + # Make sure a user is logged in, then redirect them to the MFA page to authenticate before moving on + # We'll have to remember which page they were trying to get to, the full request, basically intercept + # it and replay it after validation. + + authenticate_user_and_setup! # run normal validation first + + # Probably see if we can save/store a request? IDK this is going to get complicated. Maybe we just + # keep it for logging in only? + end + sig { void } def authenticate_user_from_api_key! if params[:api_key].blank? From 06f3c913bce4c1b71f7007521f6ee7b51f121483 Mon Sep 17 00:00:00 2001 From: Christopher Guess Date: Wed, 3 May 2023 12:00:48 +0200 Subject: [PATCH 05/10] Add better error handling '' --- app/controllers/accounts_controller.rb | 34 +++++++++++++++++++- app/controllers/users/sessions_controller.rb | 11 +++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index e1a93285..c0964cac 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -153,7 +153,33 @@ def finish_webauthn_setup begin begin webauthn_credential = relying_party(request.referer).verify_registration(typed_params.publicKeyCredential, session[:webauthn_credential_register_challenge]) - rescue StandardError + rescue WebAuthn::OriginVerificationError + logger.warn("***********************************") + logger.warn("Error setting up webauthn: Origin verification failed") + logger.warn("Requested origin: #{request.referrer}") + logger.warn("Allowed origin: #{ENV["AUTH_BASE_HOST"]}") + logger.warn("User: #{current_user.email}") + logger.warn("Time: #{Time.now}") + logger.warn("***********************************") + + render json: { + errorPartial: + render_to_string( + partial: "accounts/setup_mfa_error", + formats: :html, + layout: false, + locals: { error: "Error validating credentials: Origin mismatch. Please contact us when you receive this message with timestamp #{Time.now}." } + ) + } + return + + rescue StandardError => e + logger.warn("***********************************") + logger.warn("Error setting up webauthn: #{e}") + logger.warn("User: #{current_user.email}") + logger.warn("Time: #{Time.now}") + logger.warn("***********************************") + render json: { errorPartial: render_to_string( @@ -190,6 +216,12 @@ def finish_webauthn_setup } end rescue WebAuthn::Error => e + logger.warn("***********************************") + logger.warn("Error setting up webauthn: #{e}") + logger.warn("User: #{current_user.email}") + logger.warn("Time: #{Time.now}") + logger.warn("***********************************") + render json: { errorPartial: render_to_string( diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 9ea5b23a..874fd6e9 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -25,6 +25,17 @@ def create # This will redirect back to the login page and handle flashes and such if it's invalid warden.authenticate!(auth_options) if user.nil? || !user.valid_password?(params["user"][:password]) + # If the user doesn't have MFA setup we flush them over to the setup + if user.webauthn_credentials.empty? + flash[:notice] = "You must setup MFA before you can login" + + # We have to log the user in before they set up MFA + # I'm hoping that this doesn't open up any security issues, but this shouldn't appear unless + # it's authenticated in the lines above anyways + sign_in(user) + redirect_to(account_setup_mfa_path) && return + end + # Set the user to the session for just the next step session[:mfa_validate_user] = user.id From 5be6fa6972a37e35c5778f41e738e7e0dad07ce5 Mon Sep 17 00:00:00 2001 From: Christopher Guess Date: Thu, 4 May 2023 16:15:02 +0200 Subject: [PATCH 06/10] Add recovery code interface. --- app/controllers/users/sessions_controller.rb | 52 +++++++++++++++++++ .../controllers/webauthn_login_controller.js | 33 +++++++++++- .../_use_recovery_code_error.html.erb | 13 +++++ .../sessions/mfa_use_recovery_code.html.erb | 26 ++++++++++ .../users/sessions/mfa_validation.html.erb | 2 +- config/routes.rb | 2 + 6 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 app/views/accounts/_use_recovery_code_error.html.erb create mode 100644 app/views/users/sessions/mfa_use_recovery_code.html.erb diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 874fd6e9..7c4632bc 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -10,6 +10,11 @@ class MFAValidationError < StandardError; end class FinishWebauthnValidationParams < T::Struct const :publicKeyCredential, Hash end + + class FinishRecoverCodeValidationParams < T::Struct + const :recoveryCode, String + end + # before_action :configure_sign_in_params, only: [:create] # GET /resource/sign_in @@ -145,6 +150,53 @@ def finish_mfa_webauthn_validation end end + def mfa_use_recovery_code + # Get the user from the session or go bye bye + user_id = session[:mfa_validate_user] + raise MFAValidationError if user_id.nil? + + user = User.find(user_id) + raise MFAValidationError if user.nil? + rescue MFAValidationError + flash[:notice] = "You do not have access to the previous page" + redirect_to new_user_session_path + end + + def mfa_validate_recovery_code + typed_params = TypedParams[FinishRecoverCodeValidationParams].new.extract!(params) + + # Get the user from the session or go bye bye + user_id = session[:mfa_validate_user] + raise MFAValidationError if user_id.nil? + + user = User.find(user_id) + raise MFAValidationError if user.nil? + + # Validate the recovery code + raise MFAValidationError unless user.validate_recovery_code(typed_params.recoveryCode) + + sign_in(user) + + flash[:notice] = "You have successfully logged in with a recovery code, if you have lost your device please setup a new one in settings" + respond_to do |format| + format.json { render json: { authentication_status: "success" } } + end + rescue MFAValidationError + flash[:notice] = "Invalid recovery code." + respond_to do |format| + format.json { + render json: { + errorPartial: + render_to_string( + partial: "accounts/use_recovery_code_error", + formats: :html, + layout: false, + ) + } + } + end + end + # DELETE /resource/sign_out # def destroy # super diff --git a/app/javascript/controllers/webauthn_login_controller.js b/app/javascript/controllers/webauthn_login_controller.js index d6b94edb..0df9eb66 100644 --- a/app/javascript/controllers/webauthn_login_controller.js +++ b/app/javascript/controllers/webauthn_login_controller.js @@ -5,7 +5,7 @@ import Lottie from "lottie-web" export default class extends Controller { static values = {} - static targets = [ "output", "lock", "authenicateButton" ] + static targets = [ "output", "lock", "authenicateButton", "recoveryCode" ] async connect() { // Check if we're actually encrypting, if not, don't allow setup to continue. @@ -78,4 +78,35 @@ export default class extends Controller { this.lockTarget.firstChild.classList.replace("opacity-0", "opacity-100") } } + + async authenticateRecoveryCode() { + // Login with recovery code + // and then do the same animation as above + // and then redirect to the homepage + // or show an alert if it fails + // + // Are the recovery codes hashed in the database when saved? + // Yes + // hashed_keys = keys.map { |key| BCrypt::Password.create(key) } + + const response = await post("/users/sign_in/mfa/webauthn/use_recovery_code.json", { + body: { recoveryCode: this.recoveryCodeTarget.value }, + contentType: "application/json", + responseKind: "json" + }) + + const responseBody = await response.text + const bodyJson = JSON.parse(responseBody) + + if(bodyJson["authentication_status"] == "success") { + this.authenicateButtonTarget.textContent = "Logging In..." + this.lockAnimation.play() + await new Promise(r => setTimeout(r, 2000)) + Turbo.visit("/", { action: "replace" }) + } else { + this.lockTarget.innerHTML = bodyJson["errorPartial"] + this.lockTarget.innerHTML + this.lockTarget.firstChild.classList.add("transition-opacity", "duration-500", "ease-out") + this.lockTarget.firstChild.classList.replace("opacity-0", "opacity-100") + } + } } diff --git a/app/views/accounts/_use_recovery_code_error.html.erb b/app/views/accounts/_use_recovery_code_error.html.erb new file mode 100644 index 00000000..986b6e55 --- /dev/null +++ b/app/views/accounts/_use_recovery_code_error.html.erb @@ -0,0 +1,13 @@ + diff --git a/app/views/users/sessions/mfa_use_recovery_code.html.erb b/app/views/users/sessions/mfa_use_recovery_code.html.erb new file mode 100644 index 00000000..390561e9 --- /dev/null +++ b/app/views/users/sessions/mfa_use_recovery_code.html.erb @@ -0,0 +1,26 @@ +<% @title_tag = "Log in - Recovery Code" %> + +
+ +

+ To continue your log in, authenticate with a previously unused recovery code. +

+
+
+ + +
+

+ If you've lost your key or are on a different device, you can + <%= link_to "use a backup code here", mfa_use_recovery_code_path %> +

+
diff --git a/app/views/users/sessions/mfa_validation.html.erb b/app/views/users/sessions/mfa_validation.html.erb index 6f34b746..c895a7f1 100644 --- a/app/views/users/sessions/mfa_validation.html.erb +++ b/app/views/users/sessions/mfa_validation.html.erb @@ -16,6 +16,6 @@

If you've lost your key or are on a different device, you can - <%= link_to "use a backup code here", reset_password_path %> + <%= link_to "use a backup code here", mfa_use_recovery_code_path %>

diff --git a/config/routes.rb b/config/routes.rb index 44a9e331..4d77e769 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -25,6 +25,8 @@ get "/", to: "users/sessions#mfa_validation", as: "mfa_validation" get "/webauthn", to: "users/sessions#begin_mfa_webauthn_validation", as: "begin_mfa_webauthn_validation" post "/webauthn", to: "users/sessions#finish_mfa_webauthn_validation", as: "finish_mfa_webauthn_validation" + get "/webauthn/use_recovery_code", to: "users/sessions#mfa_use_recovery_code", as: "mfa_use_recovery_code" + post "/webauthn/use_recovery_code", to: "users/sessions#mfa_validate_recovery_code", as: "mfa_validate_recovery_code" end end From 57bb6eab3ca47aef1f20ed22d75eb22968537eb4 Mon Sep 17 00:00:00 2001 From: Christopher Guess Date: Fri, 5 May 2023 11:20:26 +0200 Subject: [PATCH 07/10] Add ROTP for TOTP --- Gemfile | 3 +++ Gemfile.lock | 2 ++ 2 files changed, 5 insertions(+) diff --git a/Gemfile b/Gemfile index ab731b2e..15ff4f8a 100644 --- a/Gemfile +++ b/Gemfile @@ -191,3 +191,6 @@ gem "comma", "~>4.7.0" # Webauthn Enabling gem "webauthn", git: "https://github.com/cedarcode/webauthn-ruby", tag: "v3.0.0.alpha2" + +# For TOTP one-time passcode (Firefox doesn't support passkeys) +gem "rotp", "~> 6.2" diff --git a/Gemfile.lock b/Gemfile.lock index 3ad03620..84dc73a9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -351,6 +351,7 @@ GEM netrc (~> 0.8) rexml (3.2.5) rolify (6.0.0) + rotp (6.2.2) rubocop (1.36.0) json (~> 2.3) parallel (~> 1.10) @@ -549,6 +550,7 @@ DEPENDENCIES rake redis (~> 4.0) rolify (~> 6.0) + rotp (~> 6.2) rubocop rubocop-minitest rubocop-performance From 4bfb14a4f55edf0d055cb149031f8e0bd7f2413f Mon Sep 17 00:00:00 2001 From: Christopher Guess Date: Wed, 10 May 2023 19:10:20 -0500 Subject: [PATCH 08/10] Add TOTP login and registration --- Gemfile | 2 + Gemfile.lock | 6 ++ app/controllers/accounts_controller.rb | 42 ++++++++++ app/controllers/application_controller.rb | 2 +- app/controllers/users/sessions_controller.rb | 44 ++++++++++- .../controllers/webauthn_controller.js | 76 ++++++++++++++----- .../controllers/webauthn_login_controller.js | 45 ++++++++--- app/models/user.rb | 35 +++++++++ app/views/accounts/_start_totp_setup.html.erb | 21 +++++ app/views/accounts/setup_mfa.html.erb | 17 +++-- .../sessions/_validate_mfa_totp.html.erb | 18 +++++ .../sessions/_validate_mfa_webauthn.html.erb | 9 +++ .../users/sessions/mfa_validation.html.erb | 15 +--- config/routes.rb | 3 + .../20230505092511_add_totp_secret_to_user.rb | 5 ++ .../20230510155606_add_totp_confirmed_flag.rb | 5 ++ db/schema.rb | 4 +- test/models/user_test.rb | 22 ++++++ 18 files changed, 318 insertions(+), 53 deletions(-) create mode 100644 app/views/accounts/_start_totp_setup.html.erb create mode 100644 app/views/users/sessions/_validate_mfa_totp.html.erb create mode 100644 app/views/users/sessions/_validate_mfa_webauthn.html.erb create mode 100644 db/migrate/20230505092511_add_totp_secret_to_user.rb create mode 100644 db/migrate/20230510155606_add_totp_confirmed_flag.rb diff --git a/Gemfile b/Gemfile index 15ff4f8a..1bede8bb 100644 --- a/Gemfile +++ b/Gemfile @@ -194,3 +194,5 @@ gem "webauthn", git: "https://github.com/cedarcode/webauthn-ruby", tag: "v3.0.0. # For TOTP one-time passcode (Firefox doesn't support passkeys) gem "rotp", "~> 6.2" +# For generating the QRCode from the TOTP setup string +gem "rqrcode", "~> 2.0" diff --git a/Gemfile.lock b/Gemfile.lock index 84dc73a9..0a5c8e5f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -154,6 +154,7 @@ GEM xpath (~> 3.2) cbor (0.5.9.6) childprocess (4.1.0) + chunky_png (1.4.0) climate_control (0.2.0) coderay (1.1.3) comma (4.7.0) @@ -352,6 +353,10 @@ GEM rexml (3.2.5) rolify (6.0.0) rotp (6.2.2) + rqrcode (2.1.2) + chunky_png (~> 1.0) + rqrcode_core (~> 1.0) + rqrcode_core (1.2.0) rubocop (1.36.0) json (~> 2.3) parallel (~> 1.10) @@ -551,6 +556,7 @@ DEPENDENCIES redis (~> 4.0) rolify (~> 6.0) rotp (~> 6.2) + rqrcode (~> 2.0) rubocop rubocop-minitest rubocop-performance diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 46406806..0f9b33a8 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -13,6 +13,9 @@ class InvalidUpdatePasswordError < StandardError; end :setup_mfa, :start_webauthn_setup, :finish_webauthn_setup, + :start_totp_setup, + :finish_totp_setup, + :clear_mfa, ] before_action :authenticate_user_and_setup!, except: [ @@ -23,6 +26,9 @@ class InvalidUpdatePasswordError < StandardError; end :setup_mfa, :start_webauthn_setup, :finish_webauthn_setup, + :start_totp_setup, + :finish_totp_setup, + :clear_mfa ] before_action :must_be_logged_out, only: [ @@ -124,6 +130,42 @@ def setup_mfa current_user.remove_role :new_user end + sig { void } + def start_totp_setup + totp_provisioning_uri = current_user.generate_totp_provisioning_uri + totp_qr = RQRCode::QRCode.new(totp_provisioning_uri) + + totp_qr_png = Base64.encode64(totp_qr.as_png(size: 400).to_blob) + + render json: { + partial: + render_to_string( + partial: "accounts/start_totp_setup", + formats: :html, + layout: false, + locals: { totp_qr_png: totp_qr_png } + ) + } + end + + sig { void } + def finish_totp_setup + if current_user.validate_totp_login_code(params[:totp_setup_code]) + current_user.update!({ totp_confirmed: true }) + render json: { registration_status: "success" } && return + end + + render json: { + errorPartial: + render_to_string( + partial: "accounts/setup_mfa_error", + formats: :html, + layout: false, + locals: { error: "Invalid TOTP authentication code. Please try again" } + ) + } + end + sig { void } def start_webauthn_setup if !current_user.webauthn_id diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a9ff5e2d..14679833 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -104,7 +104,7 @@ def must_be_logged_out sig { void } def must_have_mfa_setup - return if current_user.nil? || current_user.webauthn_credentials.count.positive? + return if current_user.nil? || current_user.mfa_enabled? redirect_to account_setup_mfa_path, allow_other_host: false, flash: { error: "You must setup two-factor authentication before continuing." } end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 7c4632bc..968c4b78 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -11,6 +11,10 @@ class FinishWebauthnValidationParams < T::Struct const :publicKeyCredential, Hash end + class FinishTotpValidationParams < T::Struct + const :totpCode, String + end + class FinishRecoverCodeValidationParams < T::Struct const :recoveryCode, String end @@ -31,7 +35,7 @@ def create warden.authenticate!(auth_options) if user.nil? || !user.valid_password?(params["user"][:password]) # If the user doesn't have MFA setup we flush them over to the setup - if user.webauthn_credentials.empty? + unless user.mfa_enabled? flash[:notice] = "You must setup MFA before you can login" # We have to log the user in before they set up MFA @@ -54,13 +58,46 @@ def mfa_validation user_id = session[:mfa_validate_user] raise MFAValidationError if user_id.nil? - user = User.find(user_id) - raise MFAValidationError if user.nil? + @user = User.find(user_id) + raise MFAValidationError if @user.nil? rescue MFAValidationError flash[:notice] = "You do not have access to the previous page" redirect_to new_user_session_path end + # POST /resource/sign_in/mfa/totp + def finish_mfa_totp_validation + # Get the user from the session or go bye bye + user_id = session[:mfa_validate_user] + raise MFAValidationError if user_id.nil? + + user = User.find(user_id) + raise MFAValidationError if user.nil? + + typed_params = TypedParams[FinishTotpValidationParams].new.extract!(params) + + raise MFAValidationError unless user.validate_totp_login_code(typed_params.totpCode) + sign_in(user) + respond_to do |format| + format.json { render json: { authentication_status: "success" } } + end + rescue MFAValidationError + # If validation failed + respond_to do |format| + format.json { + render json: { + errorPartial: + render_to_string( + partial: "accounts/setup_mfa_error", + formats: :html, + layout: false, + locals: { error: "Invalid or expired TOTP code, check your app and try again" } + ) + } + } + end + end + # GET /resource/sign_in/mfa/webauthn def begin_mfa_webauthn_validation # Get the user from the session or go bye bye @@ -74,7 +111,6 @@ def begin_mfa_webauthn_validation options = relying_party(request.referer).options_for_authentication( allow: user.webauthn_credentials.map { |c| c.external_id } ) - # options = { publicKey: options } # Something broken, but the JS side requires this key session[:authentication_challenge] = options.challenge diff --git a/app/javascript/controllers/webauthn_controller.js b/app/javascript/controllers/webauthn_controller.js index 000aef50..1f9edc3e 100644 --- a/app/javascript/controllers/webauthn_controller.js +++ b/app/javascript/controllers/webauthn_controller.js @@ -8,7 +8,7 @@ import Lottie from "lottie-web" export default class extends Controller { static values = { input: String } - static targets = [ "lock", "webauthnSetup", "totpSetup" ] + static targets = [ "lock", "webauthnContainer", "webauthnSetup", "totpSetup", "totpSetupCode" ] async connect() { // Check if we're actually encrypting, if not, don't allow setup to continue. @@ -48,18 +48,18 @@ export default class extends Controller { return } - const setup_response = await get("/setup_mfa/webauthn.json", { + const setupResponse = await get("/setup_mfa/webauthn.json", { contentType: "application/json", responseKind: "json" }) - if (!setup_response.ok) { + if (!setupResponse.ok) { alert("500 Error, please reload the page and try again") return } - const setup_response_body = await setup_response.text - const optionsJson = JSON.parse(setup_response_body) + const setupResponseBody = await setupResponse.text + const optionsJson = JSON.parse(setupResponseBody) const options = parseCreationOptionsFromJSON(optionsJson) let createResponse @@ -69,6 +69,7 @@ export default class extends Controller { console.log(error) } + // NOTE FIX THIS NICKNAME const finishWebauthnResponse = await post("/setup_mfa/webauthn.json", { body: { publicKeyCredential: createResponse, nickname: "stuffthings" }, contentType: "application/json", @@ -96,36 +97,75 @@ export default class extends Controller { } } + async beginTotpSetup() { + const setupResponse = await get("/setup_mfa/totp.json", { + contentType: "application/json", + responseKind: "json" + }) + + if (!setupResponse.ok) { + alert("500 Error, please reload the page and try again") + return + } + + const setupResponseBody = await setupResponse.text + const setupResponseJson = JSON.parse(setupResponseBody) + + + this.webauthnContainerTarget.innerHTML = setupResponseJson["partial"] + } + + async finishTotpSetup() { + const totpSetupCode = this.totpSetupCodeTarget.value + + if(!totpSetupCode.match(/^\d{6}$/)) { + alert("The TOTP code must be a six digit number only.") + } + + const setupResponse = await post("/setup_mfa/totp.json", { + body: { totp_setup_code: totpSetupCode }, + contentType: "application/json", + responseKind: "json" + }) + + if (!setupResponse.ok) { + alert("500 Error, please reload the page and try again") + return + } + + this.loadRecoveryCodes() + } + async loadRecoveryCodes() { - const setup_response = await get("/setup_mfa/webauthn/setup_recovery_codes.json", { + const setupResponse = await get("/setup_mfa/webauthn/setup_recovery_codes.json", { contentType: "application/json", responseKind: "json" }) - if (!setup_response.ok) { + if (!setupResponse.ok) { alert("500 Error, please reload the page and try again") return } - const setup_response_body = await setup_response.text - const setup_response_json = JSON.parse(setup_response_body) + const setupResponseBody = await setupResponse.text + const setupResponseJson = JSON.parse(setupResponseBody) - this.lockTarget.classList.add("transition-opacity", "duration-150", "ease-out", "opacity-0") - await new Promise(r => setTimeout(r, 500)) + if(this.hasLockTarget){ + this.lockTarget.classList.add("transition-opacity", "duration-150", "ease-out", "opacity-0") + await new Promise(r => setTimeout(r, 500)) - this.lockAnimation.destroy() + this.lockAnimation.destroy() + } - this.lockTarget.innerHTML = setup_response_json["recoveryCodePartial"] - this.lockTarget.classList.remove("setup_mfa--lock") - this.lockTarget.classList.replace("opacity-0", "opacity-100") + this.webauthnContainerTarget.innerHTML = setupResponseJson["recoveryCodePartial"] } finishSetup() { // We need to remove the codes from the DOM so the back button doesn't allow the codes to be // retrieved later on - this.lockTarget.classList.remove("opacity-100") - this.lockTarget.classList.add("transition-opacity", "duration-150", "ease-out", "opacity-0") - this.lockTarget.destroy + this.webauthnContainerTarget.classList.remove("opacity-100") + this.webauthnContainerTarget.classList.add("transition-opacity", "duration-150", "ease-out", "opacity-0") + this.webauthnContainerTarget.destroy Turbo.visit("/") } } diff --git a/app/javascript/controllers/webauthn_login_controller.js b/app/javascript/controllers/webauthn_login_controller.js index 0df9eb66..1be2e6fa 100644 --- a/app/javascript/controllers/webauthn_login_controller.js +++ b/app/javascript/controllers/webauthn_login_controller.js @@ -5,7 +5,7 @@ import Lottie from "lottie-web" export default class extends Controller { static values = {} - static targets = [ "output", "lock", "authenicateButton", "recoveryCode" ] + static targets = [ "output", "lock", "authenicateButton", "recoveryCode", "totpCode", "error" ] async connect() { // Check if we're actually encrypting, if not, don't allow setup to continue. @@ -24,15 +24,40 @@ export default class extends Controller { return } - this.lockAnimation = Lottie.loadAnimation({ - container: this.lockTarget, // the dom element that will contain the animation - renderer: 'svg', - autoplay: false, - loop: false, - path: '/lock-cpu-cyber-security.json' // the path to the animation json - }); - this.lockAnimation.setDirection(-1) - this.lockAnimation.goToAndStop(83, true) + if(this.hasLockTarget){ + this.lockAnimation = Lottie.loadAnimation({ + container: this.lockTarget, // the dom element that will contain the animation + renderer: 'svg', + autoplay: false, + loop: false, + path: '/lock-cpu-cyber-security.json' // the path to the animation json + }); + this.lockAnimation.setDirection(-1) + this.lockAnimation.goToAndStop(83, true) + } + } + + async authenticateTotp() { + const totpCode = this.totpCodeTarget.value + + if(!totpCode.match(/^\d{6}$/)) { + alert("The TOTP code must be a six digit number only.") + } + const finishWebauthnResponse = await post("/users/sign_in/mfa/totp.json", { + body: { totpCode: totpCode }, + contentType: "application/json", + responseKind: "json" + }) + + const finishWebauthnResponseBody = await finishWebauthnResponse.text + const finishedBodyJson = JSON.parse(finishWebauthnResponseBody) + + if(finishedBodyJson["authentication_status"] == "success") { + this.authenicateButtonTarget.textContent = "Logging In..." + Turbo.visit("/", { action: "replace" }) + } else { + this.errorTarget.innerHTML = finishedBodyJson["errorPartial"] + this.errorTarget.innerHTML + } } async authenticateWebauthn() { diff --git a/app/models/user.rb b/app/models/user.rb index 2c462094..287645e0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -142,6 +142,41 @@ def validate_recovery_code(recovery_code, invalidate_after_confirm: true) false # Return false since if we're here there's no comparison end + # Check if Webauthn or TOTP is enabled + sig { returns(T::Boolean) } + def mfa_enabled? + self.webauthn_credentials.count.positive? || self.totp_confirmed + end + + # Generate a TOTP provisioning uri, overwriting the old one if it's not empty + # This is generally implemented to support Firefox, though anyone can use it + # The returned URI should be rendered as a QRCode and scanned by the user's authenticator app + sig { returns(String) } + def generate_totp_provisioning_uri + # Prevent an attacker from overwriting a confirmed TOTP code + raise "TOTP already setup" if self.totp_confirmed == true + + self.update!({ totp_secret: ROTP::Base32.random_base32 }) + + totp = ROTP::TOTP.new(self.totp_secret, issuer: "Fact Check Insights\\Media Vault") + totp.provisioning_uri(self.email) + end + + def clear_totp_secret + self.update!({ totp_secret: nil, totp_confirmed: false }) + end + + # Validate a TOTP code, returning true or false + sig { params(totp_code: String).returns(T::Boolean) } + def validate_totp_login_code(totp_code) + return false if self.totp_secret.nil? # If we're not set up just always reject it all + + totp = ROTP::TOTP.new(self.totp_secret) + verify_result = totp.verify(totp_code, drift_behind: 15, at: Time.now) + + !verify_result.nil? + end + sig { returns(T::Boolean) } def can_access_fact_check_insights? self.is_admin? || self.is_fact_check_insights_user? diff --git a/app/views/accounts/_start_totp_setup.html.erb b/app/views/accounts/_start_totp_setup.html.erb new file mode 100644 index 00000000..071c013a --- /dev/null +++ b/app/views/accounts/_start_totp_setup.html.erb @@ -0,0 +1,21 @@ +
+
+

TOTP Setup

+

+ If you are using a browser that does not support Webauthn (mostly just Firefox) you can choose to use an authenticator app to generate a 2 factor authentication code for you instead. ***FILLER TEXT FOR DESCRIBING THE APPS*** +

+
+
+ +
+ +
+ +
+
+ +
+
+ +
+
diff --git a/app/views/accounts/setup_mfa.html.erb b/app/views/accounts/setup_mfa.html.erb index 9482027a..158d8ca7 100644 --- a/app/views/accounts/setup_mfa.html.erb +++ b/app/views/accounts/setup_mfa.html.erb @@ -18,14 +18,15 @@ Authenticator or Authy though we discourage this unless there's no other option. We do not support SMS-based two-factor authentication as it is inherently unencrypted and insecure.

-
-
-
- +
+
+
+
+ +
+
+ +
-
diff --git a/app/views/users/sessions/_validate_mfa_totp.html.erb b/app/views/users/sessions/_validate_mfa_totp.html.erb new file mode 100644 index 00000000..a7eb5207 --- /dev/null +++ b/app/views/users/sessions/_validate_mfa_totp.html.erb @@ -0,0 +1,18 @@ +
+
+ + Enter the six digit code from your authenticator app below +
+
+ +
+
+ +
+
+ diff --git a/app/views/users/sessions/_validate_mfa_webauthn.html.erb b/app/views/users/sessions/_validate_mfa_webauthn.html.erb new file mode 100644 index 00000000..6509d4be --- /dev/null +++ b/app/views/users/sessions/_validate_mfa_webauthn.html.erb @@ -0,0 +1,9 @@ +
+
+ +
diff --git a/app/views/users/sessions/mfa_validation.html.erb b/app/views/users/sessions/mfa_validation.html.erb index c895a7f1..db0e00c4 100644 --- a/app/views/users/sessions/mfa_validation.html.erb +++ b/app/views/users/sessions/mfa_validation.html.erb @@ -3,19 +3,12 @@

- To continue your log in, authenticate with a previously registered two factor authentication key. + To continue your log in, authenticate with your previously registered two factor authentication method.

-
-
- -
+ <%= render "validate_mfa_webauthn" if @user.webauthn_credentials.count.positive? %> + <%= render "validate_mfa_totp" if @user.totp_confirmed %>

- If you've lost your key or are on a different device, you can + If you've lost your key or your device is unavailable, you can <%= link_to "use a backup code here", mfa_use_recovery_code_path %>

diff --git a/config/routes.rb b/config/routes.rb index 4d77e769..714f5239 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -25,6 +25,7 @@ get "/", to: "users/sessions#mfa_validation", as: "mfa_validation" get "/webauthn", to: "users/sessions#begin_mfa_webauthn_validation", as: "begin_mfa_webauthn_validation" post "/webauthn", to: "users/sessions#finish_mfa_webauthn_validation", as: "finish_mfa_webauthn_validation" + post "/totp", to: "users/sessions#finish_mfa_totp_validation", as: "finish_mfa_totp_validation" get "/webauthn/use_recovery_code", to: "users/sessions#mfa_use_recovery_code", as: "mfa_use_recovery_code" post "/webauthn/use_recovery_code", to: "users/sessions#mfa_validate_recovery_code", as: "mfa_validate_recovery_code" end @@ -117,6 +118,8 @@ get "/webauthn", to: "accounts#start_webauthn_setup", as: "account_start_webauthn_setup" post "/webauthn", to: "accounts#finish_webauthn_setup", as: "account_finish_webauthn_setup" get "/webauthn/setup_recovery_codes", to: "accounts#setup_recovery_codes", as: "account_setup_recovery_codes" + get "/totp", to: "accounts#start_totp_setup", as: "account_start_totp_setup" + post "/totp", to: "accounts#finish_totp_setup", as: "account_finish_totp_setup" end get "/account/reset_password", to: "accounts#reset_password", as: "reset_password" diff --git a/db/migrate/20230505092511_add_totp_secret_to_user.rb b/db/migrate/20230505092511_add_totp_secret_to_user.rb new file mode 100644 index 00000000..897403f2 --- /dev/null +++ b/db/migrate/20230505092511_add_totp_secret_to_user.rb @@ -0,0 +1,5 @@ +class AddTotpSecretToUser < ActiveRecord::Migration[7.0] + def change + add_column :users, :totp_secret, :string + end +end diff --git a/db/migrate/20230510155606_add_totp_confirmed_flag.rb b/db/migrate/20230510155606_add_totp_confirmed_flag.rb new file mode 100644 index 00000000..b769887c --- /dev/null +++ b/db/migrate/20230510155606_add_totp_confirmed_flag.rb @@ -0,0 +1,5 @@ +class AddTotpConfirmedFlag < ActiveRecord::Migration[7.0] + def change + add_column :users, :totp_confirmed, :boolean, default: false, nil: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 24955d79..1dda1763 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_01_25_061836) do +ActiveRecord::Schema[7.0].define(version: 2023_05_10_155606) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -321,6 +321,8 @@ t.string "name", null: false t.string "webauthn_id" t.string "hashed_recovery_codes", default: [], null: false, array: true + t.string "totp_secret" + t.boolean "totp_confirmed", default: false t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 1aa75772..0a85698f 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -167,4 +167,26 @@ class UserTest < ActiveSupport::TestCase # Should run similarly assert second_validate_time - first_validate_time < 0.4 end + + test "can generate a provisioning code for a mobile device for a user" do + user = users(:fact_check_insights_user) + uri = user.generate_totp_provisioning_uri + + assert_not_nil user.totp_secret + assert_not_nil uri + end + + test "can validate a totp login code for a user" do + user = users(:fact_check_insights_user) + user.generate_totp_provisioning_uri + + assert user.validate_totp_login_code("123456") # TODO: Make this a real code somehow + end + + test "can de-validate a wrong totp login code for a user" do + user = users(:fact_check_insights_user) + user.generate_totp_provisioning_uri + + assert_equal false, user.validate_totp_login_code("123456") + end end From e3726cc7d3d5adfef5401472eedaf8b33055d072 Mon Sep 17 00:00:00 2001 From: Christopher Guess Date: Thu, 11 May 2023 18:53:00 -0400 Subject: [PATCH 09/10] Add naming of mfa devices --- app/assets/stylesheets/account.scss | 13 ++++ app/controllers/accounts_controller.rb | 67 ++++++++++++++++- .../controllers/webauthn_controller.js | 16 +++- app/views/accounts/_manage_mfa.html.erb | 73 +++++++++++++++++++ app/views/accounts/index.html.erb | 7 +- app/views/accounts/setup_mfa.html.erb | 10 ++- config/routes.rb | 19 +++-- 7 files changed, 193 insertions(+), 12 deletions(-) create mode 100644 app/views/accounts/_manage_mfa.html.erb diff --git a/app/assets/stylesheets/account.scss b/app/assets/stylesheets/account.scss index e31a53d9..92b31984 100644 --- a/app/assets/stylesheets/account.scss +++ b/app/assets/stylesheets/account.scss @@ -13,6 +13,7 @@ Used for boxes on the account page. Similar to .box in boxes.scss, but has only margin-bottom: var(--box--external-spacing); max-width: shared.$screen-2xs-ceiling; + height: fit-content; background-color: white; border: 1px solid lightgray; box-shadow: shared.$shadow--3; @@ -20,6 +21,10 @@ Used for boxes on the account page. Similar to .box in boxes.scss, but has only transition-property: margin, padding; } +.account-page__box--double-width { + max-width: shared.$screen-sm-ceiling; +} + /* A flexbox class for divs holding settings boxes */ @@ -47,6 +52,14 @@ Account page settings boxes are segmented 50/50 between headings and forms height: 50% } +.account-page__box--settings__list { + // height: 50% +} + +.account-page__box--settings__list--section { + margin-bottom: 20px; +} + /* Account page navigation sidebar To be restyled in https://github.com/TechAndCheck/zenodotus/issues/506 */ diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 0f9b33a8..dee8b433 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -55,6 +55,10 @@ class ChangeEmailParams < T::Struct const :email_confirmation, String end + class DeleteMFADeviceParams < T::Struct + const :device_id, String + end + class DestroyAccountParams < T::Struct const :password_for_deletion, String end @@ -293,6 +297,16 @@ def setup_recovery_codes locals: { recovery_codes: recovery_codes } ) } + rescue StandardError => e + render json: { + errorPartial: + render_to_string( + partial: "accounts/setup_mfa_error", + formats: :html, + layout: false, + locals: { error: e } + ) + } end sig { void } @@ -352,6 +366,57 @@ def change_email end end + sig { void } + def destroy_mfa_device + typed_params = TypedParams[DeleteMFADeviceParams].new.extract!(params) + + # Verify that they're not deleting the last device + if current_user.webauthn_credentials.count == 1 && current_user.totp_confirmed == false + flash[:error] = "You cannot delete your last MFA device. Please add another before deleting this one." + respond_to do |format| + format.turbo_stream { render turbo_stream: [ + turbo_stream.replace("flash", partial: "layouts/flashes/turbo_flashes", locals: { flash: flash }) + ]} + end + return + end + + current_user.webauthn_credentials.find_by(id: typed_params.device_id).destroy + flash[:alert] = "MFA device deleted." + + respond_to do |format| + format.turbo_stream { render turbo_stream: [ + turbo_stream.replace("flash", partial: "layouts/flashes/turbo_flashes", locals: { flash: flash }), + turbo_stream.replace("manage_mfa", partial: "accounts/manage_mfa") + ] } + end + end + + sig { void } + def destroy_totp_device + # Verify that they're not deleting the last device + if current_user.webauthn_credentials.count.zero? && current_user.totp_confirmed == true + flash[:error] = "You cannot delete your last MFA device. Please add another before deleting this one." + respond_to do |format| + format.turbo_stream { render turbo_stream: [ + turbo_stream.replace("flash", partial: "layouts/flashes/turbo_flashes", locals: { flash: flash }) + ]} + end + return + end + + current_user.clear_totp_secret + flash[:alert] = "MFA device deleted." + + respond_to do |format| + format.turbo_stream { render turbo_stream: [ + turbo_stream.replace("flash", partial: "layouts/flashes/turbo_flashes", locals: { flash: flash }), + turbo_stream.replace("manage_mfa", partial: "accounts/manage_mfa") + ] } + end + end + + sig { void } def destroy_account typed_params = TypedParams[DestroyAccountParams].new.extract!(params) @@ -365,7 +430,7 @@ def destroy_account respond_to do |format| format.turbo_stream { render turbo_stream: [ turbo_stream.replace("flash", partial: "layouts/flashes/turbo_flashes", locals: { flash: flash }), - ] } + ]} end end end diff --git a/app/javascript/controllers/webauthn_controller.js b/app/javascript/controllers/webauthn_controller.js index 1f9edc3e..1f94ea0e 100644 --- a/app/javascript/controllers/webauthn_controller.js +++ b/app/javascript/controllers/webauthn_controller.js @@ -8,7 +8,7 @@ import Lottie from "lottie-web" export default class extends Controller { static values = { input: String } - static targets = [ "lock", "webauthnContainer", "webauthnSetup", "totpSetup", "totpSetupCode" ] + static targets = [ "lock", "webauthnContainer", "webauthnSetup", "nickname", "totpSetup", "totpSetupCode" ] async connect() { // Check if we're actually encrypting, if not, don't allow setup to continue. @@ -69,9 +69,15 @@ export default class extends Controller { console.log(error) } + // Get nickname, giving a random one if blank + let nickname = this.nicknameTarget.value + if(nickname.length == 0){ + nickname = "webauthn_" + Math.floor(Math.random() * 500) + } + // NOTE FIX THIS NICKNAME const finishWebauthnResponse = await post("/setup_mfa/webauthn.json", { - body: { publicKeyCredential: createResponse, nickname: "stuffthings" }, + body: { publicKeyCredential: createResponse, nickname: nickname }, contentType: "application/json", responseKind: "json" }) @@ -157,6 +163,12 @@ export default class extends Controller { this.lockAnimation.destroy() } + // If there's an error returned we just skipped past the recovery section and move on + if(setupResponseJson["errorPartial"] != undefined) { + this.finishSetup() + return + } + this.webauthnContainerTarget.innerHTML = setupResponseJson["recoveryCodePartial"] } diff --git a/app/views/accounts/_manage_mfa.html.erb b/app/views/accounts/_manage_mfa.html.erb new file mode 100644 index 00000000..db0b0575 --- /dev/null +++ b/app/views/accounts/_manage_mfa.html.erb @@ -0,0 +1,73 @@ + + diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 211c474b..4b1b8c2a 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -10,8 +10,13 @@ +
+
+ Verify a code from your authenticator app in the text box
below to confirm everything's working. +
- +