From ebbada752fd2430570e19960a1689645eb7241b0 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 30 Nov 2022 10:06:33 +0100 Subject: [PATCH] feat(api_token): add APIToken model --- app/models/administrateur.rb | 17 +-- app/models/api_token.rb | 94 ++++++++++++--- spec/factories/administrateur.rb | 2 +- spec/models/administrateur_spec.rb | 15 --- spec/models/api_token_spec.rb | 178 +++++++++++++++++++++++++++++ 5 files changed, 256 insertions(+), 50 deletions(-) create mode 100644 spec/models/api_token_spec.rb diff --git a/app/models/administrateur.rb b/app/models/administrateur.rb index 27114acdb..63a4d369c 100644 --- a/app/models/administrateur.rb +++ b/app/models/administrateur.rb @@ -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' diff --git a/app/models/api_token.rb b/app/models/api_token.rb index 5b2d01d3f..8ee587620 100644 --- a/app/models/api_token.rb +++ b/app/models/api_token.rb @@ -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? - end + 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') - end + 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 + end - def self.signe(administrateur_id, token) - message_verifier.generate([administrateur_id, token]) - end + private - 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! - @administrateur_id, @token = self.class.message_verifier.verified(@token) || [nil, @token] - rescue + 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 ";" + { 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 diff --git a/spec/factories/administrateur.rb b/spec/factories/administrateur.rb index 479ee824c..f24db4c1b 100644 --- a/spec/factories/administrateur.rb +++ b/spec/factories/administrateur.rb @@ -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 diff --git a/spec/models/administrateur_spec.rb b/spec/models/administrateur_spec.rb index eab6cfe93..851d871a2 100644 --- a/spec/models/administrateur_spec.rb +++ b/spec/models/administrateur_spec.rb @@ -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? } diff --git a/spec/models/api_token_spec.rb b/spec/models/api_token_spec.rb new file mode 100644 index 000000000..8f96edc3f --- /dev/null +++ b/spec/models/api_token_spec.rb @@ -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