Skip to content

Commit

Permalink
One login without specs
Browse files Browse the repository at this point in the history
  • Loading branch information
CatalinVoineag committed Dec 11, 2024
1 parent 474f09f commit fbac42b
Show file tree
Hide file tree
Showing 15 changed files with 527 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
37 changes: 37 additions & 0 deletions lib/omniauth/onelogin_setup.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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', '')

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

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: "#{host_env}/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: "#{host_env}/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 fbac42b

Please sign in to comment.