#--
# Copyright (c) 2007 Robert S. Thau, Smartleaf, Inc.
# 
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
# 
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#++
require 'digest/sha2'
require 'search_name_hook'

class User < ActiveRecord::Base

  include SmartguardBasicUser

  # There are a lot of situtations where something went wrong on login
  # but we don't want to say precisely what.  Here's a message that
  # covers them all in a suitably non-specific way...

  BAD_USER_PW_MSG = "Bad username or password"

  # Some operations on users are permissioned, but users don't have
  # owners.  So, we have a nonstandard set of access-control keys for them

  never_permit_anyone :to_update_attribute => :owner_firm_id

  # Security policy.  Note that pw_administer does *not* guard
  # the bad_login_attempts attrs, as the login process itself
  # needs to be able to set them.

  require_eponymous_privilege_to :create

  require_privilege :administer,
    :to_associate_as  => 'RoleAssignment#user',
    :to_dissociate_as => 'RoleAssignment#user'

  require_privilege :set_password,
    :to_set_attribute => [:password, :password_confirmation,
                          :password_hash, :password_salt,
                          :password_expires_at]

  require_privilege :pw_administer,
    :to_invoke => :reset_bad_logins,
    :to_set_attribute => [:locked_out, :reset_bad_logins_flag]

  require_privilege :rename, 
    :to_update_attribute => [:name, :full_name, :search_name]

  # privilege to create permissions referring specifically to this
  # user's private data

  declare_privilege :allow_others_to_access

  # privilege to allow "acting as" other users

  declare_privilege :act_as

  # Associations

  belongs_to :firm, :class_name => 'Firm', 
                    :foreign_key => :owner_firm_id

  [:name, :full_name].each do |name_attr|
    validates_presence_of name_attr
    validates_length_of name_attr, :within => 3..100
  end

  validates_presence_of   :firm
  validates_uniqueness_of :name, :scope => :owner_firm_id

  # Validation of passwords --- we maintain shadow attributes for
  # the UI, (re)set the hash when the UI sets the password, and
  # allow saving only when the validations on the shadow attrs
  # pass, as per the usual.

  attr_reader :password

  def password=( new_password )
    @password = new_password
    if !new_password.blank?
      set_pw_hash_for( new_password )
      pw_lifetime = firm.password_lifetime_days
      if pw_lifetime.nil?
        self.password_expires_at = nil
      else
        self.password_expires_at = pw_lifetime.days.from_now
      end
    end
  end

  validates_length_of :password, :minimum => 6, :allow_nil => true
  validates_format_of :password, :with => /[a-zA-Z]/,
    :if => Proc.new { |user| !user.password.blank? },
    :message => 'must have at least one letter'
  validates_format_of :password, :with => /[0-9]/,
    :if => Proc.new { |user| !user.password.blank? },
    :message => 'must have at least one digit'
  validates_confirmation_of :password

  validate do |user|
    if !user.password.nil? && user.has_current_password?( user.password )
      user.errors.add( :password, "is the same as the last one" )
    end
  end

  # Wrap attributes= so that pasword attr is left unset if both
  # it and password_confirmation are blank.
  # XXX test

  def attributes=( attrs_hash )

    if %w(password password_confirmation).all? { |x| attrs_hash[x].blank?}
      attrs_hash = attrs_hash.dup
      %w(password password_confirmation).each { |x| attrs_hash[x] = nil }
    end

    super( attrs_hash )
    
  end

  # Also support UIs that want to verify that a user changing
  # their password knows the current one...

  attr_accessor :current_password_check

  validate do |user|
    pw_check = user.current_password_check
    if !pw_check.nil? && !user.has_current_password?( pw_check )
      user.errors.add( :current_password_check, 'is incorrect' )
    end
  end

  # Clear password shadow attributes after a save, so re-saving
  # the same user object won't fail the password-reuse check.
  # (This is mainly a sop to unit tests, but could also come up 
  # in some real UI cases).  A *proper* password-history table
  # would handle the matter transparently, of course...

  after_save do |user|
    user.password = nil
    user.password_confirmation = nil
  end

  # The usual search-name business

  include Smartleaf::SearchNameSetterHook

  # Pseudo-attribute for resetting bad login attempts

  attr_reader :reset_bad_logins_flag

  def reset_bad_logins_flag=( val )
    @reset_bad_logins_flag = val
    if @reset_bad_logins_flag
      reset_bad_logins
    end
  end

  # Reported status for login attempts ("Account expired",
  # "Login succeeded but there were 'n' prior failures, etc.)
  # This is set by authenticate_by_password, q.v.  The value
  # is a list of strings, or nil.

  attr_reader :authentication_status

  # Attempt to authenticate with the given password.  Also checks that
  # the password is not expired, and that the user isn't locked out
  # explicitly, or due to multiple failed login attempts.
  #
  # Returns a boolean true for success (user logged in), and a boolean
  # false for failure.
  #
  # Either way, the "authentication_status" attribute may be updated
  # to contain a list of strings with information of interest
  # ("Account expired", "Login succeeded but there were 'n' prior failures",
  # etc.)  Also, either way, various status fields are updated in the
  # database, including the "last_login_at" date on success, and the
  # various counts of bad login attempts on failure.

  def authenticate_by_password( password )

    fail_with = Proc.new {|msg| @authentication_status = [msg]; return false }

    # If the user is already banned, we don't even bother.

    fail_with.call( BAD_USER_PW_MSG ) if locked_out?
    fail_with.call("Account expired") if !password_expires_at.nil? &&
      password_expires_at.to_date < Date.today
    fail_with.call("Too many failed attempts.  Try again later.") if
      !no_login_until.nil? && no_login_until.to_time > Time.now

    # User is not banned.  Do a check...

    if self.has_password?( password )

      # Success...

      @authentication_status = []

      if !bad_login_attempts.nil?
        @authentication_status << 
          "Login succeeded.  Note that there were #{bad_login_attempts} " +
          "failed login attempts"
        self.bad_login_attempts = nil
        self.bad_logins_since_lockout = nil
        self.no_login_until = nil
      end

      pw_lifetime = current_password_remaining_days

      if !pw_lifetime.nil? && pw_lifetime < 5
        @authentication_status <<
          "Login succeeded.  Your password expires in " +
          "#{current_password_remaining_days} days.  Please change it now!"
      end

      @authentication_status = nil if @authentication_status == []

      self.last_login_at = Time.now
      self.save!

      return true

    else

      # Bad password

      @authentication_status = [BAD_USER_PW_MSG]

      self.bad_login_attempts ||= 0
      self.bad_login_attempts += 1

      self.bad_logins_since_lockout ||= 0
      self.bad_logins_since_lockout += 1
      
      if !firm.max_bad_logins.nil? && !firm.bad_login_dead_minutes.nil?
        if self.bad_logins_since_lockout >= firm.max_bad_logins
          @authentication_status=["Too many failed attempts.  Try again later"]
          self.bad_logins_since_lockout = nil
          self.no_login_until = Time.now + 60 * firm.bad_login_dead_minutes
        end
      end

      save!

      return false
      
    end

  end
  
  # Just a password check, with no accompanying foofaraw...

  def has_password?( password )
    !self.password_hash.nil? && 
      pw_hash(password, self.password_salt) == self.password_hash
  end

  # Check to see whether the user's _current_ password is the
  # same as the argument.  A new password that we're trying to
  # set becomes 'current' when it is actually saved, but not
  # before.

  def has_current_password?( password )
    !@current_pw_hash.nil? && 
      pw_hash(password, @current_pw_salt) == @current_pw_hash
  end
    
  after_save :stored_password_is_current
  after_find :stored_password_is_current
  def after_find; end

  private

  def stored_password_is_current
    @current_pw_hash = password_hash
    @current_pw_salt = password_salt
  end

  public

  # Remaining life of the current password.  Returns nil
  # if no expiration date is set.

  def current_password_remaining_days
    return nil if password_expires_at.nil?
    return password_expires_at.to_date - Date.today
  end

  # Reset the "bad login attempts" counter and associated state.
  # Note --- does *not* reset the permanent lockout bit; that's
  # a separate flag, managed by the "locked_out" boolean attribute.
  # NB as well --- does *not* do a save.

  def reset_bad_logins
    self.bad_login_attempts = nil
    self.no_login_until     = nil
  end

  private

  def pw_hash( password, salt )
    Digest::SHA256.hexdigest(salt + password)
  end

  # Updating the password

  def set_pw_hash_for( new_password )
    salt = ''
    File.open("/dev/urandom") do |f|
      salt = Base64.encode64(f.read(6))
    end
    salt += rand.to_s
    salt = salt[0..9]
    self.password_salt = salt
    self.password_hash = pw_hash( new_password, salt )
  end

end

