Merge pull request #10776 from demarches-simplifiees/ac_2fa
ETQ Instructeur, je dois utiliser le 2fa pour le fournisseur d'identité Agent Connect / Mon Compte Pro
This commit is contained in:
commit
6b322d61ca
14 changed files with 205 additions and 15 deletions
BIN
app/assets/images/instructions_moncomptepro.png
Normal file
BIN
app/assets/images/instructions_moncomptepro.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
|
@ -5,9 +5,14 @@ class AgentConnect::AgentController < ApplicationController
|
||||||
before_action :redirect_to_login_if_fc_aborted, only: [:callback]
|
before_action :redirect_to_login_if_fc_aborted, only: [:callback]
|
||||||
before_action :check_state, only: [:callback]
|
before_action :check_state, only: [:callback]
|
||||||
|
|
||||||
|
MON_COMPTE_PRO_IDP_ID = "71144ab3-ee1a-4401-b7b3-79b44f7daeeb"
|
||||||
|
|
||||||
STATE_COOKIE_NAME = :agentConnect_state
|
STATE_COOKIE_NAME = :agentConnect_state
|
||||||
NONCE_COOKIE_NAME = :agentConnect_nonce
|
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
|
def index
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -21,9 +26,19 @@ class AgentConnect::AgentController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def callback
|
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
|
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) })
|
instructeur = Instructeur.find_by(users: { email: santized_email(user_info) })
|
||||||
|
|
||||||
if instructeur.nil?
|
if instructeur.nil?
|
||||||
|
@ -35,7 +50,7 @@ class AgentConnect::AgentController < ApplicationController
|
||||||
instructeur.user.update!(email_verified_at: Time.zone.now)
|
instructeur.user.update!(email_verified_at: Time.zone.now)
|
||||||
|
|
||||||
aci = AgentConnectInformation.find_or_initialize_by(instructeur:, sub: user_info['sub'])
|
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)
|
sign_in(:user, instructeur.user)
|
||||||
|
|
||||||
|
@ -46,6 +61,31 @@ class AgentConnect::AgentController < ApplicationController
|
||||||
redirect_france_connect_error_connection
|
redirect_france_connect_error_connection
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def santized_email(user_info)
|
def santized_email(user_info)
|
||||||
|
|
|
@ -59,7 +59,8 @@ class Users::SessionsController < Devise::SessionsController
|
||||||
end
|
end
|
||||||
|
|
||||||
if agent_connect_id_token.present?
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -108,13 +109,12 @@ class Users::SessionsController < Devise::SessionsController
|
||||||
|
|
||||||
# agent connect callback
|
# agent connect callback
|
||||||
def logout
|
def logout
|
||||||
|
if cookies.encrypted[AgentConnect::AgentController::REDIRECT_TO_AC_LOGIN_COOKIE_NAME].present?
|
||||||
|
cookies.delete(AgentConnect::AgentController::REDIRECT_TO_AC_LOGIN_COOKIE_NAME)
|
||||||
|
|
||||||
|
return redirect_to agent_connect_relogin_after_2fa_config_path
|
||||||
|
end
|
||||||
|
|
||||||
redirect_to root_path, notice: I18n.t('devise.sessions.signed_out')
|
redirect_to root_path, notice: I18n.t('devise.sessions.signed_out')
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
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}"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -14,10 +14,12 @@ class AgentConnectService
|
||||||
nonce = SecureRandom.hex(16)
|
nonce = SecureRandom.hex(16)
|
||||||
|
|
||||||
uri = client.authorization_uri(
|
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:,
|
state:,
|
||||||
nonce:,
|
nonce:,
|
||||||
acr_values: 'eidas1'
|
acr_values: 'eidas1',
|
||||||
|
claims: { id_token: { amr: { essential: true } } }.to_json,
|
||||||
|
prompt: :login
|
||||||
)
|
)
|
||||||
|
|
||||||
[uri, state, nonce]
|
[uri, state, nonce]
|
||||||
|
@ -32,7 +34,15 @@ class AgentConnectService
|
||||||
id_token = ResponseObject::IdToken.decode(access_token.id_token, conf[:jwks])
|
id_token = ResponseObject::IdToken.decode(access_token.id_token, conf[:jwks])
|
||||||
id_token.verify!(conf.merge(nonce: nonce))
|
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]
|
||||||
|
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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
14
app/views/agent_connect/agent/explanation_2fa.html.haml
Normal file
14
app/views/agent_connect/agent/explanation_2fa.html.haml
Normal file
|
@ -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']
|
|
@ -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
|
|
@ -32,6 +32,9 @@ DS_ENV="staging"
|
||||||
# AGENT_CONNECT_GOUV_SECRET=""
|
# AGENT_CONNECT_GOUV_SECRET=""
|
||||||
# AGENT_CONNECT_GOUV_REDIRECT=""
|
# 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 usage
|
||||||
# CERTIGNA_ENABLED="disabled" # "enabled" by default
|
# CERTIGNA_ENABLED="disabled" # "enabled" by default
|
||||||
|
|
||||||
|
|
|
@ -20,6 +20,7 @@ end
|
||||||
# A list of features to be deployed on first push
|
# A list of features to be deployed on first push
|
||||||
features = [
|
features = [
|
||||||
:administrateur_web_hook,
|
:administrateur_web_hook,
|
||||||
|
:agent_connect_2fa,
|
||||||
:api_particulier,
|
:api_particulier,
|
||||||
:attestation_v2,
|
:attestation_v2,
|
||||||
:blocking_pending_correction,
|
:blocking_pending_correction,
|
||||||
|
|
|
@ -202,6 +202,9 @@ Rails.application.routes.draw do
|
||||||
get '' => 'agent#index'
|
get '' => 'agent#index'
|
||||||
get 'login' => 'agent#login'
|
get 'login' => 'agent#login'
|
||||||
get 'callback' => 'agent#callback'
|
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
|
end
|
||||||
|
|
||||||
namespace :champs do
|
namespace :champs do
|
||||||
|
|
|
@ -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
|
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pg_buffercache"
|
enable_extension "pg_buffercache"
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
|
@ -95,6 +95,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_09_13_150318) do
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "agent_connect_informations", force: :cascade do |t|
|
create_table "agent_connect_informations", force: :cascade do |t|
|
||||||
|
t.string "amr", default: [], array: true
|
||||||
t.string "belonging_population"
|
t.string "belonging_population"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.string "email", null: false
|
t.string "email", null: false
|
||||||
|
|
|
@ -34,10 +34,42 @@ describe AgentConnect::AgentController, type: :controller do
|
||||||
let(:code) { 'correct' }
|
let(:code) { 'correct' }
|
||||||
let(:state) { original_state }
|
let(:state) { original_state }
|
||||||
let(:user_info) { { 'sub' => 'sub', 'email' => email, 'given_name' => 'given', 'usual_name' => 'usual' } }
|
let(:user_info) { { 'sub' => 'sub', 'email' => email, 'given_name' => 'given', 'usual_name' => 'usual' } }
|
||||||
|
let(:amr) { [] }
|
||||||
|
|
||||||
context 'and user_info returns some info' do
|
context 'and user_info returns some info' do
|
||||||
before 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
|
end
|
||||||
|
|
||||||
context 'and the instructeur does not have an account yet' do
|
context 'and the instructeur does not have an account yet' do
|
||||||
|
@ -168,6 +200,37 @@ describe AgentConnect::AgentController, type: :controller do
|
||||||
end
|
end
|
||||||
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
|
def state_cookie
|
||||||
cookies.encrypted[controller.class::STATE_COOKIE_NAME]
|
cookies.encrypted[controller.class::STATE_COOKIE_NAME]
|
||||||
end
|
end
|
||||||
|
|
|
@ -311,4 +311,21 @@ describe Users::SessionsController, type: :controller do
|
||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|
19
spec/services/agent_connect_service_spec.rb
Normal file
19
spec/services/agent_connect_service_spec.rb
Normal file
|
@ -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…
Reference in a new issue