add TOTP-based two-factor authentication option

This commit is contained in:
Carl Chenet 2017-05-17 11:29:54 +02:00
parent 1ac81cbe2a
commit fd1148dc6b
10 changed files with 269 additions and 2 deletions

View file

@ -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"

View file

@ -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

View file

@ -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 <?xml> 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 = "<a href=\"#{totp_url}\">#{qr}</a>"
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

View file

@ -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

View file

@ -0,0 +1,23 @@
<div class="box wide">
<div class="legend">
Login - Two Factor Authentication
</div>
<%= form_tag twofa_login_url do %>
<p>
Enter the current TOTP code from your TOTP application:
</p>
<p>
<%= label_tag :totp_code, "TOTP Code:" %>
<%= text_field_tag :totp_code, "", :size => 10, :type => "number",
:autofocus => "autofocus" %>
<br />
</p>
<p>
<%= submit_tag "Login" %>
</p>
<% end %>
</div>

View file

@ -62,6 +62,25 @@
<br>
<div class="legend">
Security Settings
</div>
<div class="boxline">
<%= f.label :twofa, "Two-Factor Auth:", :class => "required" %>
<span>
<% if @edit_user.totp_secret.present? %>
<span style="color: green; font-weight: bold;">
Enabled
</span> (<a href="/settings/2fa">Disable</a>)
<% else %>
Disabled (<a href="/settings/2fa">Enroll</a>)
<% end %>
</span>
</div>
<br>
<div class="legend">
<%= t('.notificationsettings') %>
</div>

View file

@ -0,0 +1,32 @@
<div class="box wide">
<div class="legend right">
<a href="/settings">Back to Settings</a>
</div>
<div class="legend">
<%= @title %>
</div>
<%= form_for @user, :url => twofa_auth_url, :method => :post do |f| %>
<p>
<% 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 %>
</p>
<div class="boxline">
<%= f.label :password, "Current Password:", :class => "required" %>
<%= f.password_field :password, :size => 40, :autocomplete => "off" %>
</div>
<p>
<% if @user.has_2fa? %>
<%= submit_tag "Disable Two-Factor Authentication" %>
<% else %>
<%= submit_tag "Continue" %>
<% end %>
<% end %>
</div>

View file

@ -0,0 +1,25 @@
<div class="box wide">
<div class="legend right">
<a href="/settings">Back to Settings</a>
</div>
<div class="legend">
<%= @title %>
</div>
<p>
Scan the QR code below or click on it to open in your <a
href="https://en.wikipedia.org/wiki/Time-based_One-time_Password_Algorithm"
target="_blank">TOTP</a> application of choice:
</p>
<%= raw @qr_svg %>
<p>
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.
</p>
<p>
<%= button_to "Verify and Enable", twofa_verify_url, :method => :get %>
</div>

View file

@ -0,0 +1,24 @@
<div class="box wide">
<div class="legend right">
<a href="/settings">Back to Settings</a>
</div>
<div class="legend">
<%= @title %>
</div>
<%= form_tag twofa_update_url do %>
<p>
To enable Two-Factor Authentication on your account using your new TOTP
secret, enter the six-digit code from your TOTP application:
</p>
<div class="boxline">
<%= label_tag :totp_code, "TOTP Code:", :class => "required" %>
<%= text_field_tag :totp_code, "", :size => 10, :autocomplete => "off",
:type => "number", :autofocus => "autofocus" %>
</div>
<p>
<%= submit_tag "Verify and Enable" %>
<% end %>
</div>

View file

@ -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"