diff --git a/app/controllers/one_login_controller.rb b/app/controllers/one_login_controller.rb new file mode 100644 index 00000000000..4bfd8109c5c --- /dev/null +++ b/app/controllers/one_login_controller.rb @@ -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 diff --git a/app/models/one_login_user.rb b/app/models/one_login_user.rb new file mode 100644 index 00000000000..f41cbccc2ed --- /dev/null +++ b/app/models/one_login_user.rb @@ -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 diff --git a/app/views/candidate_interface/start_page/create_account_or_sign_in.html.erb b/app/views/candidate_interface/start_page/create_account_or_sign_in.html.erb index 350a6fddd86..021b76df73d 100644 --- a/app/views/candidate_interface/start_page/create_account_or_sign_in.html.erb +++ b/app/views/candidate_interface/start_page/create_account_or_sign_in.html.erb @@ -4,31 +4,43 @@
- <%= 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) %>

<%= t('page_titles.create_account_or_sign_in') %>

- <%= 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 %> +

+ <%= t('govuk.one_login_account_guidance') %> +

+ + <%= 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 %> + +

+ <%= t('page_titles.create_account_or_sign_in') %> +

+ + <%= 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 %> -

- 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 %>. -

+

+ 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 %>. +

+ <% end %>
diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 3deb9e3ea19..dbbaee47aad 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -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' %> diff --git a/config/application.rb b/config/application.rb index 369d73df784..06166cab23b 100644 --- a/config/application.rb +++ b/config/application.rb @@ -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. # diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb index 0e26334e100..c47823e8987 100644 --- a/config/initializers/omniauth.rb +++ b/config/initializers/omniauth.rb @@ -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'] @@ -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 diff --git a/config/locales/en.yml b/config/locales/en.yml index 3ee504b6909..aeecfe8dde3 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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 diff --git a/config/routes.rb b/config/routes.rb index f2137a1220b..1de9bd34edd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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') @@ -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 diff --git a/lib/omniauth/onelogin_setup.rb b/lib/omniauth/onelogin_setup.rb new file mode 100644 index 00000000000..def154d1efb --- /dev/null +++ b/lib/omniauth/onelogin_setup.rb @@ -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 diff --git a/lib/omniauth/strategies/govuk_one_login_openid_connect.rb b/lib/omniauth/strategies/govuk_one_login_openid_connect.rb new file mode 100644 index 00000000000..675e3f386b2 --- /dev/null +++ b/lib/omniauth/strategies/govuk_one_login_openid_connect.rb @@ -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 diff --git a/spec/factories/one_login_auth.rb b/spec/factories/one_login_auth.rb new file mode 100644 index 00000000000..7cce91bf8b2 --- /dev/null +++ b/spec/factories/one_login_auth.rb @@ -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 diff --git a/spec/models/one_login_user_spec.rb b/spec/models/one_login_user_spec.rb new file mode 100644 index 00000000000..57ec55476d0 --- /dev/null +++ b/spec/models/one_login_user_spec.rb @@ -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: 'test@email.com', + }, + }, + ) + 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: 'test@email.com') + + 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: 'test@email.com') + 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 diff --git a/spec/requests/one_login_controller_spec.rb b/spec/requests/one_login_controller_spec.rb new file mode 100644 index 00000000000..431441a8048 --- /dev/null +++ b/spec/requests/one_login_controller_spec.rb @@ -0,0 +1,122 @@ +require 'rails_helper' + +RSpec.describe 'OneLoginController' do + before do + FeatureFlag.activate(:one_login_candidate_sign_in) + OmniAuth.config.test_mode = true + OmniAuth.config.mock_auth[:onelogin] = omniauth_hash + end + + let(:omniauth_hash) do + OmniAuth::AuthHash.new( + { + provider: 'onelogin', + uid: '123', + info: { + email: 'test@email.com', + }, + credentials: { + id_token: 'id_token', + }, + }, + ) + end + + describe 'GET /auth/onelogin/callback' do + it 'redirects to candidate_interface_interstitial_path' do + candidate = create(:candidate) + create(:one_login_auth, candidate:, token: '123') + + get auth_onelogin_callback_path + + expect(response).to redirect_to(candidate_interface_interstitial_path) + end + + context 'when there is no omniauth_hash' do + let(:omniauth_hash) { nil } + + it 'returns unprocessable_entity' do + get auth_onelogin_callback_path + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'when candidate has a different onelogin token than the one returned by onelogin' do + it 'redirects to auth_onelogin_sign_out_path' do + candidate = create(:candidate, email_address: 'test@email.com') + create(:one_login_auth, candidate:, token: '456') + + get auth_onelogin_callback_path + + expect(response).to redirect_to(auth_onelogin_sign_out_path) + expect(session[:one_login_error]).to eq( + "Candidate #{candidate.id} has a different one login token than the " \ + 'user trying to login. Token used to auth 123', + ) + end + end + end + + describe 'GET /auth/onelogin/sign_out' do + context 'when candidate has a different onelogin token than the one returned by onelogin' do + it 'redirects to logout_onelogin_path and persists the session error message' do + candidate = create(:candidate, email_address: 'test@email.com') + create(:one_login_auth, candidate:, token: '456') + + get auth_onelogin_callback_path # set the session variables + get auth_onelogin_sign_out_path + + expect(session[:onelogin_id_token]).to be_nil + expect(session[:one_login_error]).to eq( + "Candidate #{candidate.id} has a different one login token than the " \ + 'user trying to login. Token used to auth 123', + ) + + expect(response).to redirect_to(logout_onelogin_path(id_token_hint: 'id_token')) + end + end + end + + describe 'GET /auth/onelogin/sign_out_complete' do + context 'when candidate has a different onelogin token than the one returned by onelogin' do + it 'redirects to logout_onelogin_path and persists the session error message' do + candidate = create(:candidate, email_address: 'test@email.com') + create(:one_login_auth, candidate:, token: '456') + allow(Sentry).to receive(:capture_message) + + get auth_onelogin_callback_path # set the session variables + get auth_onelogin_sign_out_complete_path + + expect(Sentry).to have_received(:capture_message).with( + "Candidate #{candidate.id} has a different one login token than the " \ + 'user trying to login. Token used to auth 123', + level: :error, + ) + expect(response).to redirect_to(internal_server_error_path) + end + end + + context 'candidate has no errors' do + it 'redirects to logout_onelogin_path and persists the session error message' do + get auth_onelogin_sign_out_complete_path + + expect(response).to redirect_to( + candidate_interface_create_account_or_sign_in_path, + ) + end + end + end + + describe 'GET /auth/onelogin/failure' do + it 'redirects to auth_failure_path with one login error' do + get auth_onelogin_callback_path # set the session variables + get auth_failure_path(params: { message: 'error_message' }) + + expect(session[:one_login_error]).to eq( + 'One login failure with error_message for onelogin_id_token: id_token', + ) + expect(response).to redirect_to(auth_onelogin_sign_out_path) + end + end +end diff --git a/spec/support/test_helpers/one_login_helper.rb b/spec/support/test_helpers/one_login_helper.rb new file mode 100644 index 00000000000..9eb796f40a2 --- /dev/null +++ b/spec/support/test_helpers/one_login_helper.rb @@ -0,0 +1,16 @@ +module OneLoginHelper + def user_exists_in_onelogin(email_address: 'test@email.com', uid: 'UID') + OmniAuth.config.mock_auth[:onelogin] = OmniAuth::AuthHash.new( + { + provider: 'onelogin', + uid:, + info: { + email: email_address, + }, + credentials: { + id_token: 'id_token', + }, + }, + ) + end +end diff --git a/spec/system/candidate_interface/one_login_signup/candidate_signs_in_spec.rb b/spec/system/candidate_interface/one_login_signup/candidate_signs_in_spec.rb new file mode 100644 index 00000000000..fe4dc6c1f0e --- /dev/null +++ b/spec/system/candidate_interface/one_login_signup/candidate_signs_in_spec.rb @@ -0,0 +1,92 @@ +require 'rails_helper' + +RSpec.describe 'Candidate signs in' do + include OneLoginHelper + + scenario 'Candidate signs in and signs out' do + given_i_have_a_candidate_record + given_i_have_one_login_account(@candidate.email_address) + the_one_login_feature_flag_is_active + + when_i_visit_the_candidate_application_path + then_i_am_redirected_to_the_candidate_sign_in_path + when_i_click_continue + then_i_am_redirected_to_the_candidate_application_path + + when_i_click_sign_out + then_a_request_to_one_login_is_triggered_to_sign_me_out + + when_i_visit_the_candidate_application_path + then_i_am_redirected_to_the_candidate_sign_in_path + end + + scenario 'Candidate signs in without a candidate account' do + given_i_have_one_login_account('test@email.com') + the_one_login_feature_flag_is_active + + when_i_visit_the_candidate_application_path + then_i_am_redirected_to_the_candidate_sign_in_path + when_i_click_continue + end + + scenario 'Candidate already has a different one login attached to candidate record' do + given_i_have_a_candidate_record + given_i_already_have_a_different_one_login_token(@candidate) + given_i_have_one_login_account(@candidate.email_address) + the_one_login_feature_flag_is_active + + when_i_visit_the_candidate_application_path + then_i_am_redirected_to_the_candidate_sign_in_path + when_i_click_continue + then_a_request_to_one_login_is_triggered_to_sign_me_out + end + + def given_i_have_a_candidate_record + @candidate = create(:candidate) + end + + def given_i_have_one_login_account(email_address) + user_exists_in_onelogin(email_address:) + end + + def given_i_already_have_a_different_one_login_token(candidate) + candidate.create_one_login_auth!( + token: '123', + email_address: candidate.email_address, + ) + end + + def the_one_login_feature_flag_is_active + FeatureFlag.activate(:one_login_candidate_sign_in) + end + + def when_i_visit_the_candidate_application_path + visit candidate_interface_details_path + end + + def then_i_am_redirected_to_the_candidate_sign_in_path + expect(page).to have_current_path( + candidate_interface_create_account_or_sign_in_path, + ) + end + + def when_i_click_continue + click_link_or_button 'Continue' + end + + def then_i_am_redirected_to_the_candidate_application_path + expect(page).to have_current_path( + candidate_interface_details_path, + ) + end + + def when_i_click_sign_out + click_link_or_button 'Sign out' + end + + def then_a_request_to_one_login_is_triggered_to_sign_me_out + expect(page).to have_current_path( + '/auth/onelogin/logout?id_token_hint=id_token', + ) + end +end