app/forms/security_event_form.rb
# frozen_string_literal: true
# Handles SET events (Security Event Tokens)
class SecurityEventForm
include ActionView::Helpers::TranslationHelper
include ActiveModel::Model
include Rails.application.routes.url_helpers
# From https://tools.ietf.org/html/draft-ietf-secevent-http-push-00#section-2.3
module ErrorCodes
DUP = 'dup'
JWS = 'jws'
JWT_AUD = 'jwtAud'
JWT_CRYPTO = 'jwtCrypto'
JWT_HDR = 'jwtHdr'
JWT_PARSE = 'jwtParse'
SET_DATA = 'setData'
SET_TYPE = 'setType'
end
validate :validate_iss
validate :validate_aud
validate :validate_event_type
validate :validate_subject_type
validate :validate_sub
validate :validate_typ
validate :validate_exp
validate :validate_jti
validate :validate_jwt
def initialize(body:)
@body = body
@jwt_payload, @jwt_headers = parse_jwt
end
def submit
success = valid?
if success
SecurityEvent.create!(
event_type: event_type,
issuer: service_provider.issuer,
jti: jti,
user: user,
occurred_at: occurred_at,
)
if event_type == SecurityEvent::AUTHORIZATION_FRAUD_DETECTED &&
IdentityConfig.store.reset_password_on_auth_fraud_event
ResetUserPassword.new(user: user).call
end
end
FormResponse.new(success: success, errors: errors, extra: extra_analytics_attributes)
end
def error_code
return if valid?
@error_code ||= ErrorCodes::SET_DATA
end
def description
return if valid?
errors.full_messages.join(', ')
end
def url_options
{}
end
private
attr_reader :body, :jwt_payload, :jwt_headers
# @return [Array(Hash, Hash)] parses JWT into [payload, headers]
def parse_jwt
JWT.decode(body, nil, false, algorithm: 'RS256', leeway: Float::INFINITY)
rescue JWT::DecodeError
@error_code = ErrorCodes::JWT_PARSE
[{}, {}]
end
def check_jwt_parse_error
return false if @error_code != ErrorCodes::JWT_PARSE
errors.add(
:jwt, t('risc.security_event.errors.jwt_could_not_parse'),
type: :jwt_could_not_parse
)
true
end
def check_public_key_error(public_key)
return false if public_key.present?
errors.add(:jwt, t('risc.security_event.errors.no_public_key'), type: :no_public_key)
@error_code = ErrorCodes::JWS
true
end
def validate_jwt
return if check_jwt_parse_error
error_code = nil
error_message = nil
error_reason = nil
matching_public_key = service_provider&.ssl_certs&.find do |ssl_cert|
error_code = nil
error_message = nil
error_reason = nil
JWT.decode(body, ssl_cert.public_key, true, algorithm: 'RS256', leeway: Float::INFINITY)
rescue JWT::IncorrectAlgorithm
error_code = ErrorCodes::JWT_CRYPTO
error_message = t('risc.security_event.errors.alg_unsupported', expected_alg: 'RS256')
error_reason = :incorrect_algorithm
nil
rescue JWT::VerificationError => err
error_code = ErrorCodes::JWS
error_message = err.message
error_reason = :verification_failed
nil
end
if error_code && error_message && error_reason
@error_code = error_code
errors.add(:jwt, error_message, type: error_reason)
else
check_public_key_error(matching_public_key)
end
end
def validate_jti
if jti.blank?
errors.add(:jti, t('risc.security_event.errors.jti_required'), type: :jti_required)
return
end
return if !user || !service_provider
return unless record_already_exists?
errors.add(:jti, t('risc.security_event.errors.jti_not_unique'), type: :jti_not_unique)
@error_code = ErrorCodes::DUP
end
# Memoize this because validations get reset every time valid? is called
def record_already_exists?
return @record_already_exists if defined?(@record_already_exists)
@record_already_exists = SecurityEvent.exists?(
issuer: service_provider.issuer,
jti: jti,
user_id: user.id,
)
end
def validate_iss
errors.add(:iss, 'invalid issuer', type: :invalid_issuer) if service_provider.blank?
end
def validate_aud
return if jwt_payload.blank?
return if jwt_payload['aud'] == api_risc_security_events_url
errors.add(
:aud,
t('risc.security_event.errors.aud_invalid', url: api_risc_security_events_url),
type: :aud_invalid,
)
@error_code = ErrorCodes::JWT_AUD
end
def validate_event_type
if event_type.blank?
errors.add(
:event_type, t('risc.security_event.errors.event_type_missing'),
type: :event_type_missing
)
elsif !SecurityEvent::EVENT_TYPES.include?(event_type)
errors.add(
:event_type,
t('risc.security_event.errors.event_type_unsupported', event_type: event_type),
type: :event_type_unsupported,
)
@error_code = ErrorCodes::SET_TYPE
end
end
def validate_subject_type
return if subject_type == 'iss-sub'
errors.add(
:subject_type,
t('risc.security_event.errors.subject_type_unsupported', expected_subject_type: 'iss-sub'),
type: :subject_type_unsupported,
)
end
def validate_sub
if jwt_payload['sub'].present?
errors.add(
:sub, t('risc.security_event.errors.sub_unsupported'),
type: :sub_unsupported
)
end
if user.blank?
errors.add(
:sub, t('risc.security_event.errors.sub_not_found'),
type: :sub_not_found
)
end
end
def validate_typ
return if jwt_headers.blank?
return if jwt_headers['typ'] == 'secevent+jwt'
errors.add(
:typ, t('risc.security_event.errors.typ_error', expected_typ: 'secevent+jwt'),
type: :typ_error
)
@error_code = ErrorCodes::JWT_HDR
end
def validate_exp
return if jwt_payload['exp'].blank?
errors.add(:exp, t('risc.security_event.errors.exp_present'), type: :exp_present)
end
def client_id
jwt_payload['iss']
end
def jti
jwt_payload['jti']
end
def service_provider
return @service_provider if defined?(@service_provider)
@service_provider = ServiceProvider.find_by(issuer: client_id)
end
def event
jwt_payload.dig('events', event_type) || {}
end
def event_type
return nil if jwt_payload['events'].blank?
matching_event_types = jwt_payload['events'].keys & SecurityEvent::EVENT_TYPES
if matching_event_types.present?
matching_event_types.first
else
jwt_payload['events'].keys.first
end
end
def subject_type
event.dig('subject', 'subject_type')
end
def identity
return if event.blank? || !service_provider
return @identity if defined?(@identity)
@identity = if service_provider.agency_id
identity_from_agency_identity
else
identity_from_identity
end
end
def identity_from_agency_identity
AgencyIdentity.find_by(
uuid: event.dig('subject', 'sub'),
agency_id: service_provider.agency_id,
)
end
def identity_from_identity
ServiceProviderIdentity.find_by(
uuid: event.dig('subject', 'sub'),
service_provider: service_provider.issuer,
)
end
def user
identity&.user
end
def occurred_at
occurred_at_int = event.dig('occurred_at')
Time.zone.at(occurred_at_int) if occurred_at_int
end
def extra_analytics_attributes
{
client_id: client_id,
error_code: error_code,
jti: jti,
user_id: user&.uuid,
event_type: event_type,
}
end
end