Переглянути джерело

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
master
Patrick Figel 7 роки тому
committed by Eugen
джерело
коміт
df4ff9a8e1
18 змінених файлів з 149 додано та 15 видалено
  1. +1
    -0
      .env.test
  2. +9
    -0
      app/assets/stylesheets/lists.scss
  3. +2
    -1
      app/controllers/auth/sessions_controller.rb
  4. +8
    -2
      app/controllers/settings/two_factor_auths_controller.rb
  5. +3
    -1
      app/models/user.rb
  6. +3
    -1
      app/views/auth/sessions/two_factor.html.haml
  7. +7
    -0
      app/views/settings/two_factor_auths/_recovery_codes.html.haml
  8. +4
    -0
      app/views/settings/two_factor_auths/create.html.haml
  9. +4
    -0
      app/views/settings/two_factor_auths/recovery_codes.html.haml
  10. +5
    -0
      app/views/settings/two_factor_auths/show.html.haml
  11. +1
    -0
      config/initializers/devise.rb
  12. +5
    -0
      config/locales/en.yml
  13. +2
    -0
      config/locales/simple_form.en.yml
  14. +1
    -0
      config/routes.rb
  15. +5
    -0
      db/migrate/20170414080609_add_devise_two_factor_backupable_to_users.rb
  16. +2
    -1
      db/schema.rb
  17. +84
    -9
      spec/controllers/auth/sessions_controller_spec.rb
  18. +3
    -0
      spec/models/user_spec.rb

+ 1
- 0
.env.test Переглянути файл

@@ -1,3 +1,4 @@
# Federation
LOCAL_DOMAIN=cb6e6126.ngrok.io
LOCAL_HTTPS=true
OTP_SECRET=100c7faeef00caa29242f6b04156742bf76065771fd4117990c4282b8748ff3d99f8fdae97c982ab5bd2e6756a159121377cce4421f4a8ecd2d67bd7749a3fb4

+ 9
- 0
app/assets/stylesheets/lists.scss Переглянути файл

@@ -6,3 +6,12 @@
margin: 0 5px;
}
}

.recovery-codes {
column-count: 2;
height: 100px;
li {
list-style: decimal;
margin-left: 20px;
}
}

+ 2
- 1
app/controllers/auth/sessions_controller.rb Переглянути файл

@@ -49,7 +49,8 @@ class Auth::SessionsController < Devise::SessionsController
end

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

def authenticate_with_two_factor


+ 8
- 2
app/controllers/settings/two_factor_auths_controller.rb Переглянути файл

@@ -19,9 +19,9 @@ class Settings::TwoFactorAuthsController < ApplicationController
def create
if current_user.validate_and_consume_otp!(confirmation_params[:code])
current_user.otp_required_for_login = true
@codes = current_user.generate_otp_backup_codes!
current_user.save!

redirect_to settings_two_factor_auth_path, notice: I18n.t('two_factor_auth.enabled_success')
flash[:notice] = I18n.t('two_factor_auth.enabled_success')
else
@confirmation = Form::TwoFactorConfirmation.new
set_qr_code
@@ -30,6 +30,12 @@ class Settings::TwoFactorAuthsController < ApplicationController
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
current_user.otp_required_for_login = false
current_user.save!


+ 3
- 1
app/models/user.rb Переглянути файл

@@ -5,7 +5,9 @@ class User < ApplicationRecord

devise :registerable, :recoverable,
: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
accepts_nested_attributes_for :account


+ 3
- 1
app/views/auth/sessions/two_factor.html.haml Переглянути файл

@@ -2,7 +2,9 @@
= t('auth.login')

= 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
= f.button :button, t('auth.login'), type: :submit


+ 7
- 0
app/views/settings/two_factor_auths/_recovery_codes.html.haml Переглянути файл

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

+ 4
- 0
app/views/settings/two_factor_auths/create.html.haml Переглянути файл

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

= render 'recovery_codes'

+ 4
- 0
app/views/settings/two_factor_auths/recovery_codes.html.haml Переглянути файл

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

= render 'recovery_codes'

+ 5
- 0
app/views/settings/two_factor_auths/show.html.haml Переглянути файл

@@ -8,3 +8,8 @@
= link_to t('two_factor_auth.disable'), disable_settings_two_factor_auth_path, data: { method: 'POST' }, class: 'block-button'
- else
= 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
- 0
config/initializers/devise.rb Переглянути файл

@@ -1,6 +1,7 @@
Devise.setup do |config|
config.warden do |manager|
manager.default_strategies(scope: :user).unshift :two_factor_authenticatable
manager.default_strategies(scope: :user).unshift :two_factor_backupable
end

# The secret key used by Devise. Devise uses this key to generate


+ 5
- 0
config/locales/en.yml Переглянути файл

@@ -290,8 +290,13 @@ en:
disable: Disable
enable: Enable
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."
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:'
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
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?


+ 2
- 0
config/locales/simple_form.en.yml Переглянути файл

@@ -10,6 +10,8 @@ en:
note: At most 160 characters
imports:
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:
defaults:
avatar: Avatar


+ 1
- 0
config/routes.rb Переглянути файл

@@ -64,6 +64,7 @@ Rails.application.routes.draw do
resource :two_factor_auth, only: [:show, :new, :create] do
member do
post :disable
post :recovery_codes
end
end
end


+ 5
- 0
db/migrate/20170414080609_add_devise_two_factor_backupable_to_users.rb Переглянути файл

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

+ 2
- 1
db/schema.rb Переглянути файл

@@ -10,7 +10,7 @@
#
# 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
enable_extension "plpgsql"
@@ -313,6 +313,7 @@ ActiveRecord::Schema.define(version: 20170406215816) do
t.integer "consumed_timestep"
t.boolean "otp_required_for_login"
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 ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true, using: :btree
t.index ["email"], name: "index_users_on_email", unique: true, using: :btree


+ 84
- 9
spec/controllers/auth/sessions_controller_spec.rb Переглянути файл

@@ -5,7 +5,7 @@ RSpec.describe Auth::SessionsController, type: :controller do

describe 'GET #new' do
before do
request.env["devise.mapping"] = Devise.mappings[:user]
request.env['devise.mapping'] = Devise.mappings[:user]
end

it 'returns http success' do
@@ -15,19 +15,94 @@ RSpec.describe Auth::SessionsController, type: :controller do
end

describe 'POST #create' do
let(:user) { Fabricate(:user, email: 'foo@bar.com', password: 'abcdefgh') }

before do
request.env["devise.mapping"] = Devise.mappings[:user]
post :create, params: { user: { email: user.email, password: user.password } }
request.env['devise.mapping'] = Devise.mappings[:user]
end

it 'redirects to home' do
expect(response).to redirect_to(root_path)
context 'using password authentication' do
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

it 'logs the user in' do
expect(controller.current_user).to eq user
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

+ 3
- 0
spec/models/user_spec.rb Переглянути файл

@@ -1,6 +1,9 @@
require 'rails_helper'
require 'devise_two_factor/spec_helpers'

RSpec.describe User, type: :model do
it_behaves_like 'two_factor_backupable'

describe 'validations' do
it 'is invalid without an account' do
user = Fabricate.build(:user, account: nil)


Завантаження…
Відмінити
Зберегти