#!/usr/bin/python
# -*- coding: utf-8 -*-

# This file is part of Pymetrick.  The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.

"""Modulo para identificar clientes conectados"""

try:
    from pymetrick.version import __author__, __version__, __license__, __copyright__
except ImportError:
    raise ImportError(
        'Failed to find (autogenerated) version.py '
    )

__date__ = '2012-09-21'
__modify__ = '2019-08-26'
__credits__ = ''
__text__ = 'Identificacion de conexiones y funciones de encriptacion'
__file__ = 'session.py'

#--- CHANGES ------------------------------------------------------------------
# 2012-09-21 v0.01 PL: - First version
# 2014-08-30 v0.02 PL: - Second version
# 2015-09-10 v0.03 PL: - Adaptado a python 3.x
# 2017-10-02 v0.43 PL: - Migrar a PYTHON 3.6
# 2018-02-13 v0.44 PL: - Add json web tokens JWT
# 2019-08-26 v0.49.0 PL: - Add encryp and decrypt with AES

import sys
import os
import string
import stat
import hmac
import binascii
import hashlib
import pyaes
import logging
import logging.handlers
import copy
import jwt

try:
    import json
except:
    import simplejson as json

try:
    from itertools import izip as zip
except ImportError: # will be 3.x series
    pass

from datetime import date, time, datetime, timedelta
from random import SystemRandom,choice
from time import localtime,mktime,sleep
from pymetrick.helpers import *
from pymetrick import ipaddress



# Caracteres permitidos en session
ALLOWED_CHARS = '''abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'''
# Para ofuscar la encriptacion de password
SALT_CHARS = '''abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'''
# Caracteres desordenados para generar una password aleatoria
PASSWORD_CHARS = '''BCIOQSVXYZclovxz23478ADEFGHJKLMNPRTUW15690abdefghijkmnpqrstuwy'''
# Numero de veces que se encriptara la password
DEFAULT_PBKDF2_ITERATIONS = 1000
long = int

'''
DEBUG - debug message
INFO - info message
WARNING - warn message
ERROR - error message
CRITICAL - critical message
'''
LOG_LEVEL_VALUE = {'DEBUG':10,'INFO':20,'WARNING':30,'ERROR':40,'CRITICAL':50}
if str(os.environ.get('PYMETRICK_LOG_LEVEL',None)).upper() in LOG_LEVEL_VALUE:
    LOG_LEVEL = LOG_LEVEL_VALUE[str(os.environ.get('PYMETRICK_LOG_LEVEL')).upper()]
else:
    LOG_LEVEL = 30
LOG_FILENAME = '-'.join([os.path.abspath(__file__).split(os.sep)[len(os.path.abspath(__file__).split(os.sep))-1],])[:-3]
LOG = logging.getLogger(LOG_FILENAME)

if 'LD_LIBRARY_PATH' in list(os.environ.keys()):
    # CGI environment
    sys.stdout = sys.stderr
    logging.basicConfig(stream = sys.stderr, level=LOG_LEVEL, format='%(filename)s[line:%(lineno)d] - %(levelname)s - %(message)s')
else:
    # not CGI environment
    logging.basicConfig(stream=sys.stderr)
    hdlr = logging.handlers.RotatingFileHandler(filename=LOG_FILENAME+'.log',mode='a', encoding='utf-8', maxBytes=1048576, backupCount=3)
    formatter = logging.Formatter('%(asctime)s - %(filename)s[line:%(lineno)d] - %(levelname)s - %(message)s')
    hdlr.setFormatter(formatter)
    LOG.addHandler(hdlr)
    LOG.setLevel(LOG_LEVEL)

_builtin_safe_str_cmp = getattr(hmac, 'compare_digest', None)
_sys_rng = SystemRandom()

AUTH_HEADER_NAME = 'Authorization'
JWT_ENCODING_ALGORITHM = 'HS512'
JWT_SECRET_KEY = b'A0B9C8D7E6Z1Y2X3W4V5'
JWT_DEFAULT_LIFETIME = 60 * 60 * 24 * 7     # seconds
#JWT_DEFAULT_LIFETIME = 5     # seconds

class JWTLogin:
    ''' JSON WEB TOKENS stateless authentication '''

    def __init__(self, secret=None, lifetime=None):
        '''
           @Parameters
           secret -> string with secret key
           lifetime -> seconds to expired auth
        '''
        try:
            if not secret:
                self.secret_key = JWT_SECRET_KEY
            else:
                self.secret_key = secret

            if not lifetime:
                self.lifetime = JWT_DEFAULT_LIFETIME
            else:
                self.lifetime = lifetime

            self.encode(payload={'sub':None,'role':None})

        except Exception as e:
            if hasattr(e, 'message'):
                LOG.error('JWTLogin <%s> in line %s !!!' % (e.message,format(sys.exc_info()[-1].tb_lineno)))
            else:
                LOG.error('JWTLogin <%s> in line %s !!!' % (e,format(sys.exc_info()[-1].tb_lineno)))


    def encode(self, payload=dict()):
        '''
        @Parameters
           payload : dict
           lifetima : seconds
        '''

        try:
            payload['iat'] = datetime.utcnow()
            payload['exp'] = payload['iat'] + timedelta(seconds=self.lifetime)
            return jwt.encode(payload, self.secret_key, algorithm= JWT_ENCODING_ALGORITHM)

        except Exception as e:
            if hasattr(e, 'message'):
                LOG.error('JWTLogin.encode <%s> in line %s !!!' % (e.message,format(sys.exc_info()[-1].tb_lineno)))
            else:
                LOG.error('JWTLogin.encode <%s> in line %s !!!' % (e,format(sys.exc_info()[-1].tb_lineno)))

    def decode(self, token=''):
        try:
            return jwt.decode(token, self.secret_key, algorithms=[JWT_ENCODING_ALGORITHM])
        except jwt.ExpiredSignatureError:
            # Signature has expired
            return False        
        except Exception as e:
            if hasattr(e, 'message'):
                LOG.error('JWTLogin.encode <%s> in line %s !!!' % (e.message,format(sys.exc_info()[-1].tb_lineno)))
            else:
                LOG.error('JWTLogin.encode <%s> in line %s !!!' % (e,format(sys.exc_info()[-1].tb_lineno)))
            return False


def safe_str_cmp(a, b):
    '''This function compares strings in somewhat constant time.  This
    requires that the length of at least one string is known in advance.
    Returns `True` if the two strings are equal or `False` if they are not.
    '''
    try:
        if _builtin_safe_str_cmp is not None:
            return _builtin_safe_str_cmp(a, b)
        if len(a) != len(b):
            return False
        rv = 0
        for x, y in zip(a, b):
            rv |= ord(x) ^ ord(y)
        return rv == 0
    except Exception as e:
        if hasattr(e, 'message'):
            LOG.error('safe_str_cmp <%s> in line %s !!!' % (e.message,format(sys.exc_info()[-1].tb_lineno)))
        else:
            LOG.error('safe_str_cmp <%s> in line %s !!!' % (e,format(sys.exc_info()[-1].tb_lineno)))


def gen_salt(length = 8):
    '''Genera una cadena aleatoria de SALT_CHARS
       @Parameters
           length : longitud de cadena de caracteres a obtener
       @Return
            una cadena de caracteres aleatoria

    '''
    try:
        if length <= 0:
            raise ValueError('requested salt of length <= 0')
        return (''.join(_sys_rng.choice(SALT_CHARS) for _ in range(length))).strip()
    except Exception as e:
        if hasattr(e, 'message'):
            LOG.error('gen_salt <%s> in line %s !!!' % (e.message,format(sys.exc_info()[-1].tb_lineno)))
        else:
            LOG.error('gen_salt <%s> in line %s !!!' % (e,format(sys.exc_info()[-1].tb_lineno)))

def _find_hashlib_algorithms():
    try:
        algos = getattr(hashlib, 'algorithms', None)
        if algos is None:
            algos = ('md5', 'sha1', 'sha224', 'sha256', 'sha384', 'sha512')
        # mostrar metodos disponibles
        rv = {}
        for algo in algos:
            func = getattr(hashlib, algo, None)
            if func is not None:
                rv[algo] = func
        return rv
    except Exception as e:
        if hasattr(e, 'message'):
            LOG.error('_find_hashlib_algorithms <%s> in line %s !!!' % (e.message,format(sys.exc_info()[-1].tb_lineno)))
        else:
            LOG.error('_find_hashlib_algorithms <%s> in line %s !!!' % (e,format(sys.exc_info()[-1].tb_lineno)))

_hash_funcs = _find_hashlib_algorithms()

def pbkdf2_hex(data, salt, iterations=DEFAULT_PBKDF2_ITERATIONS,
               keylen=None, hashfunc=None):
    '''como la funcion 'pbkdf2_bin' pero devuelve una cadena hexadecimal.

    @Parameters
        data: the data to derive.
        salt: the salt for the derivation.
        iterations: the number of iterations.
        keylen: the length of the resulting key.  If not provided
                   the digest size will be used.
        hashfunc: the hash function to use.  This can either be the
                     string name of a known hash function or a function
                     from the hashlib module.  Defaults to sha1.
    '''
    try:
        rv = pbkdf2_bin(data, salt, iterations, keylen, hashfunc)
        return to_native(codecs.encode(rv, 'hex_codec'))
    except Exception as e:
        if hasattr(e, 'message'):
            LOG.error('pbkdf2_hex <%s> in line %s !!!' % (e.message,format(sys.exc_info()[-1].tb_lineno)))
        else:
            LOG.error('pbkdf2_hex <%s> in line %s !!!' % (e,format(sys.exc_info()[-1].tb_lineno)))


def pbkdf2_bin(data, salt, iterations=DEFAULT_PBKDF2_ITERATIONS,
               keylen=None, hashfunc=None):
    '''Returns a binary digest for the PBKDF2 hash algorithm of `data`
    with the given `salt`. It iterates `iterations` time and produces a
    key of `keylen` bytes. By default SHA-1 is used as hash function,
    a different hashlib `hashfunc` can be provided.

    @Parameters
        data: the data to derive.
        salt: the salt for the derivation.
        iterations: the number of iterations.
        keylen: the length of the resulting key.  If not provided
                   the digest size will be used.
        hashfunc: the hash function to use.  This can either be the
                     string name of a known hash function or a function
                     from the hashlib module.  Defaults to sha1.
    '''
    try:
        if isinstance(hashfunc, string_types):
            hashfunc = _hash_funcs[hashfunc]
        elif not hashfunc:
            hashfunc = hashlib.sha1
        salt = to_bytes(salt)
        mac = hmac.HMAC(to_bytes(data), None, hashfunc)
        if not keylen:
            keylen = mac.digest_size
        def _pseudorandom(x, mac=mac):
            h = copy.copy(mac)
            h.update(x)
            return bytearray(h.digest())
        buf = bytearray()
        for block in range_type(1, -(-keylen // mac.digest_size) + 1):
            rv = u = _pseudorandom(salt + _pack_int(block))
            for i in range_type(iterations - 1):
                u = _pseudorandom(bytes(u))
                rv = bytearray(starmap(xor, izip(rv, u)))
            buf.extend(rv)
        return bytes(buf[:keylen])
    except Exception as e:
        if hasattr(e, 'message'):
            LOG.error('pbkdf2_bin <%s> in line %s !!!' % (e.message,format(sys.exc_info()[-1].tb_lineno)))
        else:
            LOG.error('pbkdf2_bin <%s> in line %s !!!' % (e,format(sys.exc_info()[-1].tb_lineno)))

def _hash_internal(method, salt , password):
    '''Contraseña Interna ayudante hash. Soporta texto claro sin ofuscar,
       contraseñas sin ofuscar y ofuscada. En el caso de contraseñas ofuscadas se utiliza
       HMAC.
    @Parameters
        method : metodo para encriptar la password, permite devolver el texto sin encriptar con 'plain'
        salt   : cadema de caracteres que permitira ofuscar los caracteres que componen la password
        password : texto que deseamos tratar.
    @Return
        tuple( texto de password encriptado y/o ofuscado, metodo de encriptacion)
    '''
    try:
        if method == 'plain':
            return password, method

        password = password.encode('utf-8')

        if method.startswith('pbkdf2:'):
            args = method[7:].split(':')
            if len(args) not in (1, 2):
                raise ValueError('Invalid number of arguments for PBKDF2')
            method = args.pop(0)
            iterations = args and int(args[0] or 0) or DEFAULT_PBKDF2_ITERATIONS
            is_pbkdf2 = True
            actual_method = 'pbkdf2:%s:%d' % (method, iterations)
        else:
            is_pbkdf2 = False
            actual_method = method

        hash_func = _hash_funcs.get(method)
        if hash_func is None:
            raise TypeError('invalid method %r' % method)

        if is_pbkdf2:
            if not salt:
                raise ValueError('Salt is required for PBKDF2')
            rv = pbkdf2_hex(password, salt, iterations,
                            hashfunc=hash_func)
        elif salt:
            salt = salt.encode('utf-8')
            rv = hmac.HMAC(salt, password, hash_func).hexdigest()
        else:
            h = hash_func()
            h.update(password)
            rv = h.hexdigest()
        return rv, actual_method
    except Exception as e:
        if hasattr(e, 'message'):
            LOG.error('_hash_internal <%s> in line %s !!!' % (e.message,format(sys.exc_info()[-1].tb_lineno)))
        else:
            LOG.error('_hash_internal <%s> in line %s !!!' % (e,format(sys.exc_info()[-1].tb_lineno)))

def generate_password_hash(password, method='sha1', salt_length=8):
    '''Encripta una contraseña con el método dado y derivada de ofuscacion con la longitud
    dada. El formato de la cadena devuelta incluye el método que se utilizó de forma que
    `check_password_hash` pueda comprobar el hash.

    El formato de la cadena hash se ve así::

        method$salt$hash

    Este método no puede generar contraseñas sin ofuscacion, pero es posible
    generar las contraseñas en texto plano. Si utiliza derivada de ofuscacion,
    HMAC se utiliza internamente para ofuscar la contraseña.

    @parameters
        password: cadena de caracteres a encriptar
        method: metodo de encriptacion empleado (soportado por hashlib)
        salt_length: numero de caracteres usados de ofuscar la contraseña
    '''
    try:
        salt = method != 'plain' and gen_salt(salt_length) or ''
        h, actual_method = _hash_internal(method, salt, password)
        return '%s$%s$%s' % (actual_method, salt, h)
    except Exception as e:
        if hasattr(e, 'message'):
            LOG.error('generate_password_hash <%s> in line %s !!!' % (e.message,format(sys.exc_info()[-1].tb_lineno)))
        else:
            LOG.error('generate_password_hash <%s> in line %s !!!' % (e,format(sys.exc_info()[-1].tb_lineno)))


def check_password_hash(pwhash, password):
    '''comprobar una contraseña con un valor de contraseña dada salada y hash.
    Con el fin de apoyar las contraseñas sin sal heredados Este método admite
    contraseñas de texto sin formato MD5 y SHA1 hashes (tanto con y sin sal).

    Returns `True` if the password matched, `False` otherwise.
    @Parameters
        pwhash: una password encriptada como la que se genera desde generate_password_hash()
        password: texto sin encriptar, correspondiente a la password, para comparar.
    @Return
        'True' si la password coincide o 'False' en cualquier otro caso.
    '''
    try:
        if pwhash.count('$') < 2:
            return False
        method, salt, hashval = pwhash.split('$', 2)
        return safe_str_cmp(_hash_internal(method, salt, password)[0], hashval)
    except Exception as e:
        if hasattr(e, 'message'):
            LOG.error('check_password_hash <%s> in line %s !!!' % (e.message,format(sys.exc_info()[-1].tb_lineno)))
        else:
            LOG.error('check_password_hash <%s> in line %s !!!' % (e,format(sys.exc_info()[-1].tb_lineno)))

def csrf_token(csrf_length=32):
    '''genera un string con valores aleatorios con una longitud determinada
       orientado al Cross Site Request Forgery protection
       puede ser usado con las comunicaciones ajax
       en la sesion del servidor deberá indicarse como csrftoken y se indicara
       el valor aleatorio en cada pagina generada, recibiendo el mismo valor
       cuando se realice una solicitud ajax o envío de un formulario GET, POST,
       PUT, DELETE.
       @Parameters
           csrf_length : longitud de la cadena de caracteres a obtener
       @Return
           cadena de caracteres aleatoria con la longitud solicitada
    '''
    try:
        return (gen_salt(csrf_length))
    except Exception as e:
        if hasattr(e, 'message'):
            LOG.error('csrf_token <%s> in line %s !!!' % (e.message,format(sys.exc_info()[-1].tb_lineno)))
        else:
            LOG.error('csrf_token <%s> in line %s !!!' % (e,format(sys.exc_info()[-1].tb_lineno)))

def new_password(length = 15, chars = None):
    '''Genera password aleatoria dependiendo de la cadena de caracteres recibida o cadena por defecto
       @Parameters
                length = longitud de password
                chars  = cadena de caracteres para generacion aleatoria de password, aunque por defecto
                         puede utilizar una cadena propia
       @Return
                String = password sin encriptacion de la longitud solicitada u 8 caracteres por defecto
    '''
    try:
        local_chars = PASSWORD_CHARS if(chars) is None else chars;
        if len(local_chars)<30:
            # si la cadena recibida contiene menos de 30 caracteres se amplia de letras y numeros
            local_chars = string.letters + string.digits
        return ''.join(choice(local_chars) for _ in range(length))
    except Exception as e:
        if hasattr(e, 'message'):
            LOG.error('new_password <%s> in line %s !!!' % (e.message,format(sys.exc_info()[-1].tb_lineno)))
        else:
            LOG.error('new_password <%s> in line %s !!!' % (e,format(sys.exc_info()[-1].tb_lineno)))

def file2checksums(__path__, blocksize=65536):
    '''obtener el hash de un fichero para identificarlo de forma unica
    '''
    try:
        hash = hashlib.md5()
        with open(__path__, "rb", encoding='utf-8') as f:
            block_read = ( lambda: f.read( blocksize ))
            for block in iter( block_read, b'' ):
                hash.update( block )
        return hash.hexdigest()
    except Exception as e:
        if hasattr(e, 'message'):
            LOG.error('file2checksums <%s> in line %s !!!' % (e.message,format(sys.exc_info()[-1].tb_lineno)))
        else:
            LOG.error('file2checksums <%s> in line %s !!!' % (e,format(sys.exc_info()[-1].tb_lineno)))

# AES encrypt or decrypt
def encryptAES(data,key=None):
    if key is None or key.length()<33:
        # 256 bit keys
        key = PASSWORD_CHARS[0:32]
    key = str2bytes(key,'utf-8')
    aes = pyaes.AESModeOfOperationCTR(key)    
    ciphertext = aes.encrypt(data)
    return ciphertext

def decryptAES(data,key=None):
    if key is None or key.length()<33:
        # 256 bit keys
        key = PASSWORD_CHARS[0:32]
    key = key.encode('utf-8')
    aes = pyaes.AESModeOfOperationCTR(key)    
    plaintext = bytes2str(aes.decrypt(data),'utf-8')
    return plaintext

if __name__ == "__main__":
    print ('''copyright {0}'''.format( __copyright__))
    print ('''license {0}'''.format( __license__))
    print ('''version {0}'''.format( __version__))
    if len(sys.argv) < 2:
        sys.stderr.write("For help use -h o --help")
    elif sys.argv[1]=='-h' or sys.argv[1]=='--help':
        print ('''Gestion de seguridad de acceso ''')

    
    

