Skip to content

Commit

Permalink
Use global signed ID for magic link auth
Browse files Browse the repository at this point in the history
- Handle valid and invalid sgid in magic link
- Handle expired resource so that end users can request a new magic link if previous link has expired
  • Loading branch information
benbaumann95 committed Jan 20, 2025
1 parent b680675 commit 8bf75da
Show file tree
Hide file tree
Showing 11 changed files with 139 additions and 30 deletions.
2 changes: 1 addition & 1 deletion config/initializers/filter_parameter_logging.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@

# Configure sensitive parameters which will be filtered from the log file.
Rails.application.config.filter_parameters += %i[
passw secret token _key crypt salt certificate otp ssn otp_attempt
passw secret token _key crypt salt certificate otp ssn otp_attempt sgid
]
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
module BopsConsultees
class ApplicationController < ActionController::Base
include BopsCore::ApplicationController
include BopsCore::MagicLinkAuthenticatable

before_action :authenticate_with_sgid!

layout "application"
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

module BopsConsultees
class DashboardsController < ApplicationController
include BopsCore::MagicLinkAuthenticatable

def show
respond_to do |format|
format.html
Expand Down
4 changes: 0 additions & 4 deletions engines/bops_consultees/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,4 @@
root to: redirect("dashboard")

resource :dashboard, only: %i[show]

get "magic_link", to: "dashboards#magic_link", as: :magic_link

devise_for :consultees, class_name: "Consultee"
end
42 changes: 38 additions & 4 deletions engines/bops_consultees/spec/system/dashboard_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,47 @@

RSpec.describe "Dashboard", type: :system do
let!(:local_authority) { create(:local_authority, :default) }
let(:consultation) { create(:consultation) }
let(:consultee) { create(:consultee, consultation:) }
let(:sgid) { consultee.sgid(expires_in: 1.day, for: "magic_link") }

before do
visit "/consultees"
visit "/consultees/dashboard?sgid=#{sgid}"
end

it "I can view the dashboard" do
expect(page).to have_current_path("/consultees/dashboard")
expect(page).to have_content("BOPS consultees")
context "with valid magic link" do
it "I can view the dashboard" do
expect(page).to have_current_path("/consultees/dashboard?sgid=#{sgid}")
expect(page).to have_content("BOPS consultees")
end
end

context "with expired magic link" do
let(:sgid) { consultee.sgid(expires_in: 1.minute, for: "magic_link") }

it "I can't view the dashboard" do
travel 2.minutes
visit "/consultees/dashboard?sgid=#{sgid}"
expect(page).not_to have_content("BOPS consultees")
expect(page).to have_content("Magic link expired")
end
end

context "with invalid sgid" do
let(:sgid) { consultee.sgid(expires_in: 1.day, for: "other_link") }

it "I can't view the dashboard" do
expect(page).not_to have_content("BOPS consultees")
expect(page).to have_content("Forbidden")
end
end

context "without sgid" do
let(:sgid) { nil }

it "I can't view the dashboard" do
expect(page).not_to have_content("BOPS consultees")
expect(page).to have_content("Not Found")
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
module BopsCore
module MagicLinkAuthenticatable
extend ActiveSupport::Concern

SGID_FOR = "magic_link"

included do
Expand All @@ -12,16 +11,10 @@ module MagicLinkAuthenticatable
end
end

def magic_link
def authenticate_with_sgid!
resource = GlobalID::Locator.locate_signed(sgid, for: SGID_FOR)

if resource.nil?
redirect_failed
return
end

sign_in(resource)
redirect_to after_magic_link_path_for(resource), notice: "Welcome!"
handle_expired_or_invalid_sgid if resource.nil?
end

private
Expand All @@ -30,16 +23,45 @@ def sgid
params.require(:sgid)
end

def redirect_failed
render plain: "Forbidden", status: :forbidden
def sgid_expired_resource
gid = parse_global_id_from_sgid(sgid)
return nil unless gid

gid.model_class.find_by(id: gid.model_id)
rescue ActiveRecord::RecordNotFound
nil
end

def after_magic_link_path_for(resource)
if resource.is_a?(Consultee)
bops_consultees.dashboard_path
def handle_expired_or_invalid_sgid
expired_resource = sgid_expired_resource

if expired_resource
render plain: "Magic link expired", status: :unprocessable_entity
else
main_app.root_path
redirect_failed
end
end

def parse_global_id_from_sgid(sgid)
decoded = Base64.decode64(sgid)

json_start = decoded.index('{"_rails"')
json_end = decoded.rindex("}")
json_payload = decoded[json_start..json_end]

parsed = JSON.parse(json_payload)

sgid_for = parsed.dig("_rails", "pur")
return nil unless sgid_for == SGID_FOR

data = parsed.dig("_rails", "data")
GlobalID.parse(data)
rescue JSON::ParserError, TypeError, ArgumentError
nil
end

def redirect_failed
render plain: "Forbidden", status: :forbidden
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,27 @@ module BopsCore
class MagicLinkMailer < ApplicationMailer
def magic_link_mail(resource:, subdomain:, subject: "Your magic link")
@resource = resource
@magic_link = resource.magic_link
@sgid = resource.sgid
@subdomain = subdomain
@url = magic_link_url

mail(
to: resource.email_address,
subject:
)
end

private

attr_reader :resource, :sgid, :subdomain

def magic_link_url
case resource
when Consultee
bops_consultees.dashboard_url(sgid:, subdomain:)
else
main_app.root_url
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module MagicLinkable
extend ActiveSupport::Concern
include GlobalID::Identification

def magic_link(expires_in: 1.day, for: "magic_link")
def sgid(expires_in: 2.days, for: "magic_link")
to_sgid(expires_in:, for:).to_s
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ Hello <%= @resource.name %>

Click the link below to access your dashboard:

<%= link_to "Access Dashboard", bops_consultees.magic_link_url(sgid: @magic_link, subdomain: @subdomain) %>
<%= link_to "Access Dashboard", @url %>

This link will expire in 24 hours.
1 change: 0 additions & 1 deletion engines/bops_core/config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# frozen_string_literal: true

BopsCore::Engine.routes.draw do
get "magic_link", to: "magic_links#show", as: :magic_link
end
43 changes: 43 additions & 0 deletions engines/bops_core/spec/models/bops_core/magic_linkable_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true

require "bops_core_helper"

RSpec.describe BopsCore::MagicLinkable, type: :model do
let(:consultation) { create(:consultation) }
let(:consultee) { create(:consultee, consultation:) }

describe "#sgid" do
it "generates a signed global ID with expiration time" do
sgid = consultee.sgid(expires_in: 1.day, for: "magic_link")
decoded = GlobalID::Locator.locate_signed(sgid, for: "magic_link")

expect(decoded).to eq(consultee)
end

it "returns nil if SGID has expired" do
sgid = consultee.sgid(expires_in: 1.second, for: "magic_link")
travel 1.minute

expect(GlobalID::Locator.locate_signed(sgid, for: "magic_link")).to be_nil
end

it "returns nil an error if the SGID is expired" do
sgid = consultee.sgid(expires_in: 1.minute, for: "magic_link")
travel 1.minute

expect(GlobalID::Locator.locate_signed(sgid, for: "magic_link")).to be_nil
Base64.decode64(sgid)
end
end

describe "#sgid_expired_resource" do
it "returns the resource if SGID is expired" do
sgid = consultee.sgid(expires_in: 1.second, for: "magic_link")
travel 1.minute

binding.pry
expect(GlobalID::Locator.locate_signed(sgid, for: "magic_link")).to be_nil
Base64.decode64(sgid)
end
end
end

0 comments on commit 8bf75da

Please sign in to comment.