diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index a222b36b..7be5df0f 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -390,7 +390,7 @@ available_notification_agents = sorted(notifiers.available_notification_agents()

Launch browser pointed to PlexPy on startup.

@@ -469,9 +469,18 @@ available_notification_agents = sorted(notifiers.available_notification_agents()
+

Password for web server authentication. Leave empty to disable.

+
+ +

Store a hashed password in the config.ini file.
Warning: Your password cannot be recovered if forgotten!

+
+
@@ -1957,6 +1966,7 @@ $(document).ready(function() { if ((serverChanged && $('#monitoring_use_websocket').is(":checked")) || authChanged || httpChanged || monitorChanged || directoryChanged) { $('#restart-modal').modal('show'); } + $("#http_hashed_password").val($("#http_hash_password").is(":checked") ? 1 : 0) } var configForm = $("#configUpdate"); @@ -2401,6 +2411,19 @@ $(document).ready(function() { }); }); + $("#http_hash_password").click(function(){ + if (!($("#http_hash_password").is(":checked")) && $("#http_hashed_password").val() == "1" && $("#http_password").val() == " ") { + $("#http_hashed_password").val(-1); + } else if ($("#http_hash_password").is(":checked") && $("#http_hashed_password").val() == "-1" && $("#http_password").val() == " ") { + $("#http_hashed_password").val(1); + $("#http_hash_password_error").html(""); + } + }); + + $('#http_password').change(function () { + $("#http_hashed_password").val($("#http_hash_password").is(":checked") ? 1 : 0); + $("#http_hash_password_error").html(""); + }); }); \ No newline at end of file diff --git a/lib/hashing_passwords.py b/lib/hashing_passwords.py new file mode 100644 index 00000000..1c2c963b --- /dev/null +++ b/lib/hashing_passwords.py @@ -0,0 +1,66 @@ +# coding: utf8 +""" + + Securely hash and check passwords using PBKDF2. + + Use random salts to protect againt rainbow tables, many iterations against + brute-force, and constant-time comparaison againt timing attacks. + + Keep parameters to the algorithm together with the hash so that we can + change the parameters and keep older hashes working. + + See more details at http://exyr.org/2011/hashing-passwords/ + + Author: Simon Sapin + License: BSD + +""" + +import hashlib +from os import urandom +from base64 import b64encode, b64decode +from itertools import izip + +# From https://github.com/mitsuhiko/python-pbkdf2 +from pbkdf2 import pbkdf2_bin + + +# Parameters to PBKDF2. Only affect new passwords. +SALT_LENGTH = 16 +KEY_LENGTH = 24 +HASH_FUNCTION = 'sha256' # Must be in hashlib. +# Linear to the hashing time. Adjust to be high but take a reasonable +# amount of time on your server. Measure with: +# python -m timeit -s 'import passwords as p' 'p.make_hash("something")' +COST_FACTOR = 29000 + + +def make_hash(password): + """Generate a random salt and return a new hash for the password.""" + if isinstance(password, unicode): + password = password.encode('utf-8') + salt = b64encode(urandom(SALT_LENGTH)) + return 'PBKDF2${}${}${}${}'.format( + HASH_FUNCTION, + COST_FACTOR, + salt, + b64encode(pbkdf2_bin(password, salt, COST_FACTOR, KEY_LENGTH, + getattr(hashlib, HASH_FUNCTION)))) + + +def check_hash(password, hash_): + """Check a password against an existing hash.""" + if isinstance(password, unicode): + password = password.encode('utf-8') + algorithm, hash_function, cost_factor, salt, hash_a = hash_.split('$') + assert algorithm == 'PBKDF2' + hash_a = b64decode(hash_a) + hash_b = pbkdf2_bin(password, salt, int(cost_factor), len(hash_a), + getattr(hashlib, hash_function)) + assert len(hash_a) == len(hash_b) # we requested this from pbkdf2_bin() + # Same as "return hash_a == hash_b" but takes a constant time. + # See http://carlos.bueno.org/2011/10/timing.html + diff = 0 + for char_a, char_b in izip(hash_a, hash_b): + diff |= ord(char_a) ^ ord(char_b) + return diff == 0 \ No newline at end of file diff --git a/lib/pbkdf2.py b/lib/pbkdf2.py new file mode 100644 index 00000000..b7a7dd42 --- /dev/null +++ b/lib/pbkdf2.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +""" + pbkdf2 + ~~~~~~ + + This module implements pbkdf2 for Python. It also has some basic + tests that ensure that it works. The implementation is straightforward + and uses stdlib only stuff and can be easily be copy/pasted into + your favourite application. + + Use this as replacement for bcrypt that does not need a c implementation + of a modified blowfish crypto algo. + + Example usage: + + >>> pbkdf2_hex('what i want to hash', 'the random salt') + 'fa7cc8a2b0a932f8e6ea42f9787e9d36e592e0c222ada6a9' + + How to use this: + + 1. Use a constant time string compare function to compare the stored hash + with the one you're generating:: + + def safe_str_cmp(a, b): + if len(a) != len(b): + return False + rv = 0 + for x, y in izip(a, b): + rv |= ord(x) ^ ord(y) + return rv == 0 + + 2. Use `os.urandom` to generate a proper salt of at least 8 byte. + Use a unique salt per hashed password. + + 3. Store ``algorithm$salt:costfactor$hash`` in the database so that + you can upgrade later easily to a different algorithm if you need + one. For instance ``PBKDF2-256$thesalt:10000$deadbeef...``. + + + :copyright: (c) Copyright 2011 by Armin Ronacher. + :license: BSD, see LICENSE for more details. +""" +import hmac +import hashlib +from struct import Struct +from operator import xor +from itertools import izip, starmap + + +_pack_int = Struct('>I').pack + + +def pbkdf2_hex(data, salt, iterations=1000, keylen=24, hashfunc=None): + """Like :func:`pbkdf2_bin` but returns a hex encoded string.""" + return pbkdf2_bin(data, salt, iterations, keylen, hashfunc).encode('hex') + + +def pbkdf2_bin(data, salt, iterations=1000, keylen=24, 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. + """ + hashfunc = hashfunc or hashlib.sha1 + mac = hmac.new(data, None, hashfunc) + def _pseudorandom(x, mac=mac): + h = mac.copy() + h.update(x) + return map(ord, h.digest()) + buf = [] + for block in xrange(1, -(-keylen // mac.digest_size) + 1): + rv = u = _pseudorandom(salt + _pack_int(block)) + for i in xrange(iterations - 1): + u = _pseudorandom(''.join(map(chr, u))) + rv = starmap(xor, izip(rv, u)) + buf.extend(rv) + return ''.join(map(chr, buf))[:keylen] + + +def test(): + failed = [] + def check(data, salt, iterations, keylen, expected): + rv = pbkdf2_hex(data, salt, iterations, keylen) + if rv != expected: + print 'Test failed:' + print ' Expected: %s' % expected + print ' Got: %s' % rv + print ' Parameters:' + print ' data=%s' % data + print ' salt=%s' % salt + print ' iterations=%d' % iterations + print + failed.append(1) + + # From RFC 6070 + check('password', 'salt', 1, 20, + '0c60c80f961f0e71f3a9b524af6012062fe037a6') + check('password', 'salt', 2, 20, + 'ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957') + check('password', 'salt', 4096, 20, + '4b007901b765489abead49d926f721d065a429c1') + check('passwordPASSWORDpassword', 'saltSALTsaltSALTsaltSALTsaltSALTsalt', + 4096, 25, '3d2eec4fe41c849b80c8d83662c0e44a8b291a964cf2f07038') + check('pass\x00word', 'sa\x00lt', 4096, 16, + '56fa6aa75548099dcc37d7f03425e0c3') + # This one is from the RFC but it just takes for ages + ##check('password', 'salt', 16777216, 20, + ## 'eefe3d61cd4da4e4e9945b3d6ba2158c2634e984') + + # From Crypt-PBKDF2 + check('password', 'ATHENA.MIT.EDUraeburn', 1, 16, + 'cdedb5281bb2f801565a1122b2563515') + check('password', 'ATHENA.MIT.EDUraeburn', 1, 32, + 'cdedb5281bb2f801565a1122b25635150ad1f7a04bb9f3a333ecc0e2e1f70837') + check('password', 'ATHENA.MIT.EDUraeburn', 2, 16, + '01dbee7f4a9e243e988b62c73cda935d') + check('password', 'ATHENA.MIT.EDUraeburn', 2, 32, + '01dbee7f4a9e243e988b62c73cda935da05378b93244ec8f48a99e61ad799d86') + check('password', 'ATHENA.MIT.EDUraeburn', 1200, 32, + '5c08eb61fdf71e4e4ec3cf6ba1f5512ba7e52ddbc5e5142f708a31e2e62b1e13') + check('X' * 64, 'pass phrase equals block size', 1200, 32, + '139c30c0966bc32ba55fdbf212530ac9c5ec59f1a452f5cc9ad940fea0598ed1') + check('X' * 65, 'pass phrase exceeds block size', 1200, 32, + '9ccad6d468770cd51b10e6a68721be611a8b4d282601db3b36be9246915ec82a') + + raise SystemExit(bool(failed)) + + +if __name__ == '__main__': + test() diff --git a/plexpy/config.py b/plexpy/config.py index 8c1ce064..791beec3 100644 --- a/plexpy/config.py +++ b/plexpy/config.py @@ -144,6 +144,8 @@ _CONFIG_DEFINITIONS = { 'HTTPS_DOMAIN': (str, 'General', 'localhost'), 'HTTPS_IP': (str, 'General', '127.0.0.1'), 'HTTP_ENVIRONMENT': (str, 'General', 'production'), + 'HTTP_HASH_PASSWORD': (int, 'General', 0), + 'HTTP_HASHED_PASSWORD': (int, 'General', 0), 'HTTP_HOST': (str, 'General', '0.0.0.0'), 'HTTP_PASSWORD': (str, 'General', ''), 'HTTP_PORT': (int, 'General', 8181), diff --git a/plexpy/webauth.py b/plexpy/webauth.py index e2ffdaed..c9ab67c8 100644 --- a/plexpy/webauth.py +++ b/plexpy/webauth.py @@ -20,6 +20,7 @@ import cherrypy from cgi import escape +from hashing_passwords import check_hash import plexpy from plexpy import logger @@ -30,8 +31,10 @@ SESSION_KEY = '_cp_username' def check_credentials(username, password): """Verifies credentials for username and password. Returns None on success or a string describing the error on failure""" - # Adapt to your needs - if username == plexpy.CONFIG.HTTP_USERNAME and password == plexpy.CONFIG.HTTP_PASSWORD: + if plexpy.CONFIG.HTTP_HASHED_PASSWORD and \ + username == plexpy.CONFIG.HTTP_USERNAME and check_hash(password, plexpy.CONFIG.HTTP_PASSWORD): + return None + elif username == plexpy.CONFIG.HTTP_USERNAME and password == plexpy.CONFIG.HTTP_PASSWORD: return None else: return u"Incorrect username or password." diff --git a/plexpy/webserve.py b/plexpy/webserve.py index b06960c9..f35d647b 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -20,6 +20,7 @@ from plexpy.webauth import AuthController, require, member_of, name_is from mako.lookup import TemplateLookup from mako import exceptions +from hashing_passwords import make_hash import plexpy import threading @@ -1192,6 +1193,8 @@ class WebInterface(object): http_password = '' config = { + "http_hash_password": checked(plexpy.CONFIG.HTTP_HASH_PASSWORD), + "http_hashed_password": plexpy.CONFIG.HTTP_HASHED_PASSWORD, "http_host": plexpy.CONFIG.HTTP_HOST, "http_username": plexpy.CONFIG.HTTP_USERNAME, "http_port": plexpy.CONFIG.HTTP_PORT, @@ -1315,7 +1318,7 @@ class WebInterface(object): "ip_logging_enable", "movie_logging_enable", "tv_logging_enable", "music_logging_enable", "pms_is_remote", "home_stats_type", "group_history_tables", "notify_consecutive", "notify_upload_posters", "notify_recently_added", "notify_recently_added_grandparent", - "monitor_pms_updates", "monitor_remote_access", "get_file_sizes", "log_blacklist" + "monitor_pms_updates", "monitor_remote_access", "get_file_sizes", "log_blacklist", "http_hash_password" ] for checked_config in checked_configs: if checked_config not in kwargs: @@ -1327,7 +1330,20 @@ class WebInterface(object): # If http password exists in config, do not overwrite when blank value received if kwargs.get('http_password'): if kwargs['http_password'] == ' ' and plexpy.CONFIG.HTTP_PASSWORD != '': - kwargs['http_password'] = plexpy.CONFIG.HTTP_PASSWORD + if kwargs.get('http_hash_password') and not plexpy.CONFIG.HTTP_HASHED_PASSWORD: + kwargs['http_password'] = make_hash(plexpy.CONFIG.HTTP_PASSWORD) + kwargs['http_hashed_password'] = 1 + else: + kwargs['http_password'] = plexpy.CONFIG.HTTP_PASSWORD + + elif kwargs['http_password'] and kwargs.get('http_hash_password'): + kwargs['http_password'] = make_hash(kwargs['http_password']) + kwargs['http_hashed_password'] = 1 + + elif not kwargs.get('http_hash_password'): + kwargs['http_hashed_password'] = 0 + else: + kwargs['http_hashed_password'] = 0 for plain_config, use_config in [(x[4:], x) for x in kwargs if x.startswith('use_')]: # the use prefix is fairly nice in the html, but does not match the actual config