Merge pull request #9877 from demarches-simplifiees/add_ip_ranges_to_api_token

API: Ajoute la possibilité de limiter l'utilisation d'un jeton à un ensemble de réseaux précis
This commit is contained in:
LeSim 2024-01-25 10:56:32 +00:00 committed by GitHub
commit b51b734399
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 942 additions and 207 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -159,5 +159,13 @@
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM7 11V13H17V11H7Z'%3E%3C/path%3E%3C/svg%3E");
}
}
&-key-line {
&:before,
&:after {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12.917 13C12.441 15.8377 9.973 18 7 18C3.68629 18 1 15.3137 1 12C1 8.68629 3.68629 6 7 6C9.973 6 12.441 8.16229 12.917 11H23V13H21V17H19V13H17V17H15V13H12.917ZM7 16C9.20914 16 11 14.2091 11 12C11 9.79086 9.20914 8 7 8C4.79086 8 3 9.79086 3 12C3 14.2091 4.79086 16 7 16Z' fill='currentColor'%3E%3C/path%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12.917 13C12.441 15.8377 9.973 18 7 18C3.68629 18 1 15.3137 1 12C1 8.68629 3.68629 6 7 6C9.973 6 12.441 8.16229 12.917 11H23V13H21V17H19V13H17V17H15V13H12.917ZM7 16C9.20914 16 11 14.2091 11 12C11 9.79086 9.20914 8 7 8C4.79086 8 3 9.79086 3 12C3 14.2091 4.79086 16 7 16Z' fill='currentColor'%3E%3C/path%3E%3C/svg%3E");
}
}
// scss-lint:enable VendorPrefix
}

View file

@ -1,22 +1,11 @@
class Profile::APITokenCardComponent < ApplicationComponent
def initialize(created_api_token: nil, created_packed_token: nil)
@created_api_token = created_api_token
@created_packed_token = created_packed_token
end
private
def render?
current_administrateur.present?
end
def api_and_packed_tokens
current_administrateur.api_tokens.order(:created_at).map do |api_token|
if api_token == @created_api_token && @created_packed_token.present?
[api_token, @created_packed_token]
else
[api_token, nil]
end
end
def api_tokens
current_administrateur.api_tokens.order(created_at: :desc)
end
end

View file

@ -2,5 +2,4 @@ en:
tokens_title: API identification tokens
first_paragraph_html: |
These tokens are needed to make calls to the API of %{application_name}. You can <strong>click on </strong><a target=_blank rel="noopener" href="%{api_doc_url}" title="See %{application_name} API documentation">this link</a> to check our documentation
second_paragraph: If you already have applications that use a token and you revoke it, access to the API will be blocked for those applications.
action: Create and display a new token
create_token: create a new token

View file

@ -2,5 +2,4 @@ fr:
tokens_title: Jetons didentification de lAPI (token)
first_paragraph_html: |
Ces jetons sont nécessaires pour effectuer des appels vers lAPI de %{application_name}. Vous pouvez <strong>consulter notre documentation en suivant </strong><a target="_blank" rel="noopener" href="%{api_doc_url}" title="Voir la documentation de l'API de %{application_name}">ce lien</a>
second_paragraph: Si vous avez déjà des applications qui utilisent un jeton et vous le révoquez, laccès à lAPI sera bloqué pour ces applications.
action: Créer et afficher un nouveau jeton
create_token: Créer un nouveau jeton

View file

@ -3,15 +3,8 @@
= t('.tokens_title')
%p
= t('.first_paragraph_html', application_name: APPLICATION_NAME, api_doc_url: API_DOC_URL)
%p
= t('.second_paragraph')
= render Dsfr::ListComponent.new do |list|
- api_and_packed_tokens.each do |(api_token, packed_token)|
- list.with_item do
.fr-card.fr-card--horizontal
.fr-card__body.width-100
.fr-card__content
= render Profile::APITokenComponent.new(api_token:, packed_token:)
= link_to t('.create_token'), nom_admin_api_tokens_path, class: "fr-btn fr-btn--secondary fr-mt-2w"
= button_to t('.action'), api_tokens_path, method: :post, class: "fr-btn fr-btn--secondary"
%ul.fr-mt-4w
= render Profile::APITokenComponent.with_collection(api_tokens)

View file

@ -1,16 +1,32 @@
class Profile::APITokenComponent < ApplicationComponent
def initialize(api_token:, packed_token: nil)
def initialize(api_token:)
@api_token = api_token
@packed_token = packed_token
end
private
def procedures_to_allow_options
@api_token.targetable_procedures.map { ["#{_1.id} #{_1.libelle}", _1.id] }
def recently_used?
@api_token.last_used_at&.> 2.weeks.ago
end
def procedures_to_allow_select_options
{ selected: @api_token.targetable_procedures.first&.id }
def autorizations
right = @api_token.write_access? ? 'lecture et écriture sur' : 'lecture seule sur'
scope = @api_token.full_access? ? 'toutes les démarches' : @api_token.procedures.map(&:libelle).join(', ')
sanitize("#{right} #{tag.b(scope)}")
end
def network_filtering
if @api_token.authorized_networks.present?
"filtrage : #{@api_token.authorized_networks_for_ui}"
else
tag.span('aucun filtrage réseau', class: 'fr-text-default--warning')
end
end
def use_and_expiration
use = @api_token.last_used_at.present? ? "utilisé il y a #{time_ago_in_words(@api_token.last_used_at)} - " : ""
expiration = @api_token.expires_at.present? ? "valable jusquʼau #{l(@api_token.expires_at, format: :long)}" : "valable indéfiniment"
"#{use} #{expiration}"
end
end

View file

@ -1,18 +0,0 @@
en:
allowed_full_access_html: This token has access to <strong>all</strong> the procedures attached to your administrator account
allowed_procedures_html:
zero: This token has no access to <strong>any</strong> process.
one: This token has access to a selected process
other: This token has access to %{count} selected procedures
security_one: For security reasons, it will not be re-posted, please note.
security_two: For security reasons, we can only show it to you when it is created.
prompt_choose_procedure: "-- Please, choose a procedure --"
security_title: "Security options"
action_all: Allow access to all procedures
action_choice: "If you want to grant access to selected procedures only :"
add: Add
delete: Delete
token_procedures: This token has access to the procedures
revoke_token: Revoke token
reading_writing: Reading and writing
reading: Read only

View file

@ -1,18 +0,0 @@
fr:
allowed_full_access_html: Ce jeton a accès à <strong>toutes</strong> les démarches attachées à votre compte administrateur
allowed_procedures_html:
zero: Ce jeton na accès à <strong>aucune</strong> démarche
one: Ce jeton a accès à une démarche sélectionnée
other: Ce jeton a accès à %{count} démarches sélectionnées
security_one: Pour des raisons de sécurité, il ne sera plus ré-affiché, notez-le bien.
security_two: Pour des raisons de sécurité, nous ne pouvons vous lafficher que lors de sa création.
security_title: "Options de sécurité"
action_all: Autoriser laccès a toutes les démarches
prompt_choose_procedure: "-- Veuillez sélectionner une procédure à ajouter --"
action_choice: Si vous souhaitez autoriser laccès seulement a des démarches choisies, ajouter les au jeton
add: Ajouter
delete: Supprimer
token_procedures: Ce jeton a accès aux démarches
revoke_token: Révoquer le jeton
reading_writing: En lecture et écriture
reading: En lecture seule

View file

@ -1,65 +1,16 @@
%h3.fr-card__title
%b= "#{@api_token.name} "
%span.fr-text--sm= @api_token.prefix
-# TODO: remove after 01/07/2023
- if @api_token.version != 3 && Time.zone.now < Time.zone.parse('01/07/2023')
.fr-alert.fr-alert--info.fr-alert--sm.mt-2
%p Attention, ce jeton ne sera plus valide à partir du 01/07/2023
.fr-card__desc
- if @packed_token.present?
.fr-text--sm{ style: "width: 80%; word-break: break-all;" }
- button = render Dsfr::CopyButtonComponent.new(text: @packed_token, title: "Copier le jeton dans le presse-papier", success: "Le jeton a été copié dans le presse-papier")
= "#{@packed_token} #{button}"
%p
= t('.security_one')
- else
%p
= t('.security_two')
= render Dsfr::AlertComponent.new(state: :info, title: t(".security_title"), heading_level: :h4) do |c|
- c.with_body do
- if @api_token.full_access?
%p.fr-text--lg
= t('.allowed_full_access_html')
- else
%p.fr-text--lg
= t('.allowed_procedures_html', count: @api_token.procedures.size)
- if @api_token.procedures.empty?
= button_to t('.action_all'), @api_token, method: :patch, params: { api_token: { become_full_access: '1' } }, class: "fr-btn fr-btn--secondary"
- else
%ul
- @api_token.procedures.each do |procedure|
%li.flex.justify-between.align-center
.truncate-80
= "#{procedure.id} #{procedure.libelle}"
= button_to t('.delete'), @api_token, method: :patch, params: { api_token: { disallow_procedure_id: procedure.id } }, class: "fr-btn fr-btn--secondary"
.fr-card__end
= form_for @api_token, namespace: dom_id(@api_token, :allowed_procedures), html: { class: 'mb-3', data: { turbo: true } } do |f|
= f.label :allowed_procedure_ids, class: 'fr-label' do
= t('.action_choice')
- if !@api_token.full_access?
- @api_token.procedures.each do |procedure|
= f.hidden_field :allowed_procedure_ids, value: procedure.id, multiple: true, id: dom_id(procedure, :allowed_procedure)
.flex.justify-between.align-center{ 'data-turbo-force': :server }
= f.select :allowed_procedure_ids, procedures_to_allow_options, {prompt: t('.prompt_choose_procedure')}, { class: 'fr-select ', name: "api_token[allowed_procedure_ids][]" }
= f.button type: :submit, class: "fr-btn fr-btn--secondary" do
= t('.add')
= form_for @api_token, namespace: dom_id(@api_token, :write_access), html: { class: 'form mb-3', data: { turbo: true, controller: 'autosubmit' } } do |f|
= f.label :write_access do
= t('.token_procedures')
%label.toggle-switch.no-margin
= f.check_box :write_access, class: 'toggle-switch-checkbox'
%span.toggle-switch-control.round
%span.toggle-switch-label.on
= t('.reading_writing')
%span.toggle-switch-label.off
= t('.reading')
= button_to t('.revoke_token'), api_token_path(@api_token), method: :delete, class: "fr-btn fr-btn--secondary", data: { turbo_confirm: "Confirmez-vous la révocation de ce jeton ? Les applications qui lutilisent actuellement seront bloquées." }
%li.fr-mt-2w.flex
.fr-mr-4w{ class: class_names('fr-text-default--success': recently_used?) }
%span.fr-icon-key-line
.flex-grow
%div
%b= @api_token.name
%span (commence par #{@api_token.prefix})
%div= autorizations
%div= network_filtering
%div= use_and_expiration
%div
= link_to 'Supprimer',
admin_api_token_path(@api_token),
method: :delete,
class: 'fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-btn--icon-left fr-icon-delete-line',
data: { confirm: "Confirmez-vous la suppression du jeton « #{@api_token.name} » ?" }

View file

@ -0,0 +1,112 @@
module Administrateurs
class APITokensController < AdministrateurController
before_action :authenticate_administrateur!
before_action :set_api_token, only: [:destroy]
def nom
@name = name
end
def autorisations
@name = name
@libelle_id_procedures = current_administrateur
.procedures
.order(:libelle)
.pluck(:libelle, :id)
.map { |libelle, id| ["#{id} - #{libelle}", id] }
end
def securite
end
def create
if params[:networkFiltering] == "customNetworks" && invalid_network?
return redirect_to securite_admin_api_tokens_path(all_params.merge(invalidNetwork: true))
end
@api_token, @packed_token = APIToken.generate(current_administrateur)
@api_token.update!(name:, write_access:,
allowed_procedure_ids:, authorized_networks:, expires_at:)
end
def destroy
@api_token.destroy
redirect_to profil_path
end
private
def all_params
[:name, :access, :target, :targets, :networkFiltering, :networks, :lifetime, :customLifetime]
.index_with { |param| params[param] }
end
def authorized_networks
if params[:networkFiltering] == "customNetworks"
networks
else
[]
end
end
def invalid_network?
params[:networks]
.split
.any? do
begin
IPAddr.new(_1)
false
rescue
true
end
end
end
def networks
params[:networks]
.split
.map { begin IPAddr.new(_1) rescue nil end }
.compact
end
def set_api_token
@api_token = current_administrateur.api_tokens.find(params[:id])
end
def name
params[:name]
end
def write_access
params[:access] == "read_write"
end
def allowed_procedure_ids
if params[:target] == "custom"
current_administrateur
.procedure_ids
.intersection(params[:targets].map(&:to_i))
else
nil
end
end
def expires_at
case params[:lifetime]
in 'oneWeek'
1.week.from_now.to_date
in 'custom'
[
Date.parse(params[:customLifetime]),
1.year.from_now
].min
in 'infinite' if authorized_networks.present?
nil
else
1.week.from_now.to_date
end
end
end
end

View file

@ -2,6 +2,8 @@ class API::V2::BaseController < ApplicationController
skip_forgery_protection if: -> { request.headers.key?('HTTP_AUTHORIZATION') }
skip_before_action :setup_tracking
before_action :authenticate_from_token
before_action :ensure_authorized_network, if: -> { @api_token.present? }
before_action :ensure_token_is_not_expired, if: -> { @api_token.present? }
before_action do
Current.browser = 'api'
@ -46,4 +48,17 @@ class API::V2::BaseController < ApplicationController
Current.user = @current_user
end
end
def ensure_authorized_network
if @api_token.forbidden_network?(request.remote_ip)
address = IPAddr.new(request.remote_ip)
render json: { errors: ["request issued from a forbidden network. Add #{address.to_string}/#{address.prefix} to your allowed adresses in your /profil"] }, status: :forbidden
end
end
def ensure_token_is_not_expired
if @api_token.expired?
render json: { errors: ['token expired'] }, status: :unauthorized
end
end
end

View file

@ -1,6 +1,8 @@
class APIController < ApplicationController
before_action :default_format_json
before_action :authenticate_from_token
before_action :ensure_authorized_network, if: -> { @api_token.present? }
before_action :ensure_token_is_not_expired, if: -> { @api_token.present? }
before_action do
Current.browser = 'api'
@ -33,4 +35,17 @@ class APIController < ApplicationController
@current_user = @api_token.administrateur.user
end
end
def ensure_authorized_network
if @api_token.forbidden_network?(request.remote_ip)
address = IPAddr.new(request.remote_ip)
render json: { errors: ["request issued from a forbidden network. Add #{address.to_string}/#{address.prefix} to your allowed adresses in your /profil"] }, status: :forbidden
end
end
def ensure_token_is_not_expired
if @api_token.expired?
render json: { errors: ['token expired'] }, status: :unauthorized
end
end
end

View file

@ -1,46 +0,0 @@
class APITokensController < ApplicationController
before_action :authenticate_administrateur!
before_action :set_api_token, only: [:update, :destroy]
def create
@api_token, @packed_token = APIToken.generate(current_administrateur)
render :index
end
def update
if become_full_access?
@api_token.become_full_access!
elsif disallow_procedure_id.present?
@api_token.untarget_procedure(disallow_procedure_id.to_i)
else
@api_token.update!(api_token_params)
end
render :index
end
def destroy
@api_token.destroy
render :index
end
private
def set_api_token
@api_token = current_administrateur.api_tokens.find(params[:id])
end
def become_full_access?
api_token_params[:become_full_access].present?
end
def disallow_procedure_id
api_token_params[:disallow_procedure_id]
end
def api_token_params
params.require(:api_token).permit(:name, :write_access, :become_full_access, :disallow_procedure_id, allowed_procedure_ids: [])
end
end

View file

@ -0,0 +1,101 @@
import { ApplicationController } from './application_controller';
export class ApiTokenAutorisationController extends ApplicationController {
static targets = [
'procedures',
'procedureSelect',
'procedureSelectGroup',
'continueButton'
];
declare readonly continueButtonTarget: HTMLButtonElement;
declare readonly procedureSelectTarget: HTMLSelectElement;
declare readonly procedureSelectGroupTarget: HTMLElement;
declare readonly proceduresTarget: HTMLElement;
connect() {
const urlSearchParams = new URLSearchParams(window.location.search);
const targetIds = urlSearchParams.getAll('targets[]');
const customTargets = urlSearchParams.get('target') == 'custom';
this.setupProceduresTarget(targetIds);
if (customTargets && targetIds.length > 0) {
this.showProcedureSelectGroup();
}
this.setContinueButtonState();
}
setupProceduresTarget(targetIds: string[]) {
const options = Array.from(this.procedureSelectTarget.options);
targetIds
.map((id) => options.find((x) => x.value == id))
.forEach((option) => option && this.addProcedureToSelect(option));
}
addProcedure(e: Event) {
e.preventDefault();
const selectedOption = this.procedureSelectTarget.selectedOptions[0];
this.addProcedureToSelect(selectedOption);
this.setContinueButtonState();
}
addProcedureToSelect(option: HTMLOptionElement) {
const template = [
`<li class='flex align-center'>`,
option.text,
"<button class='fr-btn fr-icon-delete-line fr-btn--tertiary-no-outline fr-ml-1w' data-action='click->api-token-autorisation#deleteProcedure'></button>",
`<input type='hidden' name='[targets][]' value='${option.value}' />`,
`</li>`
].join('');
this.proceduresTarget.insertAdjacentHTML('beforeend', template);
}
deleteProcedure(e: Event) {
e.preventDefault();
const target = e.target as HTMLElement;
target.closest('li')?.remove();
this.setContinueButtonState();
}
showProcedureSelectGroup() {
this.procedureSelectGroupTarget.classList.remove('hidden');
this.setContinueButtonState();
}
hideProcedureSelectGroup() {
this.procedureSelectGroupTarget.classList.add('hidden');
this.setContinueButtonState();
}
setContinueButtonState() {
if (this.targetDefined() && this.accessDefined()) {
this.continueButtonTarget.disabled = false;
} else {
this.continueButtonTarget.disabled = true;
}
}
targetDefined() {
if (this.element.querySelectorAll("[value='all']:checked").length > 0) {
return true;
}
if (
this.element.querySelectorAll("[value='custom']:checked").length > 0 &&
this.proceduresTarget.children.length > 0
) {
return true;
}
return false;
}
accessDefined() {
return this.element.querySelectorAll("[name='access']:checked").length == 1;
}
}

View file

@ -0,0 +1,103 @@
import { ApplicationController } from './application_controller';
export class ApiTokenSecuriteController extends ApplicationController {
static targets = [
'continueButton',
'networkFiltering',
'infiniteLifetime',
'customLifetime',
'customLifetimeInput',
'networks'
];
declare readonly continueButtonTarget: HTMLButtonElement;
declare readonly networkFilteringTarget: HTMLElement;
declare readonly infiniteLifetimeTarget: HTMLInputElement;
declare readonly customLifetimeTarget: HTMLElement;
declare readonly customLifetimeInputTarget: HTMLInputElement;
declare readonly networksTarget: HTMLInputElement;
connect() {
this.setContinueButtonState();
}
showNetworkFiltering() {
this.networkFilteringTarget.classList.remove('hidden');
this.setContinueButtonState();
this.infiniteLifetimeTarget.disabled = false;
}
hideNetworkFiltering() {
this.networkFilteringTarget.classList.add('hidden');
this.setContinueButtonState();
this.infiniteLifetimeTarget.checked = false;
this.infiniteLifetimeTarget.disabled = true;
}
showCustomLifetime() {
this.customLifetimeTarget.classList.remove('hidden');
this.setContinueButtonState();
}
hideCustomLifetime() {
this.customLifetimeTarget.classList.add('hidden');
this.setContinueButtonState();
}
setContinueButtonState() {
if (this.networkDefined() && this.lifetimeDefined()) {
this.continueButtonTarget.disabled = false;
} else {
this.continueButtonTarget.disabled = true;
}
}
networkDefined() {
if (
this.element.querySelectorAll(
"[name='networkFiltering'][value='none']:checked"
).length > 0
) {
return true;
}
if (
this.element.querySelectorAll(
"[name='networkFiltering'][value='customNetworks']:checked"
).length > 0 &&
this.networksTarget.value.trim() != ''
) {
return true;
}
return false;
}
lifetimeDefined() {
if (
this.element.querySelectorAll(
"[name='lifetime'][value='oneWeek']:checked"
).length > 0
) {
return true;
}
if (
this.element.querySelectorAll(
"[name='lifetime'][value='infinite']:checked"
).length > 0
) {
return true;
}
if (
this.element.querySelectorAll("[name='lifetime'][value='custom']:checked")
.length > 0 &&
this.customLifetimeInputTarget.value.trim() != ''
) {
return true;
}
return false;
}
}

View file

@ -38,20 +38,10 @@ class APIToken < ApplicationRecord
.order(:libelle)
end
def untarget_procedure(procedure_id)
new_target_ids = targeted_procedure_ids - [procedure_id]
update!(allowed_procedure_ids: new_target_ids)
end
def sanitized_targeted_procedure_ids
administrateur.procedures.ids.intersection(targeted_procedure_ids || [])
end
def become_full_access!
update_column(:allowed_procedure_ids, nil)
end
# Prefix is made of the first 6 characters of the uuid base64 encoded
# it does not leak plain token
def prefix
@ -65,6 +55,20 @@ class APIToken < ApplicationRecord
end
end
def authorized_networks_for_ui
authorized_networks.map { "#{_1.to_string}/#{_1.prefix}" }.join(', ')
end
def forbidden_network?(ip)
return false if authorized_networks.blank?
authorized_networks.none? { |range| range.include?(ip) }
end
def expired?
expires_at&.past?
end
class << self
def generate(administrateur)
plain_token = generate_unique_secure_token
@ -87,6 +91,10 @@ class APIToken < ApplicationRecord
end
end
def last_used_at
last_v2_authenticated_at || last_v1_authenticated_at
end
private
def sanitize_targeted_procedure_ids

View file

@ -0,0 +1,70 @@
- content_for :title, "Privilèges du jeton « #{@name} »"
= render partial: 'administrateurs/breadcrumbs',
locals: { steps: [['Tableau de bord', tableau_de_bord_helper_path],
[t('users.profil.show.profile'), profil_path],
[t('administrateurs.api_tokens.nom.new_token')]] }
.fr-container.fr-mt-2w{ 'data-turbo': 'true' }
%h1 Privilèges du jeton « #{@name} »
= form_with url: securite_admin_api_tokens_path,
method: :get,
data: { controller: 'api-token-autorisation' } do |f|
= render Dsfr::RadioButtonListComponent.new(form: f,
target: :target,
buttons: [ { label: 'certaines de mes démarches',
value: :custom,
checked: params[:target] == 'custom',
data: { 'action': 'click->api-token-autorisation#showProcedureSelectGroup' } },
{ label: 'toutes mes démarches',
value: :all,
hint: 'présentes et futures',
checked: params[:target] == 'all',
data: { 'action': 'click->api-token-autorisation#hideProcedureSelectGroup' } }]) do
Ce jeton accède à
.fr-mb-4w.hidden{ 'data-api-token-autorisation-target': 'procedureSelectGroup' }
.fr-select-group
%label.fr-label{ for: 'procedureSelect' } Sélectionner les démarches autorisées
.flex
= f.select :value,
options_for_select(@libelle_id_procedures),
{ include_blank: true },
{ id: 'procedureSelect',
class: 'fr-select width-33',
name: 'procedureSelect',
data: { 'api-token-autorisation-target': 'procedureSelect' } }
%button.fr-btn.fr-btn--secondary.fr-ml-1w{
'data-action': 'click->api-token-autorisation#addProcedure' } Ajouter
%style
ul:empty { padding: 0; }
ul:empty:before { content: "aucune démarche sélectionnée"; }
%ul{ 'data-api-token-autorisation-target': 'procedures' }
%div{ 'data-action': 'click->api-token-autorisation#setContinueButtonState' }
= render Dsfr::RadioButtonListComponent.new(form: f,
target: :access,
buttons: [ { label: 'de lire uniquement',
value: :read,
checked: params[:access] == 'read',
hint: 'récupérer des dossiers, des pièces-jointes' },
{ label: 'de lire et dʼécrire',
value: :read_write,
checked: params[:access] == 'read_write',
hint: 'changer le statut de dossier, écrire des messages' }]) do
Ce jeton permet
= f.hidden_field :name, value: @name
%ul.fr-btns-group.fr-btns-group--inline
%li
= f.button type: :submit,
class: "fr-btn fr-btn--primary",
disabled: true,
'data-api-token-autorisation-target': 'continueButton' do
= t('.continue')
%li
= link_to t('.cancel'), nom_admin_api_tokens_path(name: @name), class: "fr-btn fr-btn--secondary"

View file

@ -0,0 +1,40 @@
- content_for :title, "jeton « #{@name} » créé"
= render partial: 'administrateurs/breadcrumbs',
locals: { steps: [['Tableau de bord', tableau_de_bord_helper_path],
[t('users.profil.show.profile'), profil_path],
[t('administrateurs.api_tokens.nom.new_token')]] }
.fr-container.fr-mt-2w{ 'data-turbo': 'true' }
%h1 Votre jeton est prêt
%p
Vous pouvez maintenant utiliser votre jeton pour accéder à vos données.<br />
%b Attention, vous ne pourrez plus le consulter après avoir quitté cette page.
%p{ data: { controller: 'clipboard', clipboard_text_value: @packed_token } }
%code= @packed_token
%button.fr-btn.fr-btn-primary.fr-btn-small.fr-ml-2w{ data: { action: 'clipboard#copy' }, title: 'Copier dans le presse-papier' } Copier
%p.fr-mt-4w Résumé des informations du jeton :
%ul
%li
%strong Nom du jeton :
#{@api_token.name}
%li
%strong Accès :
#{@api_token.write_access? ? 'lecture et écriture' : 'lecture'}
%li
%strong Démarches :
= @api_token.full_access? ? 'toutes' : @api_token.procedures.map(&:libelle).join(', ')
%li
%strong Réseaux autorisés :
= @api_token.authorized_networks.empty? ? 'tout internet' : @api_token.authorized_networks_for_ui
%li
%strong Date de fin de validité :
- if @api_token.expires_at.present?
%span= l(@api_token.expires_at, format: :long)
- else
%span aucune
= link_to 'Retour au profil', profil_path, class: 'fr-btn fr-btn--secondary fr-mt-2w'

View file

@ -0,0 +1,22 @@
- content_for :title, 'Nouveau jeton dʼAPI'
= render partial: 'administrateurs/breadcrumbs',
locals: { steps: [['Tableau de bord', tableau_de_bord_helper_path],
[t('users.profil.show.profile'), profil_path],
[t('administrateurs.api_tokens.nom.new_token')]] }
.fr-container.fr-mt-2w{ 'data-turbo': 'true' }
%h1= t('.new_token')
= form_with url: autorisations_admin_api_tokens_path, method: :get, html: { class: 'fr-mt-2w' } do |f|
.fr-input-group
= f.label :name, class: 'fr-label' do
= t('.name')
%span.fr-hint-text= t('.name-hint')
= f.text_field :name, class: 'fr-input width-33', autocomplete: 'off', autocapitalize: 'off', autocorrect: 'off', spellcheck: false, required: true, value: @name, autofocus: true
%ul.fr-btns-group.fr-btns-group--inline
%li
= f.button type: :submit, class: "fr-btn fr-btn--primary" do
= t('.continue')
%li
= link_to t('.cancel'), profil_path, class: "fr-btn fr-btn--secondary"

View file

@ -0,0 +1,105 @@
- content_for :title, "Sécurité du jeton « #{@name} »"
= render partial: 'administrateurs/breadcrumbs',
locals: { steps: [['Tableau de bord', tableau_de_bord_helper_path],
[t('users.profil.show.profile'), profil_path],
[t('administrateurs.api_tokens.nom.new_token')]] }
.fr-container.fr-mt-2w
%h1 Sécurité
.flex.align-center
%div
= image_tag("spider-128.png", width: '128px', style: 'display: block;')
%div
%blockquote{ cite: "https://fr.wikipedia.org/wiki/Un_grand_pouvoir,_grandes_responsabilit%C3%A9s#Utilisation_dans_Spider-Man" }
%p « Avec un grand pouvoir vient une grande responsabilité »
%p.fr-text--sm Oncle Ben dans Spider-Man
%p.fr-mt-2w
Votre jeton va proprablement vous permettre de manipuler des données confidentielles, voir personnelles.<br />
%b Il est de votre responsabilité de le conserver en sécurité et d'en limiter l'utilisation aux seules personnes habilitées.
%p Pour vous aider, nous vous proposons des fonctionnalités de filtrage réseau et de durée de vie du jeton.
= form_with url: admin_api_tokens_path,
method: :post,
html: { class: 'fr-mt-2w' },
data: { controller: 'api-token-securite' } do |f|
= render Dsfr::RadioButtonListComponent.new(form: f,
target: :networkFiltering,
buttons: [ { label: 'Je veux spécifier les réseaux autorisées à utiliser mon jeton',
value: :customNetworks,
checked: params[:networkFiltering] == 'customNetworks',
'data-action': 'click->api-token-securite#showNetworkFiltering' },
{ label: 'Mon jeton peut être utilisé depuis nʼimporte quelle adresse IP dans le monde',
hint: 'dangereux',
value: :none,
checked: params[:networkFiltering] == 'none',
'data-action': 'click->api-token-securite#hideNetworkFiltering' }]) do
Filtrage réseau :
.fr-input-group.fr-mb-4w{
'data-api-token-securite-target': 'networkFiltering',
class: class_names('hidden': params[:networkFiltering] == 'none' || params[:networkFiltering].blank?, 'fr-input-group--error': params[:invalidNetwork].present?) }
= f.label :name, class: 'fr-label' do
Entrez les adresses IP autorisées
%span.fr-hint-text adresses réseaux séparées par des espaces. Ex: 176.31.79.200 192.168.33.0/24 2001:41d0:304:400::52f/128
= f.text_field :networks,
class: class_names('fr-input': true, 'fr-input--error': params[:invalidNetwork].present?),
autocomplete: 'off',
autocapitalize: 'off',
autocorrect: 'off',
spellcheck: false,
value: params[:networks],
'data-action': 'input->api-token-securite#setContinueButtonState',
'data-api-token-securite-target': 'networks'
- if params[:invalidNetwork].present?
%p.fr-error-text Vous devez entrer des adresses IPv4 ou IPv6 valides
= render Dsfr::RadioButtonListComponent.new(form: f,
target: :lifetime,
buttons: [ { label: '1 semaine',
value: :oneWeek,
checked: params[:lifetime] == 'oneWeek',
'data-action': 'click->api-token-securite#hideCustomLifetime' },
{ label: 'durée personnalisée inférieure à 1 an',
value: :custom,
checked: params[:lifetime] == 'custom',
'data-action': 'click->api-token-securite#showCustomLifetime'},
{ label: 'Infini (le filtrage réseau doit être activé)',
value: :infinite,
checked: params[:lifetime] == 'infinite',
disabled: true,
'data-api-token-securite-target': 'infiniteLifetime',
'data-action': 'click->api-token-securite#hideCustomLifetime' }]) do
Durée de vie du jeton :
.fr-input-group.fr-mb-4w.hidden{ 'data-api-token-securite-target': 'customLifetime' }
= f.label :name, class: 'fr-label' do
Entrez la date de fin de validité du jeton
%input{ type: 'date',
class: 'fr-input width-33 fr-mb-4w',
name: 'customLifetime',
'data-action': 'input->api-token-securite#setContinueButtonState',
'data-api-token-securite-target': 'customLifetimeInput',
min: Date.tomorrow.iso8601,
max: 1.year.from_now.to_date.iso8601 }
= f.hidden_field :name, value: params[:name]
= f.hidden_field :access, value: params[:access]
= f.hidden_field :target, value: params[:target]
- params[:targets]&.each do |target|
= f.hidden_field 'targets[]', value: target
%ul.fr-btns-group.fr-btns-group--inline
%li
= f.button type: :submit,
class: "fr-btn fr-btn--primary",
disabled: true,
'data-api-token-securite-target': 'continueButton' do
créer le jeton
%li
= link_to 'retour', autorisations_admin_api_tokens_path(name: params[:name], access: params[:access], target: params[:target], targets: params[:targets]), class: "fr-btn fr-btn--secondary"

View file

@ -4,7 +4,7 @@
locals: { steps: [['Tableau de bord', tableau_de_bord_helper_path],
[t('.profile')]] }
#profil-page.container
#profil-page.fr-container
%h1= t('.profile')
- if @waiting_merge_emails.present?

View file

@ -0,0 +1,13 @@
---
en:
administrateurs:
api_tokens:
nom:
new_token: New token creation
name: name of the token
name-hint: 'examples: orus prod, presta'
continue: continue
cancel: back
autorisations:
cancel: back
continue: continue

View file

@ -0,0 +1,13 @@
---
fr:
administrateurs:
api_tokens:
nom:
new_token: Création d'un nouveau jeton
name: Nom du jeton
name-hint: 'exemples: prod orus, test presta'
continue: Continuer
cancel: Retour
autorisations:
cancel: Retour
continue: Continuer

View file

@ -208,7 +208,6 @@ Rails.application.routes.draw do
resources :attachments, only: [:show, :destroy]
resources :recherche, only: [:index]
resources :api_tokens, only: [:create, :update, :destroy]
get "patron" => "root#patron" if Rails.env.development? || Rails.env.test?
get "suivi" => "root#suivi"
@ -662,6 +661,14 @@ Rails.application.routes.draw do
patch 'add_to_procedure'
end
end
resources :api_tokens, only: [:create, :destroy] do
collection do
get :nom
get :autorisations
get :securite
end
end
end
resources :release_notes, only: [:index]

View file

@ -0,0 +1,5 @@
class AddIPRangesToAPIToken < ActiveRecord::Migration[7.0]
def change
add_column :api_tokens, :authorized_networks, :inet, array: true, default: []
end
end

View file

@ -0,0 +1,5 @@
class AddExpiresAtColumnToAPIToken < ActiveRecord::Migration[7.0]
def change
add_column :api_tokens, :expires_at, :date
end
end

View file

@ -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_01_10_113623) do
ActiveRecord::Schema[7.0].define(version: 2024_01_16_155926) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@ -93,6 +93,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_01_10_113623) do
create_table "api_tokens", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.bigint "administrateur_id", null: false
t.bigint "allowed_procedure_ids", array: true
t.inet "authorized_networks", default: [], array: true
t.datetime "created_at", null: false
t.string "encrypted_token", null: false
t.datetime "last_v1_authenticated_at"
@ -102,6 +103,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_01_10_113623) do
t.datetime "updated_at", null: false
t.integer "version", default: 3, null: false
t.boolean "write_access", default: true, null: false
t.date "expires_at"
t.index ["administrateur_id"], name: "index_api_tokens_on_administrateur_id"
end

View file

@ -0,0 +1,96 @@
describe Administrateurs::APITokensController, type: :controller do
let(:admin) { create(:administrateur) }
let(:procedure) { create(:procedure, administrateur: admin) }
before { sign_in(admin.user) }
before { Timecop.freeze(Time.zone.local(2020, 1, 1, 12, 0, 0)) }
after { Timecop.return }
describe 'create' do
let(:default_params) do
{
name: 'Test',
access: 'read_write',
target: 'all',
lifetime: 'oneWeek'
}
end
let(:token) { APIToken.last }
subject { post :create, params: }
before { subject }
context 'with write access, no filtering, one week' do
let(:params) { default_params }
it 'creates a token' do
expect(token.name).to eq('Test')
expect(token.write_access?).to be true
expect(token.full_access?).to be true
expect(token.authorized_networks).to be_blank
expect(token.expires_at).to eq(1.week.from_now.to_date)
end
end
context 'with read access' do
let(:params) { default_params.merge(access: 'read') }
it { expect(token.write_access?).to be false }
end
context 'without network filtering but requiring infinite lifetime' do
let(:params) { default_params.merge(lifetime: 'infinite') }
it { expect(token.expires_at).to eq(1.week.from_now.to_date) }
end
context 'with bad network and infinite lifetime' do
let(:networks) { 'bad' }
let(:params) { default_params.merge(networkFiltering: 'customNetworks', networks:) }
it do
expect(token).to be_nil
end
end
context 'with network filtering' do
let(:networks) { '192.168.1.23/32 2001:41d0:304:400::52f/128' }
let(:params) { default_params.merge(networkFiltering: 'customNetworks', networks:) }
it {
expect(token.authorized_networks).to eq([
IPAddr.new('192.168.1.23/32'),
IPAddr.new('2001:41d0:304:400::52f/128')
])
}
end
context 'with network filtering and infinite lifetime' do
let(:networks) { '192.168.1.23/32 2001:41d0:304:400::52f/128' }
let(:params) { default_params.merge(networkFiltering: 'customNetworks', networks:, lifetime: 'infinite') }
it { expect(token.expires_at).to eq(nil) }
end
context 'with procedure filtering' do
let(:params) { default_params.merge(target: 'custom', targets: [procedure.id]) }
it do
expect(token.allowed_procedure_ids).to eq([procedure.id])
expect(token.full_access?).to be false
end
end
context 'with procedure filtering on a procedure not owned by the admin' do
let(:another_procedure) { create(:procedure) }
let(:params) { default_params.merge(target: 'custom', targets: [another_procedure.id]) }
it do
expect(token.allowed_procedure_ids).to eq([])
expect(token.full_access?).to be false
end
end
end
end

View file

@ -0,0 +1,60 @@
describe API::V2::BaseController, type: :controller do
describe 'ensure_authorized_network and token_is_not_expired' do
let(:admin) { create(:administrateur) }
let(:token_bearer_couple) { APIToken.generate(admin) }
let(:token) { token_bearer_couple[0] }
let(:bearer) { token_bearer_couple[1] }
let(:remote_ip) { '0.0.0.0' }
controller(API::V2::BaseController) { def fake_action = render(plain: 'Hello, World!') }
before do
routes.draw { get 'fake_action' => 'api/v2/base#fake_action' }
valid_headers = { 'Authorization' => "Bearer token=#{bearer}" }
request.headers.merge!(valid_headers)
request.remote_ip = remote_ip
end
describe 'GET #index' do
subject { get :fake_action }
context 'when no authorized networks are defined and the token is not expired' do
it { is_expected.to have_http_status(:ok) }
end
context 'when the token is expired' do
before do
token.update!(expires_at: 1.day.ago)
end
it { is_expected.to have_http_status(:unauthorized) }
end
context 'when this is precisely the day the token expires' do
before do
token.update!(expires_at: Time.zone.today)
end
it { is_expected.to have_http_status(:ok) }
end
context 'when a single authorized network is defined' do
before do
token.update!(authorized_networks: [IPAddr.new('192.168.1.0/24')])
end
context 'and the request comes from it' do
let(:remote_ip) { '192.168.1.23' }
it { is_expected.to have_http_status(:ok) }
end
context 'and the request does not come from it' do
let(:remote_ip) { '192.168.2.2' }
it { is_expected.to have_http_status(:forbidden) }
end
end
end
end
end

View file

@ -39,4 +39,55 @@ describe APIController, type: :controller do
end
end
end
describe 'ensure_authorized_network and token is not expired' do
let(:admin) { create(:administrateur) }
let(:token_bearer_couple) { APIToken.generate(admin) }
let(:token) { token_bearer_couple[0] }
let(:bearer) { token_bearer_couple[1] }
let(:remote_ip) { '0.0.0.0' }
controller(APIController) { def fake_action = render(plain: 'Hello, World!') }
before do
routes.draw { get 'fake_action' => 'api#fake_action' }
valid_headers = { 'Authorization' => "Bearer token=#{bearer}" }
request.headers.merge!(valid_headers)
request.remote_ip = remote_ip
end
describe 'GET #index' do
subject { get :fake_action }
context 'when no authorized networks are defined and the token is not expired' do
it { is_expected.to have_http_status(:ok) }
end
context 'when the token is expired' do
before do
token.update!(expires_at: 1.day.ago)
end
it { is_expected.to have_http_status(:unauthorized) }
end
context 'when a single authorized network is defined' do
before do
token.update!(authorized_networks: [IPAddr.new('192.168.1.0/24')])
end
context 'and the request comes from it' do
let(:remote_ip) { '192.168.1.23' }
it { is_expected.to have_http_status(:ok) }
end
context 'and the request does not come from it' do
let(:remote_ip) { '192.168.2.2' }
it { is_expected.to have_http_status(:forbidden) }
end
end
end
end
end

View file

@ -56,19 +56,6 @@ describe APIToken, type: :model do
expect(api_token.targetable_procedures).to eq([other_procedure])
expect(api_token.context).to eq(administrateur_id: administrateur.id, procedure_ids: [procedure.id], write_access: true, api_token_id: api_token.id)
end
context 'and then gain full access' do
before do
api_token.become_full_access!
api_token.reload
end
it do
expect(api_token.full_access?).to be(true)
expect(api_token.procedure_ids).to match_array([procedure.id, other_procedure.id])
expect(api_token.targetable_procedures).to eq([procedure, other_procedure])
end
end
end
context 'but acces to a wrong procedure_id' do
@ -177,4 +164,36 @@ describe APIToken, type: :model do
it { is_expected.to eq([IPAddr.new(ip)]) }
end
end
describe '#forbidden_network?' do
let(:api_token_and_packed_token) { APIToken.generate(administrateur) }
let(:api_token) { api_token_and_packed_token.first }
let(:authorized_networks) { [] }
before { api_token.update!(authorized_networks: authorized_networks) }
subject { api_token.forbidden_network?(ip) }
context 'when no authorized networks are defined' do
let(:ip) { '192.168.1.1' }
it { is_expected.to be_falsey }
end
context 'when a single authorized network is defined' do
let(:authorized_networks) { [IPAddr.new('192.168.1.0/24')] }
context 'and the request comes from it' do
let(:ip) { '192.168.1.1' }
it { is_expected.to be_falsey }
end
context 'and the request does not come from it' do
let(:ip) { '192.168.2.1' }
it { is_expected.to be_truthy }
end
end
end
end

View file

@ -7,7 +7,7 @@ import RubyPlugin from 'vite-plugin-ruby';
const plugins = [
RubyPlugin(),
ViteReact({ jsxRuntime: 'classic' }),
FullReload(['config/routes.rb', 'app/views/**/*'], { delay: 200 })
FullReload(['config/routes.rb', 'app/views/**/*', 'app/components/**/*.haml'], { delay: 200 })
];
if (shouldBuildLegacy()) {