Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ specs:
- cp -a keys.example keys
- cp -a certs.example certs
- cp pwned_passwords/pwned_passwords.txt.sample pwned_passwords/pwned_passwords.txt
- "echo -e \"test:\n redis_url: 'redis://db-redis:6379/0'\n redis_throttle_url: 'redis://db-redis:6379/1'\" > config/application.yml"
- "echo -e \"test:\n redis_url: 'redis://db-redis:6379/0'\n redis_throttle_url: 'redis://db-redis:6379/1'\n redis_attempts_api_url: 'redis://db-redis:6379/2'\" > config/application.yml"
- bundle exec rake db:create db:migrate --trace
- bundle exec rake db:seed
- bundle exec rake knapsack:rspec["--format documentation --format RspecJunitFormatter --out rspec.xml --format json --out rspec_json/${CI_NODE_INDEX}.json"]
Expand Down
86 changes: 86 additions & 0 deletions app/services/attempts_api/attempt_event.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# frozen_string_literal: true

module AttemptsApi
class AttemptEvent
attr_reader :jti, :iat, :event_type, :session_id, :occurred_at, :event_metadata

def initialize(
event_type:,
session_id:,
occurred_at:,
event_metadata:,
jti: SecureRandom.uuid,
iat: Time.zone.now.to_i
)
@jti = jti
@iat = iat
@event_type = event_type
@session_id = session_id
@occurred_at = occurred_at
@event_metadata = event_metadata
end

def to_jwe(public_key:, issuer:)
jwk = JWT::JWK.new(public_key)

JWE.encrypt(
payload_json(issuer: issuer),
public_key,
typ: 'secevent+jwe',
zip: 'DEF',
alg: 'RSA-OAEP',
enc: 'A256GCM',
kid: jwk.kid,
)
end

def self.from_jwe(jwe, private_key)
decrypted_event = JWE.decrypt(jwe, private_key)
parsed_event = JSON.parse(decrypted_event)
event_type = parsed_event['events'].keys.first.split('/').last
Comment thread
Sgtpluck marked this conversation as resolved.
event_data = parsed_event['events'].values.first
jti = parsed_event['jti'].split(':').last
AttemptEvent.new(
jti: jti,
iat: parsed_event['iat'],
event_type: event_type,
session_id: event_data['subject']['session_id'],
occurred_at: Time.zone.at(event_data['occurred_at']),
event_metadata: event_data.symbolize_keys.except(:subject, :occurred_at),
)
end

def payload(issuer:)
{
jti: jti,
iat: iat,
iss: Rails.application.routes.url_helpers.root_url,
aud: issuer,
events: {
long_event_type => event_data,
},
}
end

def payload_json(issuer:)
@payload_json ||= payload(issuer:).to_json
end

private

def event_data
{
'subject' => {
'subject_type' => 'session',
'session_id' => session_id,
},
'occurred_at' => occurred_at.to_f,
}.merge(event_metadata || {})
end

def long_event_type
dasherized_name = event_type.to_s.dasherize
"https://schemas.login.gov/secevent/attempts-api/event-type/#{dasherized_name}"
end
end
end
29 changes: 29 additions & 0 deletions app/services/attempts_api/redis_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true

module AttemptsApi
class RedisClient
def write_event(event_key:, jwe:, timestamp:, issuer:)
key = key(timestamp, issuer)
REDIS_ATTEMPTS_API_POOL.with do |client|
client.hset(key, event_key, jwe)
client.expire(key, IdentityConfig.store.attempts_api_event_ttl_seconds)
end
end

def read_events(timestamp:, issuer:, batch_size: 5000)
key = key(timestamp, issuer)
events = {}
REDIS_ATTEMPTS_API_POOL.with do |client|
client.hscan_each(key, count: batch_size) do |k, v|
events[k] = v
end
end
events
end

def key(timestamp, issuer)
formatted_time = timestamp.in_time_zone('UTC').change(min: 0, sec: 0).iso8601
"attempts-api-events:#{issuer}:#{formatted_time}"
end
end
end
3 changes: 3 additions & 0 deletions config/application.yml.default
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ allowed_verified_within_providers: '[]'
asset_host: ''
async_stale_job_timeout_seconds: 300
async_wait_timeout_seconds: 60
attempts_api_event_ttl_seconds: 3_600
attribute_encryption_key:
attribute_encryption_key_queue: '[]'
available_locales: 'en,es,fr,zh'
Expand Down Expand Up @@ -321,6 +322,8 @@ recaptcha_site_key: ''
recommend_webauthn_platform_for_sms_ab_test_account_creation_percent: 0
recommend_webauthn_platform_for_sms_ab_test_authentication_percent: 0
recovery_code_length: 4
redis_attempts_api_pool_size: 1
redis_attempts_api_url: redis://localhost:6379/2
redis_pool_size: 10
redis_throttle_pool_size: 5
redis_throttle_url: redis://localhost:6379/1
Expand Down
5 changes: 5 additions & 0 deletions config/initializers/01_redis.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@
REDIS_THROTTLE_POOL = ConnectionPool.new(size: IdentityConfig.store.redis_throttle_pool_size) do
Redis.new(url: IdentityConfig.store.redis_throttle_url)
end.freeze

REDIS_ATTEMPTS_API_POOL =
ConnectionPool.new(size: IdentityConfig.store.redis_attempts_api_pool_size) do
Redis.new(url: IdentityConfig.store.redis_attempts_api_url)
end.freeze
3 changes: 3 additions & 0 deletions lib/identity_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def self.store
config.add(:asset_host, type: :string)
config.add(:async_stale_job_timeout_seconds, type: :integer)
config.add(:async_wait_timeout_seconds, type: :integer)
config.add(:attempts_api_event_ttl_seconds, type: :integer)
config.add(:attribute_encryption_key, type: :string)
config.add(:attribute_encryption_key_queue, type: :json)
config.add(:available_locales, type: :comma_separated_string_list)
Expand Down Expand Up @@ -355,6 +356,8 @@ def self.store
config.add(:recaptcha_secret_key, type: :string)
config.add(:recaptcha_site_key, type: :string)
config.add(:recovery_code_length, type: :integer)
config.add(:redis_attempts_api_pool_size, type: :integer)
config.add(:redis_attempts_api_url, type: :string)
config.add(:redis_pool_size, type: :integer)
config.add(:redis_throttle_pool_size, type: :integer)
config.add(:redis_throttle_url, type: :string)
Expand Down
1 change: 1 addition & 0 deletions spec/rails_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ class Analytics
Telephony::Test::Call.clear_calls
PushNotification::LocalEventQueue.clear!
REDIS_THROTTLE_POOL.with { |client| client.flushdb } if Identity::Hostdata.config
REDIS_ATTEMPTS_API_POOL.with { |client| client.flushdb } if Identity::Hostdata.config
end

config.before(:each) do
Expand Down
70 changes: 70 additions & 0 deletions spec/services/attempts_api/attempt_event_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
require 'rails_helper'

RSpec.describe AttemptsApi::AttemptEvent do
let(:attempts_api_private_key) { OpenSSL::PKey::RSA.new(2048) }
let(:attempts_api_public_key) { attempts_api_private_key.public_key }

let(:jti) { 'test-unique-id' }
let(:iat) { Time.zone.now.to_i }
let(:event_type) { 'test-event' }
let(:session_id) { 'test-session-id' }
let(:occurred_at) { Time.zone.now.round }
let(:event_metadata) { { 'foo' => 'bar' } }
let(:service_provider) { build(:service_provider) }

subject do
described_class.new(
jti: jti,
iat: iat,
event_type: event_type,
session_id: session_id,
occurred_at: occurred_at,
event_metadata: event_metadata,
)
end

describe '#to_jwe' do
it 'returns a JWE for the event' do
jwe = subject.to_jwe(issuer: service_provider.issuer, public_key: attempts_api_public_key)

header_str, *_rest = JWE::Serialization::Compact.decode(jwe)
headers = JSON.parse(header_str)

expect(headers['alg']).to eq('RSA-OAEP')
expect(headers['kid']).to eq(JWT::JWK.new(attempts_api_public_key).kid)

decrypted_jwe_payload = JWE.decrypt(jwe, attempts_api_private_key)

token = JSON.parse(decrypted_jwe_payload)

expect(token['iss']).to eq(Rails.application.routes.url_helpers.root_url)
expect(token['jti']).to eq(jti)
expect(token['iat']).to eq(iat)
expect(token['aud']).to eq(service_provider.issuer)

event_key = 'https://schemas.login.gov/secevent/attempts-api/event-type/test-event'
event_data = token['events'][event_key]

expect(event_data['subject']).to eq(
'subject_type' => 'session', 'session_id' => 'test-session-id',
)
expect(event_data['foo']).to eq('bar')
expect(event_data['occurred_at']).to eq(occurred_at.to_f)
end
end

describe '.from_jwe' do
it 'returns an event decrypted from the JWE' do
jwe = subject.to_jwe(issuer: service_provider.issuer, public_key: attempts_api_public_key)

decrypted_event = described_class.from_jwe(jwe, attempts_api_private_key)

expect(decrypted_event.jti).to eq(subject.jti)
expect(decrypted_event.iat).to eq(subject.iat)
expect(decrypted_event.event_type).to eq(subject.event_type)
expect(decrypted_event.session_id).to eq(subject.session_id)
expect(decrypted_event.occurred_at).to eq(subject.occurred_at)
expect(decrypted_event.event_metadata).to eq(subject.event_metadata.symbolize_keys)
end
end
end
92 changes: 92 additions & 0 deletions spec/services/attempts_api/redis_client_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
require 'rails_helper'

RSpec.describe AttemptsApi::RedisClient do
let(:attempts_api_private_key) { OpenSSL::PKey::RSA.new(2048) }
let(:attempts_api_public_key) { attempts_api_private_key.public_key }
let(:issuer) { 'test' }

describe '#write_event' do
it 'writes the attempt data to redis with the event key as the key' do
freeze_time do
now = Time.zone.now
event = AttemptsApi::AttemptEvent.new(
event_type: 'test_event',
session_id: 'test-session-id',
occurred_at: Time.zone.now,
event_metadata: {
first_name: Idp::Constants::MOCK_IDV_APPLICANT[:first_name],
},
)
event_key = event.jti
jwe = event.to_jwe(issuer: issuer, public_key: attempts_api_public_key)

subject.write_event(event_key: event_key, jwe: jwe, timestamp: now, issuer: issuer)

result = subject.read_events(timestamp: now, issuer: issuer)
expect(result[event_key]).to eq(jwe)
end
end
end

describe '#read_events' do
it 'reads the event events from redis' do
freeze_time do
now = Time.zone.now
events = {}
3.times do
event = AttemptsApi::AttemptEvent.new(
event_type: 'test_event',
session_id: 'test-session-id',
occurred_at: now,
event_metadata: {
first_name: Idp::Constants::MOCK_IDV_APPLICANT[:first_name],
},
)
event_key = event.jti
jwe = event.to_jwe(issuer: issuer, public_key: attempts_api_public_key)
events[event_key] = jwe
end
events.each do |event_key, jwe|
subject.write_event(event_key: event_key, jwe: jwe, timestamp: now, issuer: issuer)
end

result = subject.read_events(timestamp: now, issuer: issuer)

expect(result).to eq(events)
end
end

it 'stores events in hourly buckets' do
time1 = Time.new(2022, 1, 1, 1, 0, 0, 'Z')
time2 = Time.new(2022, 1, 1, 2, 0, 0, 'Z')
event1 = AttemptsApi::AttemptEvent.new(
event_type: 'test_event',
session_id: 'test-session-id',
occurred_at: time1,
event_metadata: {
first_name: Idp::Constants::MOCK_IDV_APPLICANT[:first_name],
},
)
event2 = AttemptsApi::AttemptEvent.new(
event_type: 'test_event',
session_id: 'test-session-id',
occurred_at: time2,
event_metadata: {
first_name: Idp::Constants::MOCK_IDV_APPLICANT[:first_name],
},
)
jwe1 = event1.to_jwe(issuer: issuer, public_key: attempts_api_public_key)
jwe2 = event2.to_jwe(issuer: issuer, public_key: attempts_api_public_key)

subject.write_event(
event_key: event1.jti, jwe: jwe1, timestamp: event1.occurred_at, issuer: issuer,
)
subject.write_event(
event_key: event2.jti, jwe: jwe2, timestamp: event2.occurred_at, issuer: issuer,
)

expect(subject.read_events(timestamp: time1, issuer: issuer)).to eq({ event1.jti => jwe1 })
expect(subject.read_events(timestamp: time2, issuer: issuer)).to eq({ event2.jti => jwe2 })
end
end
end