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

ETQ Instructeur, je dois utiliser le 2fa pour le fournisseur d'identité Agent Connect / Mon Compte Pro #10776

Merged
merged 8 commits into from
Sep 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
Binary file added app/assets/images/instructions_moncomptepro.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
44 changes: 42 additions & 2 deletions app/controllers/agent_connect/agent_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ class AgentConnect::AgentController < ApplicationController
before_action :redirect_to_login_if_fc_aborted, only: [:callback]
before_action :check_state, only: [:callback]

MON_COMPTE_PRO_IDP_ID = "71144ab3-ee1a-4401-b7b3-79b44f7daeeb"

STATE_COOKIE_NAME = :agentConnect_state
NONCE_COOKIE_NAME = :agentConnect_nonce

AC_ID_TOKEN_COOKIE_NAME = :agentConnect_id_token
REDIRECT_TO_AC_LOGIN_COOKIE_NAME = :redirect_to_ac_login

def index
end

Expand All @@ -21,9 +26,19 @@ def login
end

def callback
user_info, id_token = AgentConnectService.user_info(params[:code], cookies.encrypted[NONCE_COOKIE_NAME])
user_info, id_token, amr = AgentConnectService.user_info(params[:code], cookies.encrypted[NONCE_COOKIE_NAME])
cookies.delete NONCE_COOKIE_NAME

if user_info['idp_id'] == MON_COMPTE_PRO_IDP_ID &&
!amr.include?('mfa') &&
Flipper.enabled?(:agent_connect_2fa, Struct.new(:flipper_id).new(flipper_id: user_info['email']))
# we need the id_token to disconnect the agent connect session later.
# we cannot store it in the instructeur model because the user is not yet created
# so we store it in a encrypted cookie
cookies.encrypted[AC_ID_TOKEN_COOKIE_NAME] = id_token
return redirect_to agent_connect_explanation_2fa_path
end

instructeur = Instructeur.find_by(users: { email: santized_email(user_info) })

if instructeur.nil?
Expand All @@ -35,7 +50,7 @@ def callback
instructeur.user.update!(email_verified_at: Time.zone.now)

aci = AgentConnectInformation.find_or_initialize_by(instructeur:, sub: user_info['sub'])
aci.update(user_info.slice('given_name', 'usual_name', 'email', 'sub', 'siret', 'organizational_unit', 'belonging_population', 'phone'))
aci.update(user_info.slice('given_name', 'usual_name', 'email', 'sub', 'siret', 'organizational_unit', 'belonging_population', 'phone').merge(amr:))

sign_in(:user, instructeur.user)

Expand All @@ -46,6 +61,31 @@ def callback
redirect_france_connect_error_connection
end

def explanation_2fa
end

# Special callback from MonComptePro juste after 2FA configuration
# then:
# - the current user is disconnected from the AgentConnect session by redirecting to the AgentConnect logout endpoint
# - the user is redirected to User::SessionsController#logout by agent connect (no choice)
# - the cookie redirect_to_ac_login is detected and the controller redirects to the relogin_after_2fa_config page
# - finally, the user clicks on the button to reconnect to the AgentConnect session
def logout_from_mcp
sign_out(:user) if user_signed_in?

id_token = cookies.encrypted[AC_ID_TOKEN_COOKIE_NAME]
cookies.delete(AC_ID_TOKEN_COOKIE_NAME)

return redirect_to root_path if id_token.blank?

cookies.encrypted[REDIRECT_TO_AC_LOGIN_COOKIE_NAME] = true

redirect_to AgentConnectService.logout_url(id_token, host_with_port: request.host_with_port), allow_other_host: true
end

def relogin_after_2fa_config
end

private

def santized_email(user_info)
Expand Down
14 changes: 7 additions & 7 deletions app/controllers/users/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ def destroy
end

if agent_connect_id_token.present?
return redirect_to build_agent_connect_logout_url(agent_connect_id_token), allow_other_host: true
return redirect_to AgentConnectService.logout_url(agent_connect_id_token, host_with_port: request.host_with_port),
allow_other_host: true
end
end

Expand Down Expand Up @@ -108,13 +109,12 @@ def sign_in_by_link

# agent connect callback
def logout
redirect_to root_path, notice: I18n.t('devise.sessions.signed_out')
end
if cookies.encrypted[AgentConnect::AgentController::REDIRECT_TO_AC_LOGIN_COOKIE_NAME].present?
cookies.delete(AgentConnect::AgentController::REDIRECT_TO_AC_LOGIN_COOKIE_NAME)

private
return redirect_to agent_connect_relogin_after_2fa_config_path
end

def build_agent_connect_logout_url(id_token)
h = { id_token_hint: id_token, post_logout_redirect_uri: logout_url }
"#{AGENT_CONNECT[:end_session_endpoint]}?#{h.to_query}"
redirect_to root_path, notice: I18n.t('devise.sessions.signed_out')
end
end
16 changes: 13 additions & 3 deletions app/services/agent_connect_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
nonce = SecureRandom.hex(16)

uri = client.authorization_uri(
scope: [:openid, :email, :given_name, :usual_name, :organizational_unit, :belonging_population, :siret],
scope: [:openid, :email, :given_name, :usual_name, :organizational_unit, :belonging_population, :siret, :idp_id],
state:,
nonce:,
acr_values: 'eidas1'
acr_values: 'eidas1',
claims: { id_token: { amr: { essential: true } } }.to_json,
prompt: :login
)

[uri, state, nonce]
Expand All @@ -32,7 +34,15 @@
id_token = ResponseObject::IdToken.decode(access_token.id_token, conf[:jwks])
id_token.verify!(conf.merge(nonce: nonce))

[access_token.userinfo!.raw_attributes, access_token.id_token]
amr = id_token.amr.present? ? JSON.parse(id_token.amr) : []

[access_token.userinfo!.raw_attributes, access_token.id_token, amr]

Check warning on line 39 in app/services/agent_connect_service.rb

View check run for this annotation

Codecov / codecov/patch

app/services/agent_connect_service.rb#L39

Added line #L39 was not covered by tests
end

def self.logout_url(id_token, host_with_port:)
app_logout = Rails.application.routes.url_helpers.logout_url(host: host_with_port)
h = { id_token_hint: id_token, post_logout_redirect_uri: app_logout }
"#{AGENT_CONNECT[:end_session_endpoint]}?#{h.to_query}"
end

private
Expand Down
14 changes: 14 additions & 0 deletions app/views/agent_connect/agent/explanation_2fa.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
.fr-container
%h1.fr-h2.fr-mt-4w Une validation en 2 étapes est désormais nécessaire.

%p.fr-mb-2w
La sécurité de votre compte augmente. Nous vous demandons à présent une validation en 2 étapes pour vous connecter.

%p.fr-mb-2w
Vous allez devoir <b>configurer votre mode d'authentification</b> sur le site <b>MonComptePro</b> :

%img{ src: image_url("instructions_moncomptepro.png"), alt: "MonComptePro", loading: 'lazy' }


%button.fr-btn.fr-btn--primary.fr-mb-2w
= link_to "Configurer mon appli d'authentification sur MonComptePro", ENV['MON_COMPTE_PRO_2FA_NOT_CONFIGURED_URL']
12 changes: 12 additions & 0 deletions app/views/agent_connect/agent/relogin_after_2fa_config.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.fr-container
%h1.fr-h2.fr-mt-4w Poursuivez votre connexion à #{APPLICATION_NAME}

= render Dsfr::AlertComponent.new(state: :success, extra_class_names: 'fr-mb-4w') do |c|
- c.with_body do
%p Votre application d'authentification a bien été configurée.

%p.fr-mb-4w
Vous allez maintenant pouvoir <b>vous connecter</b> à nouveau à #{APPLICATION_NAME} en effectuant la validation en 2 étapes avec votre <b>application d'authentification</b>.

%button.fr-btn.fr-btn--primary.fr-mb-2w
= link_to "Se connecter à #{APPLICATION_NAME} avec #{AgentConnect}", agent_connect_login_path
3 changes: 3 additions & 0 deletions config/env.example.optional
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ DS_ENV="staging"
# AGENT_CONNECT_GOUV_SECRET=""
# AGENT_CONNECT_GOUV_REDIRECT=""

# url to redirect user to when 2FA is not configured mon compte pro FI is used
# MON_COMPTE_PRO_2FA_NOT_CONFIGURED_URL="https://app-sandbox.moncomptepro.beta.gouv.fr/connection-and-account?notification=2fa_not_configured"

# Certigna usage
# CERTIGNA_ENABLED="disabled" # "enabled" by default

Expand Down
1 change: 1 addition & 0 deletions config/initializers/flipper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def setup_features(features)
# A list of features to be deployed on first push
features = [
:administrateur_web_hook,
:agent_connect_2fa,
:api_particulier,
:attestation_v2,
:blocking_pending_correction,
Expand Down
3 changes: 3 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@
get '' => 'agent#index'
get 'login' => 'agent#login'
get 'callback' => 'agent#callback'
get 'explanation_2fa' => 'agent#explanation_2fa'
get 'relogin_after_2fa_config' => 'agent#relogin_after_2fa_config'
get 'logout_from_mcp' => 'agent#logout_from_mcp'
end

namespace :champs do
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class AddAmrColumnToAgentConnectInformationsTable < ActiveRecord::Migration[7.0]
def change
add_column :agent_connect_informations, :amr, :string, array: true, default: []
end
end
3 changes: 2 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.0].define(version: 2024_09_13_150318) do
ActiveRecord::Schema[7.0].define(version: 2024_09_16_114050) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_buffercache"
enable_extension "pg_stat_statements"
Expand Down Expand Up @@ -95,6 +95,7 @@
end

create_table "agent_connect_informations", force: :cascade do |t|
t.string "amr", default: [], array: true
t.string "belonging_population"
t.datetime "created_at", null: false
t.string "email", null: false
Expand Down
65 changes: 64 additions & 1 deletion spec/controllers/agent_connect/agent_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,42 @@
let(:code) { 'correct' }
let(:state) { original_state }
let(:user_info) { { 'sub' => 'sub', 'email' => email, 'given_name' => 'given', 'usual_name' => 'usual' } }
let(:amr) { [] }

context 'and user_info returns some info' do
before do
expect(AgentConnectService).to receive(:user_info).with(code, nonce).and_return([user_info, id_token])
expect(AgentConnectService).to receive(:user_info).with(code, nonce).and_return([user_info, id_token, amr])
Flipper.enable(:agent_connect_2fa)
end

context 'and the instructeur use mon_compte_pro' do
before do
user_info['idp_id'] = AgentConnect::AgentController::MON_COMPTE_PRO_IDP_ID
allow(controller).to receive(:sign_in)
end

context 'without 2FA' do
it 'redirects to agent_connect_explanation_2fa_path' do
subject

expect(controller).not_to have_received(:sign_in)
expect(response).to redirect_to(agent_connect_explanation_2fa_path)
expect(state_cookie).to be_nil
expect(nonce_cookie).to be_nil
expect(cookies.encrypted[controller.class::AC_ID_TOKEN_COOKIE_NAME]).to eq(id_token)
end
end

context 'with 2FA' do
let(:amr) { ['mfa'] }

it 'creates the user, signs in and redirects to procedure_path' do
expect { subject }.to change { User.count }.by(1).and change { Instructeur.count }.by(1)

expect(controller).to have_received(:sign_in)
expect(User.last.instructeur.agent_connect_information.last.amr).to eq(amr)
end
end
end

context 'and the instructeur does not have an account yet' do
Expand Down Expand Up @@ -168,6 +200,37 @@
end
end

describe '#logout_from_mcp' do
let(:id_token) { 'id_token' }
subject { get :logout_from_mcp }

before do
cookies.encrypted[controller.class::AC_ID_TOKEN_COOKIE_NAME] = id_token
end

it 'clears the id token cookie and redirects to the agent connect logout url' do
expect(AgentConnectService).to receive(:logout_url).with(id_token, host_with_port: 'test.host')
.and_return("https://agent-connect.fr/logout/#{id_token}")

subject

expect(cookies.encrypted[controller.class::AC_ID_TOKEN_COOKIE_NAME]).to be_nil
expect(cookies.encrypted[controller.class::REDIRECT_TO_AC_LOGIN_COOKIE_NAME]).to eq(true)
expect(response).to redirect_to("https://agent-connect.fr/logout/#{id_token}")
end

context 'when the id_token is blank' do
let(:id_token) { nil }

it 'clears the cookies and redirects to the root path' do
subject

expect(cookies.encrypted[controller.class::REDIRECT_TO_AC_LOGIN_COOKIE_NAME]).to be_nil
expect(response).to redirect_to(root_path)
end
end
end

def state_cookie
cookies.encrypted[controller.class::STATE_COOKIE_NAME]
end
Expand Down
17 changes: 17 additions & 0 deletions spec/controllers/users/sessions_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -311,4 +311,21 @@
end
end
end

describe '#logout' do
subject { get :logout }

it 'redirects to root_path' do
expect(subject).to redirect_to(root_path)
end

context 'when the cookie redirect_to_ac_login is present' do
before { cookies.encrypted[AgentConnect::AgentController::REDIRECT_TO_AC_LOGIN_COOKIE_NAME] = true }

it 'redirects to relogin_after_2fa_config' do
expect(subject).to redirect_to(agent_connect_relogin_after_2fa_config_path)
expect(cookies.encrypted[AgentConnect::AgentController::REDIRECT_TO_AC_LOGIN_COOKIE_NAME]).to be_nil
end
end
end
end
19 changes: 19 additions & 0 deletions spec/services/agent_connect_service_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

describe AgentConnectService do
describe '.logout_url' do
let(:id_token) { 'id_token' }

before do
::AGENT_CONNECT ||= {}
allow(AGENT_CONNECT).to receive(:[])
.with(:end_session_endpoint).and_return("https://agent-connect.fr/logout")
end

subject { described_class.logout_url(id_token, host_with_port: 'test.host') }

it 'returns the correct url' do
expect(subject).to eq("https://agent-connect.fr/logout?id_token_hint=id_token&post_logout_redirect_uri=http%3A%2F%2Ftest.host%2Flogout")
end
end
end
Loading