Skip to content

Commit dbd84f1

Browse files
committed
add TOTP-based two-factor authentication option
1 parent f3e964b commit dbd84f1

11 files changed

+275
-2
lines changed

Gemfile

+2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ gem "dynamic_form"
2121
gem "exception_notification"
2222

2323
gem "bcrypt", "~> 3.1.2"
24+
gem "rotp"
25+
gem "rqrcode"
2426

2527
gem "nokogiri", "= 1.6.1"
2628
gem "htmlentities"

Gemfile.lock

+6
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ GEM
3232
arel (5.0.1.20140414130214)
3333
bcrypt (3.1.7)
3434
builder (3.2.2)
35+
chunky_png (1.3.8)
3536
diff-lcs (1.2.5)
3637
dynamic_form (1.1.4)
3738
erubis (2.7.0)
@@ -84,6 +85,9 @@ GEM
8485
rake (10.4.2)
8586
rdiscount (2.1.7.1)
8687
riddle (1.5.11)
88+
rotp (3.3.0)
89+
rqrcode (0.10.1)
90+
chunky_png (~> 1.0)
8791
rspec-collection_matchers (1.0.0)
8892
rspec-expectations (>= 2.99.0.beta1)
8993
rspec-core (2.99.1)
@@ -143,6 +147,8 @@ DEPENDENCIES
143147
oauth
144148
rails (= 4.1.12)
145149
rdiscount
150+
rotp
151+
rqrcode
146152
rspec-rails (~> 2.6)
147153
sqlite3
148154
thinking-sphinx (~> 3.1.2)

app/controllers/login_controller.rb

+35-2
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,18 @@ def login
4141
"unmoderated comments have been undeleted."
4242
end
4343

44-
session[:u] = user.session_token
45-
4644
if !user.password_digest.to_s.match(/^\$2a\$#{BCrypt::Engine::DEFAULT_COST}\$/)
4745
user.password = user.password_confirmation = params[:password].to_s
4846
user.save!
4947
end
5048

49+
if user.has_2fa?
50+
session[:twofa_u] = user.session_token
51+
return redirect_to "/login/2fa"
52+
end
53+
54+
session[:u] = user.session_token
55+
5156
if (rd = session[:redirect_to]).present?
5257
session.delete(:redirect_to)
5358
return redirect_to rd
@@ -126,4 +131,32 @@ def set_new_password
126131
return redirect_to forgot_password_path
127132
end
128133
end
134+
135+
def twofa
136+
if tmpu = find_twofa_user
137+
Rails.logger.info " Authenticated as user #{tmpu.id} " <<
138+
"(#{tmpu.username}), verifying TOTP"
139+
else
140+
reset_session
141+
return redirect_to "/login"
142+
end
143+
end
144+
145+
def twofa_verify
146+
if (tmpu = find_twofa_user) && tmpu.authenticate_totp(params[:totp_code])
147+
session[:u] = tmpu.session_token
148+
session.delete(:twofa_u)
149+
return redirect_to "/"
150+
else
151+
flash[:error] = "Your TOTP code did not match. Please try again."
152+
return redirect_to "/login/2fa"
153+
end
154+
end
155+
156+
private
157+
def find_twofa_user
158+
if session[:twofa_u].present?
159+
return User.where(:session_token => session[:twofa_u]).first
160+
end
161+
end
129162
end

app/controllers/settings_controller.rb

+84
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
class SettingsController < ApplicationController
22
before_filter :require_logged_in_user
33

4+
TOTP_SESSION_TIMEOUT = (60 * 15)
5+
46
def index
57
@title = "Account Settings"
68

@@ -74,6 +76,88 @@ def update
7476
render :action => "index"
7577
end
7678

79+
def twofa
80+
@title = "Two-Factor Authentication"
81+
end
82+
83+
def twofa_auth
84+
if @user.authenticate(params[:user][:password].to_s)
85+
session[:last_authed] = Time.now.to_i
86+
session.delete(:totp_secret)
87+
88+
if @user.has_2fa?
89+
@user.disable_2fa!
90+
flash[:success] = "Two-Factor Authentication has been disabled."
91+
return redirect_to "/settings"
92+
else
93+
return redirect_to twofa_enroll_url
94+
end
95+
else
96+
flash[:error] = "Your password was not correct."
97+
return redirect_to twofa_url
98+
end
99+
end
100+
101+
def twofa_enroll
102+
@title = "Two-Factor Authentication"
103+
104+
if (Time.now.to_i - session[:last_authed].to_i) > TOTP_SESSION_TIMEOUT
105+
flash[:error] = "Your enrollment period timed out."
106+
return redirect_to twofa_url
107+
end
108+
109+
if !session[:totp_secret]
110+
session[:totp_secret] = ROTP::Base32.random_base32
111+
end
112+
113+
totp = ROTP::TOTP.new(session[:totp_secret],
114+
:issuer => Rails.application.name)
115+
totp_url = totp.provisioning_uri(@user.email)
116+
117+
# no option for inline svg, so just strip off leading <?xml> tag
118+
qrcode = RQRCode::QRCode.new(totp_url)
119+
qr = qrcode.as_svg(:offset => 0, color: "000", :module_size => 5,
120+
:shape_rendering => "crispEdges").gsub(/^<\?xml.*>/, "")
121+
122+
@qr_svg = "<a href=\"#{totp_url}\">#{qr}</a>"
123+
end
124+
125+
def twofa_verify
126+
@title = "Two-Factor Authentication"
127+
128+
if ((Time.now.to_i - session[:last_authed].to_i) > TOTP_SESSION_TIMEOUT) ||
129+
!session[:totp_secret]
130+
flash[:error] = "Your enrollment period timed out."
131+
return redirect_to twofa_url
132+
end
133+
end
134+
135+
def twofa_update
136+
if ((Time.now.to_i - session[:last_authed].to_i) > TOTP_SESSION_TIMEOUT) ||
137+
!session[:totp_secret]
138+
flash[:error] = "Your enrollment period timed out."
139+
return redirect_to twofa_url
140+
end
141+
142+
@user.totp_secret = session[:totp_secret]
143+
if @user.authenticate_totp(params[:totp_code])
144+
# re-roll, just in case
145+
@user.session_token = nil
146+
@user.save!
147+
148+
session[:u] = @user.session_token
149+
150+
flash[:success] = "Two-Factor Authentication has been enabled on " <<
151+
"your account."
152+
session.delete(:totp_secret)
153+
return redirect_to "/settings"
154+
else
155+
flash[:error] = "Your TOTP code was invalid, please verify the " <<
156+
"current code in your TOTP application."
157+
return redirect_to twofa_verify_url
158+
end
159+
end
160+
77161
private
78162

79163
def user_params

app/models/user.rb

+15
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class User < ActiveRecord::Base
4545
s.boolean :show_story_previews, :default => false
4646
s.boolean :show_submitted_story_threads, :default => false
4747
s.boolean :hide_dragons, :default => false
48+
s.string :totp_secret
4849
end
4950

5051
validates :email, :format => { :with => /\A[^@ ]+@[^@ ]+\.[^@ ]+\Z/ },
@@ -116,6 +117,11 @@ def as_json(options = {})
116117
h
117118
end
118119

120+
def authenticate_totp(code)
121+
totp = ROTP::TOTP.new(self.totp_secret)
122+
totp.verify(code)
123+
end
124+
119125
def avatar_url(size = 100)
120126
"https://secure.gravatar.com/avatar/" +
121127
Digest::MD5.hexdigest(self.email.strip.downcase) +
@@ -276,6 +282,11 @@ def undelete!
276282
end
277283
end
278284

285+
def disable_2fa!
286+
self.totp_secret = nil
287+
self.save!
288+
end
289+
279290
def grant_moderatorship_by_user!(user)
280291
User.transaction do
281292
self.is_moderator = true
@@ -304,6 +315,10 @@ def initiate_password_reset_for_ip(ip)
304315
PasswordReset.password_reset_link(self, ip).deliver
305316
end
306317

318+
def has_2fa?
319+
self.totp_secret.present?
320+
end
321+
307322
def is_active?
308323
!(deleted_at? || is_banned?)
309324
end

app/views/login/twofa.html.erb

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<div class="box wide">
2+
<div class="legend">
3+
Login - Two Factor Authentication
4+
</div>
5+
6+
<%= form_tag twofa_login_url do %>
7+
<p>
8+
Enter the current TOTP code from your TOTP application:
9+
</p>
10+
11+
<p>
12+
<%= label_tag :totp_code, "TOTP Code:" %>
13+
<%= text_field_tag :totp_code, "", :size => 10, :type => "number",
14+
:autofocus => "autofocus" %>
15+
<br />
16+
</p>
17+
18+
<p>
19+
<%= submit_tag "Login" %>
20+
</p>
21+
<% end %>
22+
</div>
23+

app/views/settings/index.html.erb

+19
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,25 @@
6262

6363
<br>
6464

65+
<div class="legend">
66+
Security Settings
67+
</div>
68+
69+
<div class="boxline">
70+
<%= f.label :twofa, "Two-Factor Auth:", :class => "required" %>
71+
<span>
72+
<% if @edit_user.totp_secret.present? %>
73+
<span style="color: green; font-weight: bold;">
74+
Enabled
75+
</span> (<a href="/settings/2fa">Disable</a>)
76+
<% else %>
77+
Disabled (<a href="/settings/2fa">Enroll</a>)
78+
<% end %>
79+
</span>
80+
</div>
81+
82+
<br>
83+
6584
<div class="legend">
6685
Notification Settings
6786
</div>

app/views/settings/twofa.html.erb

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<div class="box wide">
2+
<div class="legend right">
3+
<a href="/settings">Back to Settings</a>
4+
</div>
5+
<div class="legend">
6+
<%= @title %>
7+
</div>
8+
9+
<%= form_for @user, :url => twofa_auth_url, :method => :post do |f| %>
10+
<p>
11+
<% if @user.has_2fa? %>
12+
To turn off two-factor authentication for your account, enter your
13+
current password:
14+
<% else %>
15+
To begin the two-factor authentication enrollment for your account,
16+
enter your current password:
17+
<% end %>
18+
</p>
19+
20+
<div class="boxline">
21+
<%= f.label :password, "Current Password:", :class => "required" %>
22+
<%= f.password_field :password, :size => 40, :autocomplete => "off" %>
23+
</div>
24+
25+
<p>
26+
<% if @user.has_2fa? %>
27+
<%= submit_tag "Disable Two-Factor Authentication" %>
28+
<% else %>
29+
<%= submit_tag "Continue" %>
30+
<% end %>
31+
<% end %>
32+
</div>
+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<div class="box wide">
2+
<div class="legend right">
3+
<a href="/settings">Back to Settings</a>
4+
</div>
5+
<div class="legend">
6+
<%= @title %>
7+
</div>
8+
9+
<p>
10+
Scan the QR code below or click on it to open in your <a
11+
href="https://en.wikipedia.org/wiki/Time-based_One-time_Password_Algorithm"
12+
target="_blank">TOTP</a> application of choice:
13+
</p>
14+
15+
<%= raw @qr_svg %>
16+
17+
<p>
18+
Once you have finished registering with your TOTP application, proceed to
19+
the next screen to verify your current TOTP code and actually enable
20+
Two-Factor Authentication on your account.
21+
</p>
22+
23+
<p>
24+
<%= button_to "Verify and Enable", twofa_verify_url, :method => :get %>
25+
</div>
+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<div class="box wide">
2+
<div class="legend right">
3+
<a href="/settings">Back to Settings</a>
4+
</div>
5+
<div class="legend">
6+
<%= @title %>
7+
</div>
8+
9+
<%= form_tag twofa_update_url do %>
10+
<p>
11+
To enable Two-Factor Authentication on your account using your new TOTP
12+
secret, enter the six-digit code from your TOTP application:
13+
</p>
14+
15+
<div class="boxline">
16+
<%= label_tag :totp_code, "TOTP Code:", :class => "required" %>
17+
<%= text_field_tag :totp_code, "", :size => 10, :autocomplete => "off",
18+
:type => "number", :autofocus => "autofocus" %>
19+
</div>
20+
21+
<p>
22+
<%= submit_tag "Verify and Enable" %>
23+
<% end %>
24+
</div>

config/routes.rb

+10
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
get "/login" => "login#index"
3333
post "/login" => "login#login"
3434
post "/logout" => "login#logout"
35+
get "/login/2fa" => "login#twofa"
36+
post "/login/2fa_verify" => "login#twofa_verify", :as => "twofa_login"
3537

3638
get "/signup" => "signup#index"
3739
post "/signup" => "signup#signup"
@@ -107,6 +109,14 @@
107109
get "/settings/pushover_callback" => "settings#pushover_callback"
108110
post "/settings/delete_account" => "settings#delete_account",
109111
:as => "delete_account"
112+
get "/settings/2fa" => "settings#twofa", :as => "twofa"
113+
post "/settings/2fa_auth" => "settings#twofa_auth", :as => "twofa_auth"
114+
get "/settings/2fa_enroll" => "settings#twofa_enroll",
115+
:as => "twofa_enroll"
116+
get "/settings/2fa_verify" => "settings#twofa_verify",
117+
:as => "twofa_verify"
118+
post "/settings/2fa_update" => "settings#twofa_update",
119+
:as => "twofa_update"
110120

111121
get "/filters" => "filters#index"
112122
post "/filters" => "filters#update"

0 commit comments

Comments
 (0)