diff options
Diffstat (limited to 'app')
29 files changed, 793 insertions, 113 deletions
diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 1fd755334..c1ea702ad 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -37,6 +37,22 @@ class Auth::SessionsController < Devise::SessionsController store_location_for(:user, tmp_stored_location) if continue_after? end + def webauthn_options + user = find_user + + if user.webauthn_enabled? + options_for_get = WebAuthn::Credential.options_for_get( + allow: user.webauthn_credentials.pluck(:external_id) + ) + + session[:webauthn_challenge] = options_for_get.challenge + + render json: options_for_get, status: :ok + else + render json: { error: t('webauthn_credentials.not_enabled') }, status: :unauthorized + end + end + protected def find_user @@ -51,7 +67,7 @@ class Auth::SessionsController < Devise::SessionsController end def user_params - params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt) + params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt, credential: {}) end def after_sign_in_path_for(resource) diff --git a/app/controllers/concerns/two_factor_authentication_concern.rb b/app/controllers/concerns/two_factor_authentication_concern.rb index daafe56f4..8a2a86a02 100644 --- a/app/controllers/concerns/two_factor_authentication_concern.rb +++ b/app/controllers/concerns/two_factor_authentication_concern.rb @@ -8,7 +8,23 @@ module TwoFactorAuthenticationConcern end def two_factor_enabled? - find_user&.otp_required_for_login? + find_user&.two_factor_enabled? + end + + def valid_webauthn_credential?(user, webauthn_credential) + user_credential = user.webauthn_credentials.find_by!(external_id: webauthn_credential.id) + + begin + webauthn_credential.verify( + session[:webauthn_challenge], + public_key: user_credential.public_key, + sign_count: user_credential.sign_count + ) + + user_credential.update!(sign_count: webauthn_credential.sign_count) + rescue WebAuthn::Error + false + end end def valid_otp_attempt?(user) @@ -21,14 +37,29 @@ module TwoFactorAuthenticationConcern def authenticate_with_two_factor user = self.resource = find_user - if user_params[:otp_attempt].present? && session[:attempt_user_id] - authenticate_with_two_factor_attempt(user) + if user.webauthn_enabled? && user_params[:credential].present? && session[:attempt_user_id] + authenticate_with_two_factor_via_webauthn(user) + elsif user_params[:otp_attempt].present? && session[:attempt_user_id] + authenticate_with_two_factor_via_otp(user) elsif user.present? && user.external_or_valid_password?(user_params[:password]) prompt_for_two_factor(user) end end - def authenticate_with_two_factor_attempt(user) + def authenticate_with_two_factor_via_webauthn(user) + webauthn_credential = WebAuthn::Credential.from_get(user_params[:credential]) + + if valid_webauthn_credential?(user, webauthn_credential) + session.delete(:attempt_user_id) + remember_me(user) + sign_in(user) + render json: { redirect_path: root_path }, status: :ok + else + render json: { error: t('webauthn_credentials.invalid_credential') }, status: :unprocessable_entity + end + end + + def authenticate_with_two_factor_via_otp(user) if valid_otp_attempt?(user) session.delete(:attempt_user_id) remember_me(user) @@ -43,6 +74,12 @@ module TwoFactorAuthenticationConcern set_locale do session[:attempt_user_id] = user.id @body_classes = 'lighter' + @webauthn_enabled = user.webauthn_enabled? + @scheme_type = if user.webauthn_enabled? && user_params[:otp_attempt].blank? + 'webauthn' + else + 'totp' + end render :two_factor end end diff --git a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb index ef4df3339..9f23011a7 100644 --- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb +++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb @@ -18,18 +18,21 @@ module Settings end def create - if current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) + if current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt], otp_secret: session[:new_otp_secret]) flash.now[:notice] = I18n.t('two_factor_authentication.enabled_success') current_user.otp_required_for_login = true + current_user.otp_secret = session[:new_otp_secret] @recovery_codes = current_user.generate_otp_backup_codes! current_user.save! UserMailer.two_factor_enabled(current_user).deliver_later! + session.delete(:new_otp_secret) + render 'settings/two_factor_authentication/recovery_codes/index' else - flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code') + flash.now[:alert] = I18n.t('otp_authentication.wrong_code') prepare_two_factor_form render :new end @@ -43,12 +46,15 @@ module Settings def prepare_two_factor_form @confirmation = Form::TwoFactorConfirmation.new - @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Rails.configuration.x.local_domain) + @new_otp_secret = session[:new_otp_secret] + @provision_url = current_user.otp_provisioning_uri(current_user.email, + otp_secret: @new_otp_secret, + issuer: Rails.configuration.x.local_domain) @qrcode = RQRCode::QRCode.new(@provision_url) end def ensure_otp_secret - redirect_to settings_two_factor_authentication_path unless current_user.otp_secret + redirect_to settings_otp_authentication_path if session[:new_otp_secret].blank? end end end diff --git a/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb new file mode 100644 index 000000000..6836f7ef6 --- /dev/null +++ b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Settings + module TwoFactorAuthentication + class OtpAuthenticationController < BaseController + include ChallengableConcern + + layout 'admin' + + before_action :authenticate_user! + before_action :verify_otp_not_enabled, only: [:show] + before_action :require_challenge!, only: [:create] + + skip_before_action :require_functional! + + def show + @confirmation = Form::TwoFactorConfirmation.new + end + + def create + session[:new_otp_secret] = User.generate_otp_secret(32) + + redirect_to new_settings_two_factor_authentication_confirmation_path + end + + private + + def confirmation_params + params.require(:form_two_factor_confirmation).permit(:otp_attempt) + end + + def verify_otp_not_enabled + redirect_to settings_two_factor_authentication_methods_path if current_user.otp_enabled? + end + + def acceptable_code? + current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) || + current_user.invalidate_otp_backup_code!(confirmation_params[:otp_attempt]) + end + end + end +end diff --git a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb new file mode 100644 index 000000000..a19c604f3 --- /dev/null +++ b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Settings + module TwoFactorAuthentication + class WebauthnCredentialsController < BaseController + layout 'admin' + + before_action :authenticate_user! + before_action :require_otp_enabled + before_action :require_webauthn_enabled, only: [:index, :destroy] + + def new; end + + def index; end + + def options + current_user.update(webauthn_id: WebAuthn.generate_user_id) unless current_user.webauthn_id + + options_for_create = WebAuthn::Credential.options_for_create( + user: { + name: current_user.account.username, + display_name: current_user.account.username, + id: current_user.webauthn_id, + }, + exclude: current_user.webauthn_credentials.pluck(:external_id) + ) + + session[:webauthn_challenge] = options_for_create.challenge + + render json: options_for_create, status: :ok + end + + def create + webauthn_credential = WebAuthn::Credential.from_create(params[:credential]) + + if webauthn_credential.verify(session[:webauthn_challenge]) + user_credential = current_user.webauthn_credentials.build( + external_id: webauthn_credential.id, + public_key: webauthn_credential.public_key, + nickname: params[:nickname], + sign_count: webauthn_credential.sign_count + ) + + if user_credential.save + flash[:success] = I18n.t('webauthn_credentials.create.success') + status = :ok + + if current_user.webauthn_credentials.size == 1 + UserMailer.webauthn_enabled(current_user).deliver_later! + else + UserMailer.webauthn_credential_added(current_user, user_credential).deliver_later! + end + else + flash[:error] = I18n.t('webauthn_credentials.create.error') + status = :internal_server_error + end + else + flash[:error] = t('webauthn_credentials.create.error') + status = :unauthorized + end + + render json: { redirect_path: settings_two_factor_authentication_methods_path }, status: status + end + + def destroy + credential = current_user.webauthn_credentials.find_by(id: params[:id]) + if credential + credential.destroy + if credential.destroyed? + flash[:success] = I18n.t('webauthn_credentials.destroy.success') + + if current_user.webauthn_credentials.empty? + UserMailer.webauthn_disabled(current_user).deliver_later! + else + UserMailer.webauthn_credential_deleted(current_user, credential).deliver_later! + end + else + flash[:error] = I18n.t('webauthn_credentials.destroy.error') + end + else + flash[:error] = I18n.t('webauthn_credentials.destroy.error') + end + redirect_to settings_two_factor_authentication_methods_path + end + + private + + def require_otp_enabled + unless current_user.otp_enabled? + flash[:error] = t('webauthn_credentials.otp_required') + redirect_to settings_two_factor_authentication_methods_path + end + end + + def require_webauthn_enabled + unless current_user.webauthn_enabled? + flash[:error] = t('webauthn_credentials.not_enabled') + redirect_to settings_two_factor_authentication_methods_path + end + end + end + end +end diff --git a/app/controllers/settings/two_factor_authentication_methods_controller.rb b/app/controllers/settings/two_factor_authentication_methods_controller.rb new file mode 100644 index 000000000..224d3a45c --- /dev/null +++ b/app/controllers/settings/two_factor_authentication_methods_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Settings + class TwoFactorAuthenticationMethodsController < BaseController + include ChallengableConcern + + layout 'admin' + + before_action :authenticate_user! + before_action :require_challenge!, only: :disable + before_action :require_otp_enabled + + skip_before_action :require_functional! + + def index; end + + def disable + current_user.disable_two_factor! + UserMailer.two_factor_disabled(current_user).deliver_later! + + redirect_to settings_otp_authentication_path, flash: { notice: I18n.t('two_factor_authentication.disabled_success') } + end + + private + + def require_otp_enabled + redirect_to settings_otp_authentication_path unless current_user.otp_enabled? + end + end +end diff --git a/app/controllers/settings/two_factor_authentications_controller.rb b/app/controllers/settings/two_factor_authentications_controller.rb deleted file mode 100644 index 9118a7933..000000000 --- a/app/controllers/settings/two_factor_authentications_controller.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module Settings - class TwoFactorAuthenticationsController < BaseController - include ChallengableConcern - - layout 'admin' - - before_action :authenticate_user! - before_action :verify_otp_required, only: [:create] - before_action :require_challenge!, only: [:create] - - skip_before_action :require_functional! - - def show - @confirmation = Form::TwoFactorConfirmation.new - end - - def create - current_user.otp_secret = User.generate_otp_secret(32) - current_user.save! - redirect_to new_settings_two_factor_authentication_confirmation_path - end - - def destroy - if acceptable_code? - current_user.otp_required_for_login = false - current_user.save! - UserMailer.two_factor_disabled(current_user).deliver_later! - redirect_to settings_two_factor_authentication_path - else - flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code') - @confirmation = Form::TwoFactorConfirmation.new - render :show - end - end - - private - - def confirmation_params - params.require(:form_two_factor_confirmation).permit(:otp_attempt) - end - - def verify_otp_required - redirect_to settings_two_factor_authentication_path if current_user.otp_required_for_login? - end - - def acceptable_code? - current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) || - current_user.invalidate_otp_backup_code!(confirmation_params[:otp_attempt]) - end - end -end diff --git a/app/javascript/packs/two_factor_authentication.js b/app/javascript/packs/two_factor_authentication.js new file mode 100644 index 000000000..dde06be8c --- /dev/null +++ b/app/javascript/packs/two_factor_authentication.js @@ -0,0 +1,118 @@ +import axios from 'axios'; +import * as WebAuthnJSON from '@github/webauthn-json'; +import ready from '../mastodon/ready'; +import 'regenerator-runtime/runtime'; + +function getCSRFToken() { + var CSRFSelector = document.querySelector('meta[name="csrf-token"]'); + if (CSRFSelector) { + return CSRFSelector.getAttribute('content'); + } else { + return null; + } +} + +function hideFlashMessages() { + Array.from(document.getElementsByClassName('flash-message')).forEach(function(flashMessage) { + flashMessage.classList.add('hidden'); + }); +} + +function callback(url, body) { + axios.post(url, JSON.stringify(body), { + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-Token': getCSRFToken(), + }, + credentials: 'same-origin', + }).then(function(response) { + window.location.replace(response.data.redirect_path); + }).catch(function(error) { + if (error.response.status === 422) { + const errorMessage = document.getElementById('security-key-error-message'); + errorMessage.classList.remove('hidden'); + console.error(error.response.data.error); + } else { + console.error(error); + } + }); +} + +ready(() => { + if (!WebAuthnJSON.supported()) { + const unsupported_browser_message = document.getElementById('unsupported-browser-message'); + if (unsupported_browser_message) { + unsupported_browser_message.classList.remove('hidden'); + document.querySelector('.btn.js-webauthn').disabled = true; + } + } + + + const webAuthnCredentialRegistrationForm = document.getElementById('new_webauthn_credential'); + if (webAuthnCredentialRegistrationForm) { + webAuthnCredentialRegistrationForm.addEventListener('submit', (event) => { + event.preventDefault(); + + var nickname = event.target.querySelector('input[name="new_webauthn_credential[nickname]"]'); + if (nickname.value) { + axios.get('/settings/security_keys/options') + .then((response) => { + const credentialOptions = response.data; + + WebAuthnJSON.create({ 'publicKey': credentialOptions }).then((credential) => { + var params = { 'credential': credential, 'nickname': nickname.value }; + callback('/settings/security_keys', params); + }).catch((error) => { + const errorMessage = document.getElementById('security-key-error-message'); + errorMessage.classList.remove('hidden'); + console.error(error); + }); + }).catch((error) => { + console.error(error.response.data.error); + }); + } else { + nickname.focus(); + } + }); + } + + const webAuthnCredentialAuthenticationForm = document.getElementById('webauthn-form'); + if (webAuthnCredentialAuthenticationForm) { + webAuthnCredentialAuthenticationForm.addEventListener('submit', (event) => { + event.preventDefault(); + + axios.get('sessions/security_key_options') + .then((response) => { + const credentialOptions = response.data; + + WebAuthnJSON.get({ 'publicKey': credentialOptions }).then((credential) => { + var params = { 'user': { 'credential': credential } }; + callback('sign_in', params); + }).catch((error) => { + const errorMessage = document.getElementById('security-key-error-message'); + errorMessage.classList.remove('hidden'); + console.error(error); + }); + }).catch((error) => { + console.error(error.response.data.error); + }); + }); + + const otpAuthenticationForm = document.getElementById('otp-authentication-form'); + + const linkToOtp = document.getElementById('link-to-otp'); + linkToOtp.addEventListener('click', () => { + webAuthnCredentialAuthenticationForm.classList.add('hidden'); + otpAuthenticationForm.classList.remove('hidden'); + hideFlashMessages(); + }); + + const linkToWebAuthn = document.getElementById('link-to-webauthn'); + linkToWebAuthn.addEventListener('click', () => { + otpAuthenticationForm.classList.add('hidden'); + webAuthnCredentialAuthenticationForm.classList.remove('hidden'); + hideFlashMessages(); + }); + } +}); diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index a6df51f95..a54a5fded 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -12,6 +12,10 @@ code { } .simple_form { + &.hidden { + display: none; + } + .input { margin-bottom: 15px; overflow: hidden; @@ -100,6 +104,14 @@ code { } } + .title { + color: #d9e1e8; + font-size: 20px; + line-height: 28px; + font-weight: 400; + margin-bottom: 30px; + } + .hint { color: $darker-text-color; @@ -142,7 +154,7 @@ code { } } - .otp-hint { + .authentication-hint { margin-bottom: 25px; } @@ -592,6 +604,10 @@ code { color: $error-value-color; } + &.hidden { + display: none; + } + a { display: inline-block; color: $darker-text-color; diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 2cd58e60a..b55768551 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -91,6 +91,52 @@ class UserMailer < Devise::Mailer end end + def webauthn_enabled(user, **) + @resource = user + @instance = Rails.configuration.x.local_domain + + return if @resource.disabled? + + I18n.with_locale(@resource.locale || I18n.default_locale) do + mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_enabled.subject') + end + end + + def webauthn_disabled(user, **) + @resource = user + @instance = Rails.configuration.x.local_domain + + return if @resource.disabled? + + I18n.with_locale(@resource.locale || I18n.default_locale) do + mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_disabled.subject') + end + end + + def webauthn_credential_added(user, webauthn_credential) + @resource = user + @instance = Rails.configuration.x.local_domain + @webauthn_credential = webauthn_credential + + return if @resource.disabled? + + I18n.with_locale(@resource.locale || I18n.default_locale) do + mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.added.subject') + end + end + + def webauthn_credential_deleted(user, webauthn_credential) + @resource = user + @instance = Rails.configuration.x.local_domain + @webauthn_credential = webauthn_credential + + return if @resource.disabled? + + I18n.with_locale(@resource.locale || I18n.default_locale) do + mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.deleted.subject') + end + end + def welcome(user) @resource = user @instance = Rails.configuration.x.local_domain diff --git a/app/models/user.rb b/app/models/user.rb index 306e2d435..7e3b37475 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -40,6 +40,7 @@ # approved :boolean default(TRUE), not null # sign_in_token :string # sign_in_token_sent_at :datetime +# webauthn_id :string # class User < ApplicationRecord @@ -77,6 +78,7 @@ class User < ApplicationRecord has_many :backups, inverse_of: :user has_many :invites, inverse_of: :user has_many :markers, inverse_of: :user, dependent: :destroy + has_many :webauthn_credentials, dependent: :destroy has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? } @@ -197,9 +199,25 @@ class User < ApplicationRecord prepare_returning_user! end + def otp_enabled? + otp_required_for_login + end + + def webauthn_enabled? + webauthn_credentials.any? + end + + def two_factor_enabled? + otp_required_for_login? || webauthn_credentials.any? + end + def disable_two_factor! self.otp_required_for_login = false + self.otp_secret = nil otp_backup_codes&.clear + + webauthn_credentials.destroy_all if webauthn_enabled? + save! end diff --git a/app/models/webauthn_credential.rb b/app/models/webauthn_credential.rb new file mode 100644 index 000000000..4129ce539 --- /dev/null +++ b/app/models/webauthn_credential.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: webauthn_credentials +# +# id :bigint(8) not null, primary key +# external_id :string not null +# public_key :string not null +# nickname :string not null +# sign_count :bigint(8) default(0), not null +# user_id :bigint(8) +# created_at :datetime not null +# updated_at :datetime not null +# + +class WebauthnCredential < ApplicationRecord + validates :external_id, :public_key, :nickname, :sign_count, presence: true + validates :external_id, uniqueness: true + validates :nickname, uniqueness: { scope: :user_id } + validates :sign_count, + numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 2**32 - 1 } +end diff --git a/app/views/auth/sessions/two_factor.html.haml b/app/views/auth/sessions/two_factor.html.haml index b2e36f6bc..f2f6fe19d 100644 --- a/app/views/auth/sessions/two_factor.html.haml +++ b/app/views/auth/sessions/two_factor.html.haml @@ -1,14 +1,9 @@ - content_for :page_title do = t('auth.login') -= simple_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f| - %p.hint.otp-hint= t('simple_form.hints.sessions.otp') +=javascript_pack_tag 'two_factor_authentication', integrity: true, crossorigin: 'anonymous' - .fields-group - = f.input :otp_attempt, type: :number, wrapper: :with_label, label: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt'), :autocomplete => 'off' }, autofocus: true +- if @webauthn_enabled + = render partial: 'auth/sessions/two_factor/webauthn_form', locals: { hidden: @scheme_type != 'webauthn' } - .actions - = f.button :button, t('auth.login'), type: :submit - - - if Setting.site_contact_email.present? - %p.hint.subtle-hint= t('users.otp_lost_help_html', email: mail_to(Setting.site_contact_email, nil)) += render partial: 'auth/sessions/two_factor/otp_authentication_form', locals: { hidden: @scheme_type != 'totp' } diff --git a/app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml b/app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml new file mode 100644 index 000000000..ab2d48c0a --- /dev/null +++ b/app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml @@ -0,0 +1,18 @@ += simple_form_for(resource, + as: resource_name, + url: session_path(resource_name), + html: { method: :post, id: 'otp-authentication-form' }.merge(hidden ? { class: 'hidden' } : {})) do |f| + %p.hint.authentication-hint= t('simple_form.hints.sessions.otp') + + .fields-group + = f.input :otp_attempt, type: :number, wrapper: :with_label, label: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt'), :autocomplete => 'off' }, autofocus: true + + .actions + = f.button :button, t('auth.login'), type: :submit + + - if Setting.site_contact_email.present? + %p.hint.subtle-hint= t('users.otp_lost_help_html', email: mail_to(Setting.site_contact_email, nil)) + + - if @webauthn_enabled + .form-footer + = link_to(t('auth.link_to_webauth'), '#', id: 'link-to-webauthn') diff --git a/app/views/auth/sessions/two_factor/_webauthn_form.html.haml b/app/views/auth/sessions/two_factor/_webauthn_form.html.haml new file mode 100644 index 000000000..32ed1294a --- /dev/null +++ b/app/views/auth/sessions/two_factor/_webauthn_form.html.haml @@ -0,0 +1,17 @@ +%p.flash-message.hidden#unsupported-browser-message= t 'webauthn_credentials.not_supported' +%p.flash-message.alert.hidden#security-key-error-message= t 'webauthn_credentials.invalid_credential' + + += simple_form_for(resource, + as: resource_name, + url: session_path(resource_name), + html: { method: :post, id: 'webauthn-form' }.merge(hidden ? { class: 'hidden' } : {})) do |f| + %h3.title= t('simple_form.title.sessions.webauthn') + %p.hint= t('simple_form.hints.sessions.webauthn') + + .actions + = f.button :button, t('auth.use_security_key'), class: 'js-webauthn', type: :submit + + .form-footer + %p= t('auth.dont_have_your_security_key') + = link_to(t('auth.link_to_otp'), '#', id: 'link-to-otp') diff --git a/app/views/settings/two_factor_authentication/confirmations/new.html.haml b/app/views/settings/two_factor_authentication/confirmations/new.html.haml index 86cf1f695..671237db5 100644 --- a/app/views/settings/two_factor_authentication/confirmations/new.html.haml +++ b/app/views/settings/two_factor_authentication/confirmations/new.html.haml @@ -2,17 +2,17 @@ = t('settings.two_factor_authentication') = simple_form_for @confirmation, url: settings_two_factor_authentication_confirmation_path, method: :post do |f| - %p.hint= t('two_factor_authentication.instructions_html') + %p.hint= t('otp_authentication.instructions_html') .qr-wrapper .qr-code!= @qrcode.as_svg(padding: 0, module_size: 4) .qr-alternative - %p.hint= t('two_factor_authentication.manual_instructions') - %samp.qr-alternative__code= current_user.otp_secret.scan(/.{4}/).join(' ') + %p.hint= t('otp_authentication.manual_instructions') + %samp.qr-alternative__code= @new_otp_secret.scan(/.{4}/).join(' ') .fields-group - = f.input :otp_attempt, wrapper: :with_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true + = f.input :otp_attempt, wrapper: :with_label, hint: t('otp_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true .actions - = f.button :button, t('two_factor_authentication.enable'), type: :submit + = f.button :button, t('otp_authentication.enable'), type: :submit diff --git a/app/views/settings/two_factor_authentication/otp_authentication/show.html.haml b/app/views/settings/two_factor_authentication/otp_authentication/show.html.haml new file mode 100644 index 000000000..d069ba12a --- /dev/null +++ b/app/views/settings/two_factor_authentication/otp_authentication/show.html.haml @@ -0,0 +1,9 @@ +- content_for :page_title do + = t('settings.two_factor_authentication') + +.simple_form + %p.hint= t('otp_authentication.description_html') + + %hr.spacer/ + + = link_to t('otp_authentication.setup'), settings_otp_authentication_path, data: { method: :post }, class: 'block-button' diff --git a/app/views/settings/two_factor_authentication/webauthn_credentials/index.html.haml b/app/views/settings/two_factor_authentication/webauthn_credentials/index.html.haml new file mode 100644 index 000000000..0dfd94ab9 --- /dev/null +++ b/app/views/settings/two_factor_authentication/webauthn_credentials/index.html.haml @@ -0,0 +1,17 @@ +- content_for :page_title do + = t('settings.webauthn_authentication') + +.table-wrapper + %table.table + %tbody + - current_user.webauthn_credentials.each do |credential| + %tr + %td= credential.nickname + %td= t('webauthn_credentials.registered_on', date: l(credential.created_at.to_date, format: :with_month_name)) + %td + = table_link_to 'trash', t('webauthn_credentials.delete'), settings_webauthn_credential_path(credential.id), method: :delete, data: { confirm: t('webauthn_credentials.delete_confirmation') } + +%hr.spacer/ + +.simple_form + = link_to t('webauthn_credentials.add'), new_settings_webauthn_credential_path, class: 'block-button' diff --git a/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml b/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml new file mode 100644 index 000000000..0b23bb689 --- /dev/null +++ b/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml @@ -0,0 +1,16 @@ +- content_for :page_title do + = t('settings.webauthn_authentication') + += simple_form_for(:new_webauthn_credential, url: settings_webauthn_credentials_path, html: { id: :new_webauthn_credential }) do |f| + %p.flash-message.hidden#unsupported-browser-message= t 'webauthn_credentials.not_supported' + %p.flash-message.alert.hidden#security-key-error-message= t 'webauthn_credentials.invalid_credential' + + %p.hint= t('webauthn_credentials.description_html') + + .fields_group + = f.input :nickname, wrapper: :with_block_label, hint: t('webauthn_credentials.nickname_hint'), input_html: { :autocomplete => 'off' }, required: true + + .actions + = f.button :button, t('webauthn_credentials.add'), class: 'js-webauthn', type: :submit + += javascript_pack_tag 'two_factor_authentication', integrity: true, crossorigin: 'anonymous' diff --git a/app/views/settings/two_factor_authentication_methods/index.html.haml b/app/views/settings/two_factor_authentication_methods/index.html.haml new file mode 100644 index 000000000..315443e6d --- /dev/null +++ b/app/views/settings/two_factor_authentication_methods/index.html.haml @@ -0,0 +1,41 @@ +- content_for :page_title do + = t('settings.two_factor_authentication') + +- content_for :heading_actions do + = link_to t('two_factor_authentication.disable'), disable_settings_two_factor_authentication_methods_path, class: 'button button--destructive', method: :post + +%p.hint + %span.positive-hint + = fa_icon 'check' + = ' ' + = t 'two_factor_authentication.enabled' + +.table-wrapper + %table.table + %thead + %tr + %th= t('two_factor_authentication.methods') + %th + %tbody + %tr + %td= t('two_factor_authentication.otp') + %td + = table_link_to 'pencil', t('two_factor_authentication.edit'), settings_otp_authentication_path, method: :post + %tr + %td= t('two_factor_authentication.webauthn') + - if current_user.webauthn_enabled? + %td + = table_link_to 'pencil', t('two_factor_authentication.edit'), settings_webauthn_credentials_path, method: :get + - else + %td + = table_link_to 'key', t('two_factor_authentication.add'), new_settings_webauthn_credential_path, method: :get + +%hr.spacer/ + +%h3= t('two_factor_authentication.recovery_codes') +%p.muted-hint= t('two_factor_authentication.lost_recovery_codes') + +%hr.spacer/ + +.simple_form + = link_to t('two_factor_authentication.generate_recovery_codes'), settings_two_factor_authentication_recovery_codes_path, data: { method: :post }, class: 'block-button' diff --git a/app/views/settings/two_factor_authentications/show.html.haml b/app/views/settings/two_factor_authentications/show.html.haml deleted file mode 100644 index f1eecd000..000000000 --- a/app/views/settings/two_factor_authentications/show.html.haml +++ /dev/null @@ -1,36 +0,0 @@ -- content_for :page_title do - = t('settings.two_factor_authentication') - -- if current_user.otp_required_for_login - %p.hint - %span.positive-hint - = fa_icon 'check' - = ' ' - = t 'two_factor_authentication.enabled' - - %hr.spacer/ - - = simple_form_for @confirmation, url: settings_two_factor_authentication_path, method: :delete do |f| - .fields-group - = f.input :otp_attempt, wrapper: :with_block_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true - - .actions - = f.button :button, t('two_factor_authentication.disable'), type: :submit, class: 'negative' - - %hr.spacer/ - - %h3= t('two_factor_authentication.recovery_codes') - %p.muted-hint= t('two_factor_authentication.lost_recovery_codes') - - %hr.spacer/ - - .simple_form - = link_to t('two_factor_authentication.generate_recovery_codes'), settings_two_factor_authentication_recovery_codes_path, data: { method: :post }, class: 'block-button' - -- else - .simple_form - %p.hint= t('two_factor_authentication.description_html') - - %hr.spacer/ - - = link_to t('two_factor_authentication.setup'), settings_two_factor_authentication_path, data: { method: :post }, class: 'block-button' diff --git a/app/views/user_mailer/webauthn_credential_added.html.haml b/app/views/user_mailer/webauthn_credential_added.html.haml new file mode 100644 index 000000000..81de84b56 --- /dev/null +++ b/app/views/user_mailer/webauthn_credential_added.html.haml @@ -0,0 +1,44 @@ +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.hero + .email-row + .col-6 + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.text-center.padded + %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td + = image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: '' + + %h1= t 'devise.mailer.webauthn_credential.added.title' + %p.lead= "#{t 'devise.mailer.webauthn_credential.added.explanation' }:" + %p.lead= @webauthn_credential.nickname + +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.content-start + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.button-cell + %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.button-primary + = link_to edit_user_registration_url do + %span= t('settings.account_settings') diff --git a/app/views/user_mailer/webauthn_credential_added.text.erb b/app/views/user_mailer/webauthn_credential_added.text.erb new file mode 100644 index 000000000..4319dddbf --- /dev/null +++ b/app/views/user_mailer/webauthn_credential_added.text.erb @@ -0,0 +1,7 @@ +<%= t 'devise.mailer.two_factor_enabled.title' %> + +=== + +<%= t 'devise.mailer.two_factor_enabled.explanation' %> + +=> <%= edit_user_registration_url %> diff --git a/app/views/user_mailer/webauthn_credential_deleted.html.haml b/app/views/user_mailer/webauthn_credential_deleted.html.haml new file mode 100644 index 000000000..7b47f0c88 --- /dev/null +++ b/app/views/user_mailer/webauthn_credential_deleted.html.haml @@ -0,0 +1,44 @@ +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.hero + .email-row + .col-6 + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.text-center.padded + %table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td + = image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: '' + + %h1= t 'devise.mailer.webauthn_credential.deleted.title' + %p.lead= "#{t 'devise.mailer.webauthn_credential.deleted.explanation' }:" + %p.lead= @webauthn_credential.nickname + +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.content-start + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.button-cell + %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.button-primary + = link_to edit_user_registration_url do + %span= t('settings.account_settings') diff --git a/app/views/user_mailer/webauthn_credential_deleted.text.erb b/app/views/user_mailer/webauthn_credential_deleted.text.erb new file mode 100644 index 000000000..53e5bc78c --- /dev/null +++ b/app/views/user_mailer/webauthn_credential_deleted.text.erb @@ -0,0 +1,7 @@ +<%= t 'devise.mailer.webauthn_credential.deleted.title' %> + +=== + +<%= t 'devise.mailer.webauthn_credential.deleted.explanation' %> + +=> <%= edit_user_registration_url %> diff --git a/app/views/user_mailer/webauthn_disabled.html.haml b/app/views/user_mailer/webauthn_disabled.html.haml new file mode 100644 index 000000000..81a2a7954 --- /dev/null +++ b/app/views/user_mailer/webauthn_disabled.html.haml @@ -0,0 +1,43 @@ +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.hero + .email-row + .col-6 + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.text-center.padded + %table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td + = image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: '' + + %h1= t 'devise.mailer.webauthn_disabled.title' + %p.lead= t 'devise.mailer.webauthn_disabled.explanation' + +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.content-start + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.button-cell + %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.button-primary + = link_to edit_user_registration_url do + %span= t('settings.account_settings') diff --git a/app/views/user_mailer/webauthn_disabled.text.erb b/app/views/user_mailer/webauthn_disabled.text.erb new file mode 100644 index 000000000..962df77ca --- /dev/null +++ b/app/views/user_mailer/webauthn_disabled.text.erb @@ -0,0 +1,7 @@ +<%= t 'devise.mailer.webauthn_disabled.title' %> + +=== + +<%= t 'devise.mailer.webauthn_disabled.explanation' %> + +=> <%= edit_user_registration_url %> diff --git a/app/views/user_mailer/webauthn_enabled.html.haml b/app/views/user_mailer/webauthn_enabled.html.haml new file mode 100644 index 000000000..f08e764e8 --- /dev/null +++ b/app/views/user_mailer/webauthn_enabled.html.haml @@ -0,0 +1,43 @@ +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.hero + .email-row + .col-6 + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.text-center.padded + %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td + = image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: '' + + %h1= t 'devise.mailer.webauthn_enabled.title' + %p.lead= t 'devise.mailer.webauthn_enabled.explanation' + +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.content-start + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.button-cell + %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.button-primary + = link_to edit_user_registration_url do + %span= t('settings.account_settings') diff --git a/app/views/user_mailer/webauthn_enabled.text.erb b/app/views/user_mailer/webauthn_enabled.text.erb new file mode 100644 index 000000000..4c233fefb --- /dev/null +++ b/app/views/user_mailer/webauthn_enabled.text.erb @@ -0,0 +1,7 @@ +<%= t 'devise.mailer.webauthn_credentia.added.title' %> + +=== + +<%= t 'devise.mailer.webauthn_credentia.added.explanation' %> + +=> <%= edit_user_registration_url %> |