Skip to content

Commit

Permalink
One login auth
Browse files Browse the repository at this point in the history
We want to move our candidates to use one login when sigin up/in to our
service.

This commit adds the basic implementation for users to login or create
a candidate account in apply using one login.
  • Loading branch information
CatalinVoineag committed Dec 11, 2024
1 parent 474f09f commit f6afd66
Show file tree
Hide file tree
Showing 15 changed files with 529 additions and 22 deletions.
58 changes: 58 additions & 0 deletions app/controllers/one_login_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
class OneLoginController < ApplicationController
before_action :one_login_enabled
before_action :set_sentry_context, only: :callback

def callback
auth = request.env['omniauth.auth']
session[:onelogin_id_token] = auth&.credentials&.id_token
candidate = OneLoginUser.authentificate(auth)

sign_in(candidate, scope: :candidate)
candidate.update!(last_signed_in_at: Time.zone.now)

redirect_to candidate_interface_interstitial_path
rescue OneLoginUser::Error => e
session[:one_login_error] = e.message
redirect_to auth_onelogin_sign_out_path
end

def sign_out
id_token = session[:onelogin_id_token]
one_login_error = session[:one_login_error]
reset_session

session[:one_login_error] = one_login_error
# Go back to one login to sign out the user on their end as well
redirect_to logout_onelogin_path(id_token_hint: id_token)
end

def sign_out_complete
if session[:one_login_error].present?
Sentry.capture_message(session[:one_login_error], level: :error)
redirect_to internal_server_error_path
else
redirect_to candidate_interface_create_account_or_sign_in_path
end
end

def failure
session[:one_login_error] = "One login failure with #{params[:message]} " \
"for onelogin_id_token: #{session[:onelogin_id_token]}"

redirect_to auth_onelogin_sign_out_path
end

private

def one_login_enabled
return if FeatureFlag.active?(:one_login_candidate_sign_in)

redirect_to root_path
end

def set_sentry_context
Sentry.set_extras(
omniauth_hash: request.env['omniauth.auth'].to_h,
)
end
end
50 changes: 50 additions & 0 deletions app/models/one_login_user.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
class OneLoginUser
class Error < StandardError; end
attr_reader :email_address, :token

def initialize(omniauth_object)
@email_address = omniauth_object.info.email
@token = omniauth_object.uid
end

def self.authentificate(omniauth_auth)
new(omniauth_auth).authentificate
end

def authentificate
one_login_auth = OneLoginAuth.find_by(token:)
existing_candidate = Candidate.find_by(email_address:)

return candidate_with_one_login(one_login_auth) if one_login_auth
return existing_candidate_without_one_login(existing_candidate) if existing_candidate

created_candidate
end

private

def candidate_with_one_login(one_login_auth)
one_login_auth.update!(email_address:)
one_login_auth.candidate
end

def existing_candidate_without_one_login(existing_candidate)
if existing_candidate.one_login_auth.present? && existing_candidate.one_login_auth.token != token
raise(
Error,
"Candidate #{existing_candidate.id} has a different one login " \
"token than the user trying to login. Token used to auth #{token}",
)
end

existing_candidate.create_one_login_auth!(token:, email_address:)
existing_candidate
end

def created_candidate
candidate = Candidate.create!(email_address:)
candidate.create_one_login_auth!(token:, email_address:)

candidate
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,43 @@

<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
<%= form_with(
model: @create_account_or_sign_in_form,
url: candidate_interface_create_account_or_sign_in_path(providerCode: params[:providerCode], courseCode: params[:courseCode]),
method: :post,
) do |f| %>
<%= f.govuk_error_summary %>

<% if FeatureFlag.active?(:one_login_candidate_sign_in) %>
<h1 class="govuk-heading-xl">
<%= t('page_titles.create_account_or_sign_in') %>
</h1>

<%= f.govuk_radio_buttons_fieldset :existing_account, legend: { text: 'Do you already have an account?' } do %>
<%= f.govuk_radio_button :existing_account, true, label: { text: 'Yes, sign in' }, link_errors: true do %>
<%= f.govuk_email_field :email, label: { text: 'Email address', size: 's' }, hint: { text: 'Enter the email address you used to register, and we will send you a link to sign in.' }, width: 'two-thirds', autocomplete: 'email', spellcheck: false %>
<p class="govuk-body">
<%= t('govuk.one_login_account_guidance') %>
</p>

<%= govuk_button_to(t('continue'), '/auth/onelogin') %>
<% else %>
<%= form_with(
model: @create_account_or_sign_in_form,
url: candidate_interface_create_account_or_sign_in_path(providerCode: params[:providerCode], courseCode: params[:courseCode]),
method: :post,
) do |f| %>
<%= f.govuk_error_summary %>

<h1 class="govuk-heading-xl">
<%= t('page_titles.create_account_or_sign_in') %>
</h1>

<%= f.govuk_radio_buttons_fieldset :existing_account, legend: { text: 'Do you already have an account?' } do %>
<%= f.govuk_radio_button :existing_account, true, label: { text: 'Yes, sign in' }, link_errors: true do %>
<%= f.govuk_email_field :email, label: { text: 'Email address', size: 's' }, hint: { text: 'Enter the email address you used to register, and we will send you a link to sign in.' }, width: 'two-thirds', autocomplete: 'email', spellcheck: false %>
<% end %>
<%= f.govuk_radio_button :existing_account, false, label: { text: 'No, I need to create an account' } %>
<% end %>
<%= f.govuk_radio_button :existing_account, false, label: { text: 'No, I need to create an account' } %>
<%= f.govuk_submit t('continue') %>
<% end %>
<%= f.govuk_submit t('continue') %>
<% end %>

<p class="govuk-body">
You can usually start applying for teacher training in October, the
year before your course starts. Courses can fill up quickly, so apply
as soon as you can.
<%= govuk_link_to 'Read how the application process works', candidate_interface_guidance_path %></a>.
</p>
<p class="govuk-body">
You can usually start applying for teacher training in October, the
year before your course starts. Courses can fill up quickly, so apply
as soon as you can.
<%= govuk_link_to 'Read how the application process works', candidate_interface_guidance_path %></a>.
</p>
<% end %>
</div>
</div>
3 changes: 2 additions & 1 deletion app/views/layouts/_header.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
)) %>
<%= render PhaseBannerComponent.new(no_border: current_candidate.present?) %>
<% if current_candidate %>
<% sign_out_path = FeatureFlag.active?(:one_login_candidate_sign_in) ? auth_onelogin_sign_out_path : candidate_interface_sign_out_path %>
<%= render(PrimaryNavigationComponent.new(
items: NavigationItems.candidate_primary_navigation(current_candidate:, current_controller: controller),
items_right: [NavigationItems::NavigationItem.new('Sign out', candidate_interface_sign_out_path)],
items_right: [NavigationItems::NavigationItem.new('Sign out', sign_out_path)],
)) %>
<% end %>
<% when 'support_interface' %>
Expand Down
2 changes: 1 addition & 1 deletion config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class Application < Rails::Application
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w[assets tasks generators rubocop])
config.autoload_lib(ignore: %w[assets tasks generators rubocop omniauth])

# Configuration for the application, engines, and railties goes here.
#
Expand Down
8 changes: 8 additions & 0 deletions config/initializers/omniauth.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
OmniAuth.config.logger = Rails.logger
require 'omniauth/strategies/govuk_one_login_openid_connect'
require 'omniauth/onelogin_setup'

OmniAuth.config.add_camelization('govuk_one_login_openid_connect', 'GovukOneLoginOpenIDConnect')

dfe_sign_in_identifier = ENV['DFE_SIGN_IN_CLIENT_ID']
dfe_sign_in_secret = ENV['DFE_SIGN_IN_SECRET']
Expand Down Expand Up @@ -41,3 +45,7 @@ def self.bypass?
else
Rails.application.config.middleware.use OmniAuth::Strategies::OpenIDConnect, options
end

Rails.application.config.middleware.use OmniAuth::Builder do |builder|
OneloginSetup.configure(builder)
end
1 change: 1 addition & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ en:
national_careers_service:
find_a_course_url: https://nationalcareers.service.gov.uk/find-a-course/search
govuk:
one_login_account_guidance: You need a GOV.UK One Login to sign in to this service. You can create one if you do not already have one.
url: https://www.gov.uk
terms_conditions_url: https://www.gov.uk/help/terms-conditions
safeguarding: https://www.gov.uk/tell-employer-or-college-about-criminal-record
Expand Down
8 changes: 7 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
get '/auth/developer/callback' => 'dfe_sign_in#bypass_callback'
get '/auth/dfe/sign-out' => 'dfe_sign_in#redirect_after_dsi_signout'

get '/auth/onelogin/callback', to: 'one_login#callback'
get '/auth/onelogin/sign-out', to: 'one_login#sign_out'
get '/auth/onelogin/sign-out-complete', to: 'one_login#sign_out_complete'
get 'auth/onelogin/logout', to: 'sessions#logout', as: 'logout_onelogin'
get 'auth/failure', to: 'one_login#failure'

direct :find do
if HostingEnvironment.sandbox_mode?
I18n.t('find_teacher_training.sandbox_url')
Expand Down Expand Up @@ -56,6 +62,6 @@
get '/404', to: 'errors#not_found'
get '/406', to: 'errors#not_acceptable'
get '/422', to: 'errors#unprocessable_entity'
get '/500', to: 'errors#internal_server_error'
get '/500', to: 'errors#internal_server_error', as: :internal_server_error
end
end
39 changes: 39 additions & 0 deletions lib/omniauth/onelogin_setup.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
module OneloginSetup
def self.configure(builder)
client_id = ENV.fetch('GOVUK_ONE_LOGIN_CLIENT_ID', '')
onelogin_issuer_uri = URI(ENV.fetch('GOVUK_ONE_LOGIN_ISSUER_URL', ''))
private_key_pem = ENV.fetch('GOVUK_ONE_LOGIN_PRIVATE_KEY', '')
application_url = HostingEnvironment.application_url

begin
private_key_pem = private_key_pem.gsub('\n', "\n")
private_key = OpenSSL::PKey::RSA.new(private_key_pem)
rescue OpenSSL::PKey::RSAError => e
raise e unless HostingEnvironment.development?
end

builder.provider :govuk_one_login_openid_connect,
name: :onelogin,
allow_authorize_params: %i[session_id],
callback_path: '/auth/onelogin/callback',
discovery: true,
issuer: onelogin_issuer_uri.to_s,
path_prefix: '/auth',
post_logout_redirect_uri: "#{application_url}/auth/onelogin/sign-out-complete",
response_type: :code,
scope: %w[email openid],
client_auth_method: :jwt_bearer,
client_options: {
authorization_endpoint: '/oauth2/authorize',
end_session_endpoint: '/oauth2/logout',
token_endpoint: '/oauth2/token',
userinfo_endpoint: '/oauth2/userinfo',
host: onelogin_issuer_uri.host,
identifier: client_id,
port: 443,
redirect_uri: "#{application_url}/auth/onelogin/callback",
scheme: 'https',
private_key: private_key,
}
end
end
27 changes: 27 additions & 0 deletions lib/omniauth/strategies/govuk_one_login_openid_connect.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# This strategy ensures that the id_token_hint param is included in the post_logout_redirect_uri.
# The node-oidc-provider library requires this param to be present in order for the redirect to work.
# See: https://github.com/panva/node-oidc-provider/blob/03c9bc513860e68ee7be84f99bfc9dc930b224e8/lib/actions/end_session.js#L27
# See: https://github.com/omniauth/omniauth_openid_connect/blob/34370d655d39fe7980f89f55715888e0ebd7270e/lib/omniauth/strategies/openid_connect.rb#L423
#
module OmniAuth
module Strategies
class GovukOneLoginOpenIDConnect < OmniAuth::Strategies::OpenIDConnect
TOKEN_KEY = 'id_token_hint'.freeze

def encoded_post_logout_redirect_uri
return unless options.post_logout_redirect_uri

logout_uri_params = {
'post_logout_redirect_uri' => options.post_logout_redirect_uri,
}

if query_string.present?
query_params = CGI.parse(query_string[1..])
logout_uri_params[TOKEN_KEY] = query_params[TOKEN_KEY].first if query_params.key?(TOKEN_KEY)
end

URI.encode_www_form(logout_uri_params)
end
end
end
end
7 changes: 7 additions & 0 deletions spec/factories/one_login_auth.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FactoryBot.define do
factory :one_login_auth do
candidate
token { SecureRandom.hex(10) }
email_address { "#{SecureRandom.hex(5)}@example.com" }
end
end
68 changes: 68 additions & 0 deletions spec/models/one_login_user_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
require 'rails_helper'

RSpec.describe OneLoginUser do
subject(:authentificate) { one_login_user.authentificate }

let(:one_login_user) { described_class.new(omniauth_object) }
let(:omniauth_object) do
OmniAuth::AuthHash.new(
{
uid: '123',
info: {
email: '[email protected]',
},
},
)
end

describe 'authentificate' do
it 'authentificates successfuly using the token' do
candidate = create(:candidate)
create(:one_login_auth, candidate:, token: '123')

expect { authentificate }.to not_change(
candidate.reload.one_login_auth,
:id,
)

expect(authentificate).to eq(candidate)
end

it 'authentificates successfuly using the email and creates a one_login_auth' do
candidate = create(:candidate, email_address: '[email protected]')

expect { authentificate }.to change {
candidate.reload.one_login_auth.present?
}.from(false).to(true)

expect(authentificate).to eq(candidate)
expect(authentificate.one_login_auth).to have_attributes(
email_address: authentificate.email_address,
token: '123',
)
end

it "authentificates successfuly and creates a candidate if we can't find one" do
expect { authentificate }.to change(
Candidate,
:count,
).by(1)

expect(authentificate).to eq(Candidate.last)
expect(authentificate.one_login_auth).to have_attributes(
email_address: authentificate.email_address,
token: '123',
)
end

it 'raises error if the candidate already has a different one login token' do
candidate = create(:candidate, email_address: '[email protected]')
create(:one_login_auth, candidate:, token: '456')

expect { authentificate }.to raise_exception(OneLoginUser::Error).with_message(
"Candidate #{candidate.id} has a different one login " \
'token than the user trying to login. Token used to auth 123',
)
end
end
end
Loading

0 comments on commit f6afd66

Please sign in to comment.