Skip to content

Commit

Permalink
Merge pull request #225 from tableau/pbkdf2
Browse files Browse the repository at this point in the history
Pbkdf2
  • Loading branch information
johng42 committed Mar 28, 2019
2 parents 469aa3f + d7ecc47 commit 3131d32
Show file tree
Hide file tree
Showing 13 changed files with 368 additions and 75 deletions.
3 changes: 3 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

The following security issues should be kept in mind as you use TabPy with Tableau:

- REST server and Python execution context are the same meaning they share
Python session, e.g. HTTP requests are served in the same space where
user scripts are evaluated.
- TabPy currently does not use authentication.
- Python scripts can contain code which can harm security on the server where
the TabPy is running. For example:
Expand Down
60 changes: 60 additions & 0 deletions docs/server-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,63 @@ TABPY_KEY_FILE = C:/path/to/key/file.key

Note that only PEM-encoded x509 certificates are supported for the secure
connection scenario.

## Authentication

TabPy supports basic access authentication (see
[https://en.wikipedia.org/wiki/Basic_access_authentication](https://en.wikipedia.org/wiki/Basic_access_authentication)
for more details).

### Enabling Authentication

To enable the feature specify `TABPY_PWD_FILE` parameter in TabPy
configuration file with a fully qualified name:

```sh
TABPY_PWD_FILE = c:\path\to\password\file.txt
```

### Password File

Password file is a text file containing usernames and hashed passwords
per line separated by space. For username only ASCII characters
supported.

**It is highly recommended to restrict access to the password file
with hosting OS mechanisms. Ideally the file should only be accessible
for reading with account TabPy runs as and TabPy admin account.**

There is `utils/user_management.py` utility to operate with
accounts in the password file. Run `utils/user_management.py -h` to
see how to use it.

After making any changes to the password file, TabPy needs to be restarted.

### Adding an Account

To add an account run `utils/user_management.py` utility with `add`
command providing user name, password (optional) and password file:

```sh
python utils/user_management.py add -u <username> -p <password> -f <pwdfile>
```

If `-p` agrument is not provided (recommended) password for the user name
will be generated.

### Updating an Account

To update password for an account run `utils/user_management.py` utility
with `update` command:

```sh
python utils/user_management.py update -u <username> -p <password> -f <pwdfile>
```

If `-p` agrument is not provided (recommended) password for the user name
will be generated.

### Deleting an Account

To delete an account open password file in any text editor and delete the
line with the user name.
9 changes: 6 additions & 3 deletions tabpy-server/server_tests/test_endpoint_handler.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import base64
import hashlib
import os
import tempfile
import unittest

from argparse import Namespace
from tabpy_server.app.app import TabPyApp
from tabpy_server.handlers.endpoint_handler import EndpointHandler
from tabpy_server.handlers.util import hash_password
from tornado.testing import AsyncHTTPTestCase
from unittest.mock import patch

Expand All @@ -24,8 +24,11 @@ def setUpClass(cls):
# create password file
cls.pwd_file = tempfile.NamedTemporaryFile(
mode='w+t', prefix=prefix, suffix='.txt', delete=False)
cls.pwd_file.write('username {}'.format(
hashlib.sha3_256('password'.encode('utf-8')).hexdigest()))
username = 'username'
password = 'password'
cls.pwd_file.write('{} {}'.format(
username,
hash_password(username, password)))
cls.pwd_file.close()

# create state.ini dir and file
Expand Down
9 changes: 6 additions & 3 deletions tabpy-server/server_tests/test_endpoints_handler.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import base64
import hashlib
import os
import tempfile
import unittest

from argparse import Namespace
from tabpy_server.app.app import TabPyApp
from tabpy_server.handlers.endpoints_handler import EndpointsHandler
from tabpy_server.handlers.util import hash_password
from tornado.testing import AsyncHTTPTestCase
from unittest.mock import patch

Expand All @@ -24,8 +24,11 @@ def setUpClass(cls):
# create password file
cls.pwd_file = tempfile.NamedTemporaryFile(
mode='w+t', prefix=prefix, suffix='.txt', delete=False)
cls.pwd_file.write('username {}'.format(
hashlib.sha3_256('password'.encode('utf-8')).hexdigest()))
username = 'username'
password = 'password'
cls.pwd_file.write('{} {}'.format(
username,
hash_password(username, password)))
cls.pwd_file.close()

# create state.ini dir and file
Expand Down
9 changes: 6 additions & 3 deletions tabpy-server/server_tests/test_evaluation_plane_handler.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import base64
import hashlib
import os
import tempfile
import unittest
Expand All @@ -8,6 +7,7 @@
from tabpy_server.app.app import TabPyApp
from tabpy_server.handlers.evaluation_plane_handler\
import EvaluationPlaneHandler
from tabpy_server.handlers.util import hash_password
from tornado.testing import AsyncHTTPTestCase
from unittest.mock import patch

Expand All @@ -25,8 +25,11 @@ def setUpClass(cls):
# create password file
cls.pwd_file = tempfile.NamedTemporaryFile(
mode='w+t', prefix=prefix, suffix='.txt', delete=False)
cls.pwd_file.write('username {}'.format(
hashlib.sha3_256('password'.encode('utf-8')).hexdigest()))
username = 'username'
password = 'password'
cls.pwd_file.write('{} {}\n'.format(
username,
hash_password(username, password)))
cls.pwd_file.close()

# create state.ini dir and file
Expand Down
18 changes: 12 additions & 6 deletions tabpy-server/server_tests/test_validate_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
class TestValidateBasicAuthCredentials(unittest.TestCase):
def setUp(self):
self.credentials = {
# SHA3('password')
# PBKDF2('user1', 'password', 10000)
'user1':
'c0067d4af4e87f00dbac63b6156828237059172d1bbeac67427345d6a9fda484'
('5961c87343553bf078add1189b4f59238f663eabd23dbc1dc538c5fed'
'f18a8cc546f4a46500dd7672144595ac4e9610dc9edc66ee1cb7b58cab'
'64ddb662390b3')
}

def test_given_unknown_username_expect_validation_fails(self):
Expand All @@ -43,9 +45,11 @@ def test_given_valid_creds_mixcase_login_expect_validation_passes(self):
class TestCheckAndValidateBasicAuthCredentials(unittest.TestCase):
def setUp(self):
self.credentials = {
# SHA3('password')
# PBKDF2('user1', 'password', 10000)
'user1':
'c0067d4af4e87f00dbac63b6156828237059172d1bbeac67427345d6a9fda484'
('5961c87343553bf078add1189b4f59238f663eabd23dbc1dc538c5fed'
'f18a8cc546f4a46500dd7672144595ac4e9610dc9edc66ee1cb7b58cab'
'64ddb662390b3')
}

def test_given_no_headers_expect_validation_fails(self):
Expand Down Expand Up @@ -98,9 +102,11 @@ def test_given_valid_creds_expect_validation_passes(self):
class TestHandleAuthentication(unittest.TestCase):
def setUp(self):
self.credentials = {
# SHA3('password')
# PBKDF2('user1', 'password', 10000)
'user1':
'c0067d4af4e87f00dbac63b6156828237059172d1bbeac67427345d6a9fda484'
('5961c87343553bf078add1189b4f59238f663eabd23dbc1dc538c5fed'
'f18a8cc546f4a46500dd7672144595ac4e9610dc9edc66ee1cb7b58cab'
'64ddb662390b3')
}

self.settings = {
Expand Down
56 changes: 13 additions & 43 deletions tabpy-server/tabpy_server/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
import tabpy_server
from tabpy_server import __version__
from tabpy_server.app.ConfigParameters import ConfigParameters
from tabpy_server.app.util import log_and_raise
from tabpy_server.app.util import (
log_and_raise,
parse_pwd_file)
from tabpy_server.management.state import TabPyState
from tabpy_server.management.util import _get_state_from_file
from tabpy_server.psws.callbacks import (init_model_evaluator, init_ps_server)
Expand Down Expand Up @@ -236,6 +238,8 @@ def set_parameter(settings_key,
log_and_raise('Failed to read passwords file %s' %
self.settings[ConfigParameters.TABPY_PWD_FILE],
RuntimeError)
else:
logger.info("Password file is not specified")

features = self._get_features()
self.settings['versions'] = {'v1': {'features': features}}
Expand Down Expand Up @@ -285,48 +289,14 @@ def _validate_cert_key_state(msg, cert_valid, key_valid):
log_and_raise(err, RuntimeError)

def _parse_pwd_file(self):
if ConfigParameters.TABPY_PWD_FILE not in self.settings:
logger.info("Password file is not specified")
else:
pwd_file_name = self.settings[ConfigParameters.TABPY_PWD_FILE]
logger.info('Parsing password file %s' % pwd_file_name)

if not os.path.isfile(pwd_file_name):
logger.fatal('Password file not found')
return False

with open(pwd_file_name) as pwd_file:
pwd_file_reader = csv.reader(pwd_file, delimiter=' ')
for row in pwd_file_reader:
# skip empty lines
if len(row) == 0:
continue

# skip commented lines
if row[0][0] == '#':
continue

if len(row) != 2:
logger.error(
'Incorrect entry "{}" '
'in password file'.format(row))
return False

login = row[0].lower()
if login in self.credentials:
logger.error(
'Multiple entries for username {} '
'in password file'.format(login))
return False

self.credentials[login] = row[1]
logger.debug('Found username {}'.format(login))

if len(self.credentials) == 0:
logger.error('No credentials found')
return False

return True
succeeded, self.credentials = parse_pwd_file(
self.settings[ConfigParameters.TABPY_PWD_FILE])

if succeeded and len(self.credentials) == 0:
logger.error('No credentials found')
succeeded = False

return succeeded

def _get_features(self):
features = {}
Expand Down
57 changes: 57 additions & 0 deletions tabpy-server/tabpy_server/app/util.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import csv
import logging
import os

from datetime import datetime
from OpenSSL import crypto
Expand Down Expand Up @@ -35,3 +37,58 @@ def validate_cert(cert_file_path):
if now > not_after:
log_and_raise(https_error +
'The certificate provided expired on {}.'.format(not_after), RuntimeError)


def parse_pwd_file(pwd_file_name):
'''
Parses passwords file and returns set of credentials.
Parameters
----------
pwd_file_name : str
Passwords file name.
Returns
-------
succeeded : bool
True if specified file was parsed successfully.
False if there were any issues with parsing specified file.
credentials : dict
Credentials from the file. Empty if succeeded is False.
'''
logger.info('Parsing passwords file {}...'.format(pwd_file_name))

if not os.path.isfile(pwd_file_name):
logger.fatal('Passwords file {} not found'.format(pwd_file_name))
return False, {}

credentials = {}
with open(pwd_file_name) as pwd_file:
pwd_file_reader = csv.reader(pwd_file, delimiter=' ')
for row in pwd_file_reader:
# skip empty lines
if len(row) == 0:
continue

# skip commented lines
if row[0][0] == '#':
continue

if len(row) != 2:
logger.error(
'Incorrect entry "{}" '
'in password file'.format(row))
return False, {}

login = row[0].lower()
if login in credentials:
logger.error(
'Multiple entries for username {} '
'in password file'.format(login))
return False, {}

credentials[login] = row[1]
logger.debug('Found username {}'.format(login))

return True, credentials
35 changes: 33 additions & 2 deletions tabpy-server/tabpy_server/handlers/util.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,42 @@
import base64
import binascii
import hashlib
from hashlib import pbkdf2_hmac
import logging

logger = logging.getLogger(__name__)


def hash_password(username, pwd):
'''
Hashes password using PKDBF2 method:
hash = PKDBF2('sha512', pwd, salt=username, 10000)
Parameters
----------
username : str
User name (login). Used as salt for hashing.
User name is lowercased befor being used in hashing.
Salt is formatted as '_$salt@tabpy:<username>$_' to
guarantee there's at least 16 characters.
pwd : str
Password to hash.
Returns
-------
str
Sting representation (hexidecimal) for PBKDF2 hash
for the password.
'''
salt = '_$salt@tabpy:%s$_' % username.lower()

hash = pbkdf2_hmac(hash_name='sha512',
password=pwd.encode(),
salt=salt.encode(),
iterations=10000)
return binascii.hexlify(hash).decode()


def validate_basic_auth_credentials(username, pwd, credentials):
'''
Validates username:pwd if they are the same as
Expand Down Expand Up @@ -38,7 +69,7 @@ def validate_basic_auth_credentials(username, pwd, credentials):
logger.error('User name "{}" not found'.format(username))
return False

hashed_pwd = hashlib.sha3_256(pwd.encode('utf-8')).hexdigest()
hashed_pwd = hash_password(username, pwd)
if credentials[login].lower() != hashed_pwd.lower():
logger.error('Wrong password for user name "{}"'.format(username))
return False
Expand Down
Loading

0 comments on commit 3131d32

Please sign in to comment.