diff --git a/app/assets/images/instructions_moncomptepro.png b/app/assets/images/instructions_moncomptepro.png new file mode 100644 index 000000000..554d388b5 Binary files /dev/null and b/app/assets/images/instructions_moncomptepro.png differ diff --git a/app/controllers/agent_connect/agent_controller.rb b/app/controllers/agent_connect/agent_controller.rb index 346c19a8d..973f67699 100644 --- a/app/controllers/agent_connect/agent_controller.rb +++ b/app/controllers/agent_connect/agent_controller.rb @@ -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 @@ -21,9 +26,19 @@ class AgentConnect::AgentController < ApplicationController 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? @@ -35,7 +50,7 @@ class AgentConnect::AgentController < ApplicationController 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) @@ -46,6 +61,31 @@ class AgentConnect::AgentController < ApplicationController 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) diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 1d5e49100..7b764fbff 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -59,7 +59,8 @@ class Users::SessionsController < Devise::SessionsController 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 @@ -108,13 +109,12 @@ class Users::SessionsController < Devise::SessionsController # agent connect callback 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') 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 diff --git a/app/services/agent_connect_service.rb b/app/services/agent_connect_service.rb index cbbf91814..6b2dc6ba1 100644 --- a/app/services/agent_connect_service.rb +++ b/app/services/agent_connect_service.rb @@ -14,10 +14,12 @@ class AgentConnectService 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] @@ -32,7 +34,15 @@ class AgentConnectService 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] + 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 diff --git a/app/views/agent_connect/agent/explanation_2fa.html.haml b/app/views/agent_connect/agent/explanation_2fa.html.haml new file mode 100644 index 000000000..ab52b99f1 --- /dev/null +++ b/app/views/agent_connect/agent/explanation_2fa.html.haml @@ -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 configurer votre mode d'authentification sur le site MonComptePro : + + %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'] diff --git a/app/views/agent_connect/agent/relogin_after_2fa_config.html.haml b/app/views/agent_connect/agent/relogin_after_2fa_config.html.haml new file mode 100644 index 000000000..898f57497 --- /dev/null +++ b/app/views/agent_connect/agent/relogin_after_2fa_config.html.haml @@ -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 vous connecter à nouveau à #{APPLICATION_NAME} en effectuant la validation en 2 étapes avec votre application d'authentification. + + %button.fr-btn.fr-btn--primary.fr-mb-2w + = link_to "Se connecter à #{APPLICATION_NAME} avec #{AgentConnect}", agent_connect_login_path diff --git a/config/env.example.optional b/config/env.example.optional index 82e95ce84..74451810a 100644 --- a/config/env.example.optional +++ b/config/env.example.optional @@ -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 diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb index 129702860..6c5655878 100644 --- a/config/initializers/flipper.rb +++ b/config/initializers/flipper.rb @@ -20,6 +20,7 @@ end # A list of features to be deployed on first push features = [ :administrateur_web_hook, + :agent_connect_2fa, :api_particulier, :attestation_v2, :blocking_pending_correction, diff --git a/config/routes.rb b/config/routes.rb index 57031dc7c..86b1ef45a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -202,6 +202,9 @@ Rails.application.routes.draw do 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 diff --git a/db/migrate/20240916114050_add_amr_column_to_agent_connect_informations_table.rb b/db/migrate/20240916114050_add_amr_column_to_agent_connect_informations_table.rb new file mode 100644 index 000000000..614a8b940 --- /dev/null +++ b/db/migrate/20240916114050_add_amr_column_to_agent_connect_informations_table.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 7c9e1f2a5..7aaff86f0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" @@ -95,6 +95,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_09_13_150318) do 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 diff --git a/spec/controllers/agent_connect/agent_controller_spec.rb b/spec/controllers/agent_connect/agent_controller_spec.rb index 80a255d16..45c614f88 100644 --- a/spec/controllers/agent_connect/agent_controller_spec.rb +++ b/spec/controllers/agent_connect/agent_controller_spec.rb @@ -34,10 +34,42 @@ describe AgentConnect::AgentController, type: :controller do 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 @@ -168,6 +200,37 @@ describe AgentConnect::AgentController, type: :controller do 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 diff --git a/spec/controllers/users/sessions_controller_spec.rb b/spec/controllers/users/sessions_controller_spec.rb index e4fde9b2f..42c4e0f58 100644 --- a/spec/controllers/users/sessions_controller_spec.rb +++ b/spec/controllers/users/sessions_controller_spec.rb @@ -311,4 +311,21 @@ describe Users::SessionsController, type: :controller do 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 diff --git a/spec/services/agent_connect_service_spec.rb b/spec/services/agent_connect_service_spec.rb new file mode 100644 index 000000000..f0aa30db7 --- /dev/null +++ b/spec/services/agent_connect_service_spec.rb @@ -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