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:
commit
b51b734399
34 changed files with 942 additions and 207 deletions
BIN
app/assets/images/spider-128.png
Normal file
BIN
app/assets/images/spider-128.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -2,5 +2,4 @@ fr:
|
|||
tokens_title: Jetons d’identification de l’API (token)
|
||||
first_paragraph_html: |
|
||||
Ces jetons sont nécessaires pour effectuer des appels vers l’API 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, l’accès à l’API sera bloqué pour ces applications.
|
||||
action: Créer et afficher un nouveau jeton
|
||||
create_token: Créer un nouveau jeton
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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 n’a 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 l’afficher que lors de sa création.
|
||||
security_title: "Options de sécurité"
|
||||
action_all: Autoriser l’accès a toutes les démarches
|
||||
prompt_choose_procedure: "-- Veuillez sélectionner une procédure à ajouter --"
|
||||
action_choice: Si vous souhaitez autoriser l’accè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
|
|
@ -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 l’utilisent 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} » ?" }
|
||||
|
|
112
app/controllers/administrateurs/api_tokens_controller.rb
Normal file
112
app/controllers/administrateurs/api_tokens_controller.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
101
app/javascript/controllers/api_token_autorisation_controller.ts
Normal file
101
app/javascript/controllers/api_token_autorisation_controller.ts
Normal 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;
|
||||
}
|
||||
}
|
103
app/javascript/controllers/api_token_securite_controller.ts
Normal file
103
app/javascript/controllers/api_token_securite_controller.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
70
app/views/administrateurs/api_tokens/autorisations.html.haml
Normal file
70
app/views/administrateurs/api_tokens/autorisations.html.haml
Normal 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"
|
40
app/views/administrateurs/api_tokens/create.html.haml
Normal file
40
app/views/administrateurs/api_tokens/create.html.haml
Normal 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'
|
22
app/views/administrateurs/api_tokens/nom.html.haml
Normal file
22
app/views/administrateurs/api_tokens/nom.html.haml
Normal 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"
|
105
app/views/administrateurs/api_tokens/securite.html.haml
Normal file
105
app/views/administrateurs/api_tokens/securite.html.haml
Normal 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"
|
|
@ -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?
|
||||
|
|
13
config/locales/views/api_tokens.en.yml
Normal file
13
config/locales/views/api_tokens.en.yml
Normal 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
|
13
config/locales/views/api_tokens.fr.yml
Normal file
13
config/locales/views/api_tokens.fr.yml
Normal 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
|
|
@ -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]
|
||||
|
|
5
db/migrate/20231220202003_add_ip_ranges_to_api_token.rb
Normal file
5
db/migrate/20231220202003_add_ip_ranges_to_api_token.rb
Normal 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
|
|
@ -0,0 +1,5 @@
|
|||
class AddExpiresAtColumnToAPIToken < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :api_tokens, :expires_at, :date
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
60
spec/controllers/api/v2/base_controller_spec.rb
Normal file
60
spec/controllers/api/v2/base_controller_spec.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()) {
|
||||
|
|
Loading…
Reference in a new issue