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 13, 2024
1 parent 24aefda commit 91a1081
Show file tree
Hide file tree
Showing 17 changed files with 714 additions and 22 deletions.
89 changes: 89 additions & 0 deletions app/controllers/one_login_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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 bypass_callback
one_login_user_bypass = OneLoginUserBypass.new(
token: request.env['omniauth.auth']&.uid,
)
candidate = one_login_user_bypass.authentificate

if one_login_user_bypass.valid? && candidate.present?
sign_in(candidate, scope: :candidate)
candidate.update!(last_signed_in_at: Time.zone.now)

redirect_to candidate_interface_interstitial_path
else
flash[:warning] = one_login_user_bypass.errors.full_messages.join('\n')
redirect_to candidate_interface_create_account_or_sign_in_path
end
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
if OneLogin.bypass?
redirect_to candidate_interface_create_account_or_sign_in_path
else
# Go back to one login to sign out the user on their end as well
redirect_to logout_onelogin(id_token), allow_other_host: true
end
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

def logout_onelogin(id_token_hint)
params = {
post_logout_redirect_uri: URI(auth_onelogin_sign_out_complete_url),
id_token_hint:,
}
URI.parse("#{ENV['GOVUK_ONE_LOGIN_ISSUER_URL']}logout").tap do |uri|
uri.query = URI.encode_www_form(params)
end.to_s
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
30 changes: 30 additions & 0 deletions app/models/one_login_user_bypass.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
class OneLoginUserBypass
include ActiveModel::Model

validates :token, presence: true

attr_accessor :token

def authentificate
return unless valid?

one_login_auth = OneLoginAuth.find_by(token:)

return one_login_auth.candidate if one_login_auth

created_candidate
end

private

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

candidate
end

def bypass_email_address
"#{token}@example.com"
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'), OneLogin.bypass? ? '/auth/one-login-developer' : '/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
22 changes: 22 additions & 0 deletions config/initializers/omniauth.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
OmniAuth.config.logger = Rails.logger
require 'omniauth/strategies/one_login_developer'
require 'omniauth/onelogin_setup'

dfe_sign_in_identifier = ENV['DFE_SIGN_IN_CLIENT_ID']
dfe_sign_in_secret = ENV['DFE_SIGN_IN_SECRET']
Expand Down Expand Up @@ -32,6 +34,12 @@ def self.bypass?
end
end

module ::OneLogin
def self.bypass?
HostingEnvironment.review? || HostingEnvironment.loadtest? || Rails.env.development?
end
end

if DfESignIn.bypass?
Rails.application.config.middleware.use OmniAuth::Builder do
provider :developer,
Expand All @@ -41,3 +49,17 @@ def self.bypass?
else
Rails.application.config.middleware.use OmniAuth::Strategies::OpenIDConnect, options
end

if OneLogin.bypass?
Rails.application.config.middleware.use OmniAuth::Builder do
provider :one_login_developer,
request_path: '/auth/one-login-developer',
callback_path: '/auth/one-login-developer/callback',
fields: %i[uid],
uid_field: :uid
end
else
Rails.application.config.middleware.use OmniAuth::Builder do |builder|
OneloginSetup.configure(builder)
end
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/one-login-developer/callback' => 'one_login#bypass_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/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
41 changes: 41 additions & 0 deletions lib/omniauth/onelogin_setup.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
module OneloginSetup
class OmniAuth::Strategies::GovukOneLogin < OmniAuth::Strategies::OpenIDConnect; end

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
Rails.logger.warn "GOVUK ONE LOGIN PRIVATE error, is the key present? #{e.message}"
end

builder.provider :govuk_one_login,
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
9 changes: 9 additions & 0 deletions lib/omniauth/strategies/one_login_developer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require 'omniauth'

module OmniAuth
module Strategies
class OneLoginDeveloper < Developer
include OmniAuth::Strategy
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
Loading

0 comments on commit 91a1081

Please sign in to comment.