Skip to content

Commit

Permalink
cherrypy: add SHA1 HTTP Digest Auth support
Browse files Browse the repository at this point in the history
  • Loading branch information
benfitzpatrick committed Jul 28, 2016
1 parent 56f374a commit 7cbe2ea
Show file tree
Hide file tree
Showing 4 changed files with 122 additions and 23 deletions.
55 changes: 37 additions & 18 deletions lib/cherrypy/lib/auth_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,35 @@
__author__ = 'visteya'
__date__ = 'April 2009'


import hashlib
import time
from hashlib import md5
from cherrypy._cpcompat import parse_http_list, parse_keqv_list

import cherrypy
from cherrypy._cpcompat import ntob
md5_hex = lambda s: md5(ntob(s)).hexdigest()

qop_auth = 'auth'
qop_auth_int = 'auth-int'
valid_qops = (qop_auth, qop_auth_int)

valid_algorithms = ('MD5', 'MD5-sess')
valid_algorithms = ('MD5', 'MD5-sess', 'SHA')


def hexdigest(value, algorithm='MD5'):
"""Provide the checksum hexdigest of value."""
if algorithm in ['MD5', 'MD5-sess']:
return md5_hex(value)
return sha1_hex(value)


def md5_hex(value):
"""Provide the MD5 hexdigest of value."""
return hashlib.md5(ntob(value)).hexdigest()


def sha1_hex(value):
"""Provide the SHA1 hexdigest of value."""
return hashlib.sha1(ntob(value)).hexdigest()


def TRACE(msg):
Expand All @@ -44,7 +59,7 @@ def TRACE(msg):
# of get_ha1() functions for three different kinds of credential stores.


def get_ha1_dict_plain(user_password_dict):
def get_ha1_dict_plain(user_password_dict, algorithm='MD5'):
"""Returns a get_ha1 function which obtains a plaintext password from a
dictionary of the form: {username : password}.
Expand All @@ -55,7 +70,8 @@ def get_ha1_dict_plain(user_password_dict):
def get_ha1(realm, username):
password = user_password_dict.get(username)
if password:
return md5_hex('%s:%s:%s' % (username, realm, password))
return hexdigest('%s:%s:%s' % (username, realm, password),
algorithm=algorithm)
return None

return get_ha1
Expand Down Expand Up @@ -103,7 +119,7 @@ def get_ha1(realm, username):
return get_ha1


def synthesize_nonce(s, key, timestamp=None):
def synthesize_nonce(s, key, timestamp=None, algorithm='MD5'):
"""Synthesize a nonce value which resists spoofing and can be checked
for staleness. Returns a string suitable as the value for 'nonce' in
the www-authenticate header.
Expand All @@ -120,14 +136,14 @@ def synthesize_nonce(s, key, timestamp=None):
"""
if timestamp is None:
timestamp = int(time.time())
h = md5_hex('%s:%s:%s' % (timestamp, s, key))
h = hexdigest('%s:%s:%s' % (timestamp, s, key), algorithm=algorithm)
nonce = '%s:%s' % (timestamp, h)
return nonce


def H(s):
def H(s, algorithm='MD5'):
"""The hash function H"""
return md5_hex(s)
return hexdigest(s, algorithm=algorithm)


class HttpDigestAuthorization (object):
Expand Down Expand Up @@ -217,7 +233,7 @@ def validate_nonce(self, s, key):
try:
timestamp, hashpart = self.nonce.split(':', 1)
s_timestamp, s_hashpart = synthesize_nonce(
s, key, timestamp).split(':', 1)
s, key, timestamp, algorithm=self.algorithm).split(':', 1)
is_valid = s_hashpart == hashpart
if self.debug:
TRACE('validate_nonce: %s' % is_valid)
Expand Down Expand Up @@ -254,12 +270,13 @@ def HA2(self, entity_body=''):
if self.qop is None or self.qop == "auth":
a2 = '%s:%s' % (self.http_method, self.uri)
elif self.qop == "auth-int":
a2 = "%s:%s:%s" % (self.http_method, self.uri, H(entity_body))
a2 = "%s:%s:%s" % (self.http_method, self.uri,
H(entity_body, algorithm=self.algorithm))
else:
# in theory, this should never happen, since I validate qop in
# __init__()
raise ValueError(self.errmsg("Unrecognized value for qop!"))
return H(a2)
return H(a2, algorithm=self.algorithm)

def request_digest(self, ha1, entity_body=''):
"""Calculates the Request-Digest. See :rfc:`2617` section 3.2.2.1.
Expand Down Expand Up @@ -296,9 +313,10 @@ def request_digest(self, ha1, entity_body=''):
# A1 = H( unq(username-value) ":" unq(realm-value) ":" passwd )
# ":" unq(nonce-value) ":" unq(cnonce-value)
if self.algorithm == 'MD5-sess':
ha1 = H('%s:%s:%s' % (ha1, self.nonce, self.cnonce))
ha1 = H('%s:%s:%s' % (ha1, self.nonce, self.cnonce),
algorithm=self.algorithm)

digest = H('%s:%s' % (ha1, req))
digest = H('%s:%s' % (ha1, req), algorithm=self.algorithm)
return digest


Expand All @@ -311,15 +329,15 @@ def www_authenticate(realm, key, algorithm='MD5', nonce=None, qop=qop_auth,
raise ValueError("Unsupported value for algorithm: '%s'" % algorithm)

if nonce is None:
nonce = synthesize_nonce(realm, key)
nonce = synthesize_nonce(realm, key, algorithm=algorithm)
s = 'Digest realm="%s", nonce="%s", algorithm="%s", qop="%s"' % (
realm, nonce, algorithm, qop)
if stale:
s += ', stale="true"'
return s


def digest_auth(realm, get_ha1, key, debug=False):
def digest_auth(realm, get_ha1, key, algorithm='MD5', debug=False):
"""A CherryPy tool which hooks at before_handler to perform
HTTP Digest Access Authentication, as specified in :rfc:`2617`.
Expand Down Expand Up @@ -383,7 +401,8 @@ def digest_auth(realm, get_ha1, key, debug=False):
return

# Respond with 401 status and a WWW-Authenticate header
header = www_authenticate(realm, key, stale=nonce_is_stale)
header = www_authenticate(
realm, key, algorithm=algorithm, stale=nonce_is_stale)
if debug:
TRACE(header)
cherrypy.serving.response.headers['WWW-Authenticate'] = header
Expand Down
4 changes: 2 additions & 2 deletions lib/cherrypy/lib/cptools.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Functions for builtin CherryPy tools."""

import hashlib
import logging
import re
from hashlib import md5
import sys

IS_PY3 = sys.version_info[0] == 3
Expand Down Expand Up @@ -62,7 +62,7 @@ def validate_etags(autotags=False, debug=False):
cherrypy.log('Status not 200', 'TOOLS.ETAGS')
else:
etag = response.collapse_body()
etag = '"%s"' % md5(etag).hexdigest()
etag = '"%s"' % hashlib.md5(etag).hexdigest()
if debug:
cherrypy.log('Setting ETag: %s' % etag, 'TOOLS.ETAGS')
response.headers['ETag'] = etag
Expand Down
6 changes: 3 additions & 3 deletions lib/cherrypy/lib/httpauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@

##########################################################################
import time
from hashlib import md5
import hashlib

from cherrypy._cpcompat import base64_decode, ntob
from cherrypy._cpcompat import parse_http_list, parse_keqv_list
Expand All @@ -79,8 +79,8 @@
# doAuth
#
DIGEST_AUTH_ENCODERS = {
MD5: lambda val: md5(ntob(val)).hexdigest(),
MD5_SESS: lambda val: md5(ntob(val)).hexdigest(),
MD5: lambda val: hashlib.md5(ntob(val)).hexdigest(),
MD5_SESS: lambda val: hashlib.md5(ntob(val)).hexdigest(),
# SHA: lambda val: sha.new(ntob(val)).hexdigest (),
}

Expand Down
80 changes: 80 additions & 0 deletions tests/authentication/07-sha-hash.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/bin/bash
# THIS FILE IS PART OF THE CYLC SUITE ENGINE.
# Copyright (C) 2008-2016 NIWA
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

# Test authentication - using SHA1 hash option.

. $(dirname $0)/test_header
set_test_number 9

install_suite "${TEST_NAME_BASE}" basic

TEST_NAME="${TEST_NAME_BASE}-validate"
run_ok "${TEST_NAME}" cylc validate "${SUITE_NAME}"

# Run the suite.
# Set public auth low to test that passphrase gives full control
create_test_globalrc '' '
[communication]
options = SHA1
[authentication]
public = identity'
cylc run "${SUITE_NAME}"
unset CYLC_CONF_PATH

# Wait for first task 'foo' to fail.
cylc suite-state "${SUITE_NAME}" --task=foo --status=failed --cycle=1 \
--interval=1 --max-polls=10 || exit 1

# Check scan output.
PORT=$(cylc ping -v "${SUITE_NAME}" | cut -d':' -f 2)
cylc scan -fb -n "${SUITE_NAME}" 'localhost' >'scan.out' 2>'/dev/null'
cmp_ok scan.out << __END__
${SUITE_NAME} ${USER}@localhost:${PORT}
Title:
"Authentication test suite."
Description:
"Stalls when the first task fails."
Task state totals:
failed:1 waiting:1
1 failed:1 waiting:1
__END__

# "cylc show" (suite info) OK.
TEST_NAME="${TEST_NAME_BASE}-show1"
run_ok "${TEST_NAME}" cylc show "${SUITE_NAME}"
cylc log "${SUITE_NAME}" > suite.log1
grep_ok "\[client-command\] get_suite_info ${USER}@.*:cylc-show" suite.log1

# "cylc show" (task info) OK.
TEST_NAME="${TEST_NAME_BASE}-show2"
run_ok "${TEST_NAME}" cylc show "${SUITE_NAME}" foo.1
cylc log "${SUITE_NAME}" > suite.log2
grep_ok "\[client-command\] get_task_info ${USER}@.*:cylc-show" suite.log2

# Commands OK.
# (Reset to same state).
TEST_NAME="${TEST_NAME_BASE}-trigger"
run_ok "${TEST_NAME}" cylc reset "${SUITE_NAME}" -s failed foo 1
cylc log "${SUITE_NAME}" > suite.log3
grep_ok "\[client-command\] reset_task_states ${USER}@.*:cylc-reset" suite.log3

# Shutdown and purge.
TEST_NAME="${TEST_NAME_BASE}-stop"
run_ok "${TEST_NAME}" cylc stop --max-polls=20 --interval=1 "${SUITE_NAME}"
purge_suite "${SUITE_NAME}"
exit

0 comments on commit 7cbe2ea

Please sign in to comment.