require 'rbczmq' module Softwear module Auth class StandardModel include ActiveModel::Model include ActiveModel::Conversion class AccessDeniedError < StandardError end class InvalidCommandError < StandardError end class AuthServerError < StandardError end class AuthServerDown < StandardError end # ============================= CLASS METHODS ====================== class << self def abstract_class? true end attr_writer :query_cache attr_accessor :total_query_cache attr_writer :query_cache_expiry alias_method :expire_query_cache_every, :query_cache_expiry= attr_accessor :auth_server_went_down_at attr_accessor :sent_auth_server_down_email attr_accessor :time_before_down_email alias_method :email_when_down_after, :time_before_down_email= # ==================== # Returns true if the authentication server was unreachable for the previous query. # ==================== def auth_server_down? !!auth_server_went_down_at end # ==================== # The query cache takes message keys (such as "get 12") with response values straight from # the server. So yes, this will cache error responses. # You can clear this with .query_cache.clear or .query_cache = nil # ==================== def query_cache @query_cache ||= ThreadSafe::Cache.new end def query_cache_expiry @query_cache_expiry || Figaro.env.query_cache_expiry.try(:to_f) || 1.hour end # =================== # Override this in your subclasses! The mailer should have auth_server_down(time) and # auth_server_up(time) # =================== def auth_server_down_mailer # override me end # ====================================== def primary_key :id end def base_class self end def relation_delegate_class(*) self end def unscoped self end def new(*args) if args.size == 3 assoc_class = args[2].owner.class.name assoc_name = args[2].reflection.name raise "Unsupported user association: #{assoc_class}##{assoc_name}. If this is a belongs_to "\ "association, you may have #{assoc_class} include Softwear::Auth::BelongsToUser and call "\ "`belongs_to_user_called :#{assoc_name}' instead of the traditional rails method." else super end end # ====================================== # ==================== # Not a fully featured has_many - must specify foreign_key if the association doesn't match # the model name, through is inefficient. # ==================== def has_many(assoc, options = {}) assoc = assoc.to_s if through = options[:through] source = options[:source] || assoc class_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{assoc} #{through}.flat_map(&:#{source}) end RUBY else class_name = options[:class_name] || assoc.singularize.camelize foreign_key = options[:foreign_key] || 'user_id' class_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{assoc} #{class_name}.where(#{foreign_key}: id) end RUBY end end # ==================== # Pretty much a map function - for activerecord compatibility. # ==================== def pluck(*attrs) if attrs.size == 1 all.map do |user| user.send(attrs.first) end else all.map do |user| attrs.map { |a| user.send(a) } end end end def arel_table @arel_table ||= Arel::Table.new(model_name.plural, self) end # ==================== # This is only used to record how long it takes to perform queries for development. # ==================== def record(before, after, type, body) ms = (after - before) * 1000 # The garbage in this string gives us the bold and color Rails.logger.info " \033[1m\033[33m#{type} (#{'%.1f' % ms}ms)\033[0m #{body}" end # ==================== # Host of the auth server, from 'auth_server_endpoint' env variable. # Defaults to localhost. # ==================== def auth_server_host endpoint = Figaro.env.auth_server_endpoint if endpoint.blank? 'localhost' elsif endpoint.include?(':') endpoint.split(':').first else endpoint end end # ==================== # Port of the auth server, from 'auth_server_endpoint' env variable. # Defaults to 2900. # ==================== def auth_server_port endpoint = Figaro.env.auth_server_endpoint if endpoint.try(:include?, ':') endpoint.split(':').last else 2900 end end def zmq $zmq_context ||= ZMQ::Context.new end def default_socket if existing = Thread.current[:auth_socket] return existing end Thread.current[:auth_socket] = zmq.connect( :REQ, "tcp://#{auth_server_host}:#{auth_server_port}" ) end def destroy_default_socket! Thread.current[:auth_socket].try(:destroy) rescue nil end # ==================== # Bare minimum query function - sends a message and returns the response, and # handles a broken socket. #query and #force_query call this function. # ==================== def raw_query(message) begin default_socket.send message end response = default_socket.recv.try(:chomp) if response.nil? destroy_default_socket! return raw_query(message) end response rescue Errno::ECONNREFUSED => e raise AuthServerDown, "Unable to connect to the authentication server." rescue Errno::ETIMEDOUT => e raise AuthServerDown, "Connection to authentication server timed out." end # ==================== # Expires the query cache, setting a new expiration time as well as merging # with the previous query cache, in case of an auth server outage. # ==================== def expire_query_cache before = Time.now if total_query_cache query_cache.each_pair do |key, value| total_query_cache[key] = value end else self.total_query_cache = query_cache.clone end query_cache.clear query_cache['_expire_at'] = (query_cache_expiry || 1.hour).from_now after = Time.now record(before, after, "Authentication Expire Cache", "") end # ==================== # Queries the authentication server only if there isn't a cached response. # Also keeps track of whether or not the server is reachable, and sends emails # when the server goes down and back up. # ==================== def query(message) before = Time.now expire_at = query_cache['_expire_at'] expire_query_cache if expire_at.blank? || Time.now > expire_at if cached_response = query_cache[message] response = cached_response action = "Authentication Cache" else begin response = raw_query(message) action = "Authentication Query" query_cache[message] = response if auth_server_went_down_at self.auth_server_went_down_at = nil if sent_auth_server_down_email self.sent_auth_server_down_email = false if (mailer = auth_server_down_mailer) && mailer.respond_to?(:auth_server_up) mailer.auth_server_up(Time.now).deliver_now end end end rescue AuthServerError => e raise unless total_query_cache old_response = total_query_cache[message] if old_response response = old_response action = "Authentication Cache (due to error)" Rails.logger.error "AUTHENTICATION: The authentication server encountered an error. "\ "You should probably check the auth server's logs. "\ "A cached response was used." else raise end rescue AuthServerDown => e if auth_server_went_down_at.nil? self.auth_server_went_down_at = Time.now expire_query_cache elsif auth_server_went_down_at > (time_before_down_email || 5.minutes).ago unless sent_auth_server_down_email self.sent_auth_server_down_email = true if (mailer = auth_server_down_mailer) && mailer.respond_to?(:auth_server_down) mailer.auth_server_down(auth_server_went_down_at).deliver_now end end end old_response = total_query_cache[message] if old_response response = old_response action = "Authentication Cache (server down)" else raise AuthServerDown, "An uncached query was attempted, and the authentication server is down." end end end after = Time.now record(before, after, action, message) response end # ==================== # Runs a query through the server without error or cache checking. # ==================== def force_query(message) before = Time.now response = raw_query(message) after = Time.now record(before, after, "Authentication Query (forced)", message) response end # ==================== # Expects a response string returned from #query and raises an error for the # following cases: # # - Access denied (AccessDeniedError) # - Invalid command (bad query message) (InvalidCommandError) # - Error on auth server's side (AuthServerError) # ==================== def validate_response(response_string) case response_string when 'denied' then raise AccessDeniedError, "Denied" when 'invalid' then raise InvalidCommandError, "Invalid command" when 'sorry' expire_query_cache raise AuthServerError, "Authentication server encountered an error" else response_string end end # ==================== # Finds a user with the given ID # ==================== def find(target_id) json = validate_response query "get #{target_id}" if json == 'nosuchuser' nil else object = new(JSON.parse(json)) object.instance_variable_set(:@persisted, true) object end rescue JSON::ParserError => _e Rails.logger.error "Bad user model JSON: ``` #{json} ```" nil end def filter_all(method, options) all.send(method) do |user| options.all? { |field, wanted_value| user.send(field) == wanted_value } end end # ==================== # Finds a user with the given attributes (just queries for 'all' and uses ruby filters) # ==================== def find_by(options) filter_all(:find, options) end # ==================== # Finds users with the given attributes (just queries for 'all' and uses ruby filters) # ==================== def where(options) filter_all(:select, options) end # ==================== # Returns an array of all registered users # ==================== def all json = validate_response query "all" objects = JSON.parse(json).map(&method(:new)) objects.each { |u| u.instance_variable_set(:@persisted, true) } end # ==================== # Returns array of all users with the given roles # ==================== def of_role(*roles) roles = roles.flatten.compact return [] if roles.empty? json = validate_response query "ofrole #{Figaro.env.hub_app_name} #{roles.split(' ')}" objects = JSON.parse(json).map(&method(:new)) objects.each { |u| u.instance_variable_set(:@persisted, true) } end # ==================== # Returns array of all users in the given group # ==================== def of_group(group_code) json = validate_response query "ofgroup #{Figaro.env.hub_app_name} #{group_code}" objects = JSON.parse(json).map(&method(:new)) objects.each { |u| u.instance_variable_set(:@persisted, true) } end # ==================== # Given a valid signin token: # Returns the authenticated user for the given token # Given an invalid signin token: # Returns false # ==================== def auth(token, app_name = nil) response = validate_response query "auth #{app_name || Figaro.env.hub_app_name} #{token}" return false unless response =~ /^yes/ json = response.sub(/^yes/, '') object = new(JSON.parse(json)) object.instance_variable_set(:@persisted, true) object end # ==================== # Overridable logger method used when recording query benchmarks # ==================== def logger Rails.logger end end # ============================= INSTANCE METHODS ====================== REMOTE_ATTRIBUTES = [ :id, :email, :first_name, :last_name, :roles, :groups, :profile_picture_url, :default_view, :rights ] REMOTE_ATTRIBUTES.each(&method(:attr_accessor)) attr_reader :persisted alias_method :persisted?, :persisted # ==================== # Various class methods accessible on instances def query(*a) self.class.query(*a) end def raw_query(*a) self.class.raw_query(*a) end def force_query(*a) self.class.force_query(*a) end def logger self.class.logger end # ==================== def initialize(attributes = {}) update_attributes(attributes) end def update_attributes(attributes={}) return if attributes.blank? attributes = attributes.with_indifferent_access REMOTE_ATTRIBUTES.each do |attr| instance_variable_set("@#{attr}", attributes[attr]) end end def to_json { id: @id, email: @email, first_name: @first_name, last_name: @last_name } .to_json end def reload json = validate_response query "get #{id}" update_attributes(JSON.parse(json)) @persisted = true self end def full_name "#{@first_name} #{@last_name}" end def valid_password?(pass) query("pass #{id} #{pass}") == 'yes' end def role?(*wanted_roles) return true if wanted_roles.empty? if @roles.nil? query("role #{Figaro.env.hub_app_name} #{id} #{wanted_roles.join(' ')}") == 'yes' else wanted_roles.any? { |r| @roles.include?(r.to_s) } end end def group?(group) if @groups.nil? query("group #{Figaro.env.hub_app_name} #{id} #{group}") == 'yes' else @groups.include?(group) end end end end end