feat(api_token): add APIToken model
This commit is contained in:
parent
47f716f9fa
commit
ebbada752f
5 changed files with 256 additions and 50 deletions
|
@ -3,15 +3,12 @@
|
|||
# Table name: administrateurs
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# active :boolean default(FALSE)
|
||||
# encrypted_token :string
|
||||
# created_at :datetime
|
||||
# updated_at :datetime
|
||||
# user_id :bigint not null
|
||||
#
|
||||
class Administrateur < ApplicationRecord
|
||||
include ActiveRecord::SecureToken
|
||||
|
||||
self.ignored_columns = [:active]
|
||||
|
||||
UNUSED_ADMIN_THRESHOLD = 6.months
|
||||
|
@ -20,6 +17,7 @@ class Administrateur < ApplicationRecord
|
|||
has_many :administrateurs_procedures
|
||||
has_many :procedures, through: :administrateurs_procedures
|
||||
has_many :services
|
||||
has_many :api_tokens, inverse_of: :administrateur, dependent: :destroy
|
||||
|
||||
belongs_to :user
|
||||
|
||||
|
@ -56,19 +54,6 @@ class Administrateur < ApplicationRecord
|
|||
self.inactive.find(id)
|
||||
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
|
||||
if user.active?
|
||||
'Actif'
|
||||
|
|
|
@ -1,27 +1,85 @@
|
|||
class APIToken
|
||||
attr_reader :administrateur_id, :token
|
||||
# == Schema Information
|
||||
#
|
||||
# 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)
|
||||
@token = token
|
||||
verify!
|
||||
belongs_to :administrateur, inverse_of: :api_tokens
|
||||
|
||||
# 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
|
||||
|
||||
def administrateur?
|
||||
administrateur_id.present?
|
||||
class << self
|
||||
def generate(administrateur)
|
||||
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
|
||||
Rails.application.message_verifier('api_v2_token')
|
||||
def find_and_verify(maybe_packed_token, administrateurs = [])
|
||||
case unpack(maybe_packed_token)
|
||||
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
|
||||
|
||||
def self.signe(administrateur_id, token)
|
||||
message_verifier.generate([administrateur_id, token])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def verify!
|
||||
@administrateur_id, @token = self.class.message_verifier.verified(@token) || [nil, @token]
|
||||
# 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
|
||||
|
||||
UUID_SIZE = SecureRandom.uuid.size
|
||||
def unpack(maybe_packed_token)
|
||||
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
|
||||
|
|
|
@ -18,7 +18,7 @@ FactoryBot.define do
|
|||
|
||||
trait :with_api_token do
|
||||
after(:create) do |admin|
|
||||
admin.renew_api_token
|
||||
APIToken.generate(admin)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -5,21 +5,6 @@ describe Administrateur, type: :model do
|
|||
it { is_expected.to have_and_belong_to_many(:instructeurs) }
|
||||
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
|
||||
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