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 magic link user can request a new link if previous link has expired
- Add magic link mailer for consultee
  • Loading branch information
benbaumann95 committed Jan 20, 2025
1 parent 7cb871a commit 9418c1b
Show file tree
Hide file tree
Showing 10 changed files with 212 additions and 5 deletions.
2 changes: 2 additions & 0 deletions app/models/consultee.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# frozen_string_literal: true

class Consultee < ApplicationRecord
include BopsCore::MagicLinkable

attribute :selected, :boolean, default: false

belongs_to :consultation
Expand Down
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
54 changes: 50 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,59 @@

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 expired magic link for other sgid purpose" do
let!(:sgid) { consultee.sgid(expires_in: 1.minute, for: "other_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).not_to have_content("Magic link expired")
expect(page).to have_content("Forbidden")
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
@@ -0,0 +1,37 @@
# frozen_string_literal: true

module BopsCore
module MagicLinkAuthenticatable
extend ActiveSupport::Concern

included do
rescue_from ActionController::ParameterMissing do |exception|
render plain: "Not Found", status: :not_found
end
end

def authenticate_with_sgid!
resource = sgid_authentication_service.locate_resource

handle_expired_or_invalid_sgid if resource.nil?
end

private

def sgid_authentication_service
@sgid_authentication_service ||= SgidAuthenticationService.new(sgid)
end

def sgid
params.require(:sgid)
end

def handle_expired_or_invalid_sgid
if sgid_authentication_service.expired_resource
render plain: "Magic link expired", status: :unprocessable_entity
else
render plain: "Forbidden", status: :forbidden
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# frozen_string_literal: true

module BopsCore
class MagicLinkMailer < ApplicationMailer
def magic_link_mail(resource:, subdomain:, subject: "Your magic link")
@resource = resource
@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
12 changes: 12 additions & 0 deletions engines/bops_core/app/models/concerns/bops_core/magic_linkable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

module BopsCore
module MagicLinkable
extend ActiveSupport::Concern
include GlobalID::Identification

def sgid(expires_in: 48.hours, for: "magic_link")
to_sgid(expires_in:, for:).to_s
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

module BopsCore
class SgidAuthenticationService
attr_reader :sgid, :purpose

def initialize(sgid, purpose: "magic_link")
@sgid = sgid
@purpose = purpose
end

def locate_resource
GlobalID::Locator.locate_signed(sgid, for: purpose)
end

def expired_resource
gid = parse_global_id
return nil unless gid

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

private

def parse_global_id
encoded, = sgid.split("--")
decoded = Base64.urlsafe_decode64(CGI.unescape(encoded))
parsed = JSON.parse(decoded)

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

GlobalID.parse(parsed.dig("_rails", "data"))
rescue JSON::ParserError, TypeError, ArgumentError
nil
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Hello <%= @resource.name %>

Click the link below to access your dashboard:

<%= link_to "Access Dashboard", @url %>

This link will expire in 48 hours.
31 changes: 31 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,31 @@
# 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 SGID 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 if SGID is invalid" do
sgid = consultee.sgid(expires_in: 1.minute, for: "other_link")
travel 1.minute

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

0 comments on commit 9418c1b

Please sign in to comment.