Merge pull request #6660 from betagouv/agent_connect
Prise en charge d'agent connect (#6027)
This commit is contained in:
commit
4fc224490e
22 changed files with 323 additions and 8 deletions
12
Gemfile.lock
12
Gemfile.lock
|
@ -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)
|
||||
|
|
BIN
app/assets/images/logo-agent-connect.png
Normal file
BIN
app/assets/images/logo-agent-connect.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.5 KiB |
11
app/assets/stylesheets/france-connect-agent-login.scss
Normal file
11
app/assets/stylesheets/france-connect-agent-login.scss
Normal 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;
|
||||
}
|
53
app/controllers/agent_connect/agent_controller.rb
Normal file
53
app/controllers/agent_connect/agent_controller.rb
Normal 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
|
9
app/models/agent_connect_client.rb
Normal file
9
app/models/agent_connect_client.rb
Normal 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
|
|
@ -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
|
||||
|
|
24
app/services/agent_connect_service.rb
Normal file
24
app/services/agent_connect_service.rb
Normal 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
|
6
app/views/agent_connect/agent/index.html.haml
Normal file
6
app/views/agent_connect/agent/index.html.haml
Normal 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"
|
|
@ -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("-","‑")).html_safe
|
||||
%br
|
||||
|
|
|
@ -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=""
|
||||
|
|
|
@ -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=""
|
||||
|
|
1
config/initializers/agent_connect.rb
Normal file
1
config/initializers/agent_connect.rb
Normal file
|
@ -0,0 +1 @@
|
|||
AGENT_CONNECT = Rails.application.secrets.agent_connect
|
|
@ -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
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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 à l’adresse <strong>%{email}</strong>."
|
||||
|
|
11
config/locales/views/agent_connect/agent/en.yml
Normal file
11
config/locales/views/agent_connect/agent/en.yml
Normal 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
|
11
config/locales/views/agent_connect/agent/fr.yml
Normal file
11
config/locales/views/agent_connect/agent/fr.yml
Normal 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> d’utiliser 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: S’identifier avec Agent Connect
|
|
@ -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
|
||||
|
|
|
@ -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'] %>
|
||||
|
|
|
@ -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
|
|
@ -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|
|
||||
|
|
90
spec/controllers/agent_connect/agent_controller_spec.rb
Normal file
90
spec/controllers/agent_connect/agent_controller_spec.rb
Normal 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
|
Loading…
Add table
Reference in a new issue