diff --git a/.rubocop.yml b/.rubocop.yml index 9f0d8164..9d2b7e23 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -230,7 +230,7 @@ Rails/I18nLocaleAssignment: Enabled: true Rails/I18nLocaleTexts: - Enabled: true + Enabled: false Rails/IgnoredColumnsAssignment: Enabled: true diff --git a/Gemfile b/Gemfile index 68b786cb..726264c5 100644 --- a/Gemfile +++ b/Gemfile @@ -9,6 +9,7 @@ gem 'bootsnap', '~> 1.18', require: false gem 'importmap-rails', '~> 2.2' gem 'propshaft', '~> 1.2' gem 'puma', '~> 7.0' +gem 'requestjs-rails' gem "rollbar", "~> 3.6" gem 'sqlite3', '>= 1.4' gem 'stimulus-rails', '~> 1.3' diff --git a/Gemfile.lock b/Gemfile.lock index d0b97fe9..873420ae 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -246,6 +246,8 @@ GEM regexp_parser (2.11.2) reline (0.6.2) io-console (~> 0.5) + requestjs-rails (0.0.13) + railties (>= 7.1.0) rexml (3.4.1) rollbar (3.6.2) rubocop (1.80.2) @@ -342,6 +344,7 @@ DEPENDENCIES puma (~> 7.0) rack-mini-profiler (~> 4.0) rails (~> 8.0.1) + requestjs-rails rollbar (~> 3.6) rubocop (~> 1.80) rubocop-rails (~> 2.33) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 56a08c7b..6f9a9bd4 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -32,6 +32,7 @@ body { .center { display: flex; + justify-content: center; } .center input { diff --git a/app/controllers/credentials_controller.rb b/app/controllers/credentials_controller.rb index b86d607c..6d2ce38e 100644 --- a/app/controllers/credentials_controller.rb +++ b/app/controllers/credentials_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class CredentialsController < ApplicationController - def create + def options create_options = WebAuthn::Credential.options_for_create( user: { id: current_user.webauthn_id, @@ -18,8 +18,8 @@ def create end end - def callback - webauthn_credential = WebAuthn::Credential.from_create(params) + def create + webauthn_credential = WebAuthn::Credential.from_create(JSON.parse(credential_params[:public_key_credential])) begin webauthn_credential.verify(session[:current_registration]["challenge"], user_verification: true) @@ -29,16 +29,18 @@ def callback ) if credential.update( - nickname: params[:credential_nickname], + nickname: credential_params[:nickname], public_key: webauthn_credential.public_key, sign_count: webauthn_credential.sign_count ) - render json: { status: "ok" }, status: :ok + render json: { message: "Security Key registered successfully", redirect_to: root_path }, status: :ok else - render json: "Couldn't add your Security Key", status: :unprocessable_content + render json: { message: "Couldn't add your Security Key", redirect_to: credentials_path }, + status: :unprocessable_content end rescue WebAuthn::Error => e - render json: "Verification failed: #{e.message}", status: :unprocessable_content + render json: { message: "Verification failed: #{e.message}", redirect_to: credentials_path }, + status: :unprocessable_content ensure session.delete(:current_registration) end @@ -51,4 +53,8 @@ def destroy redirect_to root_path end + + def credential_params + params.expect(credential: [:public_key_credential, :nickname]) + end end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 9a73df62..9cd9d8c3 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -4,12 +4,12 @@ class RegistrationsController < ApplicationController def new end - def create - user = User.new(username: params[:registration][:username]) + def options + user = User.new(username: registration_params[:username]) create_options = WebAuthn::Credential.options_for_create( user: { - name: params[:registration][:username], + name: registration_params[:username], id: user.webauthn_id }, authenticator_selection: { user_verification: "required" } @@ -28,8 +28,8 @@ def create end end - def callback - webauthn_credential = WebAuthn::Credential.from_create(params) + def create + webauthn_credential = WebAuthn::Credential.from_create(JSON.parse(registration_params[:public_key_credential])) user = User.new(session[:current_registration]["user_attributes"]) @@ -38,7 +38,7 @@ def callback user.credentials.build( external_id: webauthn_credential.id, - nickname: params[:credential_nickname], + nickname: registration_params[:nickname], public_key: webauthn_credential.public_key, sign_count: webauthn_credential.sign_count ) @@ -46,14 +46,21 @@ def callback if user.save sign_in(user) - render json: { status: "ok" }, status: :ok + render json: { message: "Security Key registered successfully", redirect_to: root_path }, + status: :ok else - render json: "Couldn't register your Security Key", status: :unprocessable_content + render json: { message: "Couldn't register your Security Key", redirect_to: registration_path }, + status: :unprocessable_content end rescue WebAuthn::Error => e - render json: "Verification failed: #{e.message}", status: :unprocessable_content + render json: { message: "Verification failed: #{e.message}", redirect_to: registration_path }, + status: :unprocessable_content ensure session.delete(:current_registration) end end + + def registration_params + params.expect(registration: [:username, :nickname, :public_key_credential]) + end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 33f04050..b1aa9bfc 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -4,7 +4,7 @@ class SessionsController < ApplicationController def new end - def create + def options user = User.find_by(username: session_params[:username]) if user @@ -25,8 +25,8 @@ def create end end - def callback - webauthn_credential = WebAuthn::Credential.from_get(params) + def create + webauthn_credential = WebAuthn::Credential.from_get(JSON.parse(session_params[:public_key_credential])) user = User.find_by(username: session[:current_authentication]["username"]) raise "user #{session[:current_authentication]["username"]} never initiated sign up" unless user @@ -44,9 +44,10 @@ def callback credential.update!(sign_count: webauthn_credential.sign_count) sign_in(user) - render json: { status: "ok" }, status: :ok + render json: { message: "Security Key authenticated successfully", redirect_to: root_path }, status: :ok rescue WebAuthn::Error => e - render json: "Verification failed: #{e.message}", status: :unprocessable_content + render json: { message: "Verification failed: #{e.message}", redirect_to: session_path }, + status: :unprocessable_content ensure session.delete(:current_authentication) end @@ -61,6 +62,6 @@ def destroy private def session_params - params.require(:session).permit(:username) + params.expect(session: [:username, :public_key_credential]) end end diff --git a/app/javascript/application.js b/app/javascript/application.js index 81a77f89..34e73fc8 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,7 +1,7 @@ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails import "controllers" -import "credential" import "messenger" import Rails from "@rails/ujs"; +import "@rails/request.js" Rails.start(); diff --git a/app/javascript/controllers/add_credential_controller.js b/app/javascript/controllers/add_credential_controller.js deleted file mode 100644 index d6bdf334..00000000 --- a/app/javascript/controllers/add_credential_controller.js +++ /dev/null @@ -1,13 +0,0 @@ -import { Controller } from "@hotwired/stimulus" -import * as Credential from "credential"; - -export default class extends Controller { - create(event) { - var [data, status, xhr] = event.detail; - var credentialOptions = data; - var credential_nickname = event.target.querySelector("input[name='credential[nickname]']").value; - var callback_url = `/credentials/callback?credential_nickname=${credential_nickname}` - - Credential.create(encodeURI(callback_url), credentialOptions); - } -} diff --git a/app/javascript/controllers/new_registration_controller.js b/app/javascript/controllers/new_registration_controller.js deleted file mode 100644 index 95f186c1..00000000 --- a/app/javascript/controllers/new_registration_controller.js +++ /dev/null @@ -1,29 +0,0 @@ -import { Controller } from "@hotwired/stimulus" -import * as Credential from "credential"; - -import { MDCTextField } from '@material/textfield'; - -export default class extends Controller { - static targets = ["usernameField"] - - create(event) { - var [data, status, xhr] = event.detail; - console.log(data); - var credentialOptions = data; - - // Registration - if (credentialOptions["user"]) { - var credential_nickname = event.target.querySelector("input[name='registration[nickname]']").value; - var callback_url = `/registration/callback?credential_nickname=${credential_nickname}` - - Credential.create(encodeURI(callback_url), credentialOptions); - } - } - - error(event) { - let response = event.detail[0]; - let usernameField = new MDCTextField(this.usernameFieldTarget); - usernameField.valid = false; - usernameField.helperTextContent = response["errors"][0]; - } -} diff --git a/app/javascript/controllers/new_session_controller.js b/app/javascript/controllers/new_session_controller.js deleted file mode 100644 index ddaa9aac..00000000 --- a/app/javascript/controllers/new_session_controller.js +++ /dev/null @@ -1,22 +0,0 @@ -import { Controller } from "@hotwired/stimulus" -import * as Credential from "credential"; - -import { MDCTextField } from '@material/textfield'; - -export default class extends Controller { - static targets = ["usernameField"] - - create(event) { - var [data, status, xhr] = event.detail; - console.log(data); - var credentialOptions = data; - Credential.get(credentialOptions); - } - - error(event) { - let response = event.detail[0]; - let usernameField = new MDCTextField(this.usernameFieldTarget); - usernameField.valid = false; - usernameField.helperTextContent = response["errors"][0]; - } -} diff --git a/app/javascript/controllers/webauthn_credential_controller.js b/app/javascript/controllers/webauthn_credential_controller.js new file mode 100644 index 00000000..8981f990 --- /dev/null +++ b/app/javascript/controllers/webauthn_credential_controller.js @@ -0,0 +1,79 @@ +import { Controller } from "@hotwired/stimulus" +import { showMessage } from "messenger"; + +export default class extends Controller { + static targets = ["hiddenCredentialInput", "submitButton"] + static values = { optionsUrl: String, submitUrl: String } + + async create() { + try { + const response = await fetch(this.optionsUrlValue, { + method: "POST", + body: new FormData(this.element), + }); + + const credentialOptionsJson = await response.json(); + console.log(credentialOptionsJson); + + if (response.ok) { + console.log("Creating new public key credential..."); + + const credential = await navigator.credentials.create({ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(credentialOptionsJson) }); + this.hiddenCredentialInputTarget.value = JSON.stringify(credential); + + const submitResponse = await fetch(this.submitUrlValue, { + method: "POST", + body: new FormData(this.element), + }); + + const submitResponseJson = await submitResponse.json(); + + const { redirect_to } = submitResponseJson; + + window.location.replace(redirect_to || "/"); + } else { + showMessage(credentialOptionsJson.errors?.[0] || "Sorry, something wrong happened."); + this.submitButtonTarget.disabled = false; + } + } catch (error) { + showMessage(error.message || "Sorry, something wrong happened."); + this.submitButtonTarget.disabled = false; + } + } + + async get() { + try { + const response = await fetch(this.optionsUrlValue, { + method: "POST", + body: new FormData(this.element), + }); + + const credentialOptionsJson = await response.json(); + console.log(credentialOptionsJson); + + if (response.ok) { + console.log("Getting public key credential..."); + + const credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(credentialOptionsJson) }) + this.hiddenCredentialInputTarget.value = JSON.stringify(credential); + + const submitResponse = await fetch(this.submitUrlValue, { + method: "POST", + body: new FormData(this.element), + }); + + const submitResponseJson = await submitResponse.json(); + + const { redirect_to } = submitResponseJson; + + window.location.replace(redirect_to || "/"); + } else { + showMessage(credentialOptionsJson.errors?.[0] || "Sorry, something wrong happened."); + this.submitButtonTarget.disabled = false; + } + } catch (error) { + showMessage(error.message || "Sorry, something wrong happened."); + this.submitButtonTarget.disabled = false; + } + } +} diff --git a/app/javascript/credential.js b/app/javascript/credential.js deleted file mode 100644 index d2d63099..00000000 --- a/app/javascript/credential.js +++ /dev/null @@ -1,57 +0,0 @@ -import { showMessage } from "messenger"; - -function getCSRFToken() { - var CSRFSelector = document.querySelector('meta[name="csrf-token"]') - if (CSRFSelector) { - return CSRFSelector.getAttribute("content") - } else { - return null - } -} - -function callback(url, body) { - fetch(url, { - method: "POST", - body: JSON.stringify(body), - headers: { - "Content-Type": "application/json", - "Accept": "application/json", - "X-CSRF-Token": getCSRFToken() - }, - credentials: 'same-origin' - }).then(function(response) { - if (response.ok) { - window.location.replace("/") - } else if (response.status < 500) { - response.text().then(showMessage); - } else { - showMessage("Sorry, something wrong happened."); - } - }); -} - -function create(callbackUrl, data) { - const credentialOptions = PublicKeyCredential.parseCreationOptionsFromJSON(data); - - navigator.credentials.create({ "publicKey": credentialOptions }).then(function(credential) { - callback(callbackUrl, credential); - }).catch(function(error) { - showMessage(error); - }); - - console.log("Creating new public key credential..."); -} - -function get(data) { - const credentialOptions = PublicKeyCredential.parseRequestOptionsFromJSON(data); - - navigator.credentials.get({ "publicKey": credentialOptions }).then(function(credential) { - callback("/session/callback", credential); - }).catch(function(error) { - showMessage(error); - }); - - console.log("Getting public key credential..."); -} - -export { create, get } diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index 868a3f95..cd6a5f58 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -22,17 +22,26 @@ <% end %> -