Commits

Zhang Huangbin committed 1e10be7

[mysql/pgsql] New password schemes support: SSHA, SSHA512.
Note: SSHA512 requires Dovecot-2.0 (and later), Python-2.5 (or later).

  • Participants
  • Parent commits 5397d5e

Comments (0)

Files changed (9)

 = 0.2.1 =
+    * Improvements:
+        + New password schemes support: SSHA, SSHA512.
+          Note: SSHA512 requires Dovecot-2.0 (and later), Python-2.5 (or
+          later).
+
     * Updated translations:
         + Update Czech (cs_CZ). Thanks Roman Pudil <roman _at_ webhosting.fm>.
 

libs/iredutils.py

 # encoding: utf-8
 # Author: Zhang Huangbin <zhb@iredmail.org>
 
+from os import urandom
 import gettext
 import re
 import datetime
 import time
 import urllib2
 import socket
+from base64 import b64encode, b64decode
 from xml.dom.minidom import parseString as parseXMLString
 import random
 import web
     return "".join(random.choice(chars) for x in range(length))
 
 
-def getMD5Password(p):
+def generate_md5_password(p):
     p = str(p).strip()
     return md5crypt.unix_md5_crypt(p, getRandomPassword(length=8))
 
 
-def getPlainMD5Password(p):
-    p = str(p)
+def verify_md5_password(challenge_password, plain_password):
+    """Verify salted MD5 password"""
+    if challenge_password.startswith('{MD5}'):
+        challenge_password = challenge_password.replace('{MD5}', '')
+
+    if not (
+        challenge_password.startswith('$') \
+        and len(challenge_password) == 34 \
+        and challenge_password.count('$') == 3):
+        return False
+
+    # Get salt from hashed string
+    salt = challenge_password.split('$')
+    salt[-1] = ''
+    salt = '$'.join(salt)
+
+    if md5crypt.md5crypt(p, salt) == p:
+        return True
+    else:
+        return False
+
+def generate_plain_md5_password(p):
+    p = str(p).strip()
     try:
         from hashlib import md5
         return md5(p).hexdigest()
     return p
 
 
-def getSQLPassword(p, pwscheme=settings.SQL_DEFAULT_PASSWD_SCHEME):
-    p = str(p)
-    pw = p
+def verify_plain_md5_password(challenge_password, plain_password):
+    if challenge_password.startswith('{PLAIN-MD5}'):
+        challenge_password = challenge_password.replace('{PLAIN-MD5}', '')
+
+    if challenge_password == generate_plain_md5_password(plain_password):
+        return True
+    else:
+        return False
+
+def generate_ssha_password(p):
+    p = str(p).strip()
+    salt = urandom(8)
+    try:
+        from hashlib import sha1
+        pw = sha1(p)
+    except ImportError:
+        import sha
+        pw = sha.new(p)
+    pw.update(salt)
+    return "{SSHA}" + b64encode(pw.digest() + salt)
+
+
+def verify_ssha_password(challenge_password, plain_password):
+    """Verify SSHA (salted SHA) hash with or without prefix '{SSHA}'"""
+    if challenge_password.startswith('{SSHA}'):
+        challenge_password = challenge_password.replace('{SSHA}', '')
+
+    if not len(challenge_password) > 20:
+        # Not a valid SSHA hash
+        return False
+
+    try:
+        challenge_bytes = b64decode(challenge_password)
+        digest = challenge_bytes[:20]
+        salt = challenge_bytes[20:]
+        try:
+            from hashlib import sha1
+            hr = sha1(plain_password)
+        except ImportError:
+            import sha
+            hr = sha.new(plain_password)
+        hr.update(salt)
+        return digest == hr.digest()
+    except:
+        return False
+
+
+def generate_ssha512_password(p):
+    """Generate salted SHA512 password with prefix '{SSHA512}'.
+    Return salted SHA hash if python is older than 2.5 (module hashlib)."""
+    p = str(p).strip()
+    try:
+        from hashlib import sha512
+        salt = urandom(8)
+        pw = sha512(p)
+        pw.update(salt)
+        return "{SSHA512}" + b64encode(pw.digest() + salt)
+    except ImportError:
+        # Use SSHA password instead if python is older than 2.5.
+        return generate_ssha_password(p)
+
+
+def verify_ssha512_password(challenge_password, plain_password):
+    """Verify SSHA512 password with or without prefix '{SSHA512}'.
+    Python-2.5 is required since it requires module hashlib."""
+    if challenge_password.startswith('{SSHA512}'):
+        challenge_password = challenge_password.replace('{SSHA512}', '')
+
+    # With SSHA512, hash itself is 64 bytes (512 bits/8 bits per byte),
+    # everything after that 64 bytes is the salt.
+    if not len(challenge_password) > 64:
+        return False
+
+    try:
+        challenge_bytes = b64decode(challenge_password)
+        digest = challenge_bytes[:64]
+        salt = challenge_bytes[64:]
+
+        from hashlib import sha512
+        hr = sha512(plain_password)
+        hr.update(salt)
+
+        return digest == hr.digest()
+    except:
+        return False
+
+
+def generate_password_for_sql_mail_account(p, pwscheme=None):
+    """Generate password for mail user for MySQL/PostgreSQL backend."""
+    pw = str(p).strip()
+
+    if not pwscheme:
+        pwscheme = settings.SQL_DEFAULT_PASSWD_SCHEME
 
     if pwscheme == 'MD5':
-        pw = getMD5Password(p)
+        pw = generate_md5_password(p)
     elif pwscheme == 'PLAIN-MD5':
-        pw = getPlainMD5Password(p)
+        pw = generate_plain_md5_password(p)
     elif pwscheme == 'PLAIN':
         backend = cfg.general.get('backend', 'mysql')
-        if backend == 'mysql':
+        if backend in ['mysql', 'pgsql']:
             if settings.SQL_PASSWD_PREFIX_SCHEME is True:
                 pw = '{PLAIN}' + p
             else:
                 pw = p
         elif backend == 'dbmail_mysql':
             pw = p
+    elif pwscheme == 'SSHA':
+        pw = generate_ssha_password(p)
+    elif pwscheme == 'SSHA512':
+        pw = generate_ssha512_password(p)
 
     return pw
 

libs/mysql/admin.py

                 'admin',
                 username=self.mail,
                 name=self.cn,
-                password=iredutils.getSQLPassword(self.passwd),
+                password=iredutils.generate_password_for_sql_mail_account(self.passwd),
                 language=self.preferredLanguage,
                 created=iredutils.getGMTTime(),
                 active='1',
             # Verify new passwords.
             qr = iredutils.verifyNewPasswords(self.newpw, self.confirmpw)
             if qr[0] is True:
-                self.passwd = iredutils.getSQLPassword(qr[1])
+                self.passwd = iredutils.generate_password_for_sql_mail_account(qr[1])
             else:
                 return qr
 

libs/mysql/core.py

         record = result[0]
         password_sql = str(record.password)
 
-        # Verify password.
+        # Verify password
         authenticated = False
-        if password_sql.startswith('$') and len(password_sql) == 34 and password_sql.count('$') == 3:
-            # Password is considered as a MD5 password (with salt).
-            # Get salt string from password which stored in SQL.
-            tmpsalt = password_sql.split('$')
-            tmpsalt[-1] = ''
-            salt = '$'.join(tmpsalt)
-
-            if md5crypt.md5crypt(password, salt) == password_sql:
-                authenticated = True
-
-        elif password_sql == iredutils.getPlainMD5Password(password):
-            # Plain MD5
-            authenticated = True
-        elif password_sql.upper().startswith('{PLAIN-MD5}'):
-            if password_sql == '{PLAIN-MD5}' + iredutils.getPlainMD5Password(password):
-                authenticated = True
-        elif password_sql.upper().startswith('{PLAIN}'):
-            # Plain password with prefix '{PLAIN}'.
-            if password_sql == '{PLAIN}' + password:
-                authenticated = True
-        elif password_sql == password:
-            # Plain password.
+        if iredutils.verify_md5_password(password_sql, password) \
+           or iredutils.verify_plain_md5_password(password_sql, password) \
+           or password_sql in [password, '{PLAIN}' + password] \
+           or iredutils.verify_ssha_password(password_sql, password) \
+           or iredutils.verify_ssha512_password(password_sql, password):
             authenticated = True
 
-        # Compare passwords.
         if authenticated is False:
             return (False, 'INVALID_CREDENTIALS')
 

libs/mysql/user.py

             max_passwd_length=maxPasswordLength,
         )
         if resultOfPW[0] is True:
+            pwscheme = None
             if 'storePasswordInPlainText' in data and settings.STORE_PASSWORD_IN_PLAIN:
-                passwd = iredutils.getSQLPassword(resultOfPW[1], pwscheme='PLAIN')
-            else:
-                passwd = iredutils.getSQLPassword(resultOfPW[1])
+                pwscheme = 'PLAIN'
+            passwd = iredutils.generate_password_for_sql_mail_account(resultOfPW[1], pwscheme=pwscheme)
         else:
             return resultOfPW
 
             # Verify new passwords.
             qr = iredutils.verifyNewPasswords(newpw, confirmpw)
             if qr[0] is True:
+                pwscheme = None
                 if 'storePasswordInPlainText' in data and settings.STORE_PASSWORD_IN_PLAIN:
-                    passwd = iredutils.getSQLPassword(qr[1], pwscheme='PLAIN')
-                else:
-                    passwd = iredutils.getSQLPassword(qr[1])
+                    pwscheme = 'PLAIN'
+                passwd = iredutils.generate_password_for_sql_mail_account(qr[1], pwscheme=pwscheme)
             else:
                 return qr
 

libs/pgsql/admin.py

                 'admin',
                 username=self.mail,
                 name=self.cn,
-                password=iredutils.getSQLPassword(self.passwd),
+                password=iredutils.generate_password_for_sql_mail_account(self.passwd),
                 language=self.preferredLanguage,
                 created=iredutils.getGMTTime(),
                 active='1',
             # Verify new passwords.
             qr = iredutils.verifyNewPasswords(self.newpw, self.confirmpw)
             if qr[0] is True:
-                self.passwd = iredutils.getSQLPassword(qr[1])
+                self.passwd = iredutils.generate_password_for_sql_mail_account(qr[1])
             else:
                 return qr
 

libs/pgsql/core.py

         record = result[0]
         password_sql = str(record.password)
 
-        # Verify password.
+        # Verify password
         authenticated = False
-        if password_sql.startswith('$') and len(password_sql) == 34 and password_sql.count('$') == 3:
-            # Password is considered as a MD5 password (with salt).
-            # Get salt string from password which stored in SQL.
-            tmpsalt = password_sql.split('$')
-            tmpsalt[-1] = ''
-            salt = '$'.join(tmpsalt)
-
-            if md5crypt.md5crypt(password, salt) == password_sql:
-                authenticated = True
-
-        elif password_sql == iredutils.getPlainMD5Password(password):
-            # Plain MD5
-            authenticated = True
-        elif password_sql.upper().startswith('{PLAIN-MD5}'):
-            if password_sql == '{PLAIN-MD5}' + iredutils.getPlainMD5Password(password):
-                authenticated = True
-        elif password_sql.upper().startswith('{PLAIN}'):
-            # Plain password with prefix '{PLAIN}'.
-            if password_sql == '{PLAIN}' + password:
-                authenticated = True
-        elif password_sql == password:
-            # Plain password.
+        if iredutils.verify_md5_password(password_sql, password) \
+           or iredutils.verify_plain_md5_password(password_sql, password) \
+           or password_sql in [password, '{PLAIN}' + password] \
+           or iredutils.verify_ssha_password(password_sql, password) \
+           or iredutils.verify_ssha512_password(password_sql, password):
             authenticated = True
 
-        # Compare passwords.
         if authenticated is False:
             return (False, 'INVALID_CREDENTIALS')
 

libs/pgsql/user.py

             max_passwd_length=maxPasswordLength,
         )
         if resultOfPW[0] is True:
+            pwscheme = None
             if 'storePasswordInPlainText' in data and settings.STORE_PASSWORD_IN_PLAIN:
-                passwd = iredutils.getSQLPassword(resultOfPW[1], pwscheme='PLAIN')
-            else:
-                passwd = iredutils.getSQLPassword(resultOfPW[1])
+                pwscheme = 'PLAIN'
+            passwd = iredutils.generate_password_for_sql_mail_account(resultOfPW[1], pwscheme=pwscheme)
         else:
             return resultOfPW
 
             # Verify new passwords.
             qr = iredutils.verifyNewPasswords(newpw, confirmpw)
             if qr[0] is True:
+                pwscheme = None
                 if 'storePasswordInPlainText' in data and settings.STORE_PASSWORD_IN_PLAIN:
-                    passwd = iredutils.getSQLPassword(qr[1], pwscheme='PLAIN')
-                else:
-                    passwd = iredutils.getSQLPassword(qr[1])
+                    pwscheme = 'PLAIN'
+                passwd = iredutils.generate_password_for_sql_mail_account(qr[1], pwscheme=pwscheme)
             else:
                 return qr
 
 # upgrading iRedAdmin-Pro.
 #################################### WARNING ####################################
 
+# Set http proxy server address if iRedAdmin cannot access internet
+# (iredmail.org) directly.
+# Sample:
+#   HTTP_PROXY = 'http://192.168.1.1:3128'
+HTTP_PROXY = ''
+
 # Local timezone. It must be one of below:
 #   GMT-12:00
 #   GMT-11:00
 # See LDAP_DEFAULT_PASSWD_SCHEME and SQL_DEFAULT_PASSWD_SCHEME below.
 STORE_PASSWORD_IN_PLAIN = False
 
+# Print PERMISSION_DENIED related programming info to stdout or web server
+# log file. e.g. Apache log file.
+LOG_PERMISSION_DENIED = False
+
+# Redirect to "Domains and Accounts" page instead of Dashboard.
+REDIRECT_TO_DOMAIN_LIST_AFTER_LOGIN = False
+
 ###################################
 # Maildir related.
 #
 LDAP_DEFAULT_PASSWD_SCHEME = 'SSHA'
 
 #######################################
-# MySQL backend related settings. Note: Not applicable for DBMail.
+# MySQL/PostgreSQL backends related settings. Note: Not applicable for DBMail.
 #
 
-# Default password scheme: MD5, PLAIN-MD5, PLAIN.
+# Default password scheme: MD5, SSHA, SSHA512, PLAIN-MD5, PLAIN.
 #
 # Passwords of new accounts (admin, user) will be crypted by specified scheme.
-# - MD5: MD5 based salted password hash. e.g. '$1$ozdpg0V0$0fb643pVsPtHVPX8mCZYW/'.
-# - PLAIN-MD5: MD5 based password without salt. e.g. 900150983cd24fb0d6963f7d28e17f72.
+#
+# - MD5: MD5 based salted password hash.
+#       Example: '$1$ozdpg0V0$0fb643pVsPtHVPX8mCZYW/'.
+#
+# - SSHA: {SSHA} is RFC 2307 password scheme which use the SHA1 secure hash
+#       algorithm. The {SSHA} is the seeded varient. {SSHA} is recommended
+#       over other RFC 2307 schemes.
+#       Example: {SSHA}bfxqKqOOKODJw/bGqMo54f9Q/iOvQoftOQrqWA==
+#
+# - SSHA512: {SSHA512} is salted SHA512 which uses the SHA2 secure hash
+#       algorithm, SSHA512 is better than SSHA.
+#       Example: {SSHA512}FxgXDhBVYmTqoboW+ibyyzPv/wGG7y4VJtuHWrx+wfqrs/lIH2Qxn2eA0jygXtBhMvRi7GNFmL++6aAZ0kXpcy1fxag=
+#       Note: SSHA512 support requires Dovecot-2.0 (and later) and Python-2.5
+#             (and later).
+#
+# - PLAIN-MD5: MD5 based password without salt.
+#       Example: 900150983cd24fb0d6963f7d28e17f72.
+#
 # - PLAIN: Plain text.
 #
 # Reference:
 # Amavisd related settings.
 #
 
-# Automatically remove SQL records of sent/received mails in Amavisd database
-# when viewing sent/received mails. Only one time in each login session.
-# Default is 90 days. Set to 0 to keep them forever.
-AMAVISD_REMOVE_MAILLOG_IN_DAYS = 30
+# Remove old SQL records of sent/received mails in Amavisd database.
+# NOTE: require cron job with script tools/cleanup_amavisd_db.py.
+AMAVISD_REMOVE_MAILLOG_IN_DAYS = 7
 
-# Automatically remove SQL records of quarantined mails which older than
-# specified days when list quarantined mails. Only one time in each login
-# session.
+# Remove old SQL records of quarantined mails.
 # Since quarantined mails may take much disk space, it's better to release
 # or remove them as soon as possible.
-# Default is 30 days. Set to 0 to keep them forever.
+# NOTE: require cron job with script tools/cleanup_amavisd_db.py.
 AMAVISD_REMOVE_QUARANTINED_IN_DAYS = 7
 
 # SQL command used to create necessary Amavisd policy for newly created
 #
 # List how many items in one page. e.g. domain list, user list.
 PAGE_SIZE_LIMIT = 50
-LOG_PAGE_SIZE_LIMIT = 100
 
 # Import local settings.
 try: