Added optional two-factor authentication

This commit is contained in:
Eugen Rochko 2017-01-27 20:28:46 +01:00
parent 237cb41ab4
commit ba192f12e3
16 changed files with 146 additions and 15 deletions

View File

@ -31,8 +31,10 @@ gem 'link_header'
gem 'ostatus2' gem 'ostatus2'
gem 'goldfinger' gem 'goldfinger'
gem 'devise' gem 'devise'
gem 'devise-two-factor'
gem 'doorkeeper' gem 'doorkeeper'
gem 'rabl' gem 'rabl'
gem 'rqrcode'
gem 'oj' gem 'oj'
gem 'hiredis' gem 'hiredis'
gem 'redis', '~>3.2' gem 'redis', '~>3.2'

View File

@ -43,6 +43,8 @@ GEM
public_suffix (~> 2.0, >= 2.0.2) public_suffix (~> 2.0, >= 2.0.2)
arel (7.1.4) arel (7.1.4)
ast (2.3.0) ast (2.3.0)
attr_encrypted (3.0.3)
encryptor (~> 3.0.0)
autoprefixer-rails (6.5.0.2) autoprefixer-rails (6.5.0.2)
execjs execjs
av (0.9.0) av (0.9.0)
@ -76,6 +78,7 @@ GEM
bullet (5.3.0) bullet (5.3.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
uniform_notifier (~> 1.10.0) uniform_notifier (~> 1.10.0)
chunky_png (1.3.8)
climate_control (0.1.0) climate_control (0.1.0)
cocaine (0.5.8) cocaine (0.5.8)
climate_control (>= 0.0.3, < 1.0) climate_control (>= 0.0.3, < 1.0)
@ -99,6 +102,12 @@ GEM
railties (>= 4.1.0, < 5.1) railties (>= 4.1.0, < 5.1)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
devise-two-factor (3.0.0)
activesupport
attr_encrypted (>= 1.3, < 4, != 2)
devise (~> 4.0)
railties
rotp (~> 2.0)
diff-lcs (1.2.5) diff-lcs (1.2.5)
docile (1.1.5) docile (1.1.5)
domain_name (0.5.20161129) domain_name (0.5.20161129)
@ -113,6 +122,7 @@ GEM
json json
thread thread
thread_safe thread_safe
encryptor (3.0.0)
erubis (2.7.0) erubis (2.7.0)
execjs (2.7.0) execjs (2.7.0)
fabrication (2.15.2) fabrication (2.15.2)
@ -304,6 +314,9 @@ GEM
redis (>= 2.2) redis (>= 2.2)
responders (2.3.0) responders (2.3.0)
railties (>= 4.2.0, < 5.1) railties (>= 4.2.0, < 5.1)
rotp (2.1.2)
rqrcode (0.10.1)
chunky_png (~> 1.0)
rspec (3.5.0) rspec (3.5.0)
rspec-core (~> 3.5.0) rspec-core (~> 3.5.0)
rspec-expectations (~> 3.5.0) rspec-expectations (~> 3.5.0)
@ -416,6 +429,7 @@ DEPENDENCIES
bullet bullet
coffee-rails (~> 4.1.0) coffee-rails (~> 4.1.0)
devise devise
devise-two-factor
doorkeeper doorkeeper
dotenv-rails dotenv-rails
fabrication fabrication
@ -455,6 +469,7 @@ DEPENDENCIES
react-rails react-rails
redis (~> 3.2) redis (~> 3.2)
redis-rails redis-rails
rqrcode
rspec-rails rspec-rails
rspec-sidekiq rspec-sidekiq
rubocop rubocop

View File

@ -7,6 +7,18 @@ code {
max-width: 400px; max-width: 400px;
padding: 20px; padding: 20px;
margin: 0 auto; margin: 0 auto;
p {
font-size: 14px;
line-height: 18px;
color: $color2;
margin-bottom: 20px;
strong {
color: $color5;
font-weight: 500;
}
}
} }
.simple_form { .simple_form {
@ -118,7 +130,7 @@ code {
margin-top: 30px; margin-top: 30px;
} }
button { button, .block-button {
display: block; display: block;
width: 100%; width: 100%;
border: 0; border: 0;
@ -128,6 +140,9 @@ code {
font-size: 18px; font-size: 18px;
padding: 10px; padding: 10px;
text-transform: uppercase; text-transform: uppercase;
text-decoration: none;
text-align: center;
box-sizing: border-box;
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
outline: 0; outline: 0;
@ -176,7 +191,7 @@ code {
text-align: center; text-align: center;
a { a {
color: white; color: $color5;
text-decoration: none; text-decoration: none;
&:hover { &:hover {
@ -200,3 +215,16 @@ code {
font-weight: 500; font-weight: 500;
} }
} }
.qr-code {
background: #fff;
padding: 4px;
margin-bottom: 20px;
box-shadow: 0 0 15px rgba($color8, 0.2);
display: inline-block;
svg {
display: block;
margin: 0;
}
}

View File

@ -5,6 +5,8 @@ class Auth::SessionsController < Devise::SessionsController
layout 'auth' layout 'auth'
before_action :configure_sign_in_params, only: [:create]
def create def create
super do |resource| super do |resource|
remember_me(resource) remember_me(resource)
@ -13,6 +15,10 @@ class Auth::SessionsController < Devise::SessionsController
protected protected
def configure_sign_in_params
devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
end
def after_sign_in_path_for(_resource) def after_sign_in_path_for(_resource)
last_url = stored_location_for(:user) last_url = stored_location_for(:user)

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
class Settings::TwoFactorAuthsController < ApplicationController
layout 'auth'
before_action :authenticate_user!
def show
return unless current_user.otp_required_for_login
@qrcode = RQRCode::QRCode.new(current_user.otp_provisioning_uri(current_user.email, issuer: Rails.configuration.x.local_domain))
end
def enable
current_user.otp_required_for_login = true
current_user.otp_secret = User.generate_otp_secret
current_user.save!
redirect_to settings_two_factor_auth_path
end
def disable
current_user.otp_required_for_login = false
current_user.save!
redirect_to settings_two_factor_auth_path
end
end

View File

@ -3,7 +3,9 @@
class User < ApplicationRecord class User < ApplicationRecord
include Settings::Extend include Settings::Extend
devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable, :confirmable devise :registerable, :recoverable,
:rememberable, :trackable, :validatable, :confirmable,
:two_factor_authenticatable, otp_secret_encryption_key: ENV['OTP_SECRET']
belongs_to :account, inverse_of: :user belongs_to :account, inverse_of: :user
accepts_nested_attributes_for :account accepts_nested_attributes_for :account

View File

@ -4,6 +4,7 @@
= simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| = simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f|
= f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') } = f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }
= f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password') } = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password') }
= f.input :otp_attempt, placeholder: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt') }
.actions .actions
= f.button :button, t('auth.login'), type: :submit = f.button :button, t('auth.login'), type: :submit

View File

@ -5,4 +5,6 @@
%li= link_to t('settings.preferences'), settings_preferences_path %li= link_to t('settings.preferences'), settings_preferences_path
- if controller_name != 'registrations' - if controller_name != 'registrations'
%li= link_to t('auth.change_password'), edit_user_registration_path %li= link_to t('auth.change_password'), edit_user_registration_path
- if controller_name != 'two_factor_auths'
%li= link_to t('settings.two_factor_auth'), settings_two_factor_auth_path
%li= link_to t('settings.back'), root_path %li= link_to t('settings.back'), root_path

View File

@ -0,0 +1,17 @@
- content_for :page_title do
= t('settings.two_factor_auth')
- if current_user.otp_required_for_login
%p= t('two_factor_auth.instructions_html')
.qr-code= raw @qrcode.as_svg(padding: 0, module_size: 5)
.simple_form
= link_to t('two_factor_auth.disable'), disable_settings_two_factor_auth_path, data: { method: 'POST' }, class: 'block-button'
- else
%p= t('two_factor_auth.description_html')
.simple_form
= link_to t('two_factor_auth.enable'), enable_settings_two_factor_auth_path, data: { method: 'POST' }, class: 'block-button'
.form-footer= render "settings/shared/links"

View File

@ -1,6 +1,8 @@
# Use this hook to configure devise mailer, warden hooks and so forth.
# Many of these configuration options can be set straight in your model.
Devise.setup do |config| Devise.setup do |config|
config.warden do |manager|
manager.default_strategies(scope: :user).unshift :two_factor_authenticatable
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
# random tokens. Changing this key will render invalid all existing # random tokens. Changing this key will render invalid all existing
# confirmation, reset password and unlock tokens in the database. # confirmation, reset password and unlock tokens in the database.

View File

@ -1,4 +1,4 @@
# Be sure to restart your server when you modify this file. # Be sure to restart your server when you modify this file.
# Configure sensitive parameters which will be filtered from the log file. # Configure sensitive parameters which will be filtered from the log file.
Rails.application.config.filter_parameters += [:password, :private_key, :public_key] Rails.application.config.filter_parameters += [:password, :private_key, :public_key, :otp_attempt]

View File

@ -93,6 +93,7 @@ en:
back: Back to Mastodon back: Back to Mastodon
edit_profile: Edit profile edit_profile: Edit profile
preferences: Preferences preferences: Preferences
two_factor_auth: Two-factor Authentication
statuses: statuses:
over_character_limit: character limit of %{max} exceeded over_character_limit: character limit of %{max} exceeded
stream_entries: stream_entries:
@ -104,6 +105,11 @@ en:
time: time:
formats: formats:
default: "%b %d, %Y, %H:%M" default: "%b %d, %Y, %H:%M"
two_factor_auth:
description_html: If you enable <strong>two-factor authentication</strong>, logging in will require you to be in possession of your phone, which will generate tokens for you to enter.
disable: Disable
enable: Enable
instructions_html: "<strong>Scan this QR code into Google Authenticator or a similiar app on your phone</strong>. From now on, that app will generate tokens that you will have to enter when logging in."
users: users:
invalid_email: The e-mail address is invalid invalid_email: The e-mail address is invalid
will_paginate: will_paginate:

View File

@ -17,6 +17,7 @@ en:
locked: Make account private locked: Make account private
new_password: New password new_password: New password
note: Bio note: Bio
otp_attempt: If enabled, two-factor token
password: Password password: Password
username: Username username: Username
interactions: interactions:

View File

@ -47,6 +47,13 @@ Rails.application.routes.draw do
namespace :settings do namespace :settings do
resource :profile, only: [:show, :update] resource :profile, only: [:show, :update]
resource :preferences, only: [:show, :update] resource :preferences, only: [:show, :update]
resource :two_factor_auth, only: [:show] do
member do
post :enable
post :disable
end
end
end end
resources :media, only: [:show] resources :media, only: [:show]

View File

@ -0,0 +1,9 @@
class AddDeviseTwoFactorToUsers < ActiveRecord::Migration[5.0]
def change
add_column :users, :encrypted_otp_secret, :string
add_column :users, :encrypted_otp_secret_iv, :string
add_column :users, :encrypted_otp_secret_salt, :string
add_column :users, :consumed_timestep, :integer
add_column :users, :otp_required_for_login, :boolean
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: 20170125145934) do ActiveRecord::Schema.define(version: 20170127165745) 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"
@ -240,25 +240,30 @@ ActiveRecord::Schema.define(version: 20170125145934) do
end end
create_table "users", force: :cascade do |t| create_table "users", force: :cascade do |t|
t.string "email", default: "", null: false t.string "email", default: "", null: false
t.integer "account_id", null: false t.integer "account_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "encrypted_password", default: "", null: false t.string "encrypted_password", default: "", null: false
t.string "reset_password_token" t.string "reset_password_token"
t.datetime "reset_password_sent_at" t.datetime "reset_password_sent_at"
t.datetime "remember_created_at" t.datetime "remember_created_at"
t.integer "sign_in_count", default: 0, null: false t.integer "sign_in_count", default: 0, null: false
t.datetime "current_sign_in_at" t.datetime "current_sign_in_at"
t.datetime "last_sign_in_at" t.datetime "last_sign_in_at"
t.inet "current_sign_in_ip" t.inet "current_sign_in_ip"
t.inet "last_sign_in_ip" t.inet "last_sign_in_ip"
t.boolean "admin", default: false t.boolean "admin", default: false
t.string "confirmation_token" t.string "confirmation_token"
t.datetime "confirmed_at" t.datetime "confirmed_at"
t.datetime "confirmation_sent_at" t.datetime "confirmation_sent_at"
t.string "unconfirmed_email" t.string "unconfirmed_email"
t.string "locale" t.string "locale"
t.string "encrypted_otp_secret"
t.string "encrypted_otp_secret_iv"
t.string "encrypted_otp_secret_salt"
t.integer "consumed_timestep"
t.boolean "otp_required_for_login"
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