lib/softwear/auth/model.rb in softwear-lib-1.7.0 vs lib/softwear/auth/model.rb in softwear-lib-1.7.1

- old
+ new

@@ -1,471 +1,14 @@ +require 'softwear/auth/standard_model' +require 'softwear/auth/stubbed_model' + module Softwear module Auth - class Model - include ActiveModel::Model - include ActiveModel::Conversion - - class AccessDeniedError < StandardError + if Rails.env.development? && ENV['AUTH_SERVER'].blank? + class Model < StubbedModel 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 <User Class>.query_cache.clear or <User Class>.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 default_socket - @default_socket ||= TCPSocket.open(auth_server_host, auth_server_port) - 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.puts message - - rescue Errno::EPIPE => e - @default_socket = TCPSocket.open(auth_server_host, auth_server_port) - @default_socket.puts message - end - - response = default_socket.gets.try(:chomp) - if response.nil? - @default_socket.close rescue nil - @default_socket = nil - 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 - 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) } - objects - end - - # ==================== - # Given a valid signin token: - # Returns the authenticated user for the given token - # Given an invalid signin token: - # Returns false - # ==================== - def auth(token) - response = validate_response query "auth #{Figaro.env.hub_app_name} #{token}" - - return false unless response =~ /^yes .+$/ - - _yes, json = response.split(' ', 2) - 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, - :profile_picture_url - ] - 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' + else + class Model < StandardModel end end end end