Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 195 additions & 0 deletions commons/swagger/openapi-entreprise.yaml

Large diffs are not rendered by default.

224 changes: 202 additions & 22 deletions siade/app/interactors/insee/authenticate.rb
Original file line number Diff line number Diff line change
@@ -1,49 +1,229 @@
class INSEE::Authenticate < AbstractGetToken
class INSEE::Authenticate < MakeRequest::Post
raises ProviderAuthenticationError

CACHE_KEY = :'insee/authenticate'
GUARD_CACHE_NAMESPACE = 'insee'.freeze
LOCK_CACHE_KEY = 'auth_lock'.freeze
FAILURE_CACHE_KEY = 'auth_failed'.freeze
LOCK_TTL = 90.seconds
LOCK_WAIT = 0.5
FAILURE_TTL = 30.minutes
TOKEN_EXPIRATION_MARGIN = 10
INVALID_GRANT_HTTP_CODES = [400, 401].freeze
TRANSIENT_HTTP_CODES = [408, 429].freeze

def self.invalidate_token_cache!(rejected_token)
return unless published_token == rejected_token

def self.invalidate_token_cache!
EncryptedCache.write(CACHE_KEY, nil)
end

protected
def self.published_token
outside_the_request_cache { EncryptedCache.read(CACHE_KEY) }
end

def client_url
Siade.credentials[:insee_oauth_url]
def self.outside_the_request_cache(&)
Rails.cache.with_local_cache(&)
end

def access_token(response)
JSON.parse(response.body)['access_token']
def self.clear_guards!
Rails.cache.delete(LOCK_CACHE_KEY)
Rails.cache.delete(FAILURE_CACHE_KEY, namespace: GUARD_CACHE_NAMESPACE)
end

def expires_in(response)
JSON.parse(response.body)['expires_in']
def call
return if use_mocked_data?

context.token = published_token || authenticate!
end

private
protected

def request_uri
URI(Siade.credentials[:insee_oauth_url])
end

def form_data
{
client_id:,
client_secret:,
client_id: Siade.credentials[:insee_sirene_client_id],
client_secret: Siade.credentials[:insee_sirene_client_secret],
grant_type: 'password',
username:,
password:
username: Siade.credentials[:insee_apim_username],
password: @password
}
end

def client_id
Siade.credentials[:insee_sirene_client_id]
private

def authenticate!
fail_with_temporary_error! if recently_failed?

case acquire_lock!
when true then token_under_lock
when false then token_from_concurrent_authentication
else token_from_candidates
end
end

def token_under_lock
published_token || token_from_candidates_unless_held_back
ensure
release_lock!
end

def token_from_candidates_unless_held_back
fail_with_temporary_error! if recently_failed?

token_from_candidates
end

def token_from_concurrent_authentication
sleep(LOCK_WAIT)

published_token || fail_with_temporary_error!
end

def token_from_candidates
candidates = INSEE::PasswordDerivation.candidates

token_from(candidates) ||
token_from_password_renewed_meanwhile(candidates) ||
fail_with_authentication_error!
end

def token_from(candidates)
candidates.each do |candidate|
@password = candidate

response = api_call_with_error_handling
payload = parsed_body(response)

return store_token(payload) if token_granted?(response, payload)

fail_with_temporary_error! if transient?(response)
next if invalid_grant?(response, payload)

fail_with_oauth_rejection! if client_error?(response)
fail_with_temporary_error!
end

nil
end

def token_from_password_renewed_meanwhile(candidates)
return if candidates.last == INSEE::PasswordDerivation.current_password

token_from([INSEE::PasswordDerivation.current_password])
end

def token_granted?(response, payload)
response.code.to_i == 200 && payload['access_token'].present?
end

def client_secret
Siade.credentials[:insee_sirene_client_secret]
def invalid_grant?(response, payload)
INVALID_GRANT_HTTP_CODES.include?(response.code.to_i) &&
payload['error'] == 'invalid_grant'
end

def username
Siade.credentials[:insee_apim_username]
def transient?(response)
TRANSIENT_HTTP_CODES.include?(response.code.to_i)
end

def client_error?(response)
response.code.to_i.between?(400, 499)
end

def parsed_body(response)
JSON.parse(response.body.to_s)
rescue JSON::ParserError
{}
end

def store_token(payload)
token = payload['access_token']

EncryptedCache.write(
CACHE_KEY,
token,
expires_in: [payload['expires_in'].to_i - TOKEN_EXPIRATION_MARGIN, 1].max
)

token
end

def published_token
self.class.published_token
end

def outside_the_request_cache(&)
self.class.outside_the_request_cache(&)
end

def recently_failed?
outside_the_request_cache { guard_read(FAILURE_CACHE_KEY) }.present?
end

def acquire_lock!
@lock_owner = SecureRandom.uuid

Rails.cache.write(LOCK_CACHE_KEY, @lock_owner, expires_in: LOCK_TTL, unless_exist: true)
end

def release_lock!
return unless outside_the_request_cache { Rails.cache.read(LOCK_CACHE_KEY) } == @lock_owner

Rails.cache.delete(LOCK_CACHE_KEY)
end

def guard_read(key)
Rails.cache.read(key, namespace: GUARD_CACHE_NAMESPACE)
end

def guard_write(key, value, expires_in:, unless_exist: false)
Rails.cache.write(key, value, namespace: GUARD_CACHE_NAMESPACE, expires_in:, unless_exist:)
end

def fail_with_temporary_error!
error = ProviderTemporaryError.new(
context.provider_name,
"Erreur d'authentification temporaire auprès de l'INSEE, merci de réessayer votre appel"
)
error.add_meta(retry_in: 10)

context.errors << error
context.fail!
end

def fail_with_oauth_rejection!
record_authentication_failure!(
'INSEE refused the OAuth exchange itself: client credentials revoked or account locked'
)
end

def fail_with_authentication_error!
record_authentication_failure!(
'INSEE authentication failed on every candidate: password desynchronized or account locked'
)
end

def record_authentication_failure!(message)
guard_write(FAILURE_CACHE_KEY, true, expires_in: FAILURE_TTL)

track_authentication_failure!(message)

context.errors << ProviderAuthenticationError.new(context.provider_name)
context.fail!
end

def password
INSEE::PasswordDerivation.current_password
def track_authentication_failure!(message)
MonitoringService.instance.track_with_added_context(
'error',
message,
{
period: INSEE::PasswordDerivation.current_period,
bypassed: INSEE::PasswordDerivation.bypassed?,
candidates_count: INSEE::PasswordDerivation.candidates.size
}
)
end
end
72 changes: 9 additions & 63 deletions siade/app/interactors/insee/make_request.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
class INSEE::MakeRequest < MakeRequest::Get
ROTATION_LOCK_KEY = 'insee/password_rotation_lock'.freeze
ROTATION_LOCK_TTL = 30

def call
super

rotate_password_if_needed!
retry_with_new_token! if should_retry_with_new_token?
end

Expand Down Expand Up @@ -48,78 +44,28 @@ def token_expired_response?
def retry_with_new_token!
context.token_refresh_attempted = true

fresh_token = EncryptedCache.read(INSEE::Authenticate::CACHE_KEY)
fresh_token = INSEE::Authenticate.published_token
if fresh_token && fresh_token != context.token
context.token = fresh_token
else
authenticate_with_retries!
reauthenticate!
end

api_call_with_error_handling
fail_with_temporary_auth_error! if token_expired_response?
end

def authenticate_with_retries!
INSEE::Authenticate.invalidate_token_cache!

max_auth_attempts = 5

max_auth_attempts.times do |attempt|
auth_context = INSEE::Authenticate.call(provider_name: context.provider_name)
def reauthenticate!
INSEE::Authenticate.invalidate_token_cache!(context.token)

if auth_context.success?
context.token = auth_context.token
break
end
auth_context = INSEE::Authenticate.call(provider_name: context.provider_name)

fail_with_temporary_auth_error! if attempt == max_auth_attempts - 1
sleep(0.2)
unless auth_context.success?
context.errors.concat(auth_context.errors)
context.fail!
end
end

def rotate_password_if_needed!
return unless password_rotation_needed?

return unless acquire_rotation_lock!

renew_context = INSEE::RenewPassword.call(
token: context.token,
old_password: INSEE::PasswordDerivation.previous_password,
new_password: INSEE::PasswordDerivation.current_password,
provider_name: context.provider_name
)

INSEE::Authenticate.invalidate_token_cache! if renew_context.success?
end

def password_rotation_needed?
return false if INSEE::PasswordDerivation.bypassed?
return false if context.token.blank?
return false if INSEE::PasswordDerivation.current_period < INSEE::PasswordDerivation::DERIVATION_START

pwd_changed_period = pwd_changed_period_from_token
return false if pwd_changed_period.nil?

pwd_changed_period < INSEE::PasswordDerivation.current_period
end

def pwd_changed_period_from_token
payload = JWT.decode(context.token, nil, false).first
pwd_changed_time = payload['pwdChangedTime']
return if pwd_changed_time.nil?

date = Time.zone.parse(pwd_changed_time).to_date
INSEE::PasswordDerivation.send(:period_for, date)
rescue JWT::DecodeError
nil
end

def acquire_rotation_lock!
rotation_redis.set(ROTATION_LOCK_KEY, Process.pid, nx: true, ex: ROTATION_LOCK_TTL)
end

def rotation_redis
@rotation_redis ||= RedisService.new
context.token = auth_context.token
end

def fail_with_temporary_auth_error!
Expand Down
Loading
Loading