From fd1148dc6bd9f2a990ed2484b2c6925433c7742a Mon Sep 17 00:00:00 2001 From: Carl Chenet Date: Wed, 17 May 2017 11:29:54 +0200 Subject: [PATCH] add TOTP-based two-factor authentication option --- Gemfile | 2 + app/controllers/login_controller.rb | 37 ++++++++++- app/controllers/settings_controller.rb | 84 ++++++++++++++++++++++++ app/models/user.rb | 15 +++++ app/views/login/twofa.html.erb | 23 +++++++ app/views/settings/index.html.erb | 19 ++++++ app/views/settings/twofa.html.erb | 32 +++++++++ app/views/settings/twofa_enroll.html.erb | 25 +++++++ app/views/settings/twofa_verify.html.erb | 24 +++++++ config/routes.rb | 10 +++ 10 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 app/views/login/twofa.html.erb create mode 100644 app/views/settings/twofa.html.erb create mode 100644 app/views/settings/twofa_enroll.html.erb create mode 100644 app/views/settings/twofa_verify.html.erb diff --git a/Gemfile b/Gemfile index 01b401c..a214e92 100644 --- a/Gemfile +++ b/Gemfile @@ -21,6 +21,8 @@ gem "dynamic_form" gem "exception_notification" gem "bcrypt", "~> 3.1.2" +gem "rotp" +gem "rqrcode" gem "nokogiri", "= 1.6.1" gem "htmlentities" diff --git a/app/controllers/login_controller.rb b/app/controllers/login_controller.rb index 2ee683f..1e2a9d4 100644 --- a/app/controllers/login_controller.rb +++ b/app/controllers/login_controller.rb @@ -41,13 +41,18 @@ class LoginController < ApplicationController "unmoderated comments have been undeleted." end - session[:u] = user.session_token - if !user.password_digest.to_s.match(/^\$2a\$#{BCrypt::Engine::DEFAULT_COST}\$/) user.password = user.password_confirmation = params[:password].to_s user.save! end + if user.has_2fa? + session[:twofa_u] = user.session_token + return redirect_to "/login/2fa" + end + + session[:u] = user.session_token + if (rd = session[:redirect_to]).present? session.delete(:redirect_to) return redirect_to rd @@ -126,4 +131,32 @@ class LoginController < ApplicationController return redirect_to forgot_password_path end end + + def twofa + if tmpu = find_twofa_user + Rails.logger.info " Authenticated as user #{tmpu.id} " << + "(#{tmpu.username}), verifying TOTP" + else + reset_session + return redirect_to "/login" + end + end + + def twofa_verify + if (tmpu = find_twofa_user) && tmpu.authenticate_totp(params[:totp_code]) + session[:u] = tmpu.session_token + session.delete(:twofa_u) + return redirect_to "/" + else + flash[:error] = "Your TOTP code did not match. Please try again." + return redirect_to "/login/2fa" + end + end + +private + def find_twofa_user + if session[:twofa_u].present? + return User.where(:session_token => session[:twofa_u]).first + end + end end diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 533b255..c7726c7 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -1,6 +1,8 @@ class SettingsController < ApplicationController before_filter :require_logged_in_user + TOTP_SESSION_TIMEOUT = (60 * 15) + def index @title = t('.accountsettings') @@ -73,6 +75,88 @@ class SettingsController < ApplicationController render :action => "index" end + def twofa + @title = "Two-Factor Authentication" + end + + def twofa_auth + if @user.authenticate(params[:user][:password].to_s) + session[:last_authed] = Time.now.to_i + session.delete(:totp_secret) + + if @user.has_2fa? + @user.disable_2fa! + flash[:success] = "Two-Factor Authentication has been disabled." + return redirect_to "/settings" + else + return redirect_to twofa_enroll_url + end + else + flash[:error] = "Your password was not correct." + return redirect_to twofa_url + end + end + + def twofa_enroll + @title = "Two-Factor Authentication" + + if (Time.now.to_i - session[:last_authed].to_i) > TOTP_SESSION_TIMEOUT + flash[:error] = "Your enrollment period timed out." + return redirect_to twofa_url + end + + if !session[:totp_secret] + session[:totp_secret] = ROTP::Base32.random_base32 + end + + totp = ROTP::TOTP.new(session[:totp_secret], + :issuer => Rails.application.name) + totp_url = totp.provisioning_uri(@user.email) + + # no option for inline svg, so just strip off leading tag + qrcode = RQRCode::QRCode.new(totp_url) + qr = qrcode.as_svg(:offset => 0, color: "000", :module_size => 5, + :shape_rendering => "crispEdges").gsub(/^<\?xml.*>/, "") + + @qr_svg = "#{qr}" + end + + def twofa_verify + @title = "Two-Factor Authentication" + + if ((Time.now.to_i - session[:last_authed].to_i) > TOTP_SESSION_TIMEOUT) || + !session[:totp_secret] + flash[:error] = "Your enrollment period timed out." + return redirect_to twofa_url + end + end + + def twofa_update + if ((Time.now.to_i - session[:last_authed].to_i) > TOTP_SESSION_TIMEOUT) || + !session[:totp_secret] + flash[:error] = "Your enrollment period timed out." + return redirect_to twofa_url + end + + @user.totp_secret = session[:totp_secret] + if @user.authenticate_totp(params[:totp_code]) + # re-roll, just in case + @user.session_token = nil + @user.save! + + session[:u] = @user.session_token + + flash[:success] = "Two-Factor Authentication has been enabled on " << + "your account." + session.delete(:totp_secret) + return redirect_to "/settings" + else + flash[:error] = "Your TOTP code was invalid, please verify the " << + "current code in your TOTP application." + return redirect_to twofa_verify_url + end + end + private def user_params diff --git a/app/models/user.rb b/app/models/user.rb index 115f365..6d1d348 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -45,6 +45,7 @@ class User < ActiveRecord::Base s.boolean :show_story_previews, :default => false s.boolean :show_submitted_story_threads, :default => false s.boolean :hide_dragons, :default => false + s.string :totp_secret end validates :email, :format => { :with => /\A[^@ ]+@[^@ ]+\.[^@ ]+\Z/ }, @@ -116,6 +117,11 @@ class User < ActiveRecord::Base h end + def authenticate_totp(code) + totp = ROTP::TOTP.new(self.totp_secret) + totp.verify(code) + end + def avatar_url(size = 100) "https://secure.gravatar.com/avatar/" + Digest::MD5.hexdigest(self.email.strip.downcase) + @@ -276,6 +282,11 @@ class User < ActiveRecord::Base end end + def disable_2fa! + self.totp_secret = nil + self.save! + end + def grant_moderatorship_by_user!(user) User.transaction do self.is_moderator = true @@ -304,6 +315,10 @@ class User < ActiveRecord::Base PasswordReset.password_reset_link(self, ip).deliver end + def has_2fa? + self.totp_secret.present? + end + def is_active? !(deleted_at? || is_banned?) end diff --git a/app/views/login/twofa.html.erb b/app/views/login/twofa.html.erb new file mode 100644 index 0000000..c069579 --- /dev/null +++ b/app/views/login/twofa.html.erb @@ -0,0 +1,23 @@ +
+
+ Login - Two Factor Authentication +
+ + <%= form_tag twofa_login_url do %> +

+ Enter the current TOTP code from your TOTP application: +

+ +

+ <%= label_tag :totp_code, "TOTP Code:" %> + <%= text_field_tag :totp_code, "", :size => 10, :type => "number", + :autofocus => "autofocus" %> +
+

+ +

+ <%= submit_tag "Login" %> +

+ <% end %> +
+ diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb index d4ab006..d12c181 100644 --- a/app/views/settings/index.html.erb +++ b/app/views/settings/index.html.erb @@ -62,6 +62,25 @@
+
+ Security Settings +
+ +
+ <%= f.label :twofa, "Two-Factor Auth:", :class => "required" %> + + <% if @edit_user.totp_secret.present? %> + + Enabled + (Disable) + <% else %> + Disabled (Enroll) + <% end %> + +
+ +
+
<%= t('.notificationsettings') %>
diff --git a/app/views/settings/twofa.html.erb b/app/views/settings/twofa.html.erb new file mode 100644 index 0000000..66fb958 --- /dev/null +++ b/app/views/settings/twofa.html.erb @@ -0,0 +1,32 @@ +
+ +
+ <%= @title %> +
+ + <%= form_for @user, :url => twofa_auth_url, :method => :post do |f| %> +

+ <% if @user.has_2fa? %> + To turn off two-factor authentication for your account, enter your + current password: + <% else %> + To begin the two-factor authentication enrollment for your account, + enter your current password: + <% end %> +

+ +
+ <%= f.label :password, "Current Password:", :class => "required" %> + <%= f.password_field :password, :size => 40, :autocomplete => "off" %> +
+ +

+ <% if @user.has_2fa? %> + <%= submit_tag "Disable Two-Factor Authentication" %> + <% else %> + <%= submit_tag "Continue" %> + <% end %> + <% end %> +

diff --git a/app/views/settings/twofa_enroll.html.erb b/app/views/settings/twofa_enroll.html.erb new file mode 100644 index 0000000..b505190 --- /dev/null +++ b/app/views/settings/twofa_enroll.html.erb @@ -0,0 +1,25 @@ +
+ +
+ <%= @title %> +
+ +

+ Scan the QR code below or click on it to open in your TOTP application of choice: +

+ + <%= raw @qr_svg %> + +

+ Once you have finished registering with your TOTP application, proceed to + the next screen to verify your current TOTP code and actually enable + Two-Factor Authentication on your account. +

+ +

+ <%= button_to "Verify and Enable", twofa_verify_url, :method => :get %> +

diff --git a/app/views/settings/twofa_verify.html.erb b/app/views/settings/twofa_verify.html.erb new file mode 100644 index 0000000..db6ae10 --- /dev/null +++ b/app/views/settings/twofa_verify.html.erb @@ -0,0 +1,24 @@ +
+ +
+ <%= @title %> +
+ + <%= form_tag twofa_update_url do %> +

+ To enable Two-Factor Authentication on your account using your new TOTP + secret, enter the six-digit code from your TOTP application: +

+ +
+ <%= label_tag :totp_code, "TOTP Code:", :class => "required" %> + <%= text_field_tag :totp_code, "", :size => 10, :autocomplete => "off", + :type => "number", :autofocus => "autofocus" %> +
+ +

+ <%= submit_tag "Verify and Enable" %> + <% end %> +

diff --git a/config/routes.rb b/config/routes.rb index 65c07f0..972e4d4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -32,6 +32,8 @@ Lobsters::Application.routes.draw do get "/login" => "login#index" post "/login" => "login#login" post "/logout" => "login#logout" + get "/login/2fa" => "login#twofa" + post "/login/2fa_verify" => "login#twofa_verify", :as => "twofa_login" get "/signup" => "signup#index" post "/signup" => "signup#signup" @@ -107,6 +109,14 @@ Lobsters::Application.routes.draw do get "/settings/pushover_callback" => "settings#pushover_callback" post "/settings/delete_account" => "settings#delete_account", :as => "delete_account" + get "/settings/2fa" => "settings#twofa", :as => "twofa" + post "/settings/2fa_auth" => "settings#twofa_auth", :as => "twofa_auth" + get "/settings/2fa_enroll" => "settings#twofa_enroll", + :as => "twofa_enroll" + get "/settings/2fa_verify" => "settings#twofa_verify", + :as => "twofa_verify" + post "/settings/2fa_update" => "settings#twofa_update", + :as => "twofa_update" get "/filters" => "filters#index" post "/filters" => "filters#update"