text: # -*- coding: utf-8 -*-
  """
  Created on Mar 12, 2012
  
  @author: moloch
  
      Copyright 2012 Root the Box
  
      Licensed under the Apache License, Version 2.0 (the "License");
      you may not use this file except in compliance with the License.
      You may obtain a copy of the License at
  
          http://www.apache.org/licenses/LICENSE-2.0
  
      Unless required by applicable law or agreed to in writing, software
      distributed under the License is distributed on an "AS IS" BASIS,
      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      See the License for the specific language governing permissions and
      limitations under the License.
  ----------------------------------------------------------------------------
  
  This file contiains the user object, used to store data related to an
  indiviudal user, such as handle/account/password/etc
  
  """
  
  
  import os
  import imghdr
  import string
  import random
  import io
  import xml.etree.cElementTree as ET
  from uuid import uuid4
  from hashlib import md5, sha1, sha256, sha512
  from pbkdf2 import PBKDF2
  from sqlalchemy import Column, ForeignKey, desc, func
  from sqlalchemy.orm import synonym, relationship, backref
  from sqlalchemy.types import Unicode, Integer, String, Boolean, DateTime
  from models import dbsession
  from models.Permission import Permission
  from models.MarketItem import MarketItem
  from models.BaseModels import DatabaseObject
  from models.EmailToken import EmailToken
  from models.Theme import Theme
  from libs.XSSImageCheck import MAX_AVATAR_SIZE, MIN_AVATAR_SIZE, IMG_FORMATS
  from libs.XSSImageCheck import is_xss_image, get_new_avatar, default_avatar
  from libs.ValidationError import ValidationError
  from string import printable
  from tornado.options import options
  from PIL import Image
  from resizeimage import resizeimage
  from libs.StringCoding import encode
  from builtins import str
  from past.builtins import basestring
  
  
  # Constants
  ADMIN_PERMISSION = u"admin"
  DEFAULT_HASH_ALGORITHM = u"md5"
  ITERATE = 0x2BAD  # 11181
  
  
  class User(DatabaseObject):
  
      """ User definition """
  
      uuid = Column(String(36), unique=True, nullable=False, default=lambda: str(uuid4()))
  
      team_id = Column(Integer, ForeignKey("team.id"))
      _avatar = Column(String(64))
      _locked = Column(Boolean, default=False, nullable=False)
      last_login = Column(DateTime)
      logins = Column(Integer, default=0)
      _handle = Column(Unicode(16), unique=True, nullable=False)
      _name = Column(Unicode(64), unique=False, nullable=True)
      _email = Column(Unicode(64), unique=False, nullable=True)
      _password = Column("password", String(64))
      _bank_password = Column("bank_password", String(128))
      _notes = Column(Unicode(512))
      money = Column(Integer, default=0, nullable=False)
  
      theme_id = Column(Integer, ForeignKey("theme.id"), default=3, nullable=False)
  
      algorithm = Column(String(8), default=DEFAULT_HASH_ALGORITHM, nullable=False)
  
      permissions = relationship(
          "Permission",
          backref=backref("user", lazy="select"),
          cascade="all,delete,delete-orphan",
      )
      notifications = relationship(
          "Notification",
          backref=backref("user", lazy="select"),
          cascade="all,delete,delete-orphan",
      )
  
      algorithms = {
          "md5": (md5, 1, "md5"),
          "sha1": (sha1, 2, "sha1"),
          "sha256": (sha256, 3, "sha256"),
          "sha512": (sha512, 4, "sha512"),
      }
  
      @classmethod
      def all(cls):
          """ Returns a list of all objects in the database """
          return dbsession.query(cls).all()
  
      @classmethod
      def all_users(cls):
          """ Return all non-admin user objects """
          return [user for user in cls.all() if user.is_admin() is False]
  
      @classmethod
      def not_team(cls, tid):
          """ Return all users not on a given team, exclude admins """
          teams = dbsession.query(cls).filter(cls.team_id != tid).all()
          return [user for user in teams if user.is_admin() is False]
  
      @classmethod
      def by_id(cls, _id):
          """ Returns a the object with id of _id """
          return dbsession.query(cls).filter_by(id=_id).first()
  
      @classmethod
      def by_uuid(cls, _uuid):
          """ Return and object based on a uuid """
          return dbsession.query(cls).filter_by(uuid=str(_uuid)).first()
  
      @classmethod
      def by_email(cls, email):
          """ Return an object based on a email """
          if email and len(email) > 0:
              return (
                  dbsession.query(cls)
                  .filter(func.lower(User._email) == func.lower(email))
                  .first()
              )
          else:
              return None
  
      @classmethod
      def by_handle(cls, handle, case_sensitive=True):
          """ Return the user object whose user is "_handle" """
          handle = str(handle).strip()
          if case_sensitive:
              return dbsession.query(cls).filter_by(_handle=handle).first()
          else:
              return (
                  dbsession.query(cls)
                  .filter(func.lower(User._handle) == func.lower(handle))
                  .first()
              )
  
      @classmethod
      def _hash_bank_password(cls, algorithm_name, password):
          """
          Hashes the password using Md5/Sha1/Sha256/Sha512
          only used for the admin accounts.  We only allow
          whitespace/non-ascii.
          """
          if algorithm_name is None:
              algorithm_name = DEFAULT_HASH_ALGORITHM
          if algorithm_name in cls.algorithms:
              algo = cls.algorithms[algorithm_name][0]()
              algo.update(encode(password))
              return algo.hexdigest()
          else:
              raise ValueError("Algorithm %s not supported." % algorithm_name)
  
      @classmethod
      def _hash_password(cls, password):
          return PBKDF2.crypt(password, iterations=ITERATE)
  
      @classmethod
      def ranks(cls):
          """ Returns a list of all objects in the database """
          return dbsession.query(cls).order_by(desc(cls.money)).all()
  
      @property
      def password(self):
          return self._password
  
      @password.setter
      def password(self, value):
          _password = "".join([char for char in value if char in printable[:-6]])
          if len(_password) >= int(options.min_user_password_length):
              self._password = self._hash_password(value)
          else:
              raise ValidationError(
                  "Invalid password length (min %d chars)"
                  % (options.min_user_password_length,)
              )
  
      @property
      def theme(self):
          if self.theme_id:
              return Theme.by_id(self.theme_id).name
          else:
              return options.default_theme
  
      @theme.setter
      def theme(self, value):
          theme = Theme.by_name(value)
          if theme:
              self.theme_id = theme.id
  
      @property
      def bank_password(self):
          return self._bank_password
  
      @bank_password.setter
      def bank_password(self, value):
          if not options.banking:
              # random password
              _password = "".join(
                  random.SystemRandom().choice(string.ascii_uppercase + string.digits)
                  for _ in range(options.max_password_length)
              )
          else:
              _password = "".join([char for char in value if char in printable[:-6]])
          if 0 < len(_password) <= options.max_password_length:
              self._bank_password = self._hash_bank_password(self.algorithm, _password)
          else:
              raise ValidationError(
                  "Invalid bank password length (max %d chars)"
                  % (options.max_password_length,)
              )
  
      @property
      def handle(self):
          return self._handle
  
      @handle.setter
      def handle(self, new_handle):
          new_handle = str(new_handle).strip()
          if not 3 <= len(new_handle) <= 16:
              raise ValidationError("Handle must be 3 - 16 characters")
          self._handle = new_handle
  
      @property
      def name(self):
          return self._name
  
      @name.setter
      def name(self, new_name):
          if len(new_name) > 64:
              raise ValidationError("Name must be 0 - 64 characters")
          self._name = str(new_name)
  
      @property
      def email(self):
          return self._email
  
      @email.setter
      def email(self, new_email):
          if len(new_email) > 64:
              raise ValidationError("Email must be 0 - 64 characters")
          self._email = str(new_email)
  
      @property
      def notes(self):
          if self._notes is None:
              self._notes = ""
          return self._notes
  
      @notes.setter
      def notes(self, new_notes):
          if len(new_notes) > 512:
              raise ValidationError("Notes must be 0 - 512 characters")
          self._notes = str(new_notes)
  
      @property
      def permissions_all(self):
          """ Return a set with all permissions granted to the user """
          return dbsession.query(Permission).filter_by(user_id=self.id)
  
      @property
      def permissions_names(self):
          """ Return a list with all permissions accounts granted to the user """
          return [permission.name for permission in self.permissions_all]
  
      @property
      def locked(self):
          """
          Determines if an admin has locked an account, accounts with
          administrative permissions cannot be locked.
          """
          if self.is_admin():
              return False  # Admin accounts cannot be locked
          else:
              return self._locked
  
      @locked.setter
      def locked(self, value):
          """ Setter method for _lock """
          assert isinstance(value, bool)
          if not self.is_admin():
              self._locked = value
  
      @property
      def avatar(self):
          if self._avatar is not None:
              return self._avatar
          else:
              if not options.teams:
                  avatar = default_avatar("user")
              elif self.is_admin():
                  avatar = default_avatar("user")
              else:
                  avatar = get_new_avatar("user")
                  if not avatar.startswith("default_"):
                      self._avatar = avatar
                      dbsession.add(self)
                      dbsession.commit()
              return avatar
  
      @avatar.setter
      def avatar(self, image_data):
          if MIN_AVATAR_SIZE < len(image_data) < MAX_AVATAR_SIZE:
              ext = imghdr.what("", h=image_data)
              if ext in IMG_FORMATS and not is_xss_image(image_data):
                  try:
                      if self._avatar is not None and os.path.exists(
                          options.avatar_dir + "/upload/" + self._avatar
                      ):
                          os.unlink(options.avatar_dir + "/upload/" + self._avatar)
                      file_path = str(
                          options.avatar_dir + "/upload/" + self.uuid + "." + ext
                      )
                      image = Image.open(io.BytesIO(image_data))
                      cover = resizeimage.resize_cover(image, [500, 250])
                      cover.save(file_path, image.format)
                      self._avatar = "upload/" + self.uuid + "." + ext
                  except Exception as e:
                      raise ValidationError(e)
              else:
                  raise ValidationError(
                      "Invalid image format, avatar must be: %s"
                      % (", ".join(IMG_FORMATS))
                  )
          else:
              raise ValidationError(
                  "The image is too large must be %d - %d bytes"
                  % (MIN_AVATAR_SIZE, MAX_AVATAR_SIZE)
              )
  
      def has_item(self, item_name):
          """ Check to see if a team has purchased an item """
          item = MarketItem.by_name(item_name)
          if item is None:
              raise ValueError("Item '%s' not in database." % str(item_name))
          return True if self.team and item in self.team.items else False
  
      def has_permission(self, permission):
          """ Return True if 'permission' is in permissions_names """
          return True if permission in self.permissions_names else False
  
      def is_admin(self):
          return self.has_permission(ADMIN_PERMISSION)
  
      def is_email_valid(self):
          emailtoken = EmailToken.by_user_id(self.id)
          if emailtoken is None:
              return True
          return emailtoken.valid
  
      def validate_email(self, token):
          emailtoken = EmailToken.by_user_id(self.id)
          if emailtoken and emailtoken.value == token:
              emailtoken.valid = True
              dbsession.add(emailtoken)
              dbsession.commit()
          return self.is_email_valid()
  
      def validate_password(self, attempt):
          """ Check the password against existing credentials """
          if self._password is not None:
              return self.password == PBKDF2.crypt(attempt, self.password)
          else:
              return False
  
      def validate_bank_password(self, attempt):
          """ Check the bank password against existing credentials """
          if self._bank_password is not None:
              result = self._hash_bank_password(self.algorithm, attempt)
              return self.bank_password == result
          else:
              return False
  
      def get_new_notifications(self):
          """
          Returns any unread messages
  
          @return: List of unread messages
          @rtype: List of Notification objects
          """
          return [notify for notify in self.notifications if notify.viewed is False]
  
      def get_notifications(self, limit=10):
          """
          Returns most recent notifications
  
          @param limit: Max number of notifications to return, defaults to 10
          @return: Most recent notifications
          @rtype: List of Notification objects
          """
          return self.notifications.sort(key=lambda notify: notify.created)[:limit]
  
      def next_algorithm(self):
          """ Returns next algo """
          current = self.get_algorithm(self.algorithm)
          return self.get_algorithm(current[1] + 1)
  
      def get_algorithm(self, index):
          """ Return algorithm tuple based on string or int """
          if isinstance(index, basestring) and index in self.algorithms:
              return self.algorithms[index]
          elif isinstance(index, int):  # Find by numeric index
              for key in self.algorithms:
                  if index == self.algorithms[key][1]:
                      return self.algorithms[key]
          return None
  
      def to_dict(self):
          """ Return user data as dictionary """
          return {
              "uuid": self.uuid,
              "handle": self.handle,
              "name": self.name,
              "email": self.email,
              "admin": str(self.is_admin()).lower(),
              "hash_algorithm": self.algorithm,
              "team_uuid": self.team.uuid if self.team else "",
              "avatar": self.avatar,
              "notes": self.notes,
          }
  
      def to_xml(self, parent):
          """
          Admins cannot be exported as XML, not that they would be
          exported because they're not on a team, but check anyways
          """
          if not self.is_admin():
              user_elem = ET.SubElement(parent, "user")
              ET.SubElement(user_elem, "handle").text = self.handle
              ET.SubElement(user_elem, "name").text = self.name
              ET.SubElement(user_elem, "email").text = self.email
              ET.SubElement(user_elem, "password").text = self._password
              ET.SubElement(user_elem, "notes").text = self.notes
              bpass_elem = ET.SubElement(user_elem, "bankpassword")
              bpass_elem.text = self._bank_password
              bpass_elem.set("algorithm", self.algorithm)
              with open(options.avatar_dir + self.avatar) as fp:
                  data = fp.read()
                  ET.SubElement(user_elem, "avatar").text = encode(data, "base64")
  
      def __eq__(self, other):
          return self.id == other.id
  
      def __ne__(self, other):
          return not self.__eq__(other)
  
      def __str__(self):
          return self.handle
  
      def __repr__(self):
          return "<User - handle: %s>" % (self.handle,)
  