Add recovery code support for two-factor auth (#1773)
* Add recovery code support for two-factor auth When users enable two-factor auth, the app now generates ten single-use recovery codes. Users are encouraged to print the codes and store them in a safe place. The two-factor prompt during login now accepts both OTP codes and recovery codes. The two-factor settings UI allows users to regenerated lost recovery codes. Users who have set up two-factor auth prior to this feature being added can use it to generate recovery codes for the first time. Fixes #563 and fixes #987 * Set OTP_SECRET in test enviroment * add missing .html to view file names
This commit is contained in:
parent
67ad84b7eb
commit
df4ff9a8e1
|
@ -1,3 +1,4 @@
|
||||||
# Federation
|
# Federation
|
||||||
LOCAL_DOMAIN=cb6e6126.ngrok.io
|
LOCAL_DOMAIN=cb6e6126.ngrok.io
|
||||||
LOCAL_HTTPS=true
|
LOCAL_HTTPS=true
|
||||||
|
OTP_SECRET=100c7faeef00caa29242f6b04156742bf76065771fd4117990c4282b8748ff3d99f8fdae97c982ab5bd2e6756a159121377cce4421f4a8ecd2d67bd7749a3fb4
|
||||||
|
|
|
@ -6,3 +6,12 @@
|
||||||
margin: 0 5px;
|
margin: 0 5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recovery-codes {
|
||||||
|
column-count: 2;
|
||||||
|
height: 100px;
|
||||||
|
li {
|
||||||
|
list-style: decimal;
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -49,7 +49,8 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
end
|
end
|
||||||
|
|
||||||
def valid_otp_attempt?(user)
|
def valid_otp_attempt?(user)
|
||||||
user.validate_and_consume_otp!(user_params[:otp_attempt])
|
user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
|
||||||
|
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
|
||||||
end
|
end
|
||||||
|
|
||||||
def authenticate_with_two_factor
|
def authenticate_with_two_factor
|
||||||
|
|
|
@ -19,9 +19,9 @@ class Settings::TwoFactorAuthsController < ApplicationController
|
||||||
def create
|
def create
|
||||||
if current_user.validate_and_consume_otp!(confirmation_params[:code])
|
if current_user.validate_and_consume_otp!(confirmation_params[:code])
|
||||||
current_user.otp_required_for_login = true
|
current_user.otp_required_for_login = true
|
||||||
|
@codes = current_user.generate_otp_backup_codes!
|
||||||
current_user.save!
|
current_user.save!
|
||||||
|
flash[:notice] = I18n.t('two_factor_auth.enabled_success')
|
||||||
redirect_to settings_two_factor_auth_path, notice: I18n.t('two_factor_auth.enabled_success')
|
|
||||||
else
|
else
|
||||||
@confirmation = Form::TwoFactorConfirmation.new
|
@confirmation = Form::TwoFactorConfirmation.new
|
||||||
set_qr_code
|
set_qr_code
|
||||||
|
@ -30,6 +30,12 @@ class Settings::TwoFactorAuthsController < ApplicationController
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def recovery_codes
|
||||||
|
@codes = current_user.generate_otp_backup_codes!
|
||||||
|
current_user.save!
|
||||||
|
flash[:notice] = I18n.t('two_factor_auth.recovery_codes_regenerated')
|
||||||
|
end
|
||||||
|
|
||||||
def disable
|
def disable
|
||||||
current_user.otp_required_for_login = false
|
current_user.otp_required_for_login = false
|
||||||
current_user.save!
|
current_user.save!
|
||||||
|
|
|
@ -5,7 +5,9 @@ class User < ApplicationRecord
|
||||||
|
|
||||||
devise :registerable, :recoverable,
|
devise :registerable, :recoverable,
|
||||||
:rememberable, :trackable, :validatable, :confirmable,
|
:rememberable, :trackable, :validatable, :confirmable,
|
||||||
:two_factor_authenticatable, otp_secret_encryption_key: ENV['OTP_SECRET']
|
:two_factor_authenticatable, :two_factor_backupable,
|
||||||
|
otp_secret_encryption_key: ENV['OTP_SECRET'],
|
||||||
|
otp_number_of_backup_codes: 10
|
||||||
|
|
||||||
belongs_to :account, inverse_of: :user
|
belongs_to :account, inverse_of: :user
|
||||||
accepts_nested_attributes_for :account
|
accepts_nested_attributes_for :account
|
||||||
|
|
|
@ -2,7 +2,9 @@
|
||||||
= t('auth.login')
|
= t('auth.login')
|
||||||
|
|
||||||
= simple_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f|
|
= simple_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f|
|
||||||
= f.input :otp_attempt, type: :number, placeholder: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt') }, required: true, autofocus: true, autocomplete: 'off'
|
= f.input :otp_attempt, type: :number, placeholder: t('simple_form.labels.defaults.otp_attempt'),
|
||||||
|
input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt') }, required: true, autofocus: true, autocomplete: 'off',
|
||||||
|
hint: t('simple_form.hints.sessions.otp')
|
||||||
|
|
||||||
.actions
|
.actions
|
||||||
= f.button :button, t('auth.login'), type: :submit
|
= f.button :button, t('auth.login'), type: :submit
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
%p.hint= t('two_factor_auth.recovery_instructions')
|
||||||
|
|
||||||
|
%h3= t('two_factor_auth.recovery_codes')
|
||||||
|
%ol.recovery-codes
|
||||||
|
- @codes.each do |code|
|
||||||
|
%li
|
||||||
|
%samp= code
|
|
@ -0,0 +1,4 @@
|
||||||
|
- content_for :page_title do
|
||||||
|
= t('settings.two_factor_auth')
|
||||||
|
|
||||||
|
= render 'recovery_codes'
|
|
@ -0,0 +1,4 @@
|
||||||
|
- content_for :page_title do
|
||||||
|
= t('settings.two_factor_auth')
|
||||||
|
|
||||||
|
= render 'recovery_codes'
|
|
@ -8,3 +8,8 @@
|
||||||
= link_to t('two_factor_auth.disable'), disable_settings_two_factor_auth_path, data: { method: 'POST' }, class: 'block-button'
|
= link_to t('two_factor_auth.disable'), disable_settings_two_factor_auth_path, data: { method: 'POST' }, class: 'block-button'
|
||||||
- else
|
- else
|
||||||
= link_to t('two_factor_auth.setup'), new_settings_two_factor_auth_path, class: 'block-button'
|
= link_to t('two_factor_auth.setup'), new_settings_two_factor_auth_path, class: 'block-button'
|
||||||
|
|
||||||
|
- if current_user.otp_required_for_login
|
||||||
|
.simple_form
|
||||||
|
%p.hint= t('two_factor_auth.lost_recovery_codes')
|
||||||
|
= link_to t('two_factor_auth.generate_recovery_codes'), recovery_codes_settings_two_factor_auth_path, data: { method: 'POST' }, class: 'block-button'
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
Devise.setup do |config|
|
Devise.setup do |config|
|
||||||
config.warden do |manager|
|
config.warden do |manager|
|
||||||
manager.default_strategies(scope: :user).unshift :two_factor_authenticatable
|
manager.default_strategies(scope: :user).unshift :two_factor_authenticatable
|
||||||
|
manager.default_strategies(scope: :user).unshift :two_factor_backupable
|
||||||
end
|
end
|
||||||
|
|
||||||
# The secret key used by Devise. Devise uses this key to generate
|
# The secret key used by Devise. Devise uses this key to generate
|
||||||
|
|
|
@ -290,8 +290,13 @@ en:
|
||||||
disable: Disable
|
disable: Disable
|
||||||
enable: Enable
|
enable: Enable
|
||||||
enabled_success: Two-factor authentication successfully enabled
|
enabled_success: Two-factor authentication successfully enabled
|
||||||
|
generate_recovery_codes: Generate Recovery Codes
|
||||||
instructions_html: "<strong>Scan this QR code into Google Authenticator or a similiar TOTP app on your phone</strong>. From now on, that app will generate tokens that you will have to enter when logging in."
|
instructions_html: "<strong>Scan this QR code into Google Authenticator or a similiar TOTP app on your phone</strong>. From now on, that app will generate tokens that you will have to enter when logging in."
|
||||||
|
lost_recovery_codes: Recovery codes allow you to regain access to your account if you lose your phone. If you've lost your recovery codes, you can regenerate them here. Your old recovery codes will be invalidated.
|
||||||
manual_instructions: 'If you can''t scan the QR code and need to enter it manually, here is the plain-text secret:'
|
manual_instructions: 'If you can''t scan the QR code and need to enter it manually, here is the plain-text secret:'
|
||||||
|
recovery_codes: Recovery Codes
|
||||||
|
recovery_codes_regenerated: Recovery codes successfully regenerated
|
||||||
|
recovery_instructions: If you ever lose access to your phone, you can use one of the recovery codes below to regain access to your account. Keep the recovery codes safe, for example by printing them and storing them with other important documents.
|
||||||
setup: Set up
|
setup: Set up
|
||||||
warning: If you cannot configure an authenticator app right now, you should click "disable" or you won't be able to login.
|
warning: If you cannot configure an authenticator app right now, you should click "disable" or you won't be able to login.
|
||||||
wrong_code: The entered code was invalid! Are server time and device time correct?
|
wrong_code: The entered code was invalid! Are server time and device time correct?
|
||||||
|
|
|
@ -10,6 +10,8 @@ en:
|
||||||
note: At most 160 characters
|
note: At most 160 characters
|
||||||
imports:
|
imports:
|
||||||
data: CSV file exported from another Mastodon instance
|
data: CSV file exported from another Mastodon instance
|
||||||
|
sessions:
|
||||||
|
otp: Enter the Two-factor code from your phone or use one of your recovery codes.
|
||||||
labels:
|
labels:
|
||||||
defaults:
|
defaults:
|
||||||
avatar: Avatar
|
avatar: Avatar
|
||||||
|
|
|
@ -64,6 +64,7 @@ Rails.application.routes.draw do
|
||||||
resource :two_factor_auth, only: [:show, :new, :create] do
|
resource :two_factor_auth, only: [:show, :new, :create] do
|
||||||
member do
|
member do
|
||||||
post :disable
|
post :disable
|
||||||
|
post :recovery_codes
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
class AddDeviseTwoFactorBackupableToUsers < ActiveRecord::Migration[5.0]
|
||||||
|
def change
|
||||||
|
add_column :users, :otp_backup_codes, :string, array: true
|
||||||
|
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: 20170406215816) do
|
ActiveRecord::Schema.define(version: 20170414080609) 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"
|
||||||
|
@ -313,6 +313,7 @@ ActiveRecord::Schema.define(version: 20170406215816) do
|
||||||
t.integer "consumed_timestep"
|
t.integer "consumed_timestep"
|
||||||
t.boolean "otp_required_for_login"
|
t.boolean "otp_required_for_login"
|
||||||
t.datetime "last_emailed_at"
|
t.datetime "last_emailed_at"
|
||||||
|
t.string "otp_backup_codes", array: true
|
||||||
t.index ["account_id"], name: "index_users_on_account_id", using: :btree
|
t.index ["account_id"], name: "index_users_on_account_id", using: :btree
|
||||||
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true, using: :btree
|
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true, using: :btree
|
||||||
t.index ["email"], name: "index_users_on_email", unique: true, using: :btree
|
t.index ["email"], name: "index_users_on_email", unique: true, using: :btree
|
||||||
|
|
|
@ -5,7 +5,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
|
||||||
|
|
||||||
describe 'GET #new' do
|
describe 'GET #new' do
|
||||||
before do
|
before do
|
||||||
request.env["devise.mapping"] = Devise.mappings[:user]
|
request.env['devise.mapping'] = Devise.mappings[:user]
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'returns http success' do
|
||||||
|
@ -15,10 +15,15 @@ RSpec.describe Auth::SessionsController, type: :controller do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'POST #create' do
|
describe 'POST #create' do
|
||||||
|
before do
|
||||||
|
request.env['devise.mapping'] = Devise.mappings[:user]
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'using password authentication' do
|
||||||
let(:user) { Fabricate(:user, email: 'foo@bar.com', password: 'abcdefgh') }
|
let(:user) { Fabricate(:user, email: 'foo@bar.com', password: 'abcdefgh') }
|
||||||
|
|
||||||
|
context 'using a valid password' do
|
||||||
before do
|
before do
|
||||||
request.env["devise.mapping"] = Devise.mappings[:user]
|
|
||||||
post :create, params: { user: { email: user.email, password: user.password } }
|
post :create, params: { user: { email: user.email, password: user.password } }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -30,4 +35,74 @@ RSpec.describe Auth::SessionsController, type: :controller do
|
||||||
expect(controller.current_user).to eq user
|
expect(controller.current_user).to eq user
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'using an invalid password' do
|
||||||
|
before do
|
||||||
|
post :create, params: { user: { email: user.email, password: 'wrongpw' } }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'shows a login error' do
|
||||||
|
expect(flash[:alert]).to match I18n.t('devise.failure.invalid', authentication_keys: 'Email')
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't log the user in" do
|
||||||
|
expect(controller.current_user).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'using two-factor authentication' do
|
||||||
|
let(:user) do
|
||||||
|
Fabricate(:user, email: 'x@y.com', password: 'abcdefgh',
|
||||||
|
otp_required_for_login: true, otp_secret: User.generate_otp_secret(32))
|
||||||
|
end
|
||||||
|
let(:recovery_codes) do
|
||||||
|
codes = user.generate_otp_backup_codes!
|
||||||
|
user.save
|
||||||
|
return codes
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'using a valid OTP' do
|
||||||
|
before do
|
||||||
|
post :create, params: { user: { otp_attempt: user.current_otp } }, session: { otp_user_id: user.id }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'redirects to home' do
|
||||||
|
expect(response).to redirect_to(root_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs the user in' do
|
||||||
|
expect(controller.current_user).to eq user
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'using a valid recovery code' do
|
||||||
|
before do
|
||||||
|
post :create, params: { user: { otp_attempt: recovery_codes.first } }, session: { otp_user_id: user.id }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'redirects to home' do
|
||||||
|
expect(response).to redirect_to(root_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs the user in' do
|
||||||
|
expect(controller.current_user).to eq user
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'using an invalid OTP' do
|
||||||
|
before do
|
||||||
|
post :create, params: { user: { otp_attempt: 'wrongotp' } }, session: { otp_user_id: user.id }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'shows a login error' do
|
||||||
|
expect(flash[:alert]).to match I18n.t('users.invalid_otp_token')
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't log the user in" do
|
||||||
|
expect(controller.current_user).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
require 'devise_two_factor/spec_helpers'
|
||||||
|
|
||||||
RSpec.describe User, type: :model do
|
RSpec.describe User, type: :model do
|
||||||
|
it_behaves_like 'two_factor_backupable'
|
||||||
|
|
||||||
describe 'validations' do
|
describe 'validations' do
|
||||||
it 'is invalid without an account' do
|
it 'is invalid without an account' do
|
||||||
user = Fabricate.build(:user, account: nil)
|
user = Fabricate.build(:user, account: nil)
|
||||||
|
|
Loading…
Reference in New Issue