Conflicts: - `app/controllers/accounts_controller.rb`: Upstream change too close to a glitch-soc change related to instance-local toots. Merged upstream changes. - `app/services/fan_out_on_write_service.rb`: Minor conflict due to glitch-soc's handling of Direct Messages, merged upstream changes. - `yarn.lock`: Not really a conflict, caused by glitch-soc-only dependencies being textually too close to updated upstream dependencies. Merged upstream changes.main
commit
8c3c27bf06
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
File diff suppressed because one or more lines are too long
@ -0,0 +1,69 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::Dereferencer
|
||||||
|
include JsonLdHelper
|
||||||
|
|
||||||
|
def initialize(uri, permitted_origin: nil, signature_account: nil)
|
||||||
|
@uri = uri
|
||||||
|
@permitted_origin = permitted_origin
|
||||||
|
@signature_account = signature_account
|
||||||
|
end
|
||||||
|
|
||||||
|
def object
|
||||||
|
@object ||= fetch_object!
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def bear_cap?
|
||||||
|
@uri.start_with?('bear:')
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_object!
|
||||||
|
if bear_cap?
|
||||||
|
fetch_with_token!
|
||||||
|
else
|
||||||
|
fetch_with_signature!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_with_token!
|
||||||
|
perform_request(bear_cap['u'], headers: { 'Authorization' => "Bearer #{bear_cap['t']}" })
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_with_signature!
|
||||||
|
perform_request(@uri)
|
||||||
|
end
|
||||||
|
|
||||||
|
def bear_cap
|
||||||
|
@bear_cap ||= Addressable::URI.parse(@uri).query_values
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_request(uri, headers: nil)
|
||||||
|
return if invalid_origin?(uri)
|
||||||
|
|
||||||
|
req = Request.new(:get, uri)
|
||||||
|
|
||||||
|
req.add_headers('Accept' => 'application/activity+json, application/ld+json')
|
||||||
|
req.add_headers(headers) if headers
|
||||||
|
req.on_behalf_of(@signature_account) if @signature_account
|
||||||
|
|
||||||
|
req.perform do |res|
|
||||||
|
if res.code == 200
|
||||||
|
json = body_to_json(res.body_with_limit)
|
||||||
|
json if json.present? && json['id'] == uri
|
||||||
|
else
|
||||||
|
raise Mastodon::UnexpectedResponseError, res unless response_successful?(res) || response_error_unsalvageable?(res)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def invalid_origin?(uri)
|
||||||
|
return true if unsupported_uri_scheme?(uri)
|
||||||
|
|
||||||
|
needle = Addressable::URI.parse(uri).host
|
||||||
|
haystack = Addressable::URI.parse(@permitted_origin).host
|
||||||
|
|
||||||
|
!haystack.casecmp(needle).zero?
|
||||||
|
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')
|
@ -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,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
if [ "$RUN_STREAMING" != "true" ]; then BIND=0.0.0.0 bundle exec puma -C config/puma.rb; else BIND=0.0.0.0 node ./streaming; fi
|
@ -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
|
@ -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
|
@ -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
|
||||||
it 'redirects to admin accounts page' do
|
context 'when user has OTP enabled' do
|
||||||
delete :destroy, params: { user_id: user.id }
|
before do
|
||||||
|
user.update(otp_required_for_login: true)
|
||||||
|
end
|
||||||
|
|
||||||
user.reload
|
it 'redirects to admin accounts page' do
|
||||||
expect(user.otp_required_for_login).to eq false
|
delete :destroy, params: { user_id: user.id }
|
||||||
expect(response).to redirect_to(admin_accounts_path)
|
|
||||||
|
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
|
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
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue