Commit df4ff9a8 authored by Patrick Figel's avatar Patrick Figel Committed by Eugen

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
parent 67ad84b7
# 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 @@ ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 @@ ...@@ -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
......
%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
- content_for :page_title do
= t('settings.two_factor_auth')
= render 'recovery_codes'
- content_for :page_title do
= t('settings.two_factor_auth')
= render 'recovery_codes'
...@@ -8,3 +8,8 @@ ...@@ -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'
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: ...@@ -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: ...@@ -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 ...@@ -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
......
class AddDeviseTwoFactorBackupableToUsers < ActiveRecord::Migration[5.0]
def change
add_column :users, :otp_backup_codes, :string, array: true
end
end
...@@ -10,7 +10,7 @@ ...@@ -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 ...@@ -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 ...@@ -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,19 +15,94 @@ RSpec.describe Auth::SessionsController, type: :controller do ...@@ -15,19 +15,94 @@ RSpec.describe Auth::SessionsController, type: :controller do
end end
describe 'POST #create' do describe 'POST #create' do
let(:user) { Fabricate(:user, email: 'foo@bar.com', password: 'abcdefgh') }
before do before do
request.env["devise.mapping"] = Devise.mappings[:user] request.env['devise.mapping'] = Devise.mappings[:user]
post :create, params: { user: { email: user.email, password: user.password } }
end end
it 'redirects to home' do context 'using password authentication' do
expect(response).to redirect_to(root_path) let(:user) { Fabricate(:user, email: 'foo@bar.com', password: 'abcdefgh') }
context 'using a valid password' do
before do
post :create, params: { user: { email: user.email, password: user.password } }
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 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 end
it 'logs the user in' do context 'using two-factor authentication' do
expect(controller.current_user).to eq user 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
end end
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)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment