Browse Source

Add WebAuthn as an alternative 2FA method (#14466)

* feat: add possibility of adding WebAuthn security keys to use as 2FA

This adds a basic UI for enabling WebAuthn 2FA. We did a little refactor
to the Settings page for editing the 2FA methods – now it will list the
methods that are available to the user (TOTP and WebAuthn) and from
there they'll be able to add or remove any of them.
Also, it's worth mentioning that for enabling WebAuthn it's required to
have TOTP enabled, so the first time that you go to the 2FA Settings
page, you'll be asked to set it up.
This work was inspired by the one donde by Github in their platform, and
despite it could be approached in different ways, we decided to go with
this one given that we feel that this gives a great UX.

Co-authored-by: Facundo Padula <facundo.padula@cedarcode.com>

* feat: add request for WebAuthn as second factor at login if enabled

This commits adds the feature for using WebAuthn as a second factor for
login when enabled.
If users have WebAuthn enabled, now a page requesting for the use of a
WebAuthn credential for log in will appear, although a link redirecting
to the old page for logging in using a two-factor code will also be
present.

Co-authored-by: Facundo Padula <facundo.padula@cedarcode.com>

* feat: add possibility of deleting WebAuthn Credentials

Co-authored-by: Facundo Padula <facundo.padula@cedarcode.com>

* feat: disable WebAuthn when an Admin disables 2FA for a user

Co-authored-by: Facundo Padula <facundo.padula@cedarcode.com>

* feat: remove ability to disable TOTP leaving only WebAuthn as 2FA

Following examples form other platforms like Github, we decided to make
Webauthn 2FA secondary to 2FA with TOTP, so that we removed the
possibility of removing TOTP authentication only, leaving users with
just WEbAuthn as 2FA. Instead, users will have to click on 'Disable 2FA'
in order to remove second factor auth.
The reason for WebAuthn being secondary to TOPT is that in that way,
users will still be able to log in using their code from their phone's
application if they don't have their security keys with them – or maybe
even lost them.

* We had to change a little the flow for setting up TOTP, given that now
  it's possible to setting up again if you already had TOTP, in order to
  let users modify their authenticator app – given that now it's not
  possible for them to disable TOTP and set it up again with another
  authenticator app.
  So, basically, now instead of storing the new `otp_secret` in the
  user, we store it in the session until the process of set up is
  finished.
  This was because, as it was before, when users clicked on 'Edit' in
  the new two-factor methods lists page, but then went back without
  finishing the flow, their `otp_secret` had been changed therefore
  invalidating their previous authenticator app, making them unable to
  log in again using TOTP.

Co-authored-by: Facundo Padula <facundo.padula@cedarcode.com>

* refactor: fix eslint errors

The PR build was failing given that linting returning some errors.
This commit attempts to fix them.

* refactor: normalize i18n translations

The build was failing given that i18n translations files were not
normalized.
This commits fixes that.

* refactor: avoid having the webauthn gem locked to a specific version

* refactor: use symbols for routes without '/'

* refactor: avoid sending webauthn disabled email when 2FA is disabled

When an admins disable 2FA for users, we were sending two mails
to them, one notifying that 2FA was disabled and the other to notify
that WebAuthn was disabled.
As the second one is redundant since the first email includes it, we can
remove it and send just one email to users.

* refactor: avoid creating new env variable for webauthn_origin config

* refactor: improve flash error messages for webauthn pages

Co-authored-by: Facundo Padula <facundo.padula@cedarcode.com>
master
santiagorodriguez96 3 years ago
committed by GitHub
parent
commit
e8d41bc2fe
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 1831 additions and 375 deletions
  1. +1
    -0
      Gemfile
  2. +26
    -0
      Gemfile.lock
  3. +17
    -1
      app/controllers/auth/sessions_controller.rb
  4. +41
    -4
      app/controllers/concerns/two_factor_authentication_concern.rb
  5. +10
    -4
      app/controllers/settings/two_factor_authentication/confirmations_controller.rb
  6. +42
    -0
      app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb
  7. +103
    -0
      app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
  8. +30
    -0
      app/controllers/settings/two_factor_authentication_methods_controller.rb
  9. +0
    -53
      app/controllers/settings/two_factor_authentications_controller.rb
  10. +118
    -0
      app/javascript/packs/two_factor_authentication.js
  11. +17
    -1
      app/javascript/styles/mastodon/forms.scss
  12. +46
    -0
      app/mailers/user_mailer.rb
  13. +18
    -0
      app/models/user.rb
  14. +22
    -0
      app/models/webauthn_credential.rb
  15. +4
    -9
      app/views/auth/sessions/two_factor.html.haml
  16. +18
    -0
      app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml
  17. +17
    -0
      app/views/auth/sessions/two_factor/_webauthn_form.html.haml
  18. +5
    -5
      app/views/settings/two_factor_authentication/confirmations/new.html.haml
  19. +9
    -0
      app/views/settings/two_factor_authentication/otp_authentication/show.html.haml
  20. +17
    -0
      app/views/settings/two_factor_authentication/webauthn_credentials/index.html.haml
  21. +16
    -0
      app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml
  22. +41
    -0
      app/views/settings/two_factor_authentication_methods/index.html.haml
  23. +0
    -36
      app/views/settings/two_factor_authentications/show.html.haml
  24. +44
    -0
      app/views/user_mailer/webauthn_credential_added.html.haml
  25. +7
    -0
      app/views/user_mailer/webauthn_credential_added.text.erb
  26. +44
    -0
      app/views/user_mailer/webauthn_credential_deleted.html.haml
  27. +7
    -0
      app/views/user_mailer/webauthn_credential_deleted.text.erb
  28. +43
    -0
      app/views/user_mailer/webauthn_disabled.html.haml
  29. +7
    -0
      app/views/user_mailer/webauthn_disabled.text.erb
  30. +43
    -0
      app/views/user_mailer/webauthn_enabled.html.haml
  31. +7
    -0
      app/views/user_mailer/webauthn_enabled.text.erb
  32. +24
    -0
      config/initializers/webauthn.rb
  33. +17
    -0
      config/locales/devise.en.yml
  34. +38
    -8
      config/locales/en.yml
  35. +4
    -0
      config/locales/simple_form.en.yml
  36. +1
    -1
      config/navigation.rb
  37. +17
    -1
      config/routes.rb
  38. +16
    -0
      db/migrate/20200630190240_create_webauthn_credentials.rb
  39. +5
    -0
      db/migrate/20200630190544_add_webauthn_id_to_users.rb
  40. +15
    -1
      db/schema.rb
  41. +2
    -0
      package.json
  42. +37
    -6
      spec/controllers/admin/two_factor_authentications_controller_spec.rb
  43. +136
    -55
      spec/controllers/auth/sessions_controller_spec.rb
  44. +76
    -64
      spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb
  45. +99
    -0
      spec/controllers/settings/two_factor_authentication/otp_authentication_controller_spec.rb
  46. +374
    -0
      spec/controllers/settings/two_factor_authentication/webauthn_credentials_controller_spec.rb
  47. +49
    -0
      spec/controllers/settings/two_factor_authentication_methods_controller_spec.rb
  48. +0
    -125
      spec/controllers/settings/two_factor_authentications_controller_spec.rb
  49. +7
    -0
      spec/fabricators/webauthn_credential_fabricator.rb
  50. +6
    -0
      spec/models/user_spec.rb
  51. +80
    -0
      spec/models/webauthn_credentials_spec.rb
  52. +2
    -0
      spec/rails_helper.rb
  53. +6
    -1
      yarn.lock

+ 1
- 0
Gemfile View File

@ -99,6 +99,7 @@ gem 'twitter-text', '~> 1.14'
gem 'tzinfo-data', '~> 1.2020'
gem 'webpacker', '~> 5.2'
gem 'webpush'
gem 'webauthn', '~> 3.0.0.alpha1'
gem 'json-ld'
gem 'json-ld-preloaded', '~> 3.1'

+ 26
- 0
Gemfile.lock View File

@ -67,6 +67,7 @@ GEM
public_suffix (>= 2.0.2, < 5.0)
airbrussh (1.4.0)
sshkit (>= 1.6.1, != 1.7.0)
android_key_attestation (0.3.0)
annotate (3.1.1)
activerecord (>= 3.2, < 7.0)
rake (>= 10.4, < 14.0)
@ -76,6 +77,7 @@ GEM
encryptor (~> 3.0.0)
av (0.9.0)
cocaine (~> 0.5.3)
awrence (1.1.1)
aws-eventstream (1.1.0)
aws-partitions (1.356.0)
aws-sdk-core (3.104.3)
@ -97,6 +99,7 @@ GEM
coderay (>= 1.0.0)
erubi (>= 1.0.0)
rack (>= 0.9.0)
bindata (2.4.8)
binding_of_caller (0.8.0)
debug_inspector (>= 0.0.1)
blurhash (0.1.4)
@ -138,6 +141,7 @@ GEM
xpath (~> 3.2)
case_transform (0.2)
activesupport
cbor (0.5.9.6)
charlock_holmes (0.7.7)
chewy (5.1.0)
activesupport (>= 4.0)
@ -153,6 +157,9 @@ GEM
color_diff (0.1)
concurrent-ruby (1.1.7)
connection_pool (2.2.3)
cose (1.0.0)
cbor (~> 0.5.9)
openssl-signature_algorithm (~> 0.4.0)
crack (0.4.3)
safe_yaml (~> 1.0.0)
crass (1.0.6)
@ -377,6 +384,8 @@ GEM
omniauth-saml (1.10.2)
omniauth (~> 1.3, >= 1.3.2)
ruby-saml (~> 1.9)
openssl (2.2.0)
openssl-signature_algorithm (0.4.0)
orm_adapter (0.5.0)
ox (2.13.2)
paperclip (6.0.0)
@ -547,10 +556,13 @@ GEM
rufus-scheduler (3.6.0)
fugit (~> 1.1, >= 1.1.6)
safe_yaml (1.0.5)
safety_net_attestation (0.4.0)
jwt (~> 2.0)
sanitize (5.2.1)
crass (~> 1.0.2)
nokogiri (>= 1.8.0)
nokogumbo (~> 2.0)
securecompare (1.0.0)
semantic_range (2.3.0)
sidekiq (6.1.1)
connection_pool (>= 2.2.2)
@ -605,6 +617,9 @@ GEM
thwait (0.2.0)
e2mmap
tilt (2.0.10)
tpm-key_attestation (0.9.0)
bindata (~> 2.4)
openssl-signature_algorithm (~> 0.4.0)
tty-color (0.5.2)
tty-cursor (0.7.1)
tty-prompt (0.22.0)
@ -628,6 +643,16 @@ GEM
uniform_notifier (1.13.0)
warden (1.2.8)
rack (>= 2.0.6)
webauthn (3.0.0.alpha1)
android_key_attestation (~> 0.3.0)
awrence (~> 1.1)
bindata (~> 2.4)
cbor (~> 0.5.9)
cose (~> 1.0)
openssl (~> 2.0)
safety_net_attestation (~> 0.4.0)
securecompare (~> 1.0)
tpm-key_attestation (~> 0.9.0)
webmock (3.8.3)
addressable (>= 2.3.6)
crack (>= 0.3.2)
@ -775,6 +800,7 @@ DEPENDENCIES
tty-prompt (~> 0.22)
twitter-text (~> 1.14)
tzinfo-data (~> 1.2020)
webauthn (~> 3.0.0.alpha1)
webmock (~> 3.8)
webpacker (~> 5.2)
webpush

+ 17
- 1
app/controllers/auth/sessions_controller.rb View File

@ -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)

+ 41
- 4
app/controllers/concerns/two_factor_authentication_concern.rb View File

@ -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

+ 10
- 4
app/controllers/settings/two_factor_authentication/confirmations_controller.rb View File

@ -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

+ 42
- 0
app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb View File

@ -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

+ 103
- 0
app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb View File

@ -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

+ 30
- 0
app/controllers/settings/two_factor_authentication_methods_controller.rb View File

@ -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

+ 0
- 53
app/controllers/settings/two_factor_authentications_controller.rb View File

@ -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

+ 118
- 0
app/javascript/packs/two_factor_authentication.js View File

@ -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();
});
}
});

+ 17
- 1
app/javascript/styles/mastodon/forms.scss View File

@ -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;

+ 46
- 0
app/mailers/user_mailer.rb View File

@ -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

+ 18
- 0
app/models/user.rb View File

@ -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

+ 22
- 0
app/models/webauthn_credential.rb View File

@ -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

+ 4
- 9
app/views/auth/sessions/two_factor.html.haml View File

@ -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' }

+ 18
- 0
app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml View File

@ -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')

+ 17
- 0
app/views/auth/sessions/two_factor/_webauthn_form.html.haml View File

@ -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')

+ 5
- 5
app/views/settings/two_factor_authentication/confirmations/new.html.haml View File

@ -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

+ 9
- 0
app/views/settings/two_factor_authentication/otp_authentication/show.html.haml View File

@ -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'

+ 17
- 0
app/views/settings/two_factor_authentication/webauthn_credentials/index.html.haml View File

@ -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'

+ 16
- 0
app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml View File

@ -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'

+ 41
- 0
app/views/settings/two_factor_authentication_methods/index.html.haml View File

@ -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'

+ 0
- 36
app/views/settings/two_factor_authentications/show.html.haml View File

@ -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'

+ 44
- 0
app/views/user_mailer/webauthn_credential_added.html.haml View File

@ -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')

+ 7
- 0
app/views/user_mailer/webauthn_credential_added.text.erb View File

@ -0,0 +1,7 @@
<%= t 'devise.mailer.two_factor_enabled.title' %>
===
<%= t 'devise.mailer.two_factor_enabled.explanation' %>
=> <%= edit_user_registration_url %>

+ 44
- 0
app/views/user_mailer/webauthn_credential_deleted.html.haml View File

@ -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')

+ 7
- 0
app/views/user_mailer/webauthn_credential_deleted.text.erb View File

@ -0,0 +1,7 @@
<%= t 'devise.mailer.webauthn_credential.deleted.title' %>
===
<%= t 'devise.mailer.webauthn_credential.deleted.explanation' %>
=> <%= edit_user_registration_url %>

+ 43
- 0
app/views/user_mailer/webauthn_disabled.html.haml View File

@ -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')

+ 7
- 0
app/views/user_mailer/webauthn_disabled.text.erb View File

@ -0,0 +1,7 @@
<%= t 'devise.mailer.webauthn_disabled.title' %>
===
<%= t 'devise.mailer.webauthn_disabled.explanation' %>
=> <%= edit_user_registration_url %>

+ 43
- 0
app/views/user_mailer/webauthn_enabled.html.haml View File

@ -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')

+ 7
- 0
app/views/user_mailer/webauthn_enabled.text.erb View File

@ -0,0 +1,7 @@
<%= t 'devise.mailer.webauthn_credentia.added.title' %>
===
<%= t 'devise.mailer.webauthn_credentia.added.explanation' %>
=> <%= edit_user_registration_url %>

+ 24
- 0
config/initializers/webauthn.rb View File

@ -0,0 +1,24 @@
WebAuthn.configure do |config|
# This value needs to match `window.location.origin` evaluated by
# the User Agent during registration and authentication ceremonies.
config.origin = "#{Rails.configuration.x.use_https ? 'https' : 'http' }://#{Rails.configuration.x.web_domain}"
# Relying Party name for display purposes
config.rp_name = "Mastodon"
# Optionally configure a client timeout hint, in milliseconds.
# This hint specifies how long the browser should wait for an
# attestation or an assertion response.
# This hint may be overridden by the browser.
# https://www.w3.org/TR/webauthn/#dom-publickeycredentialcreationoptions-timeout
config.credential_options_timeout = 120_000
# You can optionally specify a different Relying Party ID
# (https://www.w3.org/TR/webauthn/#relying-party-identifier)
# if it differs from the default one.
#
# In this case the default would be "auth.example.com", but you can set it to
# the suffix "example.com"
#
# config.rp_id = "example.com"
end

+ 17
- 0
config/locales/devise.en.yml View File

@ -60,6 +60,23 @@ en:
title: 2FA recovery codes changed
unlock_instructions:
subject: 'Mastodon: Unlock instructions'
webauthn_credential:
added:
explanation: The following security key has been added to your account
subject: 'Mastodon: New security key'
title: A new security key has been added
deleted:
explanation: The following security key has been deleted from your account
subject: 'Mastodon: Security key deleted'
title: One of you security keys has been deleted
webauthn_disabled:
explanation: Authentication with security keys has been disabled for your account. Login is now possible using only the token generated by the paired TOTP app.
subject: 'Mastodon: Authentication with security keys disabled'
title: Security keys disabled
webauthn_enabled:
explanation: Security key authentication has been enabled for your account. Your security key can now be used for login.
subject: 'Mastodon: Security key authentication enabled'
title: Security keys enabled
omniauth_callbacks:
failure: Could not authenticate you from %{kind} because "%{reason}".
success: Successfully authenticated from %{kind} account.

+ 38
- 8
config/locales/en.yml View File

@ -681,8 +681,11 @@ en:
prefix_sign_up: Sign up on Mastodon today!
suffix: With an account, you will be able to follow people, post updates and exchange messages with users from any Mastodon server and more!
didnt_get_confirmation: Didn't receive confirmation instructions?
dont_have_your_security_key: Don't have your security key?
forgot_password: Forgot your password?
invalid_reset_password_token: Password reset token is invalid or expired. Please request a new one.
link_to_otp: Enter a two-factor code from your phone or a recovery code
link_to_webauth: Use your security key device
login: Log in
logout: Logout
migrate_account: Move to a different account
@ -708,6 +711,7 @@ en:
pending: Your application is pending review by our staff. This may take some time. You will receive an e-mail if your application is approved.
redirecting_to: Your account is inactive because it is currently redirecting to %{acct}.
trouble_logging_in: Trouble logging in?
use_security_key: Use security key
authorize_follow:
already_following: You are already following this account
already_requested: You have already sent a follow request to that account
@ -732,6 +736,7 @@ en:
date:
formats:
default: "%b %d, %Y"
with_month_name: "%B %d, %Y"
datetime:
distance_in_words:
about_x_hours: "%{count}h"
@ -993,6 +998,14 @@ en:
thousand: K
trillion: T
unit: ''
otp_authentication:
code_hint: Enter the code generated by your authenticator app to confirm
description_html: If you enable <strong>two-factor authentication</strong> using an authenticator app, logging in will require you to be in possession of your phone, which will generate tokens for you to enter.
enable: Enable
instructions_html: "<strong>Scan this QR code into Google Authenticator or a similiar TOTP app on your phone</strong>. From now on, that app will generate tokens that you will have to enter when logging in."
manual_instructions: 'If you can''t scan the QR code and need to enter it manually, here is the plain-text secret:'
setup: Set up
wrong_code: The entered code was invalid! Are server time and device time correct?
pagination:
newer: Newer
next: Next
@ -1117,6 +1130,7 @@ en:
profile: Profile
relationships: Follows and followers
two_factor_authentication: Two-factor Auth
webauthn_authentication: Security keys
spam_check:
spam_detected: This is an automated report. Spam has been detected.
statuses:
@ -1263,21 +1277,20 @@ en:
default: "%b %d, %Y, %H:%M"
month: "%b %Y"
two_factor_authentication:
code_hint: Enter the code generated by your authenticator app to confirm
description_html: If you enable <strong>two-factor authentication</strong>, logging in will require you to be in possession of your phone, which will generate tokens for you to enter.
disable: Disable
enable: Enable
add: Add
disable: Disable 2FA
disabled_success: Two-factor authentication successfully disabled
edit: Edit
enabled: Two-factor authentication is enabled
enabled_success: Two-factor authentication successfully enabled
generate_recovery_codes: Generate recovery codes
instructions_html: "<strong>Scan this QR code into Google Authenticator or a similiar TOTP app on your phone</strong>. From now on, that app will generate tokens that you will have to enter when logging in."
lost_recovery_codes: Recovery codes allow you to regain access to your account if you lose your phone. If you've lost your recovery codes, you can regenerate them here. Your old recovery codes will be invalidated.
manual_instructions: 'If you can''t scan the QR code and need to enter it manually, here is the plain-text secret:'
methods: Two-factor methods
otp: Authenticator app
recovery_codes: Backup recovery codes
recovery_codes_regenerated: Recovery codes successfully regenerated
recovery_instructions_html: If you ever lose access to your phone, you can use one of the recovery codes below to regain access to your account. <strong>Keep the recovery codes safe</strong>. For example, you may print them and store them with other important documents.
setup: Set up
wrong_code: The entered code was invalid! Are server time and device time correct?
webauthn: Security keys
user_mailer:
backup_ready:
explanation: You requested a full backup of your Mastodon account. It's now ready for download!
@ -1339,3 +1352,20 @@ en:
verification:
explanation_html: 'You can <strong>verify yourself as the owner of the links in your profile metadata</strong>. For that, the linked website must contain a link back to your Mastodon profile. The link back <strong>must</strong> have a <code>rel="me"</code> attribute. The text content of the link does not matter. Here is an example:'
verification: Verification
webauthn_credentials:
add: Add new security key
create:
error: There was a problem adding your security key. Please try again.
success: Your security key was successfully added.
delete: Delete
delete_confirmation: Are you sure you want to delete this security key?
description_html: If you enable <strong>security key authentication</strong>, logging in will require you to use one of your security keys.
destroy:
error: There was a problem deleting you security key. Please try again.
success: Your security key was successfully deleted.
invalid_credential: Invalid security key
nickname_hint: Enter the nickname of your new security key
not_enabled: You haven't enabled WebAuthn yet
not_supported: This browser doesn't support security keys
otp_required: To use security keys please enable two-factor authentication first.
registered_on: Registered on %{date}

+ 4
- 0
config/locales/simple_form.en.yml View File

@ -67,6 +67,7 @@ en:
text: This will help us review your application
sessions:
otp: 'Enter the two-factor code generated by your phone app or use one of your recovery codes:'
webauthn: If it's an USB key be sure to insert it and, if necessary, tap it.
tag:
name: You can only change the casing of the letters, for example, to make it more readable
user:
@ -188,4 +189,7 @@ en:
required:
mark: "*"
text: required
title:
sessions:
webauthn: Use one of your security keys to sign in
'yes': 'Yes'

+ 1
- 1
config/navigation.rb View File

@ -21,7 +21,7 @@ SimpleNavigation::Configuration.run do |navigation|
n.item :security, safe_join([fa_icon('lock fw'), t('settings.account')]), edit_user_registration_url do |s|
s.item :password, safe_join([fa_icon('lock fw'), t('settings.account_settings')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete|/settings/migration|/settings/aliases}
s.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_url, highlights_on: %r{/settings/two_factor_authentication}
s.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_methods_url, highlights_on: %r{/settings/two_factor_authentication|/settings/security_keys}
s.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_url
end

+ 17
- 1
config/routes.rb View File

@ -45,6 +45,7 @@ Rails.application.routes.draw do
namespace :auth do
resource :setup, only: [:show, :update], controller: :setup
resource :challenge, only: [:create], controller: :challenges
get 'sessions/security_key_options', to: 'sessions#webauthn_options'
end
end
@ -124,7 +125,22 @@ Rails.application.routes.draw do
resources :domain_blocks, only: :index, controller: :blocked_domains
end
resource :two_factor_authentication, only: [:show, :create, :destroy]
resources :two_factor_authentication_methods, only: [:index] do
collection do
post :disable
end
end
resource :otp_authentication, only: [:show, :create], controller: 'two_factor_authentication/otp_authentication'
resources :webauthn_credentials, only: [:index, :new, :create, :destroy],
path: 'security_keys',
controller: 'two_factor_authentication/webauthn_credentials' do
collection do
get :options
end
end
namespace :two_factor_authentication do
resources :recovery_codes, only: [:create]

+ 16
- 0
db/migrate/20200630190240_create_webauthn_credentials.rb View File

@ -0,0 +1,16 @@
class CreateWebauthnCredentials < ActiveRecord::Migration[5.2]
def change
create_table :webauthn_credentials do |t|
t.string :external_id, null: false
t.string :public_key, null: false
t.string :nickname, null: false
t.bigint :sign_count, null: false, default: 0
t.index :external_id, unique: true
t.references :user, foreign_key: true
t.timestamps
end
end
end

+ 5
- 0
db/migrate/20200630190544_add_webauthn_id_to_users.rb View File

@ -0,0 +1,5 @@
class AddWebauthnIdToUsers < ActiveRecord::Migration[5.2]
def change
add_column :users, :webauthn_id, :string
end
end

+ 15
- 1
db/schema.rb View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2020_06_28_133322) do
ActiveRecord::Schema.define(version: 2020_06_30_190544) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -880,6 +880,7 @@ ActiveRecord::Schema.define(version: 2020_06_28_133322) do
t.boolean "approved", default: true, null: false
t.string "sign_in_token"
t.datetime "sign_in_token_sent_at"
t.string "webauthn_id"
t.index ["account_id"], name: "index_users_on_account_id"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id"
@ -909,6 +910,18 @@ ActiveRecord::Schema.define(version: 2020_06_28_133322) do
t.index ["user_id"], name: "index_web_settings_on_user_id", unique: true
end
create_table "webauthn_credentials", force: :cascade do |t|
t.string "external_id", null: false
t.string "public_key", null: false
t.string "nickname", null: false
t.bigint "sign_count", default: 0, null: false
t.bigint "user_id"
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 ["user_id"], name: "index_webauthn_credentials_on_user_id"
end
add_foreign_key "account_aliases", "accounts", on_delete: :cascade
add_foreign_key "account_conversations", "accounts", on_delete: :cascade
add_foreign_key "account_conversations", "conversations", on_delete: :cascade
@ -1007,4 +1020,5 @@ ActiveRecord::Schema.define(version: 2020_06_28_133322) do
add_foreign_key "web_push_subscriptions", "oauth_access_tokens", column: "access_token_id", on_delete: :cascade
add_foreign_key "web_push_subscriptions", "users", on_delete: :cascade
add_foreign_key "web_settings", "users", name: "fk_11910667b2", on_delete: :cascade
add_foreign_key "webauthn_credentials", "users"
end

+ 2
- 0
package.json View File

@ -69,6 +69,7 @@
"@babel/runtime": "^7.8.4",
"@clusterws/cws": "^2.0.0",
"@gamestdio/websocket": "^0.3.2",
"@github/webauthn-json": "^0.4.2",
"@rails/ujs": "^6.0.3",
"array-includes": "^3.1.1",
"arrow-key-navigation": "^1.2.0",
@ -147,6 +148,7 @@
"redux": "^4.0.5",
"redux-immutable": "^4.0.0",
"redux-thunk": "^2.2.0",
"regenerator-runtime": "^0.13.7",
"rellax": "^1.12.1",
"requestidlecallback": "^0.3.0",
"reselect": "^4.0.0",

+ 37
- 6
spec/controllers/admin/two_factor_authentications_controller_spec.rb View File

@ -1,20 +1,51 @@
require 'rails_helper'
require 'webauthn/fake_client'
describe Admin::TwoFactorAuthenticationsController do
render_views
let(:user) { Fabricate(:user, otp_required_for_login: true) }
let(:user) { Fabricate(:user) }
before do
sign_in Fabricate(:user, admin: true), scope: :user
end
describe 'DELETE #destroy' do
it 'redirects to admin accounts page' do
delete :destroy, params: { user_id: user.id }
context 'when user has OTP enabled' do
before do
user.update(otp_required_for_login: true)
end
user.reload
expect(user.otp_required_for_login).to eq false
expect(response).to redirect_to(admin_accounts_path)
it 'redirects to admin accounts page' do
delete :destroy, params: { user_id: user.id }
user.reload
expect(user.otp_enabled?).to eq false
expect(response).to redirect_to(admin_accounts_path)
end
end
context 'when user has OTP and WebAuthn enabled' do
let(:fake_client) { WebAuthn::FakeClient.new('http://test.host') }
before do
user.update(otp_required_for_login: true, webauthn_id: WebAuthn.generate_user_id)
public_key_credential = WebAuthn::Credential.from_create(fake_client.create)
Fabricate(:webauthn_credential,
user_id: user.id,
external_id: public_key_credential.id,
public_key: public_key_credential.public_key,
nickname: 'Security Key')
end
it 'redirects to admin accounts page' do
delete :destroy, params: { user_id: user.id }
user.reload
expect(user.otp_enabled?).to eq false
expect(user.webauthn_enabled?).to eq false
expect(response).to redirect_to(admin_accounts_path)
end
end
end
end

+ 136
- 55
spec/controllers/auth/sessions_controller_spec.rb View File

@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'rails_helper'
require 'webauthn/fake_client'
RSpec.describe Auth::SessionsController, type: :controller do
render_views
@ -183,90 +184,170 @@ RSpec.describe Auth::SessionsController, type: :controller do
end
context 'using two-factor authentication' do
let!(:user) do
Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', otp_required_for_login: true, otp_secret: User.generate_otp_secret(32))
end
let!(:recovery_codes) do
codes = user.generate_otp_backup_codes!
user.save
return codes
end
context 'using email and password' do
before do
post :create, params: { user: { email: user.email, password: user.password } }
context 'with OTP enabled as second factor' do
let!(:user) do
Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', otp_required_for_login: true, otp_secret: User.generate_otp_secret(32))
end
it 'renders two factor authentication page' do
expect(controller).to render_template("two_factor")
let!(:recovery_codes) do
codes = user.generate_otp_backup_codes!
user.save
return codes
end
end
context 'using upcase email and password' do
before do
post :create, params: { user: { email: user.email.upcase, password: user.password } }
end
context 'using email and password' do
before do
post :create, params: { user: { email: user.email, password: user.password } }
end
it 'renders two factor authentication page' do
expect(controller).to render_template("two_factor")
it 'renders two factor authentication page' do
expect(controller).to render_template("two_factor")
expect(controller).to render_template(partial: "_otp_authentication_form")
end
end
end
context 'using a valid OTP' do
before do
post :create, params: { user: { otp_attempt: user.current_otp } }, session: { attempt_user_id: user.id }
end
context 'using upcase email and password' do
before do
post :create, params: { user: { email: user.email.upcase, password: user.password } }
end
it 'redirects to home' do
expect(response).to redirect_to(root_path)
it 'renders two factor authentication page' do
expect(controller).to render_template("two_factor")
expect(controller).to render_template(partial: "_otp_authentication_form")
end
end
it 'logs the user in' do
expect(controller.current_user).to eq user
context 'using a valid OTP' do
before do
post :create, params: { user: { otp_attempt: user.current_otp } }, session: { attempt_user_id: user.id }
end
it 'redirects to home' do
expect(response).to redirect_to(root_path)
end
it 'logs the user in' do
expect(controller.current_user).to eq user
end
end
end
context 'when the server has an decryption error' do
before do
allow_any_instance_of(User).to receive(:validate_and_consume_otp!).and_raise(OpenSSL::Cipher::CipherError)
post :create, params: { user: { otp_attempt: user.current_otp } }, session: { attempt_user_id: user.id }
context 'when the server has an decryption error' do
before do
allow_any_instance_of(User).to receive(:validate_and_consume_otp!).and_raise(OpenSSL::Cipher::CipherError)
post :create, params: { user: { otp_attempt: user.current_otp } }, session: { attempt_user_id: user.id }
end
it 'shows a login error' do
expect(flash[:alert]).to match I18n.t('users.invalid_otp_token')
end
it "doesn't log the user in" do
expect(controller.current_user).to be_nil
end
end
it 'shows a login error' do
expect(flash[:alert]).to match I18n.t('users.invalid_otp_token')
context 'using a valid recovery code' do
before do
post :create, params: { user: { otp_attempt: recovery_codes.first } }, session: { attempt_user_id: user.id }
end
it 'redirects to home' do
expect(response).to redirect_to(root_path)
end
it 'logs the user in' do
expect(controller.current_user).to eq user
end
end
it "doesn't log the user in" do
expect(controller.current_user).to be_nil
context 'using an invalid OTP' do
before do
post :create, params: { user: { otp_attempt: 'wrongotp' } }, session: { attempt_user_id: user.id }
end
it 'shows a login error' do
expect(flash[:alert]).to match I18n.t('users.invalid_otp_token')
end
it "doesn't log the user in" do
expect(controller.current_user).to be_nil
end
end
end
context 'using a valid recovery code' do
before do
post :create, params: { user: { otp_attempt: recovery_codes.first } }, session: { attempt_user_id: user.id }
context 'with WebAuthn and OTP enabled as second factor' do
let!(:user) do
Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', otp_required_for_login: true, otp_secret: User.generate_otp_secret(32))
end
it 'redirects to home' do
expect(response).to redirect_to(root_path)
let!(:recovery_codes) do
codes = user.generate_otp_backup_codes!
user.save
return codes
end
it 'logs the user in' do
expect(controller.current_user).to eq user
let!(:webauthn_credential) do
user.update(webauthn_id: WebAuthn.generate_user_id)
public_key_credential = WebAuthn::Credential.from_create(fake_client.create)
user.webauthn_credentials.create(
nickname: 'SecurityKeyNickname',
external_id: public_key_credential.id,
public_key: public_key_credential.public_key,
sign_count: '1000'
)
user.webauthn_credentials.take
end
end
context 'using an invalid OTP' do
before do
post :create, params: { user: { otp_attempt: 'wrongotp' } }, session: { attempt_user_id: user.id }
let(:domain) { "#{Rails.configuration.x.use_https ? 'https' : 'http' }://#{Rails.configuration.x.web_domain}" }
let(:fake_client) { WebAuthn::FakeClient.new(domain) }
let(:challenge) { WebAuthn::Credential.options_for_get.challenge }
let(:sign_count) { 1234 }
let(:fake_credential) { fake_client.get(challenge: challenge, sign_count: sign_count) }
context 'using email and password' do
before do
post :create, params: { user: { email: user.email, password: user.password } }
end
it 'renders webauthn authentication page' do
expect(controller).to render_template("two_factor")
expect(controller).to render_template(partial: "_webauthn_form")
end
end
it 'shows a login error' do
expect(flash[:alert]).to match I18n.t('users.invalid_otp_token')
context 'using upcase email and password' do
before do
post :create, params: { user: { email: user.email.upcase, password: user.password } }
end
it 'renders webauthn authentication page' do
expect(controller).to render_template("two_factor")
expect(controller).to render_template(partial: "_webauthn_form")
end
end
it "doesn't log the user in" do
expect(controller.current_user).to be_nil
context 'using a valid webauthn credential' do
before do
@controller.session[:webauthn_challenge] = challenge
post :create, params: { user: { credential: fake_credential } }, session: { attempt_user_id: user.id }
end
it 'instructs the browser to redirect to home' do
expect(body_as_json[:redirect_path]).to eq(root_path)
end
it 'logs the user in' do
expect(controller.current_user).to eq user
end
it 'updates the sign count' do
expect(webauthn_credential.reload.sign_count).to eq(sign_count)
end
end
end
end

+ 76
- 64
spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb View File

@ -5,8 +5,6 @@ require 'rails_helper'
describe Settings::TwoFactorAuthentication::ConfirmationsController do
render_views
let(:user) { Fabricate(:user, email: 'local-part@domain', otp_secret: 'thisisasecretforthespecofnewview') }
let(:user_without_otp_secret) { Fabricate(:user, email: 'local-part@domain') }
shared_examples 'renders :new' do
it 'renders the new view' do
@ -20,87 +18,101 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
end
end
describe 'GET #new' do
context 'when signed in' do
subject do
sign_in user, scope: :user
get :new, session: { challenge_passed_at: Time.now.utc }
end
[true, false].each do |with_otp_secret|
let(:user) { Fabricate(:user, email: 'local-part@domain', otp_secret: with_otp_secret ? 'oldotpsecret' : nil) }
include_examples 'renders :new'
end
describe 'GET #new' do
context 'when signed in and a new otp secret has been setted in the session' do
subject do
sign_in user, scope: :user
get :new, session: { challenge_passed_at: Time.now.utc, new_otp_secret: 'thisisasecretforthespecofnewview' }
end
it 'redirects if not signed in' do
get :new
expect(response).to redirect_to('/auth/sign_in')
end
include_examples 'renders :new'
end
it 'redirects if user do not have otp_secret' do
sign_in user_without_otp_secret, scope: :user
get :new, session: { challenge_passed_at: Time.now.utc }
expect(response).to redirect_to('/settings/two_factor_authentication')
end
end
it 'redirects if not signed in' do
get :new
expect(response).to redirect_to('/auth/sign_in')
end
describe 'POST #create' do
context 'when signed in' do
before do
it 'redirects if a new otp_secret has not been setted in the session' do
sign_in user, scope: :user
get :new, session: { challenge_passed_at: Time.now.utc }
expect(response).to redirect_to('/settings/otp_authentication')
end
end
describe 'when form_two_factor_confirmation parameter is not provided' do
it 'raises ActionController::ParameterMissing' do
post :create, params: {}, session: { challenge_passed_at: Time.now.utc }
expect(response).to have_http_status(400)
describe 'POST #create' do
context 'when signed in' do
before do
sign_in user, scope: :user
end
end
describe 'when creation succeeds' do
it 'renders page with success' do
otp_backup_codes = user.generate_otp_backup_codes!
expect_any_instance_of(User).to receive(:generate_otp_backup_codes!) do |value|
expect(value).to eq user
otp_backup_codes
end
expect_any_instance_of(User).to receive(:validate_and_consume_otp!) do |value, arg|
expect(value).to eq user
expect(arg).to eq '123456'
true
describe 'when form_two_factor_confirmation parameter is not provided' do
it 'raises ActionController::ParameterMissing' do
post :create, params: {}, session: { challenge_passed_at: Time.now.utc, new_otp_secret: 'thisisasecretforthespecofnewview' }
expect(response).to have_http_status(400)
end
end
post :create, params: { form_two_factor_confirmation: { otp_attempt: '123456' } }, session: { challenge_passed_at: Time.now.utc }
expect(assigns(:recovery_codes)).to eq otp_backup_codes
expect(flash[:notice]).to eq 'Two-factor authentication successfully enabled'
expect(response).to have_http_status(200)
expect(response).to render_template('settings/two_factor_authentication/recovery_codes/index')
describe 'when creation succeeds' do
it 'renders page with success' do
otp_backup_codes = user.generate_otp_backup_codes!
expect_any_instance_of(User).to receive(:generate_otp_backup_codes!) do |value|
expect(value).to eq user
otp_backup_codes
end
expect_any_instance_of(User).to receive(:validate_and_consume_otp!) do |value, code, options|
expect(value).to eq user
expect(code).to eq '123456'
expect(options).to eq({ otp_secret: 'thisisasecretforthespecofnewview' })
true
end
expect do
post :create,
params: { form_two_factor_confirmation: { otp_attempt: '123456' } },
session: { challenge_passed_at: Time.now.utc, new_otp_secret: 'thisisasecretforthespecofnewview' }
end.to change { user.reload.otp_secret }.to 'thisisasecretforthespecofnewview'
expect(assigns(:recovery_codes)).to eq otp_backup_codes
expect(flash[:notice]).to eq 'Two-factor authentication successfully enabled'
expect(response).to have_http_status(200)
expect(response).to render_template('settings/two_factor_authentication/recovery_codes/index')
end
end
end
describe 'when creation fails' do
subject do
expect_any_instance_of(User).to receive(:validate_and_consume_otp!) do |value, arg|
expect(value).to eq user
expect(arg).to eq '123456'
false
describe 'when creation fails' do
subject do
expect_any_instance_of(User).to receive(:validate_and_consume_otp!) do |value, code, options|
expect(value).to eq user
expect(code).to eq '123456'
expect(options).to eq({ otp_secret: 'thisisasecretforthespecofnewview' })
false
end
expect do
post :create,
params: { form_two_factor_confirmation: { otp_attempt: '123456' } },
session: { challenge_passed_at: Time.now.utc, new_otp_secret: 'thisisasecretforthespecofnewview' }
end.to not_change { user.reload.otp_secret }
end
post :create, params: { form_two_factor_confirmation: { otp_attempt: '123456' } }, session: { challenge_passed_at: Time.now.utc }
end
it 'renders the new view' do
subject
expect(response.body).to include 'The entered code was invalid! Are server time and device time correct?'
end
it 'renders the new view' do
subject
expect(response.body).to include 'The entered code was invalid! Are server time and device time correct?'
include_examples 'renders :new'
end
include_examples 'renders :new'
end
end
context 'when not signed in' do
it 'redirects if not signed in' do
post :create, params: { form_two_factor_confirmation: { otp_attempt: '123456' } }
expect(response).to redirect_to('/auth/sign_in')
context 'when not signed in' do
it 'redirects if not signed in' do
post :create, params: { form_two_factor_confirmation: { otp_attempt: '123456' } }
expect(response).to redirect_to('/auth/sign_in')
end
end
end
end

+ 99
- 0
spec/controllers/settings/two_factor_authentication/otp_authentication_controller_spec.rb View File

@ -0,0 +1,99 @@
# frozen_string_literal: true
require 'rails_helper'
describe Settings::TwoFactorAuthentication::OtpAuthenticationController do
render_views
let(:user) { Fabricate(:user) }
describe 'GET #show' do
context 'when signed in' do
before do
sign_in user, scope: :user
end
describe 'when user has OTP enabled' do
before do
user.update(otp_required_for_login: true)
end
it 'redirects to two factor authentciation methods list page' do
get :show
expect(response).to redirect_to settings_two_factor_authentication_methods_path
end
end
describe 'when user does not have OTP enabled' do
before do
user.update(otp_required_for_login: false)
end
it 'returns http success' do
get :show
expect(response).to have_http_status(200)
end
end
end
context 'when not signed in' do
it 'redirects' do
get :show
expect(response).to redirect_to new_user_session_path
end
end
end
describe 'POST #create' do
context 'when signed in' do
before do
sign_in user, scope: :user
end
describe 'when user has OTP enabled' do
before do
user.update(otp_required_for_login: true)
end
describe 'when creation succeeds' do
it 'redirects to code confirmation page without updating user secret and setting otp secret in the session' do
expect do
post :create, session: { challenge_passed_at: Time.now.utc }
end.to not_change { user.reload.otp_secret }
.and change { session[:new_otp_secret] }
expect(response).to redirect_to(new_settings_two_factor_authentication_confirmation_path)
end
end
end
describe 'when user does not have OTP enabled' do
before do
user.update(otp_required_for_login: false)
end
describe 'when creation succeeds' do
it 'redirects to code confirmation page without updating user secret and setting otp secret in the session' do
expect do
post :create, session: { challenge_passed_at: Time.now.utc }
end.to not_change { user.reload.otp_secret }
.and change { session[:new_otp_secret] }
expect(response).to redirect_to(new_settings_two_factor_authentication_confirmation_path)
end
end
end
end
context 'when not signed in' do
it 'redirects to login' do
get :show
expect(response).to redirect_to new_user_session_path
end
end
end
end

+ 374
- 0
spec/controllers/settings/two_factor_authentication/webauthn_credentials_controller_spec.rb View File

@ -0,0 +1,374 @@
# frozen_string_literal: true
require 'rails_helper'
require 'webauthn/fake_client'
describe Settings::TwoFactorAuthentication::WebauthnCredentialsController do
render_views
let(:user) { Fabricate(:user) }
let(:domain) { "#{Rails.configuration.x.use_https ? 'https' : 'http' }://#{Rails.configuration.x.web_domain}" }
let(:fake_client) { WebAuthn::FakeClient.new(domain) }
def add_webauthn_credential(user)
Fabricate(:webauthn_credential, user_id: user.id, nickname: 'USB Key')
end
describe 'GET #new' do
context 'when signed in' do
before do
sign_in user, scope: :user
end
context 'when user has otp enabled' do
before do
user.update(otp_required_for_login: true)
end
it 'returns http success' do
get :new
expect(response).to have_http_status(200)
end
end
context 'when user does not have otp enabled' do
before do
user.update(otp_required_for_login: false)
end
it 'requires otp enabled first' do
get :new
expect(response).to redirect_to settings_two_factor_authentication_methods_path
expect(flash[:error]).to be_present
end
end
end
end
describe 'GET #index' do
context 'when signed in' do
before do
sign_in user, scope: :user
end
context 'when user has otp enabled' do
before do
user.update(otp_required_for_login: true)
end
context 'when user has webauthn enabled' do
before do
user.update(webauthn_id: WebAuthn.generate_user_id)
add_webauthn_credential(user)
end
it 'returns http success' do
get :index
expect(response).to have_http_status(200)
end
end
context 'when user does not has webauthn enabled' do
it 'redirects to 2FA methods list page' do
get :index
expect(response).to redirect_to settings_two_factor_authentication_methods_path
expect(flash[:error]).to be_present
end
end
end
context 'when user does not have otp enabled' do
before do
user.update(otp_required_for_login: false)
end
it 'requires otp enabled first' do
get :index
expect(response).to redirect_to settings_two_factor_authentication_methods_path
expect(flash[:error]).to be_present
end
end
end
context 'when not signed in' do
it 'redirects to login' do
delete :index
expect(response).to redirect_to new_user_session_path
end
end
end
describe 'GET /options #options' do
context 'when signed in' do
before do
sign_in user, scope: :user
end
context 'when user has otp enabled' do
before do
user.update(otp_required_for_login: true)
end
context 'when user has webauthn enabled' do
before do
user.update(webauthn_id: WebAuthn.generate_user_id)
add_webauthn_credential(user)
end
it 'returns http success' do
get :options
expect(response).to have_http_status(200)
end
it 'stores the challenge on the session' do
get :options
expect(@controller.session[:webauthn_challenge]).to be_present
end
it 'does not change webauthn_id' do
expect { get :options }.to_not change { user.webauthn_id }
end
it "includes existing credentials in list of excluded credentials" do
get :options
excluded_credentials_ids = JSON.parse(response.body)['excludeCredentials'].map { |credential| credential['id'] }
expect(excluded_credentials_ids).to match_array(user.webauthn_credentials.pluck(:external_id))
end
end
context 'when user does not have webauthn enabled' do
it 'returns http success' do
get :options
expect(response).to have_http_status(200)
end
it 'stores the challenge on the session' do
get :options
expect(@controller.session[:webauthn_challenge]).to be_present
end
it 'sets user webauthn_id' do
get :options
expect(user.reload.webauthn_id).to be_present
end
end
end
context 'when user has not enabled otp' do
before do
user.update(otp_required_for_login: false)
end
it 'requires otp enabled first' do
get :options
expect(response).to redirect_to settings_two_factor_authentication_methods_path
expect(flash[:error]).to be_present
end
end
end
context 'when not signed in' do
it 'redirects to login' do
get :options
expect(response).to redirect_to new_user_session_path
end
end
end
describe 'POST #create' do
let(:nickname) { 'SecurityKeyNickname' }
let(:challenge) do
WebAuthn::Credential.options_for_create(
user: { id: user.id, name: user.account.username, display_name: user.account.display_name }
).challenge
end
let(:new_webauthn_credential) { fake_client.create(challenge: challenge) }
context 'when signed in' do
before do
sign_in user, scope: :user
end
context 'when user has enabled otp' do
before do
user.update(otp_required_for_login: true)
end
context 'when user has enabled webauthn' do
before do
user.update(webauthn_id: WebAuthn.generate_user_id)
add_webauthn_credential(user)
end
context 'when creation succeeds' do
it 'returns http success' do
@controller.session[:webauthn_challenge] = challenge
post :create, params: { credential: new_webauthn_credential, nickname: nickname }
expect(response).to have_http_status(200)
end
it 'adds a new credential to user credentials' do
@controller.session[:webauthn_challenge] = challenge
expect do
post :create, params: { credential: new_webauthn_credential, nickname: nickname }
end.to change { user.webauthn_credentials.count }.by(1)
end
it 'does not change webauthn_id' do
@controller.session[:webauthn_challenge] = challenge
expect do
post :create, params: { credential: new_webauthn_credential, nickname: nickname }
end.to_not change { user.webauthn_id }
end
end
context 'when the nickname is already used' do
it 'fails' do
@controller.session[:webauthn_challenge] = challenge
post :create, params: { credential: new_webauthn_credential, nickname: 'USB Key' }
expect(response).to have_http_status(500)
expect(flash[:error]).to be_present
end
end
context 'when the credential already exists' do
before do
user2 = Fabricate(:user)
public_key_credential = WebAuthn::Credential.from_create(new_webauthn_credential)
Fabricate(:webauthn_credential,
user_id: user2.id,
external_id: public_key_credential.id,
public_key: public_key_credential.public_key)
end
it 'fails' do
@controller.session[:webauthn_challenge] = challenge
post :create, params: { credential: new_webauthn_credential, nickname: nickname }
expect(response).to have_http_status(500)
expect(flash[:error]).to be_present
end
end
end
context 'when user have not enabled webauthn' do
context 'creation succeeds' do
it 'creates a webauthn credential' do
@controller.session[:webauthn_challenge] = challenge
expect do
post :create, params: { credential: new_webauthn_credential, nickname: nickname }
end.to change { user.webauthn_credentials.count }.by(1)
end
end
end
end
context 'when user has not enabled otp' do
before do
user.update(otp_required_for_login: false)
end
it 'requires otp enabled first' do
post :create, params: { credential: new_webauthn_credential, nickname: nickname }
expect(response).to redirect_to settings_two_factor_authentication_methods_path
expect(flash[:error]).to be_present
end
end
end
context 'when not signed in' do
it 'redirects to login' do
post :create, params: { credential: new_webauthn_credential, nickname: nickname }
expect(response).to redirect_to new_user_session_path
end
end
end
describe 'DELETE #destroy' do
context 'when signed in' do
before do
sign_in user, scope: :user
end
context 'when user has otp enabled' do
before do
user.update(otp_required_for_login: true)
end
context 'when user has webauthn enabled' do
before do
user.update(webauthn_id: WebAuthn.generate_user_id)
add_webauthn_credential(user)
end
context 'when deletion succeeds' do
it 'redirects to 2FA methods list and shows flash success' do
delete :destroy, params: { id: user.webauthn_credentials.take.id }
expect(response).to redirect_to settings_two_factor_authentication_methods_path
expect(flash[:success]).to be_present
end
it 'deletes the credential' do
expect do
delete :destroy, params: { id: user.webauthn_credentials.take.id }
end.to change { user.webauthn_credentials.count }.by(-1)
end
end
end
context 'when user does not have webauthn enabled' do
it 'redirects to 2FA methods list and shows flash error' do
delete :destroy, params: { id: '1' }
expect(response).to redirect_to settings_two_factor_authentication_methods_path
expect(flash[:error]).to be_present
end
end
end
context 'when user does not have otp enabled' do
it 'requires otp enabled first' do
delete :destroy, params: { id: '1' }
expect(response).to redirect_to settings_two_factor_authentication_methods_path
expect(flash[:error]).to be_present
end
end
end
context 'when not signed in' do
it 'redirects to login' do
delete :destroy, params: { id: '1' }
expect(response).to redirect_to new_user_session_path
end
end
end
end

+ 49
- 0
spec/controllers/settings/two_factor_authentication_methods_controller_spec.rb View File

@ -0,0 +1,49 @@
# frozen_string_literal: true
require 'rails_helper'
describe Settings::TwoFactorAuthenticationMethodsController do
render_views
let(:user) { Fabricate(:user) }
describe 'GET #index' do
context 'when signed in' do
before do
sign_in user, scope: :user
end
describe 'when user has enabled otp' do
before do
user.update(otp_required_for_login: true)
end
it 'returns http success' do
get :index
expect(response).to have_http_status(200)
end
end
describe 'when user has not enabled otp' do
before do
user.update(otp_required_for_login: false)
end
it 'redirects to enable otp' do
get :index
expect(response).to redirect_to(settings_otp_authentication_path)
end
end
end
context 'when not signed in' do
it 'redirects' do
get :index
expect(response).to redirect_to '/auth/sign_in'
end
end
end
end

+ 0
- 125
spec/controllers/settings/two_factor_authentications_controller_spec.rb View File

@ -1,125 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
describe Settings::TwoFactorAuthenticationsController do
render_views
let(:user) { Fabricate(:user) }
describe 'GET #show' do
context 'when signed in' do
before do
sign_in user, scope: :user
end
describe 'when user requires otp for login already' do
it 'returns http success' do
user.update(otp_required_for_login: true)
get :show
expect(response).to have_http_status(200)
end
end
describe 'when user does not require otp for login' do
it 'returns http success' do
user.update(otp_required_for_login: false)
get :show
expect(response).to have_http_status(200)
end
end
end
context 'when not signed in' do
it 'redirects' do
get :show
expect(response).to redirect_to '/auth/sign_in'
end
end
end
describe 'POST #create' do
context 'when signed in' do
before do
sign_in user, scope: :user
end
describe 'when user requires otp for login already' do
it 'redirects to show page' do
user.update(otp_required_for_login: true)
post :create
expect(response).to redirect_to(settings_two_factor_authentication_path)
end
end
describe 'when creation succeeds' do
it 'updates user secret' do
before = user.otp_secret
post :create, session: { challenge_passed_at: Time.now.utc }
expect(user.reload.otp_secret).not_to eq(before)
expect(response).to redirect_to(new_settings_two_factor_authentication_confirmation_path)
end
end
end
context 'when not signed in' do
it 'redirects' do
get :show
expect(response).to redirect_to '/auth/sign_in'
end
end
end
describe 'POST #destroy' do
before do
user.update(otp_required_for_login: true)
end
context 'when signed in' do
before do
sign_in user, scope: :user
end
it 'turns off otp requirement with correct code' do
expect_any_instance_of(User).to receive(:validate_and_consume_otp!) do |value, arg|
expect(value).to eq user
expect(arg).to eq '123456'
true
end
post :destroy, params: { form_two_factor_confirmation: { otp_attempt: '123456' } }
expect(response).to redirect_to(settings_two_factor_authentication_path)
user.reload
expect(user.otp_required_for_login).to eq(false)
end
it 'does not turn off otp if code is incorrect' do
expect_any_instance_of(User).to receive(:validate_and_consume_otp!) do |value, arg|
expect(value).to eq user
expect(arg).to eq '057772'
false
end
post :destroy, params: { form_two_factor_confirmation: { otp_attempt: '057772' } }
user.reload
expect(user.otp_required_for_login).to eq(true)
end
it 'raises ActionController::ParameterMissing if code is missing' do
post :destroy
expect(response).to have_http_status(400)
end
end
it 'redirects if not signed in' do
get :show
expect(response).to redirect_to '/auth/sign_in'
end
end
end

+ 7
- 0
spec/fabricators/webauthn_credential_fabricator.rb View File

@ -0,0 +1,7 @@
Fabricator(:webauthn_credential) do
user_id { Fabricate(:user).id }
external_id { Base64.urlsafe_encode64(SecureRandom.random_bytes(16)) }
public_key { OpenSSL::PKey::EC.new("prime256v1").generate_key.public_key }
nickname 'USB key'
sign_count 0
end

+ 6
- 0
spec/models/user_spec.rb View File

@ -151,6 +151,12 @@ RSpec.describe User, type: :model do
expect(user.reload.otp_required_for_login).to be false
end
it 'saves nil for otp_secret' do
user = Fabricate.build(:user, otp_secret: 'oldotpcode')
user.disable_two_factor!
expect(user.reload.otp_secret).to be nil
end
it 'saves cleared otp_backup_codes' do
user = Fabricate.build(:user, otp_backup_codes: %w(dummy dummy))
user.disable_two_factor!

+ 80
- 0
spec/models/webauthn_credentials_spec.rb View File

@ -0,0 +1,80 @@
require 'rails_helper'
RSpec.describe WebauthnCredential, type: :model do
describe 'validations' do
it 'is invalid without an external id' do
webauthn_credential = Fabricate.build(:webauthn_credential, external_id: nil)
webauthn_credential.valid?
expect(webauthn_credential).to model_have_error_on_field(:external_id)
end
it 'is invalid without a public key' do
webauthn_credential = Fabricate.build(:webauthn_credential, public_key: nil)
webauthn_credential.valid?
expect(webauthn_credential).to model_have_error_on_field(:public_key)
end
it 'is invalid without a nickname' do
webauthn_credential = Fabricate.build(:webauthn_credential, nickname: nil)
webauthn_credential.valid?
expect(webauthn_credential).to model_have_error_on_field(:nickname)
end
it 'is invalid without a sign_count' do
webauthn_credential = Fabricate.build(:webauthn_credential, sign_count: nil)
webauthn_credential.valid?
expect(webauthn_credential).to model_have_error_on_field(:sign_count)
end
it 'is invalid if already exist a webauthn credential with the same external id' do
existing_webauthn_credential = Fabricate(:webauthn_credential, external_id: "_Typ0ygudDnk9YUVWLQayw")
new_webauthn_credential = Fabricate.build(:webauthn_credential, external_id: "_Typ0ygudDnk9YUVWLQayw")
new_webauthn_credential.valid?
expect(new_webauthn_credential).to model_have_error_on_field(:external_id)
end
it 'is invalid if user already registered a webauthn credential with the same nickname' do
user = Fabricate(:user)
existing_webauthn_credential = Fabricate(:webauthn_credential, user_id: user.id, nickname: 'USB Key')
new_webauthn_credential = Fabricate.build(:webauthn_credential, user_id: user.id, nickname: 'USB Key')
new_webauthn_credential.valid?
expect(new_webauthn_credential).to model_have_error_on_field(:nickname)
end
it 'is invalid if sign_count is not a number' do
webauthn_credential = Fabricate.build(:webauthn_credential, sign_count: 'invalid sign_count')
webauthn_credential.valid?
expect(webauthn_credential).to model_have_error_on_field(:sign_count)
end
it 'is invalid if sign_count is negative number' do
webauthn_credential = Fabricate.build(:webauthn_credential, sign_count: -1)
webauthn_credential.valid?
expect(webauthn_credential).to model_have_error_on_field(:sign_count)
end
it 'is invalid if sign_count is greater 2**32 - 1' do
webauthn_credential = Fabricate.build(:webauthn_credential, sign_count: 2**32)
webauthn_credential.valid?
expect(webauthn_credential).to model_have_error_on_field(:sign_count)
end
end
end

+ 2
- 0
spec/rails_helper.rb View File

@ -70,6 +70,8 @@ RSpec::Sidekiq.configure do |config|
config.warn_when_jobs_not_processed_by_sidekiq = false
end
RSpec::Matchers.define_negated_matcher :not_change, :change
def request_fixture(name)
File.read(Rails.root.join('spec', 'fixtures', 'requests', name))
end

+ 6
- 1
yarn.lock View File

@ -1139,6 +1139,11 @@
resolved "https://registry.yarnpkg.com/@gamestdio/websocket/-/websocket-0.3.2.tgz#321ba0976ee30fd14e51dbf8faa85ce7b325f76a"
integrity sha512-J3n5SKim+ZoLbe44hRGI/VYAwSMCeIJuBy+FfP6EZaujEpNchPRFcIsVQLWAwpU1bP2Ji63rC+rEUOd1vjUB6Q==
"@github/webauthn-json@^0.4.2":
version "0.4.2"
resolved "https://registry.yarnpkg.com/@github/webauthn-json/-/webauthn-json-0.4.2.tgz#573bba7f30f035d82a6b6040430eb4e9729db849"
integrity sha512-10RwfEzpg0y68Coj480tYE4KajRO39ii0652bWbKM0OfXlV9szw6N5vMwnNzelvAMigcEDiAtFcvFCvB8GnTtA==
"@istanbuljs/load-nyc-config@^1.0.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced"
@ -9191,7 +9196,7 @@ regenerator-runtime@^0.12.0:
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de"
integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==
regenerator-runtime@^0.13.4:
regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.7:
version "0.13.7"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55"
integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==

Loading…
Cancel
Save