Merge pull request #8140 from tchak/feat-api-token-rotate
feat(api): api tokens can be rotated
This commit is contained in:
commit
3ca5e07e4f
26 changed files with 440 additions and 135 deletions
22
app/components/profile/api_token_card_component.rb
Normal file
22
app/components/profile/api_token_card_component.rb
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
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
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,12 @@
|
||||||
|
.card.no-list{ 'data-turbo': 'true', id: dom_id(current_administrateur, :profil_api_token) }
|
||||||
|
.card-title Jetons d’identification de l’API (token)
|
||||||
|
%p Ces jetons sont nécessaire pour effectuer des appels vers l’API de #{APPLICATION_NAME}.
|
||||||
|
%p 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.
|
||||||
|
|
||||||
|
= render Dsfr::ListComponent.new do |list|
|
||||||
|
- api_and_packed_tokens.each do |(api_token, packed_token)|
|
||||||
|
- list.with_item do
|
||||||
|
= render Profile::APITokenComponent.new(api_token:, packed_token:)
|
||||||
|
|
||||||
|
%br
|
||||||
|
= button_to "Créer et afficher un nouveau jeton", api_tokens_path, method: :post, class: "fr-btn fr-btn--secondary"
|
6
app/components/profile/api_token_component.rb
Normal file
6
app/components/profile/api_token_component.rb
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
class Profile::APITokenComponent < ApplicationComponent
|
||||||
|
def initialize(api_token:, packed_token: nil)
|
||||||
|
@api_token = api_token
|
||||||
|
@packed_token = packed_token
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,15 @@
|
||||||
|
%p
|
||||||
|
%b= "#{@api_token.name} "
|
||||||
|
%span.fr-text--sm= @api_token.prefix
|
||||||
|
|
||||||
|
- 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 Pour des raisons de sécurité, il ne sera plus ré-affiché, notez-le bien.
|
||||||
|
|
||||||
|
- else
|
||||||
|
%p Pour des raisons de sécurité, nous ne pouvons vous l’afficher que lors de sa création.
|
||||||
|
|
||||||
|
= button_to "Révoquer le jeton", 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." }
|
|
@ -8,15 +8,15 @@ class API::V2::BaseController < ApplicationController
|
||||||
private
|
private
|
||||||
|
|
||||||
def context
|
def context
|
||||||
# new token give administrateur_id
|
# new token
|
||||||
if api_token.administrateur?
|
if api_token.present?
|
||||||
{ administrateur_id: api_token.administrateur_id, token: api_token.token }
|
{ token: authorization_bearer_token, administrateur_id: api_token.administrateur.id }
|
||||||
# web interface (/graphql) give current_administrateur
|
# web interface (/graphql) give current_administrateur
|
||||||
elsif current_administrateur.present?
|
elsif current_administrateur.present?
|
||||||
{ administrateur_id: current_administrateur.id }
|
{ administrateur_id: current_administrateur.id }
|
||||||
# old token
|
# old token
|
||||||
else
|
else
|
||||||
{ token: api_token.token }
|
{ token: authorization_bearer_token }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -24,6 +24,19 @@ class API::V2::BaseController < ApplicationController
|
||||||
authorization_bearer_token.present?
|
authorization_bearer_token.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def authenticate_administrateur_from_token
|
||||||
|
if api_token.present?
|
||||||
|
@current_user = api_token.administrateur.user
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def api_token
|
||||||
|
if @api_token.nil?
|
||||||
|
@api_token = APIToken.find_and_verify(authorization_bearer_token) || false
|
||||||
|
end
|
||||||
|
@api_token
|
||||||
|
end
|
||||||
|
|
||||||
def authorization_bearer_token
|
def authorization_bearer_token
|
||||||
@authorization_bearer_token ||= begin
|
@authorization_bearer_token ||= begin
|
||||||
received_token = nil
|
received_token = nil
|
||||||
|
@ -33,17 +46,4 @@ class API::V2::BaseController < ApplicationController
|
||||||
received_token
|
received_token
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def authenticate_administrateur_from_token
|
|
||||||
if api_token.administrateur?
|
|
||||||
administrateur = Administrateur.includes(:user).find_by(id: api_token.administrateur_id)
|
|
||||||
if administrateur.valid_api_token?(api_token.token)
|
|
||||||
@current_user = administrateur.user
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def api_token
|
|
||||||
@api_token ||= APIToken.new(authorization_bearer_token)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,8 +4,9 @@ class APIController < ApplicationController
|
||||||
protected
|
protected
|
||||||
|
|
||||||
def find_administrateur_for_token(procedure)
|
def find_administrateur_for_token(procedure)
|
||||||
procedure.administrateurs.find do |administrateur|
|
api_token = APIToken.find_and_verify(authorization_bearer_token, procedure.administrateurs)
|
||||||
administrateur.valid_api_token?(api_token.token)
|
if api_token.present? && procedure.administrateurs.include?(api_token.administrateur)
|
||||||
|
api_token.administrateur
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -15,10 +16,6 @@ class APIController < ApplicationController
|
||||||
request.format = "json" if !request.params[:format]
|
request.format = "json" if !request.params[:format]
|
||||||
end
|
end
|
||||||
|
|
||||||
def api_token
|
|
||||||
@api_token ||= APIToken.new(authorization_bearer_token)
|
|
||||||
end
|
|
||||||
|
|
||||||
def authorization_bearer_token
|
def authorization_bearer_token
|
||||||
params_token.presence || header_token
|
params_token.presence || header_token
|
||||||
end
|
end
|
||||||
|
|
38
app/controllers/api_tokens_controller.rb
Normal file
38
app/controllers/api_tokens_controller.rb
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
class APITokensController < ApplicationController
|
||||||
|
before_action :authenticate_administrateur!
|
||||||
|
|
||||||
|
def create
|
||||||
|
@api_token, @packed_token = APIToken.generate(current_administrateur)
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.turbo_stream { render :index }
|
||||||
|
format.html { redirect_back(fallback_location: profil_path) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
@api_token = current_administrateur.api_tokens.find(params[:id])
|
||||||
|
@api_token.update!(api_token_params)
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.turbo_stream { render :index }
|
||||||
|
format.html { redirect_back(fallback_location: profil_path) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@api_token = current_administrateur.api_tokens.find(params[:id])
|
||||||
|
@api_token.destroy
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.turbo_stream { render :index }
|
||||||
|
format.html { redirect_back(fallback_location: profil_path) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def api_token_params
|
||||||
|
params.require(:api_token).permit(:name)
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,18 +1,12 @@
|
||||||
module Users
|
module Users
|
||||||
class ProfilController < UserController
|
class ProfilController < UserController
|
||||||
before_action :ensure_update_email_is_authorized, only: :update_email
|
before_action :ensure_update_email_is_authorized, only: :update_email
|
||||||
before_action :find_transfers, only: [:show, :renew_api_token]
|
before_action :find_transfers, only: [:show]
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@france_connect_informations = FranceConnectInformation.where(user: current_user)
|
@france_connect_informations = FranceConnectInformation.where(user: current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
def renew_api_token
|
|
||||||
@token = current_administrateur.renew_api_token
|
|
||||||
flash.now.notice = 'Votre jeton a été regénéré.'
|
|
||||||
render :show
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_email
|
def update_email
|
||||||
requested_user = User.find_by(email: requested_email)
|
requested_user = User.find_by(email: requested_email)
|
||||||
if requested_user.present? && current_user.ask_for_merge(requested_user)
|
if requested_user.present? && current_user.ask_for_merge(requested_user)
|
||||||
|
|
|
@ -23,15 +23,11 @@ class API::V2::Context < GraphQL::Query::Context
|
||||||
# We are caching authorization logic because it is called for each node
|
# We are caching authorization logic because it is called for each node
|
||||||
# of the requested graph and can be expensive. Context is reset per request so it is safe.
|
# of the requested graph and can be expensive. Context is reset per request so it is safe.
|
||||||
self[:authorized] ||= Hash.new do |hash, demarche_id|
|
self[:authorized] ||= Hash.new do |hash, demarche_id|
|
||||||
# Compute the hash value dynamically when first requested
|
hash[demarche_id] = if self[:token]
|
||||||
authorized_administrateur = demarche.administrateurs.find do |administrateur|
|
APIToken.find_and_verify(self[:token], demarche.administrateurs).present?
|
||||||
if self[:token]
|
elsif self[:administrateur_id]
|
||||||
administrateur.valid_api_token?(self[:token])
|
demarche.administrateurs.map(&:id).include?(self[:administrateur_id])
|
||||||
else
|
|
||||||
administrateur.id == self[:administrateur_id]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
hash[demarche_id] = authorized_administrateur.present?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
self[:authorized][demarche.id]
|
self[:authorized][demarche.id]
|
||||||
|
|
|
@ -3,15 +3,12 @@
|
||||||
# Table name: administrateurs
|
# Table name: administrateurs
|
||||||
#
|
#
|
||||||
# id :integer not null, primary key
|
# id :integer not null, primary key
|
||||||
# active :boolean default(FALSE)
|
|
||||||
# encrypted_token :string
|
# encrypted_token :string
|
||||||
# created_at :datetime
|
# created_at :datetime
|
||||||
# updated_at :datetime
|
# updated_at :datetime
|
||||||
# user_id :bigint not null
|
# user_id :bigint not null
|
||||||
#
|
#
|
||||||
class Administrateur < ApplicationRecord
|
class Administrateur < ApplicationRecord
|
||||||
include ActiveRecord::SecureToken
|
|
||||||
|
|
||||||
self.ignored_columns = [:active]
|
self.ignored_columns = [:active]
|
||||||
|
|
||||||
UNUSED_ADMIN_THRESHOLD = 6.months
|
UNUSED_ADMIN_THRESHOLD = 6.months
|
||||||
|
@ -20,6 +17,7 @@ class Administrateur < ApplicationRecord
|
||||||
has_many :administrateurs_procedures
|
has_many :administrateurs_procedures
|
||||||
has_many :procedures, through: :administrateurs_procedures
|
has_many :procedures, through: :administrateurs_procedures
|
||||||
has_many :services
|
has_many :services
|
||||||
|
has_many :api_tokens, inverse_of: :administrateur, dependent: :destroy
|
||||||
|
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
|
||||||
|
@ -56,19 +54,6 @@ class Administrateur < ApplicationRecord
|
||||||
self.inactive.find(id)
|
self.inactive.find(id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def renew_api_token
|
|
||||||
api_token = Administrateur.generate_unique_secure_token
|
|
||||||
encrypted_token = BCrypt::Password.create(api_token)
|
|
||||||
update(encrypted_token: encrypted_token)
|
|
||||||
APIToken.signe(id, api_token)
|
|
||||||
end
|
|
||||||
|
|
||||||
def valid_api_token?(api_token)
|
|
||||||
BCrypt::Password.new(encrypted_token) == api_token
|
|
||||||
rescue BCrypt::Errors::InvalidHash
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
def registration_state
|
def registration_state
|
||||||
if user.active?
|
if user.active?
|
||||||
'Actif'
|
'Actif'
|
||||||
|
|
|
@ -1,27 +1,85 @@
|
||||||
class APIToken
|
# == Schema Information
|
||||||
attr_reader :administrateur_id, :token
|
#
|
||||||
|
# Table name: api_tokens
|
||||||
|
#
|
||||||
|
# id :uuid not null, primary key
|
||||||
|
# encrypted_token :string not null
|
||||||
|
# name :string not null
|
||||||
|
# version :integer default(3), not null
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# administrateur_id :bigint not null
|
||||||
|
#
|
||||||
|
class APIToken < ApplicationRecord
|
||||||
|
include ActiveRecord::SecureToken
|
||||||
|
|
||||||
def initialize(token)
|
belongs_to :administrateur, inverse_of: :api_tokens
|
||||||
@token = token
|
|
||||||
verify!
|
# Prefix is made of the first 6 characters of the uuid base64 encoded
|
||||||
|
# it does not leak plain token
|
||||||
|
def prefix
|
||||||
|
Base64.urlsafe_encode64(id).slice(0, 5)
|
||||||
end
|
end
|
||||||
|
|
||||||
def administrateur?
|
class << self
|
||||||
administrateur_id.present?
|
def generate(administrateur)
|
||||||
end
|
plain_token = generate_unique_secure_token
|
||||||
|
encrypted_token = BCrypt::Password.create(plain_token)
|
||||||
|
api_token = create!(administrateur:, encrypted_token:, name: Date.today.strftime('Jeton d’API généré le %d/%m/%Y'))
|
||||||
|
packed_token = Base64.urlsafe_encode64([api_token.id, plain_token].join(';'))
|
||||||
|
[api_token, packed_token]
|
||||||
|
end
|
||||||
|
|
||||||
def self.message_verifier
|
def find_and_verify(maybe_packed_token, administrateurs = [])
|
||||||
Rails.application.message_verifier('api_v2_token')
|
case unpack(maybe_packed_token)
|
||||||
end
|
in { plain_token:, id: } # token v3
|
||||||
|
find_by(id:, version: 3)&.then(&ensure_valid_token(plain_token))
|
||||||
|
in { plain_token:, administrateur_id: } # token v2
|
||||||
|
# the migration to the APIToken model set `version: 1` for all the v1 and v2 token
|
||||||
|
# this is the only place where we can fix the version
|
||||||
|
where(administrateur_id:, version: 1).update_all(version: 2) # update to v2
|
||||||
|
find_by(administrateur_id:, version: 2)&.then(&ensure_valid_token(plain_token)) ||
|
||||||
|
find_with_administrateur_encrypted_token(plain_token, administrateurs) # before migration
|
||||||
|
in { plain_token: } # token v1
|
||||||
|
where(administrateur: administrateurs, version: 1).find(&ensure_valid_token(plain_token)) ||
|
||||||
|
find_with_administrateur_encrypted_token(plain_token, administrateurs) # before migration
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def self.signe(administrateur_id, token)
|
private
|
||||||
message_verifier.generate([administrateur_id, token])
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
# FIXME remove after migration
|
||||||
|
def find_with_administrateur_encrypted_token(plain_token, administrateurs)
|
||||||
|
administrateurs
|
||||||
|
.lazy
|
||||||
|
.filter { _1.encrypted_token.present? }
|
||||||
|
.map { APIToken.new(administrateur: _1, encrypted_token: _1.encrypted_token, version: 1) }
|
||||||
|
.find(&ensure_valid_token(plain_token))
|
||||||
|
end
|
||||||
|
|
||||||
def verify!
|
UUID_SIZE = SecureRandom.uuid.size
|
||||||
@administrateur_id, @token = self.class.message_verifier.verified(@token) || [nil, @token]
|
def unpack(maybe_packed_token)
|
||||||
rescue
|
case message_verifier.verified(maybe_packed_token)
|
||||||
|
in [administrateur_id, plain_token]
|
||||||
|
{ plain_token:, administrateur_id: }
|
||||||
|
else
|
||||||
|
case Base64.urlsafe_decode64(maybe_packed_token).split(';')
|
||||||
|
in [id, plain_token] if id.size == UUID_SIZE # valid format "<uuid>;<random token>"
|
||||||
|
{ plain_token:, id: }
|
||||||
|
else
|
||||||
|
{ plain_token: maybe_packed_token }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
{ plain_token: maybe_packed_token }
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_verifier
|
||||||
|
Rails.application.message_verifier('api_v2_token')
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_valid_token(plain_token)
|
||||||
|
-> (api_token) { api_token if BCrypt::Password.new(api_token.encrypted_token) == plain_token }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
2
app/views/api_tokens/index.turbo_stream.haml
Normal file
2
app/views/api_tokens/index.turbo_stream.haml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
= turbo_stream.morph dom_id(current_administrateur, :profil_api_token) do
|
||||||
|
= render Profile::APITokenCardComponent.new created_api_token: @api_token, created_packed_token: @packed_token
|
|
@ -59,25 +59,7 @@
|
||||||
- @waiting_transfers.each do |email, nb_dossier|
|
- @waiting_transfers.each do |email, nb_dossier|
|
||||||
%li= t('.one_waiting_transfer', email: email, count: nb_dossier)
|
%li= t('.one_waiting_transfer', email: email, count: nb_dossier)
|
||||||
|
|
||||||
- if current_administrateur.present?
|
= render Profile::APITokenCardComponent.new
|
||||||
.card
|
|
||||||
.card-title Jeton d’identification de l’API (token)
|
|
||||||
%p Ce jeton est nécessaire pour effectuer des appels vers l’API de #{APPLICATION_NAME}.
|
|
||||||
|
|
||||||
- if defined?(@token)
|
|
||||||
%p Jeton : <b>#{@token}</b>
|
|
||||||
%p Pour des raisons de sécurité, ce jeton ne sera plus ré-affiché, notez-le bien.
|
|
||||||
|
|
||||||
- else
|
|
||||||
%p Pour des raisons de sécurité, nous ne pouvons vous l’afficher que lors de sa génération.
|
|
||||||
%p Attention, si vous avez déjà des applications qui utilisent votre jeton, le regénérer bloquera leurs accès à l’API.
|
|
||||||
|
|
||||||
= link_to "Regénérer et afficher mon jeton",
|
|
||||||
renew_api_token_path,
|
|
||||||
method: :post,
|
|
||||||
class: "fr-btn fr-btn--secondary",
|
|
||||||
data: { confirm: "Confirmez-vous la regénération de votre jeton ? Les applications qui l’utilisent actuellement seront bloquées.",
|
|
||||||
disable: true }
|
|
||||||
|
|
||||||
- if @france_connect_informations.present?
|
- if @france_connect_informations.present?
|
||||||
.card
|
.card
|
||||||
|
|
|
@ -173,6 +173,7 @@ Rails.application.routes.draw do
|
||||||
|
|
||||||
resources :attachments, only: [:show, :destroy]
|
resources :attachments, only: [:show, :destroy]
|
||||||
resources :recherche, only: [:index]
|
resources :recherche, only: [:index]
|
||||||
|
resources :api_tokens, only: [:create, :update, :destroy]
|
||||||
|
|
||||||
get "patron" => "root#patron"
|
get "patron" => "root#patron"
|
||||||
get "suivi" => "root#suivi"
|
get "suivi" => "root#suivi"
|
||||||
|
@ -306,9 +307,6 @@ Rails.application.routes.draw do
|
||||||
get 'demarches' => 'demarches#index'
|
get 'demarches' => 'demarches#index'
|
||||||
|
|
||||||
get 'profil' => 'profil#show'
|
get 'profil' => 'profil#show'
|
||||||
post 'renew-api-token' => 'profil#renew_api_token'
|
|
||||||
# allow refresh 'renew api token' page
|
|
||||||
get 'renew-api-token' => redirect('/profil')
|
|
||||||
patch 'update_email' => 'profil#update_email'
|
patch 'update_email' => 'profil#update_email'
|
||||||
post 'transfer_all_dossiers' => 'profil#transfer_all_dossiers'
|
post 'transfer_all_dossiers' => 'profil#transfer_all_dossiers'
|
||||||
post 'accept_merge' => 'profil#accept_merge'
|
post 'accept_merge' => 'profil#accept_merge'
|
||||||
|
|
11
db/migrate/20221129104327_create_api_tokens.rb
Normal file
11
db/migrate/20221129104327_create_api_tokens.rb
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
class CreateAPITokens < ActiveRecord::Migration[6.1]
|
||||||
|
def change
|
||||||
|
create_table :api_tokens, id: :uuid do |t|
|
||||||
|
t.references :administrateur, null: false, foreign_key: true
|
||||||
|
t.string :encrypted_token, null: false
|
||||||
|
t.string :name, null: false
|
||||||
|
t.integer :version, null: false, default: 3
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
13
db/schema.rb
13
db/schema.rb
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema.define(version: 2022_12_01_091658) do
|
ActiveRecord::Schema.define(version: 2022_12_05_144624) do
|
||||||
|
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
|
@ -86,6 +86,16 @@ ActiveRecord::Schema.define(version: 2022_12_01_091658) do
|
||||||
t.index ["procedure_id"], name: "index_administrateurs_procedures_on_procedure_id"
|
t.index ["procedure_id"], name: "index_administrateurs_procedures_on_procedure_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "api_tokens", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.bigint "administrateur_id", null: false
|
||||||
|
t.datetime "created_at", precision: 6, null: false
|
||||||
|
t.string "encrypted_token", null: false
|
||||||
|
t.string "name", null: false
|
||||||
|
t.datetime "updated_at", precision: 6, null: false
|
||||||
|
t.integer "version", default: 3, null: false
|
||||||
|
t.index ["administrateur_id"], name: "index_api_tokens_on_administrateur_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "archives", force: :cascade do |t|
|
create_table "archives", force: :cascade do |t|
|
||||||
t.datetime "created_at", precision: 6, null: false
|
t.datetime "created_at", precision: 6, null: false
|
||||||
t.string "job_status", null: false
|
t.string "job_status", null: false
|
||||||
|
@ -928,6 +938,7 @@ ActiveRecord::Schema.define(version: 2022_12_01_091658) do
|
||||||
add_foreign_key "administrateurs_instructeurs", "instructeurs"
|
add_foreign_key "administrateurs_instructeurs", "instructeurs"
|
||||||
add_foreign_key "administrateurs_procedures", "administrateurs"
|
add_foreign_key "administrateurs_procedures", "administrateurs"
|
||||||
add_foreign_key "administrateurs_procedures", "procedures"
|
add_foreign_key "administrateurs_procedures", "procedures"
|
||||||
|
add_foreign_key "api_tokens", "administrateurs"
|
||||||
add_foreign_key "archives_groupe_instructeurs", "archives"
|
add_foreign_key "archives_groupe_instructeurs", "archives"
|
||||||
add_foreign_key "archives_groupe_instructeurs", "groupe_instructeurs"
|
add_foreign_key "archives_groupe_instructeurs", "groupe_instructeurs"
|
||||||
add_foreign_key "assign_tos", "groupe_instructeurs"
|
add_foreign_key "assign_tos", "groupe_instructeurs"
|
||||||
|
|
30
lib/tasks/deployment/20221129162903_migrate_api_tokens.rake
Normal file
30
lib/tasks/deployment/20221129162903_migrate_api_tokens.rake
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
namespace :after_party do
|
||||||
|
desc 'Deployment task: migrate_api_tokens'
|
||||||
|
task migrate_api_tokens: :environment do
|
||||||
|
puts "Running deploy task 'migrate_api_tokens'"
|
||||||
|
|
||||||
|
administrateurs = Administrateur
|
||||||
|
.where.not(encrypted_token: nil)
|
||||||
|
.where.missing(:api_tokens)
|
||||||
|
|
||||||
|
progress = ProgressReport.new(administrateurs.count)
|
||||||
|
|
||||||
|
administrateurs.find_each do |administrateur|
|
||||||
|
administrateur.transaction do
|
||||||
|
administrateur
|
||||||
|
.api_tokens
|
||||||
|
.create!(name: administrateur.updated_at.strftime('Jeton d’API généré le %d/%m/%Y'),
|
||||||
|
encrypted_token: administrateur.encrypted_token,
|
||||||
|
version: 1)
|
||||||
|
administrateur.update_column(:encrypted_token, nil)
|
||||||
|
end
|
||||||
|
progress.inc
|
||||||
|
end
|
||||||
|
progress.finish
|
||||||
|
|
||||||
|
# Update task as completed. If you remove the line below, the task will
|
||||||
|
# run with every deploy (or every time you call after_party:run).
|
||||||
|
AfterParty::TaskRecord
|
||||||
|
.create version: AfterParty::TaskRecorder.new(__FILE__).timestamp
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,6 +1,6 @@
|
||||||
describe API::V1::DossiersController do
|
describe API::V1::DossiersController do
|
||||||
let(:admin) { create(:administrateur) }
|
let(:admin) { create(:administrateur) }
|
||||||
let(:token) { admin.renew_api_token }
|
let(:token) { APIToken.generate(admin)[1] }
|
||||||
let(:procedure) { create(:procedure, :with_type_de_champ, :with_type_de_champ_private, administrateur: admin) }
|
let(:procedure) { create(:procedure, :with_type_de_champ, :with_type_de_champ_private, administrateur: admin) }
|
||||||
let(:wrong_procedure) { create(:procedure) }
|
let(:wrong_procedure) { create(:procedure) }
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
describe API::V1::ProceduresController, type: :controller do
|
describe API::V1::ProceduresController, type: :controller do
|
||||||
let!(:admin) { create(:administrateur, :with_api_token) }
|
let!(:admin) { create(:administrateur, :with_api_token) }
|
||||||
let!(:token) { admin.renew_api_token }
|
let!(:token) { APIToken.generate(admin)[1] }
|
||||||
|
|
||||||
it { expect(described_class).to be < APIController }
|
it { expect(described_class).to be < APIController }
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
describe API::V2::GraphqlController do
|
describe API::V2::GraphqlController do
|
||||||
let(:admin) { create(:administrateur) }
|
let(:admin) { create(:administrateur) }
|
||||||
let(:token) { admin.renew_api_token }
|
let(:token) { APIToken.generate(admin)[1] }
|
||||||
let(:legacy_token) { APIToken.new(token).token }
|
let(:legacy_token) { APIToken.send(:unpack, token)[:plain_token] }
|
||||||
let(:procedure) { create(:procedure, :published, :for_individual, :with_service, administrateurs: [admin]) }
|
let(:procedure) { create(:procedure, :published, :for_individual, :with_service, administrateurs: [admin]) }
|
||||||
let(:dossier) { create(:dossier, :en_construction, :with_individual, procedure: procedure) }
|
let(:dossier) { create(:dossier, :en_construction, :with_individual, procedure: procedure) }
|
||||||
let(:dossier1) { create(:dossier, :en_construction, :with_individual, procedure: procedure, en_construction_at: 1.day.ago) }
|
let(:dossier1) { create(:dossier, :en_construction, :with_individual, procedure: procedure, en_construction_at: 1.day.ago) }
|
||||||
|
@ -108,6 +108,7 @@ describe API::V2::GraphqlController do
|
||||||
|
|
||||||
before do
|
before do
|
||||||
request.env['HTTP_AUTHORIZATION'] = authorization_header
|
request.env['HTTP_AUTHORIZATION'] = authorization_header
|
||||||
|
admin.api_tokens.first.update(version: 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "returns the demarche" do
|
it "returns the demarche" do
|
||||||
|
@ -141,7 +142,7 @@ describe API::V2::GraphqlController do
|
||||||
|
|
||||||
context "when the token is revoked" do
|
context "when the token is revoked" do
|
||||||
before do
|
before do
|
||||||
admin.update(encrypted_token: nil)
|
admin.api_tokens.destroy_all
|
||||||
end
|
end
|
||||||
|
|
||||||
it {
|
it {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
describe API::V2::GraphqlController do
|
describe API::V2::GraphqlController do
|
||||||
let(:admin) { create(:administrateur) }
|
let(:admin) { create(:administrateur) }
|
||||||
let(:token) { admin.renew_api_token }
|
let(:token) { APIToken.generate(admin)[1] }
|
||||||
let(:legacy_token) { APIToken.new(token).token }
|
let(:legacy_token) { APIToken.send(:unpack, token)[:plain_token] }
|
||||||
let(:procedure) { create(:procedure, :published, :for_individual, :with_service, administrateurs: [admin]) }
|
let(:procedure) { create(:procedure, :published, :for_individual, :with_service, administrateurs: [admin]) }
|
||||||
let(:dossier) { create(:dossier, :en_construction, :with_individual, procedure: procedure) }
|
let(:dossier) { create(:dossier, :en_construction, :with_individual, procedure: procedure) }
|
||||||
let(:dossier1) { create(:dossier, :en_construction, :with_individual, procedure: procedure, en_construction_at: 1.day.ago) }
|
let(:dossier1) { create(:dossier, :en_construction, :with_individual, procedure: procedure, en_construction_at: 1.day.ago) }
|
||||||
|
|
|
@ -12,7 +12,7 @@ describe APIController, type: :controller do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when the admin has a token' do
|
context 'when the admin has a token' do
|
||||||
let!(:token) { admin.renew_api_token }
|
let!(:token) { APIToken.generate(admin)[1] }
|
||||||
|
|
||||||
context 'and the token is given by params' do
|
context 'and the token is given by params' do
|
||||||
before { controller.params[:token] = token }
|
before { controller.params[:token] = token }
|
||||||
|
|
|
@ -31,22 +31,6 @@ describe Users::ProfilController, type: :controller do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'POST #renew_api_token' do
|
|
||||||
let(:administrateur) { create(:administrateur) }
|
|
||||||
|
|
||||||
before { sign_in(administrateur.user) }
|
|
||||||
|
|
||||||
before do
|
|
||||||
allow(administrateur).to receive(:renew_api_token)
|
|
||||||
allow(controller).to receive(:current_administrateur) { administrateur }
|
|
||||||
post :renew_api_token
|
|
||||||
end
|
|
||||||
|
|
||||||
it { expect(administrateur).to have_received(:renew_api_token) }
|
|
||||||
it { expect(response.status).to render_template(:show) }
|
|
||||||
it { expect(flash.notice).to eq('Votre jeton a été regénéré.') }
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'PATCH #update_email' do
|
describe 'PATCH #update_email' do
|
||||||
context 'when email is same as user' do
|
context 'when email is same as user' do
|
||||||
it 'fails' do
|
it 'fails' do
|
||||||
|
|
|
@ -18,7 +18,7 @@ FactoryBot.define do
|
||||||
|
|
||||||
trait :with_api_token do
|
trait :with_api_token do
|
||||||
after(:create) do |admin|
|
after(:create) do |admin|
|
||||||
admin.renew_api_token
|
APIToken.generate(admin)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -5,21 +5,6 @@ describe Administrateur, type: :model do
|
||||||
it { is_expected.to have_and_belong_to_many(:instructeurs) }
|
it { is_expected.to have_and_belong_to_many(:instructeurs) }
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "#renew_api_token" do
|
|
||||||
let(:administrateur) { create(:administrateur) }
|
|
||||||
let!(:token) { administrateur.renew_api_token }
|
|
||||||
let(:encrypted_token) { BCrypt::Password.new(administrateur.encrypted_token) }
|
|
||||||
let(:base_token) { APIToken.new(token).token }
|
|
||||||
|
|
||||||
it { expect(encrypted_token).to eq(base_token) }
|
|
||||||
|
|
||||||
context 'when it s called twice' do
|
|
||||||
let!(:new_token) { administrateur.renew_api_token }
|
|
||||||
|
|
||||||
it { expect(new_token).not_to eq(token) }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "#can_be_deleted?" do
|
describe "#can_be_deleted?" do
|
||||||
subject { administrateur.can_be_deleted? }
|
subject { administrateur.can_be_deleted? }
|
||||||
|
|
||||||
|
|
178
spec/models/api_token_spec.rb
Normal file
178
spec/models/api_token_spec.rb
Normal file
|
@ -0,0 +1,178 @@
|
||||||
|
describe APIToken, type: :model do
|
||||||
|
let(:administrateur) { create(:administrateur) }
|
||||||
|
let(:api_token_and_packed_token) { APIToken.generate(administrateur) }
|
||||||
|
let(:api_token) { api_token_and_packed_token.first }
|
||||||
|
let(:packed_token) { api_token_and_packed_token.second }
|
||||||
|
let(:plain_token) { APIToken.send(:unpack, packed_token)[:plain_token] }
|
||||||
|
let(:packed_token_v2) { APIToken.send(:message_verifier).generate([administrateur.id, plain_token]) }
|
||||||
|
|
||||||
|
describe '#generate' do
|
||||||
|
it do
|
||||||
|
expect(api_token.administrateur).to eq(administrateur)
|
||||||
|
expect(api_token.prefix).to eq(packed_token.slice(0, 5))
|
||||||
|
expect(api_token.version).to eq(3)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#find_and_verify' do
|
||||||
|
let(:result) { APIToken.find_and_verify(token, administrateurs) }
|
||||||
|
let(:token) { packed_token }
|
||||||
|
let(:administrateurs) { [administrateur] }
|
||||||
|
|
||||||
|
context 'without administrateur' do
|
||||||
|
let(:administrateurs) { [] }
|
||||||
|
|
||||||
|
context 'with packed token' do
|
||||||
|
it { expect(result).to be_truthy }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with packed token v2' do
|
||||||
|
before { api_token.update(version: 2) }
|
||||||
|
let(:token) { packed_token_v2 }
|
||||||
|
it { expect(result).to be_truthy }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with plain token' do
|
||||||
|
before { api_token.update(version: 1) }
|
||||||
|
let(:token) { plain_token }
|
||||||
|
it { expect(result).to be_falsey }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with destroyed token' do
|
||||||
|
before { api_token.destroy }
|
||||||
|
|
||||||
|
context 'with packed token' do
|
||||||
|
it { expect(result).to be_falsey }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with packed token v2' do
|
||||||
|
let(:token) { packed_token_v2 }
|
||||||
|
it { expect(result).to be_falsey }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with plain token' do
|
||||||
|
let(:token) { plain_token }
|
||||||
|
it { expect(result).to be_falsey }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with destroyed administrateur' do
|
||||||
|
before { api_token.administrateur.destroy }
|
||||||
|
let(:administrateurs) { [] }
|
||||||
|
|
||||||
|
context 'with packed token' do
|
||||||
|
it { expect(result).to be_falsey }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with packed token v2' do
|
||||||
|
let(:token) { packed_token_v2 }
|
||||||
|
it { expect(result).to be_falsey }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with plain token' do
|
||||||
|
let(:token) { plain_token }
|
||||||
|
it { expect(result).to be_falsey }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with other administrateur' do
|
||||||
|
let(:other_administrateur) { create(:administrateur, :with_api_token) }
|
||||||
|
let(:administrateurs) { [other_administrateur] }
|
||||||
|
|
||||||
|
context 'with packed token' do
|
||||||
|
it { expect(result).to be_truthy }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with packed token v2' do
|
||||||
|
before { api_token.update(version: 2) }
|
||||||
|
|
||||||
|
let(:token) { packed_token_v2 }
|
||||||
|
it { expect(result).to be_truthy }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with plain token' do
|
||||||
|
before { api_token.update(version: 1) }
|
||||||
|
|
||||||
|
let(:token) { plain_token }
|
||||||
|
it { expect(result).to be_falsey }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with many administrateurs' do
|
||||||
|
let(:other_administrateur) { create(:administrateur, :with_api_token) }
|
||||||
|
let(:other_api_token_and_packed_token) { APIToken.generate(other_administrateur) }
|
||||||
|
let(:other_api_token) { other_api_token_and_packed_token.first }
|
||||||
|
let(:other_packed_token) { other_api_token_and_packed_token.second }
|
||||||
|
let(:other_plain_token) { APIToken.send(:unpack, other_packed_token)[:plain_token] }
|
||||||
|
let(:administrateurs) { [administrateur, other_administrateur] }
|
||||||
|
|
||||||
|
context 'with plain token' do
|
||||||
|
before do
|
||||||
|
api_token.update(version: 1)
|
||||||
|
other_api_token.update(version: 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:token) { plain_token }
|
||||||
|
it { expect(result).to be_truthy }
|
||||||
|
|
||||||
|
context 'with other plain token' do
|
||||||
|
let(:token) { other_plain_token }
|
||||||
|
it { expect(result).to be_truthy }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with plain token (before migration)' do
|
||||||
|
before do
|
||||||
|
administrateur.update(encrypted_token: api_token.encrypted_token)
|
||||||
|
other_administrateur.update(encrypted_token: other_api_token.encrypted_token)
|
||||||
|
api_token.destroy
|
||||||
|
other_api_token.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:token) { plain_token }
|
||||||
|
it { expect(result).to be_truthy }
|
||||||
|
|
||||||
|
context 'with other plain token' do
|
||||||
|
let(:token) { other_plain_token }
|
||||||
|
it { expect(result).to be_truthy }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with packed token' do
|
||||||
|
it { expect(result).to be_truthy }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with packed token v2' do
|
||||||
|
before { api_token.update(version: 2) }
|
||||||
|
|
||||||
|
let(:token) { packed_token_v2 }
|
||||||
|
it { expect(result).to be_truthy }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with plain token' do
|
||||||
|
before { api_token.update(version: 1) }
|
||||||
|
|
||||||
|
let(:token) { plain_token }
|
||||||
|
it { expect(result).to be_truthy }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with plain token (before migration)' do
|
||||||
|
before do
|
||||||
|
administrateur.update(encrypted_token: api_token.encrypted_token)
|
||||||
|
api_token.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:token) { plain_token }
|
||||||
|
it { expect(result).to be_truthy }
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with valid garbage base64" do
|
||||||
|
before { api_token.update(version: 1, encrypted_token: BCrypt::Password.create(token)) }
|
||||||
|
|
||||||
|
let(:token) { "R5dAqE7nMxfMp93PcuuevDtn" }
|
||||||
|
it { expect(result).to be_truthy }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in a new issue