Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

One login auth #10160

Merged
merged 2 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions app/controllers/one_login_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
class OneLoginController < ApplicationController
before_action :redirect_to_candidate_sign_in_unless_one_login_enabled

def callback
auth = request.env['omniauth.auth']
session[:one_login_id_token] = auth&.credentials&.id_token
candidate = OneLoginUser.authenticate_or_create_by(auth)
CatalinVoineag marked this conversation as resolved.
Show resolved Hide resolved

sign_in_candidate(candidate)

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

def bypass_callback
one_login_user_bypass = OneLoginUserBypass.new(
token: request.env['omniauth.auth']&.uid,
)
candidate = one_login_user_bypass.authenticate

if candidate.present?
sign_in_candidate(candidate)

CatalinVoineag marked this conversation as resolved.
Show resolved Hide resolved
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[:one_login_id_token]
one_login_error = session[:one_login_error]
reset_session

session[:one_login_error] = one_login_error
CatalinVoineag marked this conversation as resolved.
Show resolved Hide resolved
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_one_login(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 one_login_id_token: #{session[:one_login_id_token]}"
CatalinVoineag marked this conversation as resolved.
Show resolved Hide resolved

redirect_to auth_one_login_sign_out_path
end

private

def redirect_to_candidate_sign_in_unless_one_login_enabled
if FeatureFlag.inactive?(:one_login_candidate_sign_in)
redirect_to candidate_interface_create_account_or_sign_in_path
end
end

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

def logout_one_login(id_token_hint)
params = {
post_logout_redirect_uri: URI(auth_one_login_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
CatalinVoineag marked this conversation as resolved.
Show resolved Hide resolved
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.authenticate_or_create_by(omniauth_auth)
new(omniauth_auth).authenticate_or_create_by
end

def authenticate_or_create_by
one_login_auth = OneLoginAuth.find_by(token:)
existing_candidate = Candidate.find_by(email_address:)
CatalinVoineag marked this conversation as resolved.
Show resolved Hide resolved

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

create_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}",
CatalinVoineag marked this conversation as resolved.
Show resolved Hide resolved
)
end

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

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

candidate
end
end
29 changes: 29 additions & 0 deletions app/models/one_login_user_bypass.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
class OneLoginUserBypass
include ActiveModel::Model

validates :token, presence: true
validate :token_format

attr_accessor :token

def authenticate
return unless valid?

bypass_one_login = OneLoginAuth.find_by(token: 'dev-candidate')

if bypass_one_login && bypass_one_login.token == token
bypass_one_login.candidate
else
errors.add(:base, "There is no candidate with #{token} uid")
nil
end
end

private

def token_format
return if token.nil?

errors.add(:token, :invalid) if token.match?(URI::MailTo::EMAIL_REGEXP)
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,39 @@

<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 %>
<h1 class="govuk-heading-xl">
<%= t('page_titles.create_account_or_sign_in') %>
</h1>

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

<%= 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 %>
<%= govuk_button_to(t('continue'), OneLogin.bypass? ? '/auth/one-login-developer' : '/auth/one_login') %>
<% 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 %>

<%= 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 %>.
</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_one_login_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/one_login_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.
CatalinVoineag marked this conversation as resolved.
Show resolved Hide resolved
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/one-login/callback', to: 'one_login#callback'
get '/auth/one-login-developer/callback' => 'one_login#bypass_callback'
get '/auth/one-login/sign-out', to: 'one_login#sign_out'
get '/auth/one-login/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/one_login_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', '')
one_login_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: :one_login,
allow_authorize_params: %i[session_id],
callback_path: '/auth/one-login/callback',
discovery: true,
issuer: one_login_issuer_uri.to_s,
path_prefix: '/auth',
post_logout_redirect_uri: "#{application_url}/auth/one-login/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: one_login_issuer_uri.host,
identifier: client_id,
port: 443,
redirect_uri: "#{application_url}/auth/one-login/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 lib/tasks/local_dev.rake
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ task setup_local_dev_data: %i[environment copy_feature_flags_from_production syn
first_name: 'Susan',
last_name: 'Upport',
)
candidate = Candidate.create!(
email_address: '[email protected]',
)
candidate.create_one_login_auth!(
token: 'dev-candidate',
email_address: candidate.email_address,
)

puts 'Creating various provider users...'
CreateExampleProviderUsersWithPermissions.call
Expand Down
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
Loading