mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-11 07:46:07 -07:00
Add option to hash password in config file
This commit is contained in:
parent
6f97173b00
commit
11aa7d0140
6 changed files with 245 additions and 5 deletions
|
@ -390,7 +390,7 @@ available_notification_agents = sorted(notifiers.available_notification_agents()
|
||||||
</div>
|
</div>
|
||||||
<div class="checkbox">
|
<div class="checkbox">
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="launch_browser" value="1" ${config['launch_browser']}> Launch Browser on Startup
|
<input type="checkbox" name="launch_browser" id="launch_browser" value="1" ${config['launch_browser']}> Launch Browser on Startup
|
||||||
</label>
|
</label>
|
||||||
<p class="help-block">Launch browser pointed to PlexPy on startup.</p>
|
<p class="help-block">Launch browser pointed to PlexPy on startup.</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -469,9 +469,18 @@ available_notification_agents = sorted(notifiers.available_notification_agents()
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<input type="password" class="form-control auth-settings" id="http_password" name="http_password" value="${config['http_password']}" size="30">
|
<input type="password" class="form-control auth-settings" id="http_password" name="http_password" value="${config['http_password']}" size="30">
|
||||||
</div>
|
</div>
|
||||||
|
<div id="http_hash_password_error" class="alert alert-danger settings-alert" role="alert"></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="help-block">Password for web server authentication. Leave empty to disable.</p>
|
<p class="help-block">Password for web server authentication. Leave empty to disable.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="http_hash_password" id="http_hash_password" value="1" ${config['http_hash_password']} data-parsley-trigger="change"> Hash Password in the Config File
|
||||||
|
</label>
|
||||||
|
<p class="help-block">Store a hashed password in the config.ini file.<br />Warning: Your password cannot be recovered if forgotten!</p>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="http_hashed_password" name="http_hashed_password" value="${config['http_hashed_password']}" style="display: none;" data-parsley-trigger="change" data-parsley-type="integer" data-parsley-range="[0, 1]"
|
||||||
|
data-parsley-errors-container="#http_hash_password_error" data-parsley-error-message="Cannot un-hash password, please set a new password." data-parsley-no-focus required>
|
||||||
|
|
||||||
|
|
||||||
<div class="padded-header">
|
<div class="padded-header">
|
||||||
|
@ -1957,6 +1966,7 @@ $(document).ready(function() {
|
||||||
if ((serverChanged && $('#monitoring_use_websocket').is(":checked")) || authChanged || httpChanged || monitorChanged || directoryChanged) {
|
if ((serverChanged && $('#monitoring_use_websocket').is(":checked")) || authChanged || httpChanged || monitorChanged || directoryChanged) {
|
||||||
$('#restart-modal').modal('show');
|
$('#restart-modal').modal('show');
|
||||||
}
|
}
|
||||||
|
$("#http_hashed_password").val($("#http_hash_password").is(":checked") ? 1 : 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
var configForm = $("#configUpdate");
|
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("");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</%def>
|
</%def>
|
66
lib/hashing_passwords.py
Normal file
66
lib/hashing_passwords.py
Normal file
|
@ -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
|
130
lib/pbkdf2.py
Normal file
130
lib/pbkdf2.py
Normal file
|
@ -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()
|
|
@ -144,6 +144,8 @@ _CONFIG_DEFINITIONS = {
|
||||||
'HTTPS_DOMAIN': (str, 'General', 'localhost'),
|
'HTTPS_DOMAIN': (str, 'General', 'localhost'),
|
||||||
'HTTPS_IP': (str, 'General', '127.0.0.1'),
|
'HTTPS_IP': (str, 'General', '127.0.0.1'),
|
||||||
'HTTP_ENVIRONMENT': (str, 'General', 'production'),
|
'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_HOST': (str, 'General', '0.0.0.0'),
|
||||||
'HTTP_PASSWORD': (str, 'General', ''),
|
'HTTP_PASSWORD': (str, 'General', ''),
|
||||||
'HTTP_PORT': (int, 'General', 8181),
|
'HTTP_PORT': (int, 'General', 8181),
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
|
|
||||||
import cherrypy
|
import cherrypy
|
||||||
from cgi import escape
|
from cgi import escape
|
||||||
|
from hashing_passwords import check_hash
|
||||||
|
|
||||||
import plexpy
|
import plexpy
|
||||||
from plexpy import logger
|
from plexpy import logger
|
||||||
|
@ -30,8 +31,10 @@ SESSION_KEY = '_cp_username'
|
||||||
def check_credentials(username, password):
|
def check_credentials(username, password):
|
||||||
"""Verifies credentials for username and password.
|
"""Verifies credentials for username and password.
|
||||||
Returns None on success or a string describing the error on failure"""
|
Returns None on success or a string describing the error on failure"""
|
||||||
# Adapt to your needs
|
if plexpy.CONFIG.HTTP_HASHED_PASSWORD and \
|
||||||
if username == plexpy.CONFIG.HTTP_USERNAME and password == plexpy.CONFIG.HTTP_PASSWORD:
|
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
|
return None
|
||||||
else:
|
else:
|
||||||
return u"Incorrect username or password."
|
return u"Incorrect username or password."
|
||||||
|
|
|
@ -20,6 +20,7 @@ from plexpy.webauth import AuthController, require, member_of, name_is
|
||||||
|
|
||||||
from mako.lookup import TemplateLookup
|
from mako.lookup import TemplateLookup
|
||||||
from mako import exceptions
|
from mako import exceptions
|
||||||
|
from hashing_passwords import make_hash
|
||||||
|
|
||||||
import plexpy
|
import plexpy
|
||||||
import threading
|
import threading
|
||||||
|
@ -1192,6 +1193,8 @@ class WebInterface(object):
|
||||||
http_password = ''
|
http_password = ''
|
||||||
|
|
||||||
config = {
|
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_host": plexpy.CONFIG.HTTP_HOST,
|
||||||
"http_username": plexpy.CONFIG.HTTP_USERNAME,
|
"http_username": plexpy.CONFIG.HTTP_USERNAME,
|
||||||
"http_port": plexpy.CONFIG.HTTP_PORT,
|
"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",
|
"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",
|
"pms_is_remote", "home_stats_type", "group_history_tables", "notify_consecutive", "notify_upload_posters",
|
||||||
"notify_recently_added", "notify_recently_added_grandparent",
|
"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:
|
for checked_config in checked_configs:
|
||||||
if checked_config not in kwargs:
|
if checked_config not in kwargs:
|
||||||
|
@ -1327,8 +1330,21 @@ class WebInterface(object):
|
||||||
# If http password exists in config, do not overwrite when blank value received
|
# If http password exists in config, do not overwrite when blank value received
|
||||||
if kwargs.get('http_password'):
|
if kwargs.get('http_password'):
|
||||||
if kwargs['http_password'] == ' ' and plexpy.CONFIG.HTTP_PASSWORD != '':
|
if kwargs['http_password'] == ' ' and 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
|
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_')]:
|
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
|
# the use prefix is fairly nice in the html, but does not match the actual config
|
||||||
kwargs[plain_config] = kwargs[use_config]
|
kwargs[plain_config] = kwargs[use_config]
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue