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 @@ +
+ 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 %> ++ <% 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 %> +
+ ++ <% if @user.has_2fa? %> + <%= submit_tag "Disable Two-Factor Authentication" %> + <% else %> + <%= submit_tag "Continue" %> + <% end %> + <% end %> +
+ 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 %> +
+ To enable Two-Factor Authentication on your account using your new TOTP + secret, enter the six-digit code from your TOTP application: +
+ ++ <%= submit_tag "Verify and Enable" %> + <% end %> +