Merge pull request #6660 from betagouv/agent_connect

Prise en charge d'agent connect (#6027)
This commit is contained in:
LeSim 2021-11-23 14:26:05 +01:00 committed by GitHub
commit 4fc224490e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 323 additions and 8 deletions

View file

@ -376,7 +376,7 @@ GEM
regexp_parser (~> 2.0)
uri_template (~> 0.7)
jsonapi-renderer (0.2.2)
jwt (2.2.2)
jwt (2.3.0)
kaminari (1.2.1)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.1)
@ -421,7 +421,7 @@ GEM
mime-types-data (~> 3.2015)
mime-types-data (3.2021.0212)
mini_magick (4.11.0)
mini_mime (1.1.1)
mini_mime (1.1.2)
mini_portile2 (2.6.1)
minitest (5.14.4)
momentjs-rails (2.20.1)
@ -437,7 +437,7 @@ GEM
mini_portile2 (~> 2.6.1)
racc (~> 1.4)
open4 (1.3.4)
openid_connect (1.2.0)
openid_connect (1.3.0)
activemodel
attr_required (>= 1.0.0)
json-jwt (>= 1.5.0)
@ -492,7 +492,7 @@ GEM
rack (>= 1.0, < 3)
rack-mini-profiler (2.3.1)
rack (>= 1.2.0)
rack-oauth2 (1.16.0)
rack-oauth2 (1.19.0)
activesupport
attr_required
httpclient
@ -700,7 +700,7 @@ GEM
actionpack (>= 4.0)
activesupport (>= 4.0)
sprockets (>= 3.0.0)
swd (1.2.0)
swd (1.3.0)
activesupport (>= 3)
attr_required (>= 0.0.5)
httpclient (>= 2.4)
@ -747,7 +747,7 @@ GEM
nokogiri (~> 1.6)
rubyzip (>= 1.3.0)
selenium-webdriver (>= 3.0, < 4.0)
webfinger (1.1.0)
webfinger (1.2.0)
activesupport
httpclient (>= 2.4)
webmock (3.11.2)

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -0,0 +1,11 @@
@import "colors";
@import "constants";
.france-connect-agent-login-button {
background-image: image-url("logo-agent-connect.png");
display: block;
height: 60px;
width: 230px;
margin: 20px auto;
font-size: 0;
}

View file

@ -0,0 +1,53 @@
# doc: https://github.com/france-connect/Documentation-AgentConnect
class AgentConnect::AgentController < ApplicationController
before_action :redirect_to_login_if_fc_aborted, only: [:callback]
def index
end
def login
redirect_to AgentConnectService.authorization_uri
end
def callback
user_info = AgentConnectService.user_info(params[:code])
instructeur = Instructeur.find_by(agent_connect_id: user_info['sub'])
if instructeur.nil?
instructeur = Instructeur.find_by(users: { email: santized_email(user_info) })
instructeur&.update(agent_connect_id: user_info['sub'])
end
if instructeur.nil?
user = User.create_or_promote_to_instructeur(santized_email(user_info), Devise.friendly_token[0, 20])
instructeur = user.instructeur
instructeur.update(agent_connect_id: user_info['sub'])
end
sign_in(:user, instructeur.user)
redirect_to instructeur_procedures_path
rescue Rack::OAuth2::Client::Error => e
Rails.logger.error e.message
redirect_france_connect_error_connection
end
private
def santized_email(user_info)
user_info['email'].strip.downcase
end
def redirect_to_login_if_fc_aborted
if params[:code].blank?
redirect_to new_user_session_path
end
end
def redirect_france_connect_error_connection
flash.alert = t('errors.messages.france_connect.connexion')
redirect_to(new_user_session_path)
end
end

View file

@ -0,0 +1,9 @@
class AgentConnectClient < OpenIDConnect::Client
def initialize(code = nil)
super(AGENT_CONNECT)
if code.present?
self.authorization_code = code
end
end
end

View file

@ -8,6 +8,7 @@
# login_token_created_at :datetime
# created_at :datetime
# updated_at :datetime
# agent_connect_id :string
#
class Instructeur < ApplicationRecord
has_many :administrateurs_instructeurs

View file

@ -0,0 +1,24 @@
class AgentConnectService
def self.enabled?
ENV.fetch("AGENT_CONNECT_ENABLED", "enabled") == "enabled"
end
def self.authorization_uri
client = AgentConnectClient.new
client.authorization_uri(
scope: [:openid, :email],
state: SecureRandom.hex(16),
nonce: SecureRandom.hex(16),
acr_values: 'eidas1'
)
end
def self.user_info(code)
client = AgentConnectClient.new(code)
client.access_token!(client_auth_method: :secret)
.userinfo!
.raw_attributes
end
end

View file

@ -0,0 +1,6 @@
.container
%h1.mt-2.mb-2= t('.connect')
%p= t('.intro_html', app_name: APPLICATION_NAME)
= link_to t('.cta'), agent_connect_login_path, class: "france-connect-agent-login-button"

View file

@ -23,7 +23,13 @@
= f.submit t('views.users.sessions.new.connection'), class: "button large primary expand"
%hr
.france-connect-login-separator
= t('views.shared.france_connect_login.separator')
- if AgentConnectService.enabled?
.center
%p.mb-2= t('views.users.sessions.new.instructor_or_admin')
= link_to t('views.users.sessions.new.connect_with_agent_connect'), agent_connect_path
%hr
%p.center
%span= t('views.users.sessions.new.are_you_new', app_name: APPLICATION_NAME.gsub("-","&#8209;")).html_safe
%br

View file

@ -45,6 +45,12 @@ FC_PARTICULIER_ID=""
FC_PARTICULIER_SECRET=""
FC_PARTICULIER_BASE_URL=""
# Service externe: authentification Agent Connect
AGENT_CONNECT_ID=""
AGENT_CONNECT_SECRET=""
AGENT_CONNECT_BASE_URL=""
AGENT_CONNECT_JWKS=""
# Service externe: Support Utilisateur HelpScout | Spécifique démarches-simplifiées.fr
HELPSCOUT_MAILBOX_ID=""
HELPSCOUT_CLIENT_ID=""

View file

@ -11,6 +11,9 @@ DS_ENV="staging"
# Utilisation de France Connect
# FRANCE_CONNECT_ENABLED="disabled" # "enabled" par défaut
# Utilisation de Agent Connect
# AGENT_CONNECT_ENABLED="disabled" # "enabled" par défaut
# Personnalisation d'instance - URLs des CGU et des mentions légales
# CGU_URL=""
# MENTIONS_LEGALES_URL=""

View file

@ -0,0 +1 @@
AGENT_CONNECT = Rails.application.secrets.agent_connect

View file

@ -3,3 +3,59 @@ OpenIDConnect.logger = Rails.logger
Rack::OAuth2.logger = Rails.logger
# Webfinger.logger = Rails.logger
SWD.logger = Rails.logger
# the openid_connect gem does not support
# jwt format in the userinfo call.
# A PR is open to improve the situation
# https://github.com/nov/openid_connect/pull/54
module OpenIDConnect
class AccessToken < Rack::OAuth2::AccessToken::Bearer
private
def jwk_loader
JSON.parse(URI.parse(ENV['AGENT_CONNECT_JWKS']).read).deep_symbolize_keys
end
def decode_jwt(requested_host, jwt)
agent_connect_host = URI.parse(ENV['AGENT_CONNECT_BASE_URL']).host
if requested_host == agent_connect_host
# rubocop:disable Lint/UselessAssignment
JWT.decode(jwt, key = nil, verify = true, { algorithms: ['ES256'], jwks: jwk_loader })[0]
# rubocop:enable Lint/UselessAssignment
else
raise "unknwon host : #{requested_host}"
end
end
def resource_request
res = yield
case res.status
when 200
hash = case parse_type_and_subtype(res.content_type)
when 'application/jwt'
requested_host = URI.parse(client.userinfo_endpoint).host
decode_jwt(requested_host, res.body)
when 'application/json'
JSON.parse(res.body)
end
hash&.with_indifferent_access
when 400
raise BadRequest.new('API Access Faild', res)
when 401
raise Unauthorized.new('Access Token Invalid or Expired', res)
when 403
raise Forbidden.new('Insufficient Scope', res)
else
raise HttpError.new(res.status, 'Unknown HttpError', res)
end
end
# https://datatracker.ietf.org/doc/html/rfc2045#section-5.1
# - type and subtype are the first member
# they are case insensitive
def parse_type_and_subtype(content_type)
content_type.split(';')[0].strip.downcase
end
end
end

View file

@ -203,6 +203,8 @@ en:
connection: Sign in
are_you_new: First time on %{app_name}?
find_procedure: Find your procedure
instructor_or_admin: Instructor or Administrator ?
connect_with_agent_connect: Connect with AgentConnect
passwords:
reset_link_sent:
got_it: Got it!

View file

@ -199,6 +199,8 @@ fr:
connection: Se connecter
are_you_new: Vous êtes nouveau sur %{app_name} ?
find_procedure: Trouvez votre démarche
instructor_or_admin: Vous êtes instructeur ou administrateur ?
connect_with_agent_connect: Se connecter avec AgentConnect
passwords:
reset_link_sent:
email_sent_html: "Nous vous avons envoyé un email à ladresse <strong>%{email}</strong>."

View file

@ -0,0 +1,11 @@
en:
agent_connect:
agent:
index:
connect: Connect with AgentConnect
intro_html: |
AgentConnect allows <b class='bold'>instructors et administrators</b> to use their usual login credentials to connect to %{app_name}.
<br />
<br />
Only agents of <b class='bold'>the Ministry of Ecological Transition</b> can currently benefit from it.
cta: Connect with AgentConnect

View file

@ -0,0 +1,11 @@
fr:
agent_connect:
agent:
index:
connect: Connectez-vous avec AgentConnect
intro_html: |
AgentConnect permet aux <b class='bold'>instructeurs et administrateurs</b> dutiliser leurs identifiants habituels pour se connecter à %{app_name}.
<br />
<br />
Seul les agents du <b class='bold'>ministère de la Transition écologique</b> peuvent actuellement en bénéficier.
cta: Sidentifier avec Agent Connect

View file

@ -129,6 +129,12 @@ Rails.application.routes.draw do
post 'particulier/merge_with_new_account' => 'particulier#merge_with_new_account'
end
namespace :agent_connect do
get '' => 'agent#index'
get 'login' => 'agent#login'
get 'callback' => 'agent#callback'
end
namespace :champs do
get ':position/siret', to: 'siret#show', as: :siret
get ':position/dossier_link', to: 'dossier_link#show', as: :dossier_link

View file

@ -25,6 +25,14 @@ defaults: &defaults
token_endpoint: <%= ENV['FC_PARTICULIER_BASE_URL'] %>/api/v1/token
userinfo_endpoint: <%= ENV['FC_PARTICULIER_BASE_URL'] %>/api/v1/userinfo
logout_endpoint: <%= ENV['FC_PARTICULIER_BASE_URL'] %>/api/v1/logout
agent_connect:
identifier: <%= ENV['AGENT_CONNECT_ID'] %>
secret: <%= ENV['AGENT_CONNECT_SECRET'] %>
redirect_uri: http://test.localhost:3000/agent_connect/callback
authorization_endpoint: <%= ENV['AGENT_CONNECT_BASE_URL'] %>/api/v2/authorize
token_endpoint: <%= ENV['AGENT_CONNECT_BASE_URL'] %>/api/v2/token
userinfo_endpoint: <%= ENV['AGENT_CONNECT_BASE_URL'] %>/api/v2/userinfo
logout_endpoint: <%= ENV['AGENT_CONNECT_BASE_URL'] %>/api/v2/session/end
mailjet:
api_key: <%= ENV['MAILJET_API_KEY'] %>
secret_key: <%= ENV['MAILJET_SECRET_KEY'] %>

View file

@ -0,0 +1,6 @@
class AddAgentConnectSubColumnToInstructeursTable < ActiveRecord::Migration[6.1]
def change
add_column :instructeurs, :agent_connect_id, :string
add_index :instructeurs, :agent_connect_id, unique: true
end
end

View file

@ -10,7 +10,8 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2021_11_15_112933) do
ActiveRecord::Schema.define(version: 2021_11_19_112046) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
enable_extension "unaccent"
@ -534,6 +535,8 @@ ActiveRecord::Schema.define(version: 2021_11_15_112933) do
t.text "encrypted_login_token"
t.datetime "login_token_created_at"
t.boolean "bypass_email_login_token", default: false, null: false
t.string "agent_connect_id"
t.index ["agent_connect_id"], name: "index_instructeurs_on_agent_connect_id", unique: true
end
create_table "invites", id: :serial, force: :cascade do |t|

View file

@ -0,0 +1,90 @@
describe AgentConnect::AgentController, type: :controller do
describe '#callback' do
let(:email) { 'i@email.com' }
subject { get :callback, params: { code: code } }
context 'when the callback code is correct' do
let(:code) { 'correct' }
let(:user_info) { { 'sub' => 'sub', 'email' => ' I@email.com' } }
context 'and user_info returns some info' do
before do
expect(AgentConnectService).to receive(:user_info).and_return(user_info)
end
context 'and the instructeur does not have an account yet' do
before do
expect(controller).to receive(:sign_in)
end
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)
last_user = User.last
expect(last_user.email).to eq(email)
expect(last_user.confirmed_at).to be_present
expect(last_user.instructeur.agent_connect_id).to eq('sub')
expect(response).to redirect_to(instructeur_procedures_path)
end
end
context 'and the instructeur already has an account' do
let!(:instructeur) { create(:instructeur, email: email) }
before do
expect(controller).to receive(:sign_in)
end
it 'reuses the account, signs in and redirects to procedure_path' do
expect { subject }.to change { User.count }.by(0).and change { Instructeur.count }.by(0)
instructeur.reload
expect(instructeur.agent_connect_id).to eq('sub')
expect(response).to redirect_to(instructeur_procedures_path)
end
end
context 'and the instructeur already has an account as a user' do
let!(:user) { create(:user, email: email) }
before do
expect(controller).to receive(:sign_in)
end
it 'reuses the account, signs in and redirects to procedure_path' do
expect { subject }.to change { User.count }.by(0).and change { Instructeur.count }.by(1)
instructeur = user.reload.instructeur
expect(instructeur.agent_connect_id).to eq('sub')
expect(response).to redirect_to(instructeur_procedures_path)
end
end
end
context 'but user_info raises and error' do
before do
expect(AgentConnectService).to receive(:user_info).and_raise(Rack::OAuth2::Client::Error.new(500, error: 'Unknown'))
end
it 'aborts the processus' do
expect { subject }.to change { User.count }.by(0).and change { Instructeur.count }.by(0)
expect(response).to redirect_to(new_user_session_path)
end
end
end
context 'when the callback code is blank' do
let(:code) { '' }
it 'aborts the processus' do
expect { subject }.to change { User.count }.by(0).and change { Instructeur.count }.by(0)
expect(response).to redirect_to(new_user_session_path)
end
end
end
end