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:
Patrick Figel 2017-04-15 13:26:03 +02:00 committed by Eugen
parent 67ad84b7eb
commit df4ff9a8e1
18 changed files with 149 additions and 15 deletions

View file

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

View file

@ -6,3 +6,12 @@
margin: 0 5px; margin: 0 5px;
} }
} }
.recovery-codes {
column-count: 2;
height: 100px;
li {
list-style: decimal;
margin-left: 20px;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,4 @@
- content_for :page_title do
= t('settings.two_factor_auth')
= render 'recovery_codes'

View file

@ -0,0 +1,4 @@
- content_for :page_title do
= t('settings.two_factor_auth')
= render 'recovery_codes'

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
class AddDeviseTwoFactorBackupableToUsers < ActiveRecord::Migration[5.0]
def change
add_column :users, :otp_backup_codes, :string, array: true
end
end

View file

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

View file

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

View file

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