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