# frozen_string_literal: true require 'barong/activity_logger' module Barong # AuthZ functionality class Authorize STATE_CHANGING_VERBS = %w[POST PUT PATCH DELETE TRACE].freeze # Custom Error class to support error status and message class AuthError < StandardError attr_reader :code # init an error with status and text to return in api response def initialize(code) super @code = code end end # init base request info, fetch black and white lists def initialize(request, path) @request = request @path = path @rules = lists['rules'] end # main: switch between cookie and api key logic, return bearer token def auth auth_type = 'cookie' auth_type = 'api_key' if api_key_headers? auth_owner = method("#{auth_type}_owner").call 'Bearer ' + codec.encode(auth_owner.as_payload) # encoded user info end # cookies validations def cookie_owner validate_csrf! error!({ errors: ['authz.invalid_session'] }, 401) unless session[:uid] user = User.find_by!(uid: session[:uid]) Rails.logger.debug "User #{user} authorization via cookies" validate_session! unless user.state.in?(%w[active pending]) error!({ errors: ['authz.user_not_active'] }, 401) end validate_permissions!(user) user # returns user(whose session is inside cookie) end def validate_session! unless @request.env['HTTP_USER_AGENT'] == session[:user_agent] && Time.now.to_i < session[:expire_time] && find_ip.include?(remote_ip) session.destroy Rails.logger.debug("Session mismatch! Valid session is: { agent: #{session[:user_agent]}," \ " expire_time: #{session[:expire_time]}, ip: #{session[:user_ip]} }," \ " but request contains: { agent: #{@request.env['HTTP_USER_AGENT']}, ip: #{remote_ip} }") error!({ errors: ['authz.client_session_mismatch'] }, 401) end session[:expire_time] = Time.now.to_i + Barong::App.config.session_expire_time end def find_ip ip_addr = IPAddr.new(session[:user_ip]) if ip_addr.ipv4? ip_addr.mask(16) else ip_addr.mask(96) end end # api key validations def api_key_owner api_key = APIKeysVerifier.new(api_key_params) # validate that nonce is a positive integer error!({ errors: ['authz.nonce_not_valid_timestamp'] }, 401) if api_key_params[:nonce].to_i <= 0 # timestamp_window is a difference between server_time and nonce creation time nonce_timestamp_window = ((Time.now.to_f * 1000).to_i - api_key_params[:nonce].to_i).abs Rails.logger.debug("Api key authorization via key: #{api_key_params[:kid]} to path #{@path} \ with nonce: #{api_key_params[:nonce]} in a window of #{nonce_timestamp_window}") # (server_time - nonce) should not be more than nonce lifetime error!({ errors: ['authz.nonce_expired'] }, 401) if nonce_timestamp_window >= Barong::App.config.apikey_nonce_lifetime # signature should be valid error!({ errors: ['authz.invalid_signature'] }, 401) unless api_key.verify_hmac_payload? current_api_key = APIKey.find_by_kid(api_key_params[:kid]) # corresponding Api Key should be active error!({ errors: ['authz.apikey_not_active'] }, 401) unless current_api_key.active? # here User is either User object or ServiceAccount object user = current_api_key.key_holder_account validate_user!(user) validate_permissions!(user) user # returns user(api key creator) rescue ActiveRecord::RecordNotFound error!({ errors: ['authz.unexistent_apikey'] }, 401) end def validate_csrf! return unless Barong::App.config.csrf_protection && @request.env['REQUEST_METHOD'].in?(STATE_CHANGING_VERBS) unless headers['X-CSRF-Token'] Rails.logger.info("CSRF attack warning! Missing token for uid: #{session[:uid]} in request to #{@path} by #{@request.env['REQUEST_METHOD']}") error!({ errors: ['authz.missing_csrf_token'] }, 401) end unless headers['X-CSRF-Token'] == session[:csrf_token] Rails.logger.info("CSRF attack warning! Token is not valid for uid: #{session[:uid]} in request to #{@path} by #{@request.env['REQUEST_METHOD']}") error!({ errors: ['authz.csrf_token_mismatch'] }, 401) end end def validate_permissions!(user) # Caches Permission.all result to optimize permissions = Rails.cache.fetch('permissions', expires_in: 5.minutes) { Permission.all.to_ary } permissions.select! { |a| a.role == user.role && ( a.verb == @request.env['REQUEST_METHOD'] || a.verb == 'ALL' ) && @path.starts_with?(a.path) } actions = permissions.blank? ? [] : permissions.pluck(:action).uniq if permissions.blank? || actions.include?('DROP') || !actions.include?('ACCEPT') log_activity(user.id, 'denied') if user.is_a?(User) error!({ errors: ['authz.invalid_permission'] }, 401) end if actions.include?('AUDIT') topic = permissions.select { |a| a.action == 'AUDIT' }[0].topic log_activity(user.id, 'succeed', topic) if user.is_a?(User) end end def log_activity(user_id, result, topic = nil) if Rails.env.test? ActivityLogger.sync_write(activity_params(user_id, result, topic)) else ActivityLogger.async_write(activity_params(user_id, result, topic)) end end def activity_params(user_id, result, topic) { user_id: user_id, result: result, user_agent: @request.env['HTTP_USER_AGENT'], user_ip: remote_ip, path: @path, topic: topic, verb: @request.env['REQUEST_METHOD'], payload: @request.params } end # black/white list validation. takes ['block', 'pass'] as a parameter def under_path_rules?(type) return false if @rules[type].nil? # if no authz rules provided @rules[type].each do |t| return true if @path.starts_with?(t) # if request path is inside the rules list end false # default end def remote_ip # default behaviour, IP from HTTP_X_FORWARDED_FOR ip = @request.remote_ip if Barong::App.config.gateway == 'akamai' # custom header that contains only client IP true_client_ip = @request.env['HTTP_TRUE_CLIENT_IP'] # take IP from TRUE_CLIENT_IP only if its not nil or empty ip = true_client_ip unless true_client_ip.nil? || true_client_ip.empty? end return ip end private # encode helper method def codec @_codec ||= Barong::JWT.new(key: Barong::App.config.keystore.private_key) end # fetch authz rules from yml def lists YAML.safe_load( ERB.new( File.read( Barong::App.config.authz_rules_file ) ).result ) end # checks if api key headers are present in request def api_key_headers? return false if headers['X-Auth-Apikey'].nil? && headers['X-Auth-Nonce'].nil? && headers['X-Auth-Signature'].nil? @api_key_headers = [headers['X-Auth-Apikey'], headers['X-Auth-Nonce'], headers['X-Auth-Signature']] validate_headers? end def validate_user!(user) unless user.state.in?(%w[active pending]) error!({ errors: ['authz.invalid_session'] }, 401) end return if skip_api_key_2fa? if user.is_a?(User) && !user.otp error!({ errors: ['authz.disabled_2fa'] }, 401) end end def skip_api_key_2fa? ENV['BARONG_SKIP_API_KEY_2FA'] == 'true' end # api key headers nil, blank validation def validate_headers? @api_key_headers.each do |k| error!({ errors: ['authz.invalid_api_key_headers'] }, 422) if k.blank? end end # converts header into hash of parameters def api_key_params { 'kid': headers['X-Auth-Apikey'], 'nonce': headers['X-Auth-Nonce'], 'signature': headers['X-Auth-Signature'] } end # custom error, calls AuthError class def error!(text, code) Rails.logger.debug "Error raised with code #{code} and error message #{text.to_json}" raise AuthError.new(code), text.to_json end def headers @request.headers end def session @request.session end end end