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>
th-downstream
santiagorodriguez96 4 years ago committed by GitHub
parent c950a85d9e
commit f142983484

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

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

@ -37,6 +37,22 @@ class Auth::SessionsController < Devise::SessionsController
store_location_for(:user, tmp_stored_location) if continue_after? store_location_for(:user, tmp_stored_location) if continue_after?
end 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 protected
def find_user def find_user
@ -51,7 +67,7 @@ class Auth::SessionsController < Devise::SessionsController
end end
def user_params 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 end
def after_sign_in_path_for(resource) def after_sign_in_path_for(resource)

@ -8,7 +8,23 @@ module TwoFactorAuthenticationConcern
end end
def two_factor_enabled? 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 end
def valid_otp_attempt?(user) def valid_otp_attempt?(user)
@ -21,14 +37,29 @@ module TwoFactorAuthenticationConcern
def authenticate_with_two_factor def authenticate_with_two_factor
user = self.resource = find_user user = self.resource = find_user
if user_params[:otp_attempt].present? && session[:attempt_user_id] if user.webauthn_enabled? && user_params[:credential].present? && session[:attempt_user_id]
authenticate_with_two_factor_attempt(user) 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]) elsif user.present? && user.external_or_valid_password?(user_params[:password])
prompt_for_two_factor(user) prompt_for_two_factor(user)
end end
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) if valid_otp_attempt?(user)
session.delete(:attempt_user_id) session.delete(:attempt_user_id)
remember_me(user) remember_me(user)
@ -43,6 +74,12 @@ module TwoFactorAuthenticationConcern
set_locale do set_locale do
session[:attempt_user_id] = user.id session[:attempt_user_id] = user.id
@body_classes = 'lighter' @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 render :two_factor
end end
end end

@ -18,18 +18,21 @@ module Settings
end end
def create 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') flash.now[:notice] = I18n.t('two_factor_authentication.enabled_success')
current_user.otp_required_for_login = true current_user.otp_required_for_login = true
current_user.otp_secret = session[:new_otp_secret]
@recovery_codes = current_user.generate_otp_backup_codes! @recovery_codes = current_user.generate_otp_backup_codes!
current_user.save! current_user.save!
UserMailer.two_factor_enabled(current_user).deliver_later! UserMailer.two_factor_enabled(current_user).deliver_later!
session.delete(:new_otp_secret)
render 'settings/two_factor_authentication/recovery_codes/index' render 'settings/two_factor_authentication/recovery_codes/index'
else else
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code') flash.now[:alert] = I18n.t('otp_authentication.wrong_code')
prepare_two_factor_form prepare_two_factor_form
render :new render :new
end end
@ -43,12 +46,15 @@ module Settings
def prepare_two_factor_form def prepare_two_factor_form
@confirmation = Form::TwoFactorConfirmation.new @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) @qrcode = RQRCode::QRCode.new(@provision_url)
end end
def ensure_otp_secret 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 end
end end

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

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

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

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

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

@ -12,6 +12,10 @@ code {
} }
.simple_form { .simple_form {
&.hidden {
display: none;
}
.input { .input {
margin-bottom: 15px; margin-bottom: 15px;
overflow: hidden; overflow: hidden;
@ -100,6 +104,14 @@ code {
} }
} }
.title {
color: #d9e1e8;
font-size: 20px;
line-height: 28px;
font-weight: 400;
margin-bottom: 30px;
}
.hint { .hint {
color: $darker-text-color; color: $darker-text-color;
@ -142,7 +154,7 @@ code {
} }
} }
.otp-hint { .authentication-hint {
margin-bottom: 25px; margin-bottom: 25px;
} }
@ -592,6 +604,10 @@ code {
color: $error-value-color; color: $error-value-color;
} }
&.hidden {
display: none;
}
a { a {
display: inline-block; display: inline-block;
color: $darker-text-color; color: $darker-text-color;

@ -91,6 +91,52 @@ class UserMailer < Devise::Mailer
end end
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) def welcome(user)
@resource = user @resource = user
@instance = Rails.configuration.x.local_domain @instance = Rails.configuration.x.local_domain

@ -40,6 +40,7 @@
# approved :boolean default(TRUE), not null # approved :boolean default(TRUE), not null
# sign_in_token :string # sign_in_token :string
# sign_in_token_sent_at :datetime # sign_in_token_sent_at :datetime
# webauthn_id :string
# #
class User < ApplicationRecord class User < ApplicationRecord
@ -77,6 +78,7 @@ class User < ApplicationRecord
has_many :backups, inverse_of: :user has_many :backups, inverse_of: :user
has_many :invites, inverse_of: :user has_many :invites, inverse_of: :user
has_many :markers, inverse_of: :user, dependent: :destroy 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 has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? } accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
@ -197,9 +199,25 @@ class User < ApplicationRecord
prepare_returning_user! prepare_returning_user!
end 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! def disable_two_factor!
self.otp_required_for_login = false self.otp_required_for_login = false
self.otp_secret = nil
otp_backup_codes&.clear otp_backup_codes&.clear
webauthn_credentials.destroy_all if webauthn_enabled?
save! save!
end end

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

@ -1,14 +1,9 @@
- content_for :page_title do - content_for :page_title do
= t('auth.login') = t('auth.login')
= simple_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f| =javascript_pack_tag 'two_factor_authentication', integrity: true, crossorigin: 'anonymous'
%p.hint.otp-hint= t('simple_form.hints.sessions.otp')
.fields-group - if @webauthn_enabled
= 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 = render partial: 'auth/sessions/two_factor/webauthn_form', locals: { hidden: @scheme_type != 'webauthn' }
.actions = render partial: 'auth/sessions/two_factor/otp_authentication_form', locals: { hidden: @scheme_type != 'totp' }
= 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))

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

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

@ -2,17 +2,17 @@
= t('settings.two_factor_authentication') = t('settings.two_factor_authentication')
= simple_form_for @confirmation, url: settings_two_factor_authentication_confirmation_path, method: :post do |f| = 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-wrapper
.qr-code!= @qrcode.as_svg(padding: 0, module_size: 4) .qr-code!= @qrcode.as_svg(padding: 0, module_size: 4)
.qr-alternative .qr-alternative
%p.hint= t('two_factor_authentication.manual_instructions') %p.hint= t('otp_authentication.manual_instructions')
%samp.qr-alternative__code= current_user.otp_secret.scan(/.{4}/).join(' ') %samp.qr-alternative__code= @new_otp_secret.scan(/.{4}/).join(' ')
.fields-group .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 .actions
= f.button :button, t('two_factor_authentication.enable'), type: :submit = f.button :button, t('otp_authentication.enable'), type: :submit

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -60,6 +60,23 @@ en:
title: 2FA recovery codes changed title: 2FA recovery codes changed
unlock_instructions: unlock_instructions:
subject: 'Mastodon: 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: omniauth_callbacks:
failure: Could not authenticate you from %{kind} because "%{reason}". failure: Could not authenticate you from %{kind} because "%{reason}".
success: Successfully authenticated from %{kind} account. success: Successfully authenticated from %{kind} account.

@ -681,8 +681,11 @@ en:
prefix_sign_up: Sign up on Mastodon today! 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! 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? didnt_get_confirmation: Didn't receive confirmation instructions?
dont_have_your_security_key: Don't have your security key?
forgot_password: Forgot your password? forgot_password: Forgot your password?
invalid_reset_password_token: Password reset token is invalid or expired. Please request a new one. 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 login: Log in
logout: Logout logout: Logout
migrate_account: Move to a different account 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. 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}. redirecting_to: Your account is inactive because it is currently redirecting to %{acct}.
trouble_logging_in: Trouble logging in? trouble_logging_in: Trouble logging in?
use_security_key: Use security key
authorize_follow: authorize_follow:
already_following: You are already following this account already_following: You are already following this account
already_requested: You have already sent a follow request to that account already_requested: You have already sent a follow request to that account
@ -732,6 +736,7 @@ en:
date: date:
formats: formats:
default: "%b %d, %Y" default: "%b %d, %Y"
with_month_name: "%B %d, %Y"
datetime: datetime:
distance_in_words: distance_in_words:
about_x_hours: "%{count}h" about_x_hours: "%{count}h"
@ -993,6 +998,14 @@ en:
thousand: K thousand: K
trillion: T trillion: T
unit: '' 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: pagination:
newer: Newer newer: Newer
next: Next next: Next
@ -1117,6 +1130,7 @@ en:
profile: Profile profile: Profile
relationships: Follows and followers relationships: Follows and followers
two_factor_authentication: Two-factor Auth two_factor_authentication: Two-factor Auth
webauthn_authentication: Security keys
spam_check: spam_check:
spam_detected: This is an automated report. Spam has been detected. spam_detected: This is an automated report. Spam has been detected.
statuses: statuses:
@ -1263,21 +1277,20 @@ en:
default: "%b %d, %Y, %H:%M" default: "%b %d, %Y, %H:%M"
month: "%b %Y" month: "%b %Y"
two_factor_authentication: two_factor_authentication:
code_hint: Enter the code generated by your authenticator app to confirm add: Add
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 2FA
disable: Disable disabled_success: Two-factor authentication successfully disabled
enable: Enable edit: Edit
enabled: Two-factor authentication is enabled enabled: Two-factor authentication is enabled
enabled_success: Two-factor authentication successfully enabled enabled_success: Two-factor authentication successfully enabled
generate_recovery_codes: Generate recovery codes 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. 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: Backup recovery codes
recovery_codes_regenerated: Recovery codes successfully regenerated 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. 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 webauthn: Security keys
wrong_code: The entered code was invalid! Are server time and device time correct?
user_mailer: user_mailer:
backup_ready: backup_ready:
explanation: You requested a full backup of your Mastodon account. It's now ready for download! explanation: You requested a full backup of your Mastodon account. It's now ready for download!
@ -1339,3 +1352,20 @@ en:
verification: 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:' 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 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}

@ -67,6 +67,7 @@ en:
text: This will help us review your application text: This will help us review your application
sessions: sessions:
otp: 'Enter the two-factor code generated by your phone app or use one of your recovery codes:' 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: tag:
name: You can only change the casing of the letters, for example, to make it more readable name: You can only change the casing of the letters, for example, to make it more readable
user: user:
@ -188,4 +189,7 @@ en:
required: required:
mark: "*" mark: "*"
text: required text: required
title:
sessions:
webauthn: Use one of your security keys to sign in
'yes': 'Yes' 'yes': 'Yes'

@ -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| 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 :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 s.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_url
end end

@ -45,6 +45,7 @@ Rails.application.routes.draw do
namespace :auth do namespace :auth do
resource :setup, only: [:show, :update], controller: :setup resource :setup, only: [:show, :update], controller: :setup
resource :challenge, only: [:create], controller: :challenges resource :challenge, only: [:create], controller: :challenges
get 'sessions/security_key_options', to: 'sessions#webauthn_options'
end end
end end
@ -124,7 +125,22 @@ Rails.application.routes.draw do
resources :domain_blocks, only: :index, controller: :blocked_domains resources :domain_blocks, only: :index, controller: :blocked_domains
end 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 namespace :two_factor_authentication do
resources :recovery_codes, only: [:create] resources :recovery_codes, only: [:create]

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

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

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

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

@ -1,20 +1,51 @@
require 'rails_helper' require 'rails_helper'
require 'webauthn/fake_client'
describe Admin::TwoFactorAuthenticationsController do describe Admin::TwoFactorAuthenticationsController do
render_views render_views
let(:user) { Fabricate(:user, otp_required_for_login: true) } let(:user) { Fabricate(:user) }
before do before do
sign_in Fabricate(:user, admin: true), scope: :user sign_in Fabricate(:user, admin: true), scope: :user
end end
describe 'DELETE #destroy' do describe 'DELETE #destroy' do
context 'when user has OTP enabled' do
before do
user.update(otp_required_for_login: true)
end
it 'redirects to admin accounts page' do it 'redirects to admin accounts page' do
delete :destroy, params: { user_id: user.id } delete :destroy, params: { user_id: user.id }
user.reload user.reload
expect(user.otp_required_for_login).to eq false expect(user.otp_enabled?).to eq false
expect(response).to redirect_to(admin_accounts_path) expect(response).to redirect_to(admin_accounts_path)
end end
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 end

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper' require 'rails_helper'
require 'webauthn/fake_client'
RSpec.describe Auth::SessionsController, type: :controller do RSpec.describe Auth::SessionsController, type: :controller do
render_views render_views
@ -183,6 +184,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
end end
context 'using two-factor authentication' do context 'using two-factor authentication' do
context 'with OTP enabled as second factor' do
let!(:user) do let!(:user) do
Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', otp_required_for_login: true, otp_secret: User.generate_otp_secret(32)) Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', otp_required_for_login: true, otp_secret: User.generate_otp_secret(32))
end end
@ -200,6 +202,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
it 'renders two factor authentication page' do it 'renders two factor authentication page' do
expect(controller).to render_template("two_factor") expect(controller).to render_template("two_factor")
expect(controller).to render_template(partial: "_otp_authentication_form")
end end
end end
@ -210,6 +213,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
it 'renders two factor authentication page' do it 'renders two factor authentication page' do
expect(controller).to render_template("two_factor") expect(controller).to render_template("two_factor")
expect(controller).to render_template(partial: "_otp_authentication_form")
end end
end end
@ -271,6 +275,83 @@ RSpec.describe Auth::SessionsController, type: :controller do
end end
end end
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
let!(:recovery_codes) do
codes = user.generate_otp_backup_codes!
user.save
return codes
end
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
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
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
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
context 'when 2FA is disabled and IP is unfamiliar' do context 'when 2FA is disabled and IP is unfamiliar' do
let!(:user) { Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', current_sign_in_at: 3.weeks.ago, current_sign_in_ip: '0.0.0.0') } let!(:user) { Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', current_sign_in_at: 3.weeks.ago, current_sign_in_ip: '0.0.0.0') }

@ -5,8 +5,6 @@ require 'rails_helper'
describe Settings::TwoFactorAuthentication::ConfirmationsController do describe Settings::TwoFactorAuthentication::ConfirmationsController do
render_views 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 shared_examples 'renders :new' do
it 'renders the new view' do it 'renders the new view' do
@ -20,11 +18,14 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
end end
end end
[true, false].each do |with_otp_secret|
let(:user) { Fabricate(:user, email: 'local-part@domain', otp_secret: with_otp_secret ? 'oldotpsecret' : nil) }
describe 'GET #new' do describe 'GET #new' do
context 'when signed in' do context 'when signed in and a new otp secret has been setted in the session' do
subject do subject do
sign_in user, scope: :user sign_in user, scope: :user
get :new, session: { challenge_passed_at: Time.now.utc } get :new, session: { challenge_passed_at: Time.now.utc, new_otp_secret: 'thisisasecretforthespecofnewview' }
end end
include_examples 'renders :new' include_examples 'renders :new'
@ -35,10 +36,10 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
expect(response).to redirect_to('/auth/sign_in') expect(response).to redirect_to('/auth/sign_in')
end end
it 'redirects if user do not have otp_secret' do it 'redirects if a new otp_secret has not been setted in the session' do
sign_in user_without_otp_secret, scope: :user sign_in user, scope: :user
get :new, session: { challenge_passed_at: Time.now.utc } get :new, session: { challenge_passed_at: Time.now.utc }
expect(response).to redirect_to('/settings/two_factor_authentication') expect(response).to redirect_to('/settings/otp_authentication')
end end
end end
@ -50,7 +51,7 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
describe 'when form_two_factor_confirmation parameter is not provided' do describe 'when form_two_factor_confirmation parameter is not provided' do
it 'raises ActionController::ParameterMissing' do it 'raises ActionController::ParameterMissing' do
post :create, params: {}, session: { challenge_passed_at: Time.now.utc } post :create, params: {}, session: { challenge_passed_at: Time.now.utc, new_otp_secret: 'thisisasecretforthespecofnewview' }
expect(response).to have_http_status(400) expect(response).to have_http_status(400)
end end
end end
@ -62,13 +63,18 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
expect(value).to eq user expect(value).to eq user
otp_backup_codes otp_backup_codes
end end
expect_any_instance_of(User).to receive(:validate_and_consume_otp!) do |value, arg| expect_any_instance_of(User).to receive(:validate_and_consume_otp!) do |value, code, options|
expect(value).to eq user expect(value).to eq user
expect(arg).to eq '123456' expect(code).to eq '123456'
expect(options).to eq({ otp_secret: 'thisisasecretforthespecofnewview' })
true true
end end
post :create, params: { form_two_factor_confirmation: { otp_attempt: '123456' } }, session: { challenge_passed_at: Time.now.utc } 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(assigns(:recovery_codes)).to eq otp_backup_codes
expect(flash[:notice]).to eq 'Two-factor authentication successfully enabled' expect(flash[:notice]).to eq 'Two-factor authentication successfully enabled'
@ -79,13 +85,18 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
describe 'when creation fails' do describe 'when creation fails' do
subject do subject do
expect_any_instance_of(User).to receive(:validate_and_consume_otp!) do |value, arg| expect_any_instance_of(User).to receive(:validate_and_consume_otp!) do |value, code, options|
expect(value).to eq user expect(value).to eq user
expect(arg).to eq '123456' expect(code).to eq '123456'
expect(options).to eq({ otp_secret: 'thisisasecretforthespecofnewview' })
false false
end end
post :create, params: { form_two_factor_confirmation: { otp_attempt: '123456' } }, session: { challenge_passed_at: Time.now.utc } 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 end
it 'renders the new view' do it 'renders the new view' do
@ -104,4 +115,5 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
end end
end end
end end
end
end end

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

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

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

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

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

@ -151,6 +151,12 @@ RSpec.describe User, type: :model do
expect(user.reload.otp_required_for_login).to be false expect(user.reload.otp_required_for_login).to be false
end 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 it 'saves cleared otp_backup_codes' do
user = Fabricate.build(:user, otp_backup_codes: %w(dummy dummy)) user = Fabricate.build(:user, otp_backup_codes: %w(dummy dummy))
user.disable_two_factor! user.disable_two_factor!

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

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

@ -1139,6 +1139,11 @@
resolved "https://registry.yarnpkg.com/@gamestdio/websocket/-/websocket-0.3.2.tgz#321ba0976ee30fd14e51dbf8faa85ce7b325f76a" resolved "https://registry.yarnpkg.com/@gamestdio/websocket/-/websocket-0.3.2.tgz#321ba0976ee30fd14e51dbf8faa85ce7b325f76a"
integrity sha512-J3n5SKim+ZoLbe44hRGI/VYAwSMCeIJuBy+FfP6EZaujEpNchPRFcIsVQLWAwpU1bP2Ji63rC+rEUOd1vjUB6Q== 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": "@istanbuljs/load-nyc-config@^1.0.0":
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" 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" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de"
integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg== 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" version "0.13.7"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55"
integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew== integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==

Loading…
Cancel
Save