@@ -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' | ||||
@@ -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 | ||||
@@ -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; | |||||
} | |||||
} |
@@ -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) | ||||
@@ -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 |
@@ -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 | ||||
@@ -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 | ||||
@@ -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 | ||||
%li= link_to t('settings.back'), root_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 |
@@ -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" |
@@ -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. | ||||
@@ -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] |
@@ -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: | ||||
@@ -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: | ||||
@@ -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] | ||||
@@ -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 |
@@ -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.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.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 | ||||