diff --git a/Gemfile b/Gemfile
index 77f4658160..fb9a11ba18 100644
--- a/Gemfile
+++ b/Gemfile
@@ -31,8 +31,10 @@ gem 'link_header'
gem 'ostatus2'
gem 'goldfinger'
gem 'devise'
+gem 'devise-two-factor'
gem 'doorkeeper'
gem 'rabl'
+gem 'rqrcode'
gem 'oj'
gem 'hiredis'
gem 'redis', '~>3.2'
diff --git a/Gemfile.lock b/Gemfile.lock
index a37a06b011..5361b2a05e 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -43,6 +43,8 @@ GEM
public_suffix (~> 2.0, >= 2.0.2)
arel (7.1.4)
ast (2.3.0)
+ attr_encrypted (3.0.3)
+ encryptor (~> 3.0.0)
autoprefixer-rails (6.5.0.2)
execjs
av (0.9.0)
@@ -76,6 +78,7 @@ GEM
bullet (5.3.0)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.10.0)
+ chunky_png (1.3.8)
climate_control (0.1.0)
cocaine (0.5.8)
climate_control (>= 0.0.3, < 1.0)
@@ -99,6 +102,12 @@ GEM
railties (>= 4.1.0, < 5.1)
responders
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)
docile (1.1.5)
domain_name (0.5.20161129)
@@ -113,6 +122,7 @@ GEM
json
thread
thread_safe
+ encryptor (3.0.0)
erubis (2.7.0)
execjs (2.7.0)
fabrication (2.15.2)
@@ -304,6 +314,9 @@ GEM
redis (>= 2.2)
responders (2.3.0)
railties (>= 4.2.0, < 5.1)
+ rotp (2.1.2)
+ rqrcode (0.10.1)
+ chunky_png (~> 1.0)
rspec (3.5.0)
rspec-core (~> 3.5.0)
rspec-expectations (~> 3.5.0)
@@ -416,6 +429,7 @@ DEPENDENCIES
bullet
coffee-rails (~> 4.1.0)
devise
+ devise-two-factor
doorkeeper
dotenv-rails
fabrication
@@ -455,6 +469,7 @@ DEPENDENCIES
react-rails
redis (~> 3.2)
redis-rails
+ rqrcode
rspec-rails
rspec-sidekiq
rubocop
diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss
index 3653965110..560388f8fa 100644
--- a/app/assets/stylesheets/forms.scss
+++ b/app/assets/stylesheets/forms.scss
@@ -7,6 +7,18 @@ code {
max-width: 400px;
padding: 20px;
margin: 0 auto;
+
+ p {
+ font-size: 14px;
+ line-height: 18px;
+ color: $color2;
+ margin-bottom: 20px;
+
+ strong {
+ color: $color5;
+ font-weight: 500;
+ }
+ }
}
.simple_form {
@@ -118,7 +130,7 @@ code {
margin-top: 30px;
}
- button {
+ button, .block-button {
display: block;
width: 100%;
border: 0;
@@ -128,6 +140,9 @@ code {
font-size: 18px;
padding: 10px;
text-transform: uppercase;
+ text-decoration: none;
+ text-align: center;
+ box-sizing: border-box;
cursor: pointer;
font-weight: 500;
outline: 0;
@@ -176,7 +191,7 @@ code {
text-align: center;
a {
- color: white;
+ color: $color5;
text-decoration: none;
&:hover {
@@ -200,3 +215,16 @@ code {
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;
+ }
+}
diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb
index c8350f9a1e..889b20e11f 100644
--- a/app/controllers/auth/sessions_controller.rb
+++ b/app/controllers/auth/sessions_controller.rb
@@ -5,6 +5,8 @@ class Auth::SessionsController < Devise::SessionsController
layout 'auth'
+ before_action :configure_sign_in_params, only: [:create]
+
def create
super do |resource|
remember_me(resource)
@@ -13,6 +15,10 @@ class Auth::SessionsController < Devise::SessionsController
protected
+ def configure_sign_in_params
+ devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
+ end
+
def after_sign_in_path_for(_resource)
last_url = stored_location_for(:user)
diff --git a/app/controllers/settings/two_factor_auths_controller.rb b/app/controllers/settings/two_factor_auths_controller.rb
new file mode 100644
index 0000000000..66a82aab77
--- /dev/null
+++ b/app/controllers/settings/two_factor_auths_controller.rb
@@ -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
diff --git a/app/models/user.rb b/app/models/user.rb
index 71d3ee0b85..b34144f2cf 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -3,7 +3,9 @@
class User < ApplicationRecord
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
accepts_nested_attributes_for :account
diff --git a/app/views/auth/sessions/new.html.haml b/app/views/auth/sessions/new.html.haml
index 93b9629f1d..192a54bc6e 100644
--- a/app/views/auth/sessions/new.html.haml
+++ b/app/views/auth/sessions/new.html.haml
@@ -4,6 +4,7 @@
= 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 :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
= f.button :button, t('auth.login'), type: :submit
diff --git a/app/views/settings/shared/_links.html.haml b/app/views/settings/shared/_links.html.haml
index a6e90f4571..6490ffdd86 100644
--- a/app/views/settings/shared/_links.html.haml
+++ b/app/views/settings/shared/_links.html.haml
@@ -5,4 +5,6 @@
%li= link_to t('settings.preferences'), settings_preferences_path
- if controller_name != 'registrations'
%li= link_to t('auth.change_password'), edit_user_registration_path
- %li= link_to t('settings.back'), root_path
\ No newline at end of file
+ - 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
diff --git a/app/views/settings/two_factor_auths/show.html.haml b/app/views/settings/two_factor_auths/show.html.haml
new file mode 100644
index 0000000000..5070bb9d4e
--- /dev/null
+++ b/app/views/settings/two_factor_auths/show.html.haml
@@ -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"
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
index 85ba1082bb..5eba34aa57 100644
--- a/config/initializers/devise.rb
+++ b/config/initializers/devise.rb
@@ -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|
+ 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
# random tokens. Changing this key will render invalid all existing
# confirmation, reset password and unlock tokens in the database.
diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb
index d2452f355b..06cb15bbb1 100644
--- a/config/initializers/filter_parameter_logging.rb
+++ b/config/initializers/filter_parameter_logging.rb
@@ -1,4 +1,4 @@
# Be sure to restart your server when you modify this 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]
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 831fdbc7ae..4f02a87e28 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -93,6 +93,7 @@ en:
back: Back to Mastodon
edit_profile: Edit profile
preferences: Preferences
+ two_factor_auth: Two-factor Authentication
statuses:
over_character_limit: character limit of %{max} exceeded
stream_entries:
@@ -104,6 +105,11 @@ en:
time:
formats:
default: "%b %d, %Y, %H:%M"
+ two_factor_auth:
+ description_html: If you enable two-factor authentication, 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: "Scan this QR code into Google Authenticator or a similiar app on your phone. From now on, that app will generate tokens that you will have to enter when logging in."
users:
invalid_email: The e-mail address is invalid
will_paginate:
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index 578208700e..e45a9a7a6e 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -17,6 +17,7 @@ en:
locked: Make account private
new_password: New password
note: Bio
+ otp_attempt: If enabled, two-factor token
password: Password
username: Username
interactions:
diff --git a/config/routes.rb b/config/routes.rb
index 9423a0ae28..87f35770a5 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -47,6 +47,13 @@ Rails.application.routes.draw do
namespace :settings do
resource :profile, only: [:show, :update]
resource :preferences, only: [:show, :update]
+
+ resource :two_factor_auth, only: [:show] do
+ member do
+ post :enable
+ post :disable
+ end
+ end
end
resources :media, only: [:show]
diff --git a/db/migrate/20170127165745_add_devise_two_factor_to_users.rb b/db/migrate/20170127165745_add_devise_two_factor_to_users.rb
new file mode 100644
index 0000000000..f4183e4a92
--- /dev/null
+++ b/db/migrate/20170127165745_add_devise_two_factor_to_users.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index 72ce63133e..7a7fea86b9 100644
--- a/db/schema.rb
+++ b/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: 20170125145934) do
+ActiveRecord::Schema.define(version: 20170127165745) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -240,25 +240,30 @@ ActiveRecord::Schema.define(version: 20170125145934) do
end
create_table "users", force: :cascade do |t|
- t.string "email", default: "", null: false
- t.integer "account_id", null: false
- t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
- t.string "encrypted_password", default: "", null: false
+ t.string "email", default: "", null: false
+ t.integer "account_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.string "encrypted_password", default: "", null: false
t.string "reset_password_token"
t.datetime "reset_password_sent_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 "last_sign_in_at"
t.inet "current_sign_in_ip"
t.inet "last_sign_in_ip"
- t.boolean "admin", default: false
+ t.boolean "admin", default: false
t.string "confirmation_token"
t.datetime "confirmed_at"
t.datetime "confirmation_sent_at"
t.string "unconfirmed_email"
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 ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true, using: :btree
t.index ["email"], name: "index_users_on_email", unique: true, using: :btree