Skip to content

Commit

Permalink
Proper Two-Factor Auth
Browse files Browse the repository at this point in the history
  • Loading branch information
parterburn committed Jan 16, 2024
1 parent 78a2e7c commit cb75454
Show file tree
Hide file tree
Showing 25 changed files with 338 additions and 70 deletions.
3 changes: 2 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ gem 'pg', '~> 1.5', '>= 1.2.3'
# gem 'barnes'

# users
gem "devise", ">= 4.7.1"
gem "devise"
gem 'devise-security'
gem "devise-otp"
gem "turnstile-captcha", require: "turnstile"
gem 'gibbon' # mailchimp connector

Expand Down
8 changes: 7 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise-otp (0.6.0)
devise (>= 4.8.0, < 5.0)
rails (>= 6.1, < 7.2)
rotp (>= 2.0.0)
devise-security (0.17.0)
devise (>= 4.3.0)
diff-lcs (1.4.4)
Expand Down Expand Up @@ -366,6 +370,7 @@ GEM
reverse_markdown (2.1.1)
nokogiri
rinku (2.0.6)
rotp (6.3.0)
rouge (4.2.0)
rspec (3.10.0)
rspec-core (~> 3.10.0)
Expand Down Expand Up @@ -492,7 +497,8 @@ DEPENDENCIES
database_cleaner
delorean
derailed_benchmarks
devise (>= 4.7.1)
devise
devise-otp
devise-security
email_reply_trimmer
email_spec
Expand Down
1 change: 1 addition & 0 deletions app/assets/javascripts/application.js.erb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//= require i18n
//= require i18n/translations
//= require summernote
//= require devise-otp
//= require_tree .

I18n.defaultLocale = '<%= I18n.default_locale.to_s %>';
Expand Down
9 changes: 9 additions & 0 deletions app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ class SessionsController < Devise::SessionsController
after_action :track_ga_event, only: :create
prepend_before_action :check_captcha, only: [:create]

def validate_otp
if current_user.validate_otp_token(params[:user][:token].gsub(/\D/, ''))
current_user.update(otp_enabled: true, otp_enabled_on: Time.now)
else
flash[:alert] = "Invalid token. It should be a 6 digit number generated by the app you scanned the QR code with."
end
redirect_back(fallback_location: root_path)
end

private

def check_captcha
Expand Down
7 changes: 0 additions & 7 deletions app/mailers/user_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,6 @@ def welcome_email(user)
email.mailgun_options = {tag: 'Welcome'}
end

def confirm_user(user)
@user = user
return nil unless @user.paranoid_verification_code.present?
email = mail(to: user.cleaned_to_address, subject: "Login code for Dabble Me")
email.mailgun_options = {tag: 'Login Code'}
end

def second_welcome_email(user)
@user = user
@user.increment!(:emails_sent)
Expand Down
14 changes: 2 additions & 12 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ class User < ActiveRecord::Base

# Include default devise modules. Others available are:
# :confirmable, :timeoutable and :omniauthable, :lockable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable, :paranoid_verification
devise :otp_authenticatable, :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable

randomized_field :user_key, length: 18 do |slug_value|
"u#{slug_value}"
Expand Down Expand Up @@ -172,16 +172,6 @@ def random_entries
@random_entries ||= entries.where.not(id: past_filter_entry_ids)
end

def after_database_authentication
if self.admin? && self.generate_paranoid_code
UserMailer.confirm_user(self).deliver_later
elsif self.need_paranoid_verification?
UserMailer.confirm_user(self).deliver_later
end
rescue
nil
end

def remember_me
super.present? ? super : true
end
Expand Down
25 changes: 25 additions & 0 deletions app/views/devise/otp_credentials/refresh.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<div class="row">
<div class="col-md-8 col-md-offset-2" style="margin-bottom: 40px;">
<h3><%= I18n.t('title', :scope => 'devise.otp.credentials_refresh') %></h3>
<p><%= I18n.t('explain', :scope => 'devise.otp.credentials_refresh') %></p>

<%= form_for(resource, :as => resource_name, :url => [:refresh, resource_name, :otp_credential], :html => { :method => :put, "data-turbo" => false }) do |f| %>

<%= render "devise/shared/error_messages", resource: resource %>

<div>
<%= f.label :email %><br />
<%= f.text_field :email, :disabled => :true, class: "form-control" %>
</div>
<br>

<div>
<%= f.label :password %><br />
<%= f.password_field :refresh_password, :autocomplete => :off, :autofocus => true, class: "form-control" %>
</div>
<br>

<div><%= f.submit I18n.t(:go_on, :scope => 'devise.otp.credentials_refresh'), class: "btn btn-primary" %></div>
<% end %>
</div>
</div>
38 changes: 38 additions & 0 deletions app/views/devise/otp_credentials/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<div class="row">
<div class="col-md-6 col-md-offset-3" style="margin-bottom: 40px;">

<h3><%= I18n.t('title', :scope => 'devise.otp.submit_token') %></h3>
<p><%= I18n.t('explain', :scope => 'devise.otp.submit_token') %></p>

<%= form_for(resource, :as => resource_name, :url => [resource_name, :otp_credential], :html => { :method => :put, "data-turbo" => false }) do |f| %>

<%= f.hidden_field :challenge, {:value => @challenge} %>
<%= f.hidden_field :recovery, {:value => @recovery} %>

<% if @recovery %>
<p>
<%= f.label :token, I18n.t('recovery_prompt', :scope => 'devise.otp.submit_token') %><br />
<%= f.text_field :otp_recovery_counter, :autocomplete => :off, :disabled => true, :size => 4, class: "form-control" %>
</p>
<% else %>
<p>
<%= f.label :token, I18n.t('prompt', :scope => 'devise.otp.submit_token') %><br />
</p>
<% end %>

<%= f.text_field :token, :autocomplete => :off, :autofocus => true, :size => 6, :value => '', class: "form-control" %><br>

<!--
<%= label_tag :enable_persistence do %>
<%= check_box_tag :enable_persistence, true, false %> <%= I18n.t('remember', :scope => 'devise.otp.general') %>
<% end %>
-->

<p><%= f.submit I18n.t('submit', :scope => 'devise.otp.submit_token'), class: "btn btn-primary form-control" %></p>

<% if !@recovery && recovery_enabled? %>
<p class="text-center"><%= link_to I18n.t('recovery_link', :scope => 'devise.otp.submit_token'), otp_credential_path_for(resource_name, :challenge => @challenge, :recovery => true) %></p>
<% end %>
<% end %>
</div>
</div>
51 changes: 51 additions & 0 deletions app/views/devise/otp_tokens/_token_secret.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<% unless resource.otp_enabled? %>
<hr>
<p><%= I18n.t('explain', :scope => 'devise.otp.token_secret') %></p>
<%= otp_authenticator_token_image(resource) %>
<br/>
<!--
<p>
<strong><%= I18n.t('manual_provisioning', :scope => 'devise.otp.token_secret') %>:</strong>
<code><%= resource.otp_auth_secret %></code>
</p>
-->
<% end %>

<% if resource.otp_enabled? %>
<%- if recovery_enabled? && resource.otp_enabled_on > 10.minutes.ago %>
<div class="well" style="margin: 20px auto;">
<h4><%= I18n.t('title', :scope => 'devise.otp.otp_tokens.recovery') %></h4>
<p><%= I18n.t('explain', :scope => 'devise.otp.otp_tokens.recovery') %></p>
<p><%= link_to I18n.t('codes_list', :scope => 'devise.otp.otp_tokens.recovery'), recovery_otp_token_for(resource_name) %></p>
<p><%= link_to I18n.t('download_codes', :scope => 'devise.otp.otp_tokens.recovery'), recovery_otp_token_for(resource_name, format: :text) %></p>
</div>
<% end %>

<%= render :partial => 'trusted_devices' if trusted_devices_enabled? %>
<hr>
<p>
<%= I18n.t('reset_explain', :scope => 'devise.otp.token_secret') %>
<strong><%= I18n.t('reset_explain_warn', :scope => 'devise.otp.token_secret') %></strong>
</p>
<p><%= button_to I18n.t('reset_otp', :scope => 'devise.otp.token_secret'), @resource, :method => :delete, :data => { "turbo-method": "DELETE" }, class: "btn btn-danger" %></p>
<% else %>
<%= form_for(resource, :as => resource_name, :url => validate_otp_path, :html => { :method => :post, "data-turbo" => false }) do |f| %>

<%= f.hidden_field :challenge, {:value => @challenge} %>
<%= f.hidden_field :recovery, {:value => @recovery} %>

<% if @recovery %>
<p>
<%= f.label :token, I18n.t('recovery_prompt', :scope => 'devise.otp.submit_token') %><br />
<%= f.text_field :otp_recovery_counter, :autocomplete => :off, :disabled => true, :size => 4, class: "form-control" %>
</p>
<% else %>
<p>
<%= f.label :token, I18n.t('prompt', :scope => 'devise.otp.submit_token') %><br />
</p>
<% end %>

<%= f.text_field :token, :autocomplete => :off, :autofocus => true, :size => 6, :value => '', class: "form-control" %><br>
<%= f.submit "Enable Two-Factor Authentication", class: "btn btn-primary form-control" %><br>
<% end %>
<% end %>
12 changes: 12 additions & 0 deletions app/views/devise/otp_tokens/_trusted_devices.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<h4><%= I18n.t('title', :scope => 'devise.otp.trusted_browsers') %></h4>
<p><%= I18n.t('explain', :scope => 'devise.otp.trusted_browsers') %></p>

<%- if is_otp_trusted_browser_for? resource %>
<p><em class="text-success"><%= I18n.t('browser_trusted', :scope => 'devise.otp.trusted_browsers') %></em></p>
<p><%= link_to I18n.t('trust_remove', :scope => 'devise.otp.trusted_browsers'), persistence_otp_token_path_for(resource_name), :method => :post, :data => { "turbo-method": "POST" } %></p>
<% else %>
<p><%= I18n.t('browser_not_trusted', :scope => 'devise.otp.trusted_browsers') %></p>
<p><%= link_to I18n.t('trust_add', :scope => 'devise.otp.trusted_browsers'), persistence_otp_token_path_for(resource_name) %></p>
<% end %>

<p><%= button_to I18n.t('trust_clear', :scope => 'devise.otp.trusted_browsers'), persistence_otp_token_path_for(resource_name), :method => :delete, :data => { "turbo-method": "DELETE" }, class: "btn btn-warning" %></p>
25 changes: 25 additions & 0 deletions app/views/devise/otp_tokens/recovery.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<div class="row">
<div class="col-md-8 col-md-offset-2" style="margin-bottom: 40px;">
<h3><%= I18n.t('title', :scope => 'devise.otp.otp_tokens.recovery') %></h3>
<p><%= I18n.t('explain', :scope => 'devise.otp.otp_tokens.recovery') %></p>

<table>
<caption>
<thead>
<tr>
<th><%= I18n.t('code', :scope => 'devise.otp.otp_tokens.sequence') %></th>
<th><%= I18n.t('code', :scope => 'devise.otp.otp_tokens.recovery') %></th>
</tr>
</thead>
<tbody>
<%- resource.next_otp_recovery_tokens.each do |seq, code| %>
<tr>
<td><%= seq %></td>
<td><%= code %></td>
</tr>
<% end %>
</tbody>
</caption>
</table>
</div>
</div>
3 changes: 3 additions & 0 deletions app/views/devise/otp_tokens/recovery_codes.text.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<% resource.next_otp_recovery_tokens.each do |seq, code| %>
<%= code %>
<% end %>
8 changes: 8 additions & 0 deletions app/views/devise/otp_tokens/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div class="row">
<div class="col-md-8 col-md-offset-2" style="margin-bottom: 40px;">
<h3><%= I18n.t('title', :scope => 'devise.otp.otp_tokens') %></h3>
<p><%= I18n.t('explain', :scope => 'devise.otp.otp_tokens') %></p>

<%= render :partial => 'token_secret' %>
</div>
</div>
20 changes: 0 additions & 20 deletions app/views/devise/paranoid_verification_code/show.html.haml

This file was deleted.

2 changes: 1 addition & 1 deletion app/views/devise/registrations/_user_settings.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
%em{style: "color: #666;"}
%span<> Visit
= link_to "Account Security", security_path, class: "s-delete", style: "opacity: 100%;margin-left: 5px;"
to change your email, password, or delete your account.
to change your email, password, two-factor settings, or delete your account.
%br

.col-md-8.col-md-offset-2
Expand Down
7 changes: 6 additions & 1 deletion app/views/devise/registrations/security.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,19 @@
= f.password_field :password_confirmation, autocomplete: "off", class: "form-control"
%br

.col-md-8.col-md-offset-2
%strong= link_to (resource.otp_enabled? ? "✅ Change Two-Factor Authentication" : "⚠️ Setup Two-Factor Authentication"), "users/otp/token"
%br
%br

.col-md-8.col-md-offset-2
.well{style: 'background-color: hsla(40, 33%, 96%, 1); padding: 15px 20px;'}
= f.label :current_password
to confirm your changes
= f.password_field :current_password, autocomplete: "off", class: "form-control"

.col-md-8.col-md-offset-2
= f.button "Update Security", class: "btn btn-primary form-control j-edit-security", name: "submit_method", value: "security", type: :submit
= f.submit "Update Security", class: "btn btn-primary form-control j-edit-security", name: "submit_method", type: :submit

.clearfix

Expand Down
10 changes: 5 additions & 5 deletions app/views/devise/sessions/new.html.haml
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
- title 'Login to Dabble Me'

.row
.col-md-4.col-md-offset-4
.col-md-6.col-md-offset-3
%br
= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f|

.col-md-4.col-md-offset-4
.col-md-6.col-md-offset-3
= render "devise/shared/error_messages", resource: resource

.col-md-4.col-md-offset-4
.col-md-6.col-md-offset-3
= f.label :email
= f.email_field :email, autofocus: true, :class => "form-control", required: true
%br

.col-md-4.col-md-offset-4
.col-md-6.col-md-offset-3
= f.label :password
= f.password_field :password, autocomplete: "off", :class => "form-control", required: true
%br

- if devise_mapping.rememberable?
.col-md-4.col-md-offset-4
.col-md-6.col-md-offset-3
= f.check_box :remember_me
= f.label :remember_me, "Stay logged in for 2 weeks"
%br
Expand Down
11 changes: 0 additions & 11 deletions app/views/user_mailer/confirm_user.html.haml

This file was deleted.

Loading

0 comments on commit cb75454

Please sign in to comment.