# authentic2 - versatile identity manager
# Copyright (C) 2010-2019 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import base64
import hashlib
import math
from binascii import hexlify, unhexlify
from collections import OrderedDict

from django.contrib.auth import hashers
from django.contrib.auth.hashers import make_password
from django.utils.crypto import constant_time_compare
from django.utils.encoding import force_bytes, force_text
from django.utils.translation import ugettext_noop as _


class Drupal7PasswordHasher(hashers.BasePasswordHasher):
    """
    Secure password hashing using the algorithm used by Drupal 7 (recommended)
    """

    algorithm = "drupal7_sha512"
    iterations = 10000
    digest = hashlib.sha512
    alphabet = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'

    def atoi64(self, v):
        return self.alphabet.find(v)

    def i64toa(self, v):
        return self.alphabet[v]

    def b64encode(self, v):
        out = ''
        count = len(v)
        i = 0
        while i < count:
            value = v[i]
            i += 1
            out += self.i64toa(value & 0x3F)
            if i < count:
                value |= v[i] << 8
            out += self.i64toa((value >> 6) & 0x3F)
            if i == count:
                break
            i += 1
            if i < count:
                value |= v[i] << 16
            out += self.i64toa((value >> 12) & 0x3F)
            if i == count:
                break
            i += 1
            out += self.i64toa((value >> 18) & 0x3F)
        return out

    def from_drupal(self, encoded):
        ident, log_count, salt, h = encoded[:3], encoded[3], encoded[4:12], encoded[12:]
        if ident != '$S$':
            raise ValueError('Not a Drupal7 SHA-512 hashed password')
        count = 1 << self.atoi64(log_count)
        return '%s$%s$%s$%s' % (self.algorithm, count, salt, h)

    def to_drupal(self, encoded):
        algo, count, salt, h = encoded.split('$', 3)
        count = self.i64toa(math.ceil(math.log(int(count), 2)))
        return '$S$%s%s%s' % (count, salt, h)

    def encode(self, password, salt, iterations):
        assert password
        assert salt and '$' not in salt
        h = salt.encode()
        password = password.encode()
        for i in range(iterations + 1):
            h = self.digest(h + password).digest()
        return "%s$%d$%s$%s" % (self.algorithm, iterations, salt, self.b64encode(h)[:43])

    def verify(self, password, encoded):
        algorithm, iterations, salt, hash = encoded.split('$', 3)
        assert algorithm == self.algorithm
        encoded_2 = self.encode(password, salt, int(iterations))
        return constant_time_compare(encoded, encoded_2)

    def safe_summary(self, encoded):
        algorithm, iterations, salt, hash = encoded.split('$', 3)
        assert algorithm == self.algorithm
        return OrderedDict(
            [
                (_('algorithm'), algorithm),
                (_('iterations'), iterations),
                (_('salt'), hashers.mask_hash(salt)),
                (_('hash'), hashers.mask_hash(hash)),
            ]
        )


class CommonPasswordHasher(hashers.BasePasswordHasher):
    """
    The Salted MD5 password hashing algorithm (not recommended)
    """

    algorithm = None
    digest = None

    def encode(self, password, salt):
        assert password
        assert '$' not in salt
        hash = self.digest(force_bytes(salt + password)).hexdigest()
        return "%s$%s$%s" % (self.algorithm, salt, hash)

    def verify(self, password, encoded):
        algorithm, salt, hash = encoded.split('$', 2)
        assert algorithm == self.algorithm
        encoded_2 = self.encode(password, salt)
        return constant_time_compare(encoded, encoded_2)

    def safe_summary(self, encoded):
        algorithm, salt, hash = encoded.split('$', 2)
        assert algorithm == self.algorithm
        return OrderedDict(
            [
                (_('algorithm'), algorithm),
                (_('salt'), hashers.mask_hash(salt, show=2)),
                (_('hash'), hashers.mask_hash(hash)),
            ]
        )


OPENLDAP_ALGO_MAPPING = {
    'SHA': ('sha-oldap', 0, True),
    'SSHA': ('ssha-oldap', 20, True),
    'MD5': ('md5-oldap', 0, True),
    'SMD5': ('md5-oldap', 16, True),
}


def olap_password_to_dj(password):
    '''Convert an LDAP password for Django use eventually hashed'''
    if password[0] == '{' and '}' in password:
        algo = password[1:].split('}')[0].upper()
        if algo not in OPENLDAP_ALGO_MAPPING:
            raise ValueError('unknown algorithm %r' % algo)
        password = password[1:].split('}')[1]
        try:
            password = base64.b64decode(password)
        except ValueError:
            raise ValueError('unable to decode base64 hash %r' % password)
        algo_name, salt_offset, hex_encode = OPENLDAP_ALGO_MAPPING[algo]
        salt, password = (password[salt_offset:], password[:salt_offset]) if salt_offset else ('', password)
        if hex_encode:
            password = force_text(hexlify(password), encoding='ascii')
        salt = force_text(hexlify(force_bytes(salt)), encoding='ascii')
        return '%s$%s$%s' % (algo_name, salt, password)
    else:
        return make_password(password)


class OpenLDAPPasswordHasher(CommonPasswordHasher):
    def encode(self, password, salt):
        assert password
        assert b'$' not in salt
        hash = self.digest(force_bytes(password + salt)).hexdigest()
        salt = force_text(hexlify(salt), encoding='ascii')
        return "%s$%s$%s" % (self.algorithm, salt, hash)

    def verify(self, password, encoded):
        algorithm, salt, hash = encoded.split('$', 2)
        hash = unhexlify(hash)
        salt = unhexlify(salt)
        assert algorithm == self.algorithm
        encoded_2 = self.encode(force_bytes(password), salt)
        return constant_time_compare(encoded, encoded_2)


class SHA256PasswordHasher(CommonPasswordHasher):
    algorithm = 'sha256'
    digest = hashlib.sha256


class SSHA1PasswordHasher(OpenLDAPPasswordHasher):
    algorithm = 'ssha-oldap'
    digest = hashlib.sha1


class SMD5PasswordHasher(OpenLDAPPasswordHasher):
    algorithm = 'smd5-oldap'
    digest = hashlib.md5


class SHA1OLDAPPasswordHasher(OpenLDAPPasswordHasher):
    algorithm = 'sha-oldap'
    digest = hashlib.sha1

    def salt(self):
        return ''


class MD5OLDAPPasswordHasher(OpenLDAPPasswordHasher):
    algorithm = 'md5-oldap'
    digest = hashlib.md5

    def salt(self):
        return ''


class JoomlaPasswordHasher(CommonPasswordHasher):
    algorithm = 'joomla'
    digest = hashlib.md5

    def encode(self, password, salt):
        assert password
        assert b'$' not in salt
        hash = self.digest(force_bytes(password) + salt).hexdigest()
        salt = force_text(hexlify(force_bytes(salt)), encoding='ascii')
        return "%s$md5$%s$%s" % (self.algorithm, salt, hash)

    def verify(self, password, encoded):
        algorithm, subalgo, salt, hash = encoded.split('$', 3)
        salt = unhexlify(salt)
        if algorithm != self.algorithm:
            raise ValueError('not a joomla encoded password')
        encoded_2 = self.encode(password, salt)
        return constant_time_compare(encoded, encoded_2)

    @classmethod
    def from_joomla(cls, encoded):
        if encoded.startswith('$P$'):
            raise NotImplementedError
        elif encoded.startswith('$'):
            raise NotImplementedError
        elif encoded.startswith('{SHA256}'):
            raise NotImplementedError
        else:
            if ':' in encoded:
                h, salt = encoded.split(':', 1)
            else:
                h, salt = encoded, ''
            salt = force_text(hexlify(force_bytes(salt)), encoding='ascii')
            return '%s$md5$%s$%s' % (cls.algorithm, salt, h)

    @classmethod
    def to_joomla(cls, encoded):
        algorithm, subalgo, salt, _hash = encoded.split('$', 4)
        if algorithm != cls.algorithm:
            raise ValueError('not a joomla encoded password')
        if subalgo != 'md5':
            raise NotImplementedError
        if salt:
            return '%s:%s' % (_hash, force_text(unhexlify(force_bytes(salt))))
        else:
            return _hash


class PloneSHA1PasswordHasher(hashers.SHA1PasswordHasher):
    # from https://www.fourdigits.nl/blog/converting-plone-data-to-django/
    """
    The SHA1 password hashing algorithm used by Plone.

    Plone uses `password + salt`, Django has `salt + password`.
    """

    algorithm = "plonesha1"
    _prefix = '{SSHA}'

    def encode(self, password, salt):
        """Encode a plain text password into a plonesha1 style hash."""
        assert password is not None
        assert salt
        password = force_bytes(password)
        salt = force_bytes(salt)

        hashed = base64.b64encode(hashlib.sha1(password + salt).digest() + salt)
        return "%s$%s%s" % (self.algorithm, self._prefix, force_text(hashed))

    def verify(self, password, encoded):
        """Verify the given password against the encoded string."""
        algorithm, data = encoded.split('$', 1)
        assert algorithm == self.algorithm

        # throw away the prefix
        if data.startswith(self._prefix):
            data = data[len(self._prefix) :]

        # extract salt from encoded data
        intermediate = base64.b64decode(data)
        salt = intermediate[20:].strip()

        password_encoded = self.encode(password, salt)
        return constant_time_compare(password_encoded, encoded)

    def safe_summary(self, encoded):
        algorithm, hash = encoded.split('$', 1)
        assert algorithm == self.algorithm
        return OrderedDict(
            [
                (_('algorithm'), algorithm),
                (_('hash'), hashers.mask_hash(hash)),
            ]
        )
