about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2017-01-27 20:28:46 +0100
committerEugen Rochko <eugen@zeonfederated.com>2017-01-27 20:35:16 +0100
commitba192f12e381842c90df0fab2fcb1a23cae97fc4 (patch)
tree1af8032d690d9c20af8b481d42978829b01334b6
parent237cb41ab4d841fb215ce6707c8d7695ef44b103 (diff)
Added optional two-factor authentication
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock15
-rw-r--r--app/assets/stylesheets/forms.scss32
-rw-r--r--app/controllers/auth/sessions_controller.rb6
-rw-r--r--app/controllers/settings/two_factor_auths_controller.rb28
-rw-r--r--app/models/user.rb4
-rw-r--r--app/views/auth/sessions/new.html.haml1
-rw-r--r--app/views/settings/shared/_links.html.haml4
-rw-r--r--app/views/settings/two_factor_auths/show.html.haml17
-rw-r--r--config/initializers/devise.rb6
-rw-r--r--config/initializers/filter_parameter_logging.rb2
-rw-r--r--config/locales/en.yml6
-rw-r--r--config/locales/simple_form.en.yml1
-rw-r--r--config/routes.rb7
-rw-r--r--db/migrate/20170127165745_add_devise_two_factor_to_users.rb9
-rw-r--r--db/schema.rb21
16 files changed, 146 insertions, 15 deletions
diff --git a/Gemfile b/Gemfile
index 77f465816..fb9a11ba1 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 a37a06b01..5361b2a05 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 365396511..560388f8f 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 c8350f9a1..889b20e11 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 000000000..66a82aab7
--- /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 71d3ee0b8..b34144f2c 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 93b9629f1..192a54bc6 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 a6e90f457..6490ffdd8 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 000000000..5070bb9d4
--- /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 85ba1082b..5eba34aa5 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 d2452f355..06cb15bbb 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 831fdbc7a..4f02a87e2 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 <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:
     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 578208700..e45a9a7a6 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 9423a0ae2..87f35770a 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 000000000..f4183e4a9
--- /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 72ce63133..7a7fea86b 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