Skip to content

Commit

Permalink
feat: pages for enabling and verifying two factor authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
kjellberg committed Apr 2, 2024
1 parent a38aef8 commit 96b8888
Show file tree
Hide file tree
Showing 21 changed files with 285 additions and 12 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,5 @@
coverage
node_modules
.DS_Store

/config/credentials/development.key
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ gem "devise-two-factor", "~> 5.0.0"
gem "dry-initializer", "~> 3.1"
gem "meta-tags", "~> 2.20"
gem "public_uid", "~> 2.2"
gem "rqrcode", "~> 2.0"
gem "simple_form", "~> 5.3.0"
gem "view_component", "~> 3.11"
gem "view_component-contrib", "~> 0.2.2"
Expand Down
6 changes: 6 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ GEM
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
childprocess (5.0.0)
chunky_png (1.4.0)
concurrent-ruby (1.2.3)
connection_pool (2.4.1)
crass (1.0.6)
Expand Down Expand Up @@ -271,6 +272,10 @@ GEM
railties (>= 5.2)
rexml (3.2.6)
rotp (6.3.0)
rqrcode (2.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
rqrcode_core (1.2.0)
rubocop (1.62.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
Expand Down Expand Up @@ -403,6 +408,7 @@ DEPENDENCIES
puma (>= 5.0)
rails (~> 7.1.3, >= 7.1.3.2)
rails-controller-testing
rqrcode (~> 2.0)
selenium-webdriver
simple_form (~> 5.3.0)
simplecov
Expand Down
8 changes: 6 additions & 2 deletions app/assets/stylesheets/application.tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@
@apply bg-surface text-text w-full rounded-lg p-8 shadow;
}

.kiqr-button {
@apply border-border rounded bg-rose-500 px-4 py-2 text-white;
.button {
@apply border-border bg-button inline-block cursor-pointer rounded px-5 py-2 font-bold text-white no-underline;
}

.button.danger {
@apply bg-rose-500;
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<a href="<%= path %>" class="flex py-4 hover:bg-surface-hover <%= current_page?(path) ? "bg-surface-hover" : nil %>">
<a href="<%= path %>" class="flex py-4 hover:bg-surface-hover <%= is_active? ? "bg-surface-hover" : nil %>">
<div class="w-20 flex justify-center items-center">
<i class="<%= icon %>"></i>
</div>
<div class="w-full flex flex-col">
<strong class="<%= current_page?(path) ? "text-primary" : nil %>"><%= label %></strong>
<strong class="<%= is_active? ? "text-primary" : nil %>"><%= label %></strong>
<span class="text-sm"><%= description %></span>
</div>
</a>
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,9 @@ class PageLayouts::Settings::NavigationItem::Component < ApplicationViewComponen
option :description
option :icon
option :path
option :active, optional: true, default: false

def is_active?
current_page?(path) || active
end
end
1 change: 0 additions & 1 deletion app/controllers/users/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ def show_otp_code_form
def validate_otp_code
self.resource = User.find(session[:otp_user_id])
if resource.validate_and_consume_otp!(sign_in_params[:otp_attempt])

set_flash_message!(:notice, :signed_in)
sign_in(resource_name, resource)
redirect_to after_sign_in_path_for(resource)
Expand Down
51 changes: 51 additions & 0 deletions app/controllers/users/two_factor_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
class Users::TwoFactorController < ApplicationController
before_action :setup_user
before_action :ensure_not_enabled, only: %i[new setup verify]

def new
# Reset the OTP secret to make sure that the user has a fresh secret key.
# This will also reset the otp_required_for_login flag to make sure the user
# doesn't get locked out of their account.
@user.reset_otp_secret!

# Redirect to the setup page to show the QR code for verification.
redirect_to setup_two_factor_path
end

def setup
# Generate the QR code for scanning the OTP secret.
# We'll use the RQRCode gem to generate the QR code.
@qr_png = RQRCode::QRCode.new(current_user.otp_uri).as_svg(
module_size: 4
)

@qr_code_image = @qr_png.html_safe
end

def verify
if @user.validate_and_consume_otp!(params[:user][:otp_attempt])
@user.update(otp_required_for_login: true)
redirect_to edit_two_factor_path, notice: I18n.t("users.two_factor.setup.success")
else
@user.errors.add(:otp_attempt, I18n.t("users.two_factor.setup.invalid_code"))
render turbo_stream: turbo_stream.replace("two_factor_form", partial: "users/two_factor/form", locals: {user: @user})
end
end

private

# Don't refresh the OTP secret if it's already enabled. This may lock the user
# out of their account if they've already setup 2FA.
def ensure_not_enabled
redirect_to edit_two_factor_path if enabled?
end

def setup_user
@user = current_user
end

def enabled?
current_user.otp_required_for_login?
end
helper_method :enabled?
end
9 changes: 9 additions & 0 deletions app/helpers/current_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,13 @@ def onboarded?
def personal_account
current_user&.personal_account
end

# This checks if the current path contains the string path provided.
# Allowing for more flexibility than current_page?(path).
#
# Example:
# current_base_path?(edit_two_factor_path) will return true if the current path is '/users/two-factor/setup'
def current_base_path?(path)
request.path.include?(path)
end
end
15 changes: 15 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,19 @@ class User < ApplicationRecord
def onboarded?
personal_account.present? && personal_account.persisted?
end

def otp_uri
issuer = Kiqr::Config.app_name
label = "#{issuer}:#{email}"
otp_provisioning_uri(label, issuer: issuer)
end

def reset_otp_secret!
update!(
otp_secret: User.generate_otp_secret,
otp_required_for_login: false,
consumed_timestep: nil,
otp_backup_codes: nil
)
end
end
2 changes: 1 addition & 1 deletion app/views/partials/navigations/_protected.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<%= navbar.account_selector if onboarded? %>
<%= navbar.item(icon: "fa fa-gear", path: edit_account_path) %>
<%= navbar.dark_mode_switch %>
<%= button_to destroy_user_session_path, method: :delete, class: "button" do %>
<%= button_to destroy_user_session_path, method: :delete do %>
<i class="fa fa-sign-out text-rose-400"></i>
<% end %>
</nav>
8 changes: 8 additions & 0 deletions app/views/partials/navigations/_settings.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
<i class="fa fa-arrow-turn-down w-3 h-3"></i>
</div>

<%= render(PageLayouts::Settings::NavigationItem::Component.new(
label: t('.items.two_factor.label'),
description: t('.items.two_factor.description'),
icon: "fa fa-shield-halved",
path: edit_two_factor_path,
active: current_base_path?(edit_two_factor_path)
)) %>

<%= render(PageLayouts::Settings::NavigationItem::Component.new(
label: t('.items.user.label'),
description: t('.items.user.description'),
Expand Down
2 changes: 1 addition & 1 deletion app/views/users/cancellations/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<% end %>
</ul>
<% else %>
<%= button_to t(".submit"), user_registration_path, name: "commit", class:"kiqr-button", data: { confirm: t(".confirmation_message"), turbo_confirm: t(".confirmation_message") }, method: :delete %>
<%= button_to t(".submit"), user_registration_path, name: "commit", class:"button danger", data: { confirm: t(".confirmation_message"), turbo_confirm: t(".confirmation_message") }, method: :delete %>
<% end %>
</article>
</div>
Expand Down
9 changes: 9 additions & 0 deletions app/views/users/two_factor/_form.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<div id="two_factor_form">
<p><%= t(".instructions") %></p>
<%= simple_form_for(user, url: verify_two_factor_path, method: :post) do |f| %>
<%= f.input :otp_attempt, label: t(".label"), placeholder: t(".placeholder") %>
<div>
<%= f.submit t(".verify_button"), class: "button" %>
</div>
<% end %>
</div>
30 changes: 30 additions & 0 deletions app/views/users/two_factor/setup.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<% title t(".title") %>

<%= render(PageLayouts::Settings::Component.new(
title: t(".title"),
description: t(".description")
)) do %>
<div class="box">
<header class="border-b pb-4 mb-4">
<h3 class="font-bold text-primary uppercase"><%= t(".title") %></h3>
</header>

<article class="prose dark:prose-invert">
<p><%= t(".instructions") %></p>
<ol>
<% t(".steps").each do |step| %>
<li><%= step %></li>
<% end %>
</ol>

<div class="flex flex-col md:flex-row gap-6">
<div id="qr-code-wrapper" class="flex justify-center items-center py-8">
<%= @qr_code_image %>
</div>
<div>
<%= render "form", user: @user %>
</div>
</div>
</article>
</div>
<% end %>
19 changes: 19 additions & 0 deletions app/views/users/two_factor/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<% title t(".title") %>

<%= render(PageLayouts::Settings::Component.new(
title: t(".title"),
description: t(".description")
)) do %>
<div class="box">
<header class="border-b pb-4 mb-4">
<h3 class="font-bold text-primary uppercase">
<%= t(".title") %>
</h3>
</header>
<article class="prose dark:prose-invert">
<p><%= t(".content") %></p>
<%= link_to t(".enable.button"), new_two_factor_path, class:"button" unless enabled? %>
<%= link_to t(".disable.button"), "#", class:"button danger" if enabled? %>
</article>
</div>
<% end %>
4 changes: 0 additions & 4 deletions config/initializers/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,6 @@
# Use this hook to configure devise mailer, warden hooks and so forth.
# Many of these configuration options can be set straight in your model.
Devise.setup do |config|
config.warden do |manager|
manager.default_strategies(scope: :user).unshift :two_factor_authenticatable
end

# The secret key used by Devise. Devise uses this key to generate
# random tokens. Changing this key will render invalid all existing
# confirmation, reset password and unlock tokens in the database.
Expand Down
33 changes: 32 additions & 1 deletion config/locales/kiqr.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ en:
description: "Edit your account profile"
user:
label: "Login credentials"
description: "Change user email or password."
description: "Change user email or password"
two_factor:
label: "Two-factor authentication"
description: "One-time passwords"
delete_user:
label: "Delete user account"
description: "Remove all user related data"
Expand Down Expand Up @@ -97,3 +100,31 @@ en:
owned_team_accounts: "Team accounts you are the owner of:"
submit: "Delete my account"
confirmation_message: "Are you sure? This will DELETE all of your data."
two_factor:
show:
title: "Two-factor authentication"
description: "Add an extra layer of security to your user account."
enable:
box_title: "Enable two-factor authentication (2FA)"
button: "Enable two-factor authentication"
disable:
box_title: "Disable two-factor authentication (2FA)"
button: "Disable two-factor authentication"
content: "Secure your account with an extra layer of protection. Two-Factor Authentication (2FA) adds a critical second step to your login process, combining something you know (your password) with something you have (a mobile device or authentication app). This makes it significantly harder for potential intruders to gain access to your account, even if they know your password."
enabled: "Two-factor authentication has been enabled."
disabled: "Two-factor authentication has been disabled."
setup:
title: "Setup two-factor authentication"
description: "Add an extra layer of security to your user account."
instructions: "To enable two factor authentication, scan the QR code below with your 2FA app (like Google Authenticator or Authy). This will link your user account directly to your 2FA app, generating unique, one-time codes that you'll use for secure access to your account."
steps:
- "Download an authenticator app on your smartphone if you haven't already."
- "Scan the QR code with your authenticator app."
- "Enter the 6-digit code generated by your authenticator app."
success: "Two-factor authentication has been enabled."
invalid_code: "Invalid code. Please try again."
form:
instructions: "Enter the code provided by your app to finish the two factor setup:"
label: "One-time password"
placeholder: "Enter the 6-digit code"
verify_button: "Verify configuration"
6 changes: 6 additions & 0 deletions config/routes/authentication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
scope module: :users, path: :users do
get "onboarding" => "onboarding#new"
post "onboarding" => "onboarding#create"

get "two-factor" => "two_factor#show", :as => :edit_two_factor
get "two-factor/new" => "two_factor#new", :as => :new_two_factor
get "two-factor/setup" => "two_factor#setup", :as => :setup_two_factor
post "two-factor/verify" => "two_factor#verify", :as => :verify_two_factor

get "delete" => "cancellations#show", :as => :delete_user_registration
end

Expand Down
21 changes: 21 additions & 0 deletions test/controllers/users/two_factor_controller_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
require "test_helper"

class Users::TwoFactorControllerTest < ActionDispatch::IntegrationTest
test "should not be able to setup 2fa if already enabled" do
sign_in create(:user, :otp_enabled)
get setup_two_factor_path
assert_redirected_to edit_two_factor_path
end

test "secret code is refreshed on new setup" do
user = create(:user)
sign_in user
get new_two_factor_path

user.reload
assert user.otp_secret

get new_two_factor_path
assert_not_equal user.otp_secret, user.reload.otp_secret
end
end
Loading

0 comments on commit 96b8888

Please sign in to comment.