New upstream release.
Debian Janitor
1 year, 3 months ago
21 | 21 | strategy: |
22 | 22 | matrix: |
23 | 23 | os: [ubuntu-latest, macos-latest, windows-latest] |
24 | python: ['3.6', '3.7', '3.8', '3.9', 'pypy3'] | |
25 | exclude: | |
26 | # pypy3 currently fails to run on Windows | |
27 | - os: windows-latest | |
28 | python: pypy3 | |
24 | python: ['3.6', '3.7', '3.8', '3.9', '3.10', 'pypy-3.8'] | |
29 | 25 | fail-fast: false |
30 | 26 | runs-on: ${{ matrix.os }} |
31 | 27 | steps: |
0 | 0 | # Flask-HTTPAuth change log |
1 | ||
2 | **Release 4.7.0** - 2022-05-29 | |
3 | ||
4 | - Fallback to latin-1 encoding for credentials when utf-8 fails [#151](https://github.com/miguelgrinberg/flask-httpauth/issues/151) ([commit](https://github.com/miguelgrinberg/flask-httpauth/commit/4a92b75b79ea8e29ed76910792208d0a0a9e897a)) | |
5 | - Documentation updates ([commit](https://github.com/miguelgrinberg/flask-httpauth/commit/b42168ed174cde0a9404dbf0b05b5b5c5d6eb46d)) | |
6 | ||
7 | **Release 4.6.0** - 2022-04-21 | |
8 | ||
9 | - Add MD5-Sess algorithm for Digest auth ([commit](https://github.com/miguelgrinberg/flask-httpauth/commit/8a5d1eb87c9b3cb71cc6c5839a4a3411ede1f505)) | |
10 | - Add qop=auth option for Digest auth ([commit](https://github.com/miguelgrinberg/flask-httpauth/commit/d311fe5e996d43316989daf8a67ded22a6a567e2)) (thanks **Edward**!) | |
11 | - Add Python 3.10 and PyPy 3.8 to build ([commit](https://github.com/miguelgrinberg/flask-httpauth/commit/ffeab170a8230e4defb117f242937865072c8094)) | |
1 | 12 | |
2 | 13 | **Release 4.5.0** - 2021-10-25 |
3 | 14 |
0 | python-flask-httpauth (4.7.0-1) UNRELEASED; urgency=low | |
1 | ||
2 | * New upstream release. | |
3 | ||
4 | -- Debian Janitor <janitor@jelmer.uk> Sat, 31 Dec 2022 00:20:44 -0000 | |
5 | ||
0 | 6 | python-flask-httpauth (4.5.0-4) unstable; urgency=medium |
1 | 7 | |
2 | 8 | * Another attempt at fixing the manpage. (Hopefully) closes: #1003606 |
71 | 71 | if __name__ == '__main__': |
72 | 72 | app.run() |
73 | 73 | |
74 | Security Concerns with Digest Authentication | |
75 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
76 | ||
77 | The digest authentication algorithm requires a *challenge* to be sent to the client for use in encrypting the password for transmission. This challenge needs to be used again when the password is decoded at the server, so the challenge information needs to be stored so that it can be recalled later. | |
78 | ||
79 | By default, Flask-HTTPAuth stores the challenge data in the Flask session. To make the authentication flow secure when using session storage, it is required that server-side sessions are used instead of the default Flask cookie based sessions, as this ensures that the challenge data is not at risk of being captured as it moves in a cookie between server and client. The Flask-Session and Flask-KVSession extensions are both very good options to implement server-side sessions. | |
80 | ||
81 | As an alternative to using server-side sessions, an application can implement its own generation and storage of challenge data. To do this, there are four callback functions that the application needs to implement:: | |
82 | ||
83 | @auth.generate_nonce | |
84 | def generate_nonce(): | |
85 | """Return the nonce value to use for this client.""" | |
86 | pass | |
87 | ||
88 | @auth.generate_opaque | |
89 | def generate_opaque(): | |
90 | """Return the opaque value to use for this client.""" | |
91 | pass | |
92 | ||
93 | @auth.verify_nonce | |
94 | def verify_nonce(nonce): | |
95 | """Verify that the nonce value sent by the client is correct.""" | |
96 | pass | |
97 | ||
98 | @auth.verify_opaque | |
99 | def verify_opaque(opaque): | |
100 | """Verify that the opaque value sent by the client is correct.""" | |
101 | pass | |
102 | ||
103 | For information of what the ``nonce`` and ``opaque`` values are and how they are used in digest authentication, consult `RFC 2617 <http://tools.ietf.org/html/rfc2617#section-3.2.1>`_. | |
104 | ||
105 | 74 | Token Authentication Example |
106 | 75 | ---------------------------- |
107 | 76 | |
325 | 294 | |
326 | 295 | .. class:: HTTPDigestAuth |
327 | 296 | |
328 | This class handles HTTP Digest authentication for Flask routes. The ``SECRET_KEY`` configuration must be set in the Flask application to enable the session to work. Flask by default stores user sessions in the client as secure cookies, so the client must be able to handle cookies. To make this authentication method secure, a `session interface <http://flask.pocoo.org/docs/api/#flask.Flask.session_interface>`_ that writes sessions in the server must be used. | |
329 | ||
330 | .. method:: __init__(self, scheme=None, realm=None, use_ha1_pw=False) | |
297 | This class handles HTTP Digest authentication for Flask routes. The ``SECRET_KEY`` configuration must be set in the Flask application to enable the session to work. Flask by default stores user sessions in the client as secure cookies, so the client must be able to handle cookies. | |
298 | ||
299 | .. method:: __init__(self, scheme=None, realm=None, use_ha1_pw=False, qop='auth', algorithm='MD5') | |
331 | 300 | |
332 | 301 | Create a digest authentication object. |
333 | 302 | |
336 | 305 | The ``realm`` argument can be used to provide an application defined realm with the ``WWW-Authenticate`` header. |
337 | 306 | |
338 | 307 | If ``use_ha1_pw`` is False, then the ``get_password`` callback needs to return the plain text password for the given user. If ``use_ha1_pw`` is True, the ``get_password`` callback needs to return the HA1 value for the given user. The advantage of setting ``use_ha1_pw`` to ``True`` is that it allows the application to store the HA1 hash of the password in the user database. |
308 | ||
309 | The ``qop`` option configures a list of accepted quality of protection extensions. This argument can be given as a comma-separated string, a list of strings, or ``None`` to disable. The default is ``auth``. The ``auth-int`` option is currently not implemented. | |
310 | ||
311 | The ``algorithm`` option configures the hash generation algorithm to use. The default is ``MD5``. The two algorithms that are implemented are ``MD5`` and ``MD5-Sess``. | |
339 | 312 | |
340 | 313 | .. method:: generate_ha1(username, password) |
341 | 314 |
0 | #!/usr/bin/env python | |
1 | """Digest authentication example | |
2 | ||
3 | This example demonstrates how to protect Flask endpoints with digest | |
4 | authentication. | |
5 | ||
6 | After running this example, visit http://localhost:5000 in your browser. To | |
7 | gain access, you can use (username=john, password=hello) or | |
8 | (username=susan, password=bye). | |
9 | """ | |
10 | from flask import Flask | |
11 | from flask_httpauth import HTTPDigestAuth | |
12 | ||
13 | app = Flask(__name__) | |
14 | app.secret_key = 'this-is-a-secret-key' | |
15 | auth = HTTPDigestAuth(qop='auth') | |
16 | ||
17 | users = { | |
18 | "john": "hello", | |
19 | "susan": "bye", | |
20 | } | |
21 | ||
22 | ||
23 | @auth.get_password | |
24 | def get_password(username): | |
25 | return users.get(username) | |
26 | ||
27 | ||
28 | @app.route('/') | |
29 | @auth.login_required | |
30 | def index(): | |
31 | return "Hello, %s!" % auth.current_user() | |
32 | ||
33 | ||
34 | if __name__ == '__main__': | |
35 | app.run(debug=True, host='0.0.0.0') |
0 | 0 | [metadata] |
1 | 1 | name = Flask-HTTPAuth |
2 | version = 4.5.0 | |
2 | version = 4.7.0 | |
3 | 3 | author = Miguel Grinberg |
4 | 4 | author_email = miguel.grinberg@gmail.com |
5 | 5 | description = HTTP authentication for Flask routes |
216 | 216 | value = request.headers[header].encode('utf-8') |
217 | 217 | try: |
218 | 218 | scheme, credentials = value.split(b' ', 1) |
219 | username, password = b64decode(credentials).split(b':', 1) | |
219 | encoded_username, encoded_password = b64decode( | |
220 | credentials).split(b':', 1) | |
220 | 221 | except (ValueError, TypeError): |
221 | 222 | return None |
222 | 223 | try: |
223 | username = username.decode('utf-8') | |
224 | password = password.decode('utf-8') | |
224 | username = encoded_username.decode('utf-8') | |
225 | password = encoded_password.decode('utf-8') | |
225 | 226 | except UnicodeDecodeError: |
226 | username = None | |
227 | password = None | |
227 | # try to decode again with latin-1, which should always work | |
228 | username = encoded_username.decode('latin1') | |
229 | password = encoded_password.decode('latin1') | |
230 | ||
228 | 231 | return Authorization( |
229 | 232 | scheme, {'username': username, 'password': password}) |
230 | 233 | |
253 | 256 | |
254 | 257 | |
255 | 258 | class HTTPDigestAuth(HTTPAuth): |
256 | def __init__(self, scheme=None, realm=None, use_ha1_pw=False): | |
259 | def __init__(self, scheme=None, realm=None, use_ha1_pw=False, qop='auth', | |
260 | algorithm='MD5'): | |
257 | 261 | super(HTTPDigestAuth, self).__init__(scheme or 'Digest', realm) |
258 | 262 | self.use_ha1_pw = use_ha1_pw |
263 | if isinstance(qop, str): | |
264 | self.qop = [v.strip() for v in qop.split(',')] | |
265 | else: | |
266 | self.qop = qop | |
267 | if algorithm.lower() == 'md5': | |
268 | self.algorithm = 'MD5' | |
269 | elif algorithm.lower() == 'md5-sess': | |
270 | self.algorithm = 'MD5-Sess' | |
271 | else: | |
272 | raise ValueError(f'Algorithm {algorithm} is not supported') | |
259 | 273 | self.random = SystemRandom() |
260 | 274 | try: |
261 | 275 | self.random.random() |
325 | 339 | def authenticate_header(self): |
326 | 340 | nonce = self.get_nonce() |
327 | 341 | opaque = self.get_opaque() |
328 | return '{0} realm="{1}",nonce="{2}",opaque="{3}"'.format( | |
329 | self.scheme, self.realm, nonce, | |
330 | opaque) | |
342 | if self.qop: | |
343 | return ('{0} realm="{1}",nonce="{2}",opaque="{3}",algorithm="{4}"' | |
344 | ',qop="{5}"').format( | |
345 | self.scheme, self.realm, nonce, | |
346 | opaque, self.algorithm, ','.join(self.qop)) | |
347 | else: | |
348 | return '{0} realm="{1}",nonce="{2}",opaque="{3}"'.format( | |
349 | self.scheme, self.realm, nonce, | |
350 | opaque) | |
331 | 351 | |
332 | 352 | def authenticate(self, auth, stored_password_or_ha1): |
333 | 353 | if not auth or not auth.username or not auth.realm or not auth.uri \ |
337 | 357 | if not(self.verify_nonce_callback(auth.nonce)) or \ |
338 | 358 | not(self.verify_opaque_callback(auth.opaque)): |
339 | 359 | return False |
360 | if auth.qop and auth.qop not in self.qop: # pragma: no cover | |
361 | return False | |
340 | 362 | if self.use_ha1_pw: |
341 | 363 | ha1 = stored_password_or_ha1 |
342 | 364 | else: |
343 | 365 | a1 = auth.username + ":" + auth.realm + ":" + \ |
344 | 366 | stored_password_or_ha1 |
345 | 367 | ha1 = md5(a1.encode('utf-8')).hexdigest() |
368 | if self.algorithm == 'MD5-Sess': | |
369 | ha1 = md5((ha1 + ':' + auth.nonce + ':' + auth.cnonce).encode( | |
370 | 'utf-8')).hexdigest() | |
346 | 371 | a2 = request.method + ":" + auth.uri |
347 | 372 | ha2 = md5(a2.encode('utf-8')).hexdigest() |
348 | a3 = ha1 + ":" + auth.nonce + ":" + ha2 | |
373 | if auth.qop == 'auth': | |
374 | a3 = ha1 + ":" + auth.nonce + ":" + auth.nc + ":" + \ | |
375 | auth.cnonce + ":auth:" + ha2 | |
376 | else: | |
377 | a3 = ha1 + ":" + auth.nonce + ":" + ha2 | |
349 | 378 | response = md5(a3.encode('utf-8')).hexdigest() |
350 | 379 | return hmac.compare_digest(response, auth.response) |
351 | 380 |
20 | 20 | return password == 'hello' |
21 | 21 | elif username == 'susan': |
22 | 22 | return password == 'bye' |
23 | elif username == 'garçon': | |
24 | return password == 'áéÃóú' | |
23 | 25 | elif username == '': |
24 | 26 | g.anon = True |
25 | 27 | return True |
30 | 32 | return 'john' |
31 | 33 | elif username == 'susan' and password == 'bye': |
32 | 34 | return 'susan' |
35 | elif username == 'garçon' and password == 'áéÃóú': | |
36 | return 'garçon' | |
33 | 37 | elif username == '': |
34 | 38 | g.anon = True |
35 | 39 | return '' |
58 | 62 | self.client = app.test_client() |
59 | 63 | |
60 | 64 | def test_verify_auth_login_valid(self): |
61 | creds = base64.b64encode(b'susan:bye').decode('utf-8') | |
65 | creds = base64.b64encode(b'susan:bye').decode() | |
62 | 66 | response = self.client.get( |
63 | 67 | '/basic-verify', headers={'Authorization': 'Basic ' + creds}) |
64 | 68 | self.assertEqual(response.data, b'basic_verify_auth:susan anon:False') |
69 | ||
70 | def test_verify_auth_login_valid_latin1(self): | |
71 | creds = base64.b64encode('garçon:áéÃóú'.encode('latin1')).decode() | |
72 | response = self.client.get( | |
73 | '/basic-verify', headers={'Authorization': 'Basic ' + creds}) | |
74 | self.assertEqual(response.data.decode(), | |
75 | 'basic_verify_auth:garçon anon:False') | |
65 | 76 | |
66 | 77 | def test_verify_auth_login_empty(self): |
67 | 78 | response = self.client.get('/basic-verify') |
68 | 79 | self.assertEqual(response.data, b'basic_verify_auth: anon:True') |
69 | 80 | |
70 | 81 | def test_verify_auth_login_invalid(self): |
71 | creds = base64.b64encode(b'john:bye').decode('utf-8') | |
82 | creds = base64.b64encode(b'john:bye').decode() | |
72 | 83 | response = self.client.get( |
73 | 84 | '/basic-verify', headers={'Authorization': 'Basic ' + creds}) |
74 | 85 | self.assertEqual(response.status_code, 403) |
8 | 8 | app = Flask(__name__) |
9 | 9 | app.config['SECRET_KEY'] = 'my secret' |
10 | 10 | |
11 | digest_auth_my_realm = HTTPDigestAuth(realm='My Realm') | |
11 | digest_auth_my_realm = HTTPDigestAuth(realm='My Realm', qop=None) | |
12 | 12 | |
13 | 13 | @digest_auth_my_realm.get_password |
14 | 14 | def get_digest_password_3(username): |
0 | 0 | import unittest |
1 | 1 | import re |
2 | import pytest | |
2 | 3 | from hashlib import md5 as basic_md5 |
3 | 4 | from flask import Flask |
4 | 5 | from flask_httpauth import HTTPDigestAuth |
45 | 46 | self.digest_auth = digest_auth |
46 | 47 | self.client = app.test_client() |
47 | 48 | |
49 | def test_constructor(self): | |
50 | d = HTTPDigestAuth() | |
51 | assert d.qop == ['auth'] | |
52 | assert d.algorithm == 'MD5' | |
53 | d = HTTPDigestAuth(qop=None) | |
54 | assert d.qop is None | |
55 | d = HTTPDigestAuth(qop='auth') | |
56 | assert d.qop == ['auth'] | |
57 | d = HTTPDigestAuth(qop=['foo', 'bar']) | |
58 | assert d.qop == ['foo', 'bar'] | |
59 | d = HTTPDigestAuth(qop='foo,bar, baz') | |
60 | assert d.qop == ['foo', 'bar', 'baz'] | |
61 | d = HTTPDigestAuth(algorithm='md5') | |
62 | assert d.algorithm == 'MD5' | |
63 | d = HTTPDigestAuth(algorithm='md5-sess') | |
64 | assert d.algorithm == 'MD5-Sess' | |
65 | with pytest.raises(ValueError): | |
66 | HTTPDigestAuth(algorithm='foo') | |
67 | ||
48 | 68 | def test_digest_auth_prompt(self): |
49 | 69 | response = self.client.get('/digest') |
50 | 70 | self.assertEqual(response.status_code, 401) |
51 | 71 | self.assertTrue('WWW-Authenticate' in response.headers) |
52 | 72 | self.assertTrue(re.match(r'^Digest realm="Authentication Required",' |
53 | r'nonce="[0-9a-f]+",opaque="[0-9a-f]+"$', | |
73 | r'nonce="[0-9a-f]+",opaque="[0-9a-f]+",' | |
74 | r'algorithm="MD5",qop="auth"$', | |
54 | 75 | response.headers['WWW-Authenticate'])) |
55 | 76 | |
56 | 77 | def test_digest_auth_ignore_options(self): |
69 | 90 | ha1 = md5(a1).hexdigest() |
70 | 91 | a2 = 'GET:/digest' |
71 | 92 | ha2 = md5(a2).hexdigest() |
72 | a3 = ha1 + ':' + d['nonce'] + ':' + ha2 | |
73 | auth_response = md5(a3).hexdigest() | |
74 | ||
75 | response = self.client.get( | |
76 | '/digest', headers={ | |
77 | 'Authorization': 'Digest username="john",realm="{0}",' | |
78 | 'nonce="{1}",uri="/digest",response="{2}",' | |
93 | a3 = ha1 + ':' + d['nonce'] + ':00000001:foobar:auth:' + ha2 | |
94 | auth_response = md5(a3).hexdigest() | |
95 | ||
96 | response = self.client.get( | |
97 | '/digest', headers={ | |
98 | 'Authorization': 'Digest username="john",realm="{0}",' | |
99 | 'nonce="{1}",uri="/digest",qop=auth,' | |
100 | 'nc=00000001,cnonce="foobar",response="{2}",' | |
79 | 101 | 'opaque="{3}"'.format(d['realm'], |
80 | 102 | d['nonce'], |
81 | 103 | auth_response, |
82 | 104 | d['opaque'])}) |
83 | 105 | self.assertEqual(response.data, b'digest_auth:john') |
84 | 106 | |
107 | def test_digest_auth_md5_sess_login_valid(self): | |
108 | self.digest_auth.algorithm = 'MD5-Sess' | |
109 | ||
110 | response = self.client.get('/digest') | |
111 | self.assertTrue(response.status_code == 401) | |
112 | header = response.headers.get('WWW-Authenticate') | |
113 | auth_type, auth_info = header.split(None, 1) | |
114 | d = parse_dict_header(auth_info) | |
115 | ||
116 | a1 = 'john:' + d['realm'] + ':bye' | |
117 | ha1 = md5( | |
118 | md5(a1).hexdigest() + ':' + d['nonce'] + ':foobar').hexdigest() | |
119 | a2 = 'GET:/digest' | |
120 | ha2 = md5(a2).hexdigest() | |
121 | a3 = ha1 + ':' + d['nonce'] + ':00000001:foobar:auth:' + ha2 | |
122 | auth_response = md5(a3).hexdigest() | |
123 | ||
124 | response = self.client.get( | |
125 | '/digest', headers={ | |
126 | 'Authorization': 'Digest username="john",realm="{0}",' | |
127 | 'nonce="{1}",uri="/digest",qop=auth,' | |
128 | 'nc=00000001,cnonce="foobar",response="{2}",' | |
129 | 'opaque="{3}"'.format(d['realm'], | |
130 | d['nonce'], | |
131 | auth_response, | |
132 | d['opaque'])}) | |
133 | self.assertEqual(response.data, b'digest_auth:john') | |
134 | ||
85 | 135 | def test_digest_auth_login_bad_realm(self): |
86 | 136 | response = self.client.get('/digest') |
87 | 137 | self.assertTrue(response.status_code == 401) |
93 | 143 | ha1 = md5(a1).hexdigest() |
94 | 144 | a2 = 'GET:/digest' |
95 | 145 | ha2 = md5(a2).hexdigest() |
96 | a3 = ha1 + ':' + d['nonce'] + ':' + ha2 | |
97 | auth_response = md5(a3).hexdigest() | |
98 | ||
99 | response = self.client.get( | |
100 | '/digest', headers={ | |
101 | 'Authorization': 'Digest username="john",realm="{0}",' | |
102 | 'nonce="{1}",uri="/digest",response="{2}",' | |
146 | a3 = ha1 + ':' + d['nonce'] + ':00000001:foobar:auth:' + ha2 | |
147 | auth_response = md5(a3).hexdigest() | |
148 | ||
149 | response = self.client.get( | |
150 | '/digest', headers={ | |
151 | 'Authorization': 'Digest username="john",realm="{0}",' | |
152 | 'nonce="{1}",uri="/digest",qop=auth,' | |
153 | 'nc=00000001,cnonce="foobar",response="{2}",' | |
103 | 154 | 'opaque="{3}"'.format(d['realm'], |
104 | 155 | d['nonce'], |
105 | 156 | auth_response, |
107 | 158 | self.assertEqual(response.status_code, 401) |
108 | 159 | self.assertTrue('WWW-Authenticate' in response.headers) |
109 | 160 | self.assertTrue(re.match(r'^Digest realm="Authentication Required",' |
110 | r'nonce="[0-9a-f]+",opaque="[0-9a-f]+"$', | |
161 | r'nonce="[0-9a-f]+",opaque="[0-9a-f]+",' | |
162 | r'algorithm="MD5",qop="auth"$', | |
111 | 163 | response.headers['WWW-Authenticate'])) |
112 | 164 | |
113 | 165 | def test_digest_auth_login_invalid2(self): |
121 | 173 | ha1 = md5(a1).hexdigest() |
122 | 174 | a2 = 'GET:/digest' |
123 | 175 | ha2 = md5(a2).hexdigest() |
124 | a3 = ha1 + ':' + d['nonce'] + ':' + ha2 | |
125 | auth_response = md5(a3).hexdigest() | |
126 | ||
127 | response = self.client.get( | |
128 | '/digest', headers={ | |
129 | 'Authorization': 'Digest username="david",realm="{0}",' | |
130 | 'nonce="{1}",uri="/digest",response="{2}",' | |
176 | a3 = ha1 + ':' + d['nonce'] + ':00000001:foobar:auth:' + ha2 | |
177 | auth_response = md5(a3).hexdigest() | |
178 | ||
179 | response = self.client.get( | |
180 | '/digest', headers={ | |
181 | 'Authorization': 'Digest username="john",realm="{0}",' | |
182 | 'nonce="{1}",uri="/digest",qop=auth,' | |
183 | 'nc=00000001,cnonce="foobar",response="{2}",' | |
131 | 184 | 'opaque="{3}"'.format(d['realm'], |
132 | 185 | d['nonce'], |
133 | 186 | auth_response, |
135 | 188 | self.assertEqual(response.status_code, 401) |
136 | 189 | self.assertTrue('WWW-Authenticate' in response.headers) |
137 | 190 | self.assertTrue(re.match(r'^Digest realm="Authentication Required",' |
138 | r'nonce="[0-9a-f]+",opaque="[0-9a-f]+"$', | |
191 | r'nonce="[0-9a-f]+",opaque="[0-9a-f]+",' | |
192 | r'algorithm="MD5",qop="auth"$', | |
139 | 193 | response.headers['WWW-Authenticate'])) |
140 | 194 | |
141 | 195 | def test_digest_generate_ha1(self): |
179 | 233 | ha1 = md5(a1).hexdigest() |
180 | 234 | a2 = 'GET:/digest' |
181 | 235 | ha2 = md5(a2).hexdigest() |
182 | a3 = ha1 + ':' + d['nonce'] + ':' + ha2 | |
183 | auth_response = md5(a3).hexdigest() | |
184 | ||
185 | response = self.client.get( | |
186 | '/digest', headers={ | |
187 | 'Authorization': 'Digest username="john",realm="{0}",' | |
188 | 'nonce="{1}",uri="/digest",response="{2}",' | |
236 | a3 = ha1 + ':' + d['nonce'] + ':00000001:foobar:auth:' + ha2 | |
237 | auth_response = md5(a3).hexdigest() | |
238 | ||
239 | response = self.client.get( | |
240 | '/digest', headers={ | |
241 | 'Authorization': 'Digest username="john",realm="{0}",' | |
242 | 'nonce="{1}",uri="/digest",qop=auth,' | |
243 | 'nc=00000001,cnonce="foobar",response="{2}",' | |
189 | 244 | 'opaque="{3}"'.format(d['realm'], |
190 | 245 | d['nonce'], |
191 | 246 | auth_response, |
0 | import unittest | |
1 | import re | |
2 | from hashlib import md5 as basic_md5 | |
3 | from flask import Flask | |
4 | from flask_httpauth import HTTPDigestAuth | |
5 | from werkzeug.http import parse_dict_header | |
6 | ||
7 | ||
8 | def md5(str): | |
9 | if type(str).__name__ == 'str': | |
10 | str = str.encode('utf-8') | |
11 | return basic_md5(str) | |
12 | ||
13 | ||
14 | def get_ha1(user, pw, realm): | |
15 | a1 = user + ":" + realm + ":" + pw | |
16 | return md5(a1).hexdigest() | |
17 | ||
18 | ||
19 | class HTTPAuthTestCase(unittest.TestCase): | |
20 | def setUp(self): | |
21 | app = Flask(__name__) | |
22 | app.config['SECRET_KEY'] = 'my secret' | |
23 | ||
24 | digest_auth = HTTPDigestAuth(qop=None) | |
25 | ||
26 | @digest_auth.get_password | |
27 | def get_digest_password_2(username): | |
28 | if username == 'susan': | |
29 | return 'hello' | |
30 | elif username == 'john': | |
31 | return 'bye' | |
32 | else: | |
33 | return None | |
34 | ||
35 | @app.route('/') | |
36 | def index(): | |
37 | return 'index' | |
38 | ||
39 | @app.route('/digest') | |
40 | @digest_auth.login_required | |
41 | def digest_auth_route(): | |
42 | return 'digest_auth:' + digest_auth.username() | |
43 | ||
44 | self.app = app | |
45 | self.digest_auth = digest_auth | |
46 | self.client = app.test_client() | |
47 | ||
48 | def test_digest_auth_prompt(self): | |
49 | response = self.client.get('/digest') | |
50 | self.assertEqual(response.status_code, 401) | |
51 | self.assertTrue('WWW-Authenticate' in response.headers) | |
52 | self.assertTrue(re.match(r'^Digest realm="Authentication Required",' | |
53 | r'nonce="[0-9a-f]+",opaque="[0-9a-f]+"$', | |
54 | response.headers['WWW-Authenticate'])) | |
55 | ||
56 | def test_digest_auth_ignore_options(self): | |
57 | response = self.client.options('/digest') | |
58 | self.assertEqual(response.status_code, 200) | |
59 | self.assertTrue('WWW-Authenticate' not in response.headers) | |
60 | ||
61 | def test_digest_auth_login_valid(self): | |
62 | response = self.client.get('/digest') | |
63 | self.assertTrue(response.status_code == 401) | |
64 | header = response.headers.get('WWW-Authenticate') | |
65 | auth_type, auth_info = header.split(None, 1) | |
66 | d = parse_dict_header(auth_info) | |
67 | ||
68 | a1 = 'john:' + d['realm'] + ':bye' | |
69 | ha1 = md5(a1).hexdigest() | |
70 | a2 = 'GET:/digest' | |
71 | ha2 = md5(a2).hexdigest() | |
72 | a3 = ha1 + ':' + d['nonce'] + ':' + ha2 | |
73 | auth_response = md5(a3).hexdigest() | |
74 | ||
75 | response = self.client.get( | |
76 | '/digest', headers={ | |
77 | 'Authorization': 'Digest username="john",realm="{0}",' | |
78 | 'nonce="{1}",uri="/digest",response="{2}",' | |
79 | 'opaque="{3}"'.format(d['realm'], | |
80 | d['nonce'], | |
81 | auth_response, | |
82 | d['opaque'])}) | |
83 | self.assertEqual(response.data, b'digest_auth:john') | |
84 | ||
85 | def test_digest_auth_login_bad_realm(self): | |
86 | response = self.client.get('/digest') | |
87 | self.assertTrue(response.status_code == 401) | |
88 | header = response.headers.get('WWW-Authenticate') | |
89 | auth_type, auth_info = header.split(None, 1) | |
90 | d = parse_dict_header(auth_info) | |
91 | ||
92 | a1 = 'john:' + 'Wrong Realm' + ':bye' | |
93 | ha1 = md5(a1).hexdigest() | |
94 | a2 = 'GET:/digest' | |
95 | ha2 = md5(a2).hexdigest() | |
96 | a3 = ha1 + ':' + d['nonce'] + ':' + ha2 | |
97 | auth_response = md5(a3).hexdigest() | |
98 | ||
99 | response = self.client.get( | |
100 | '/digest', headers={ | |
101 | 'Authorization': 'Digest username="john",realm="{0}",' | |
102 | 'nonce="{1}",uri="/digest",response="{2}",' | |
103 | 'opaque="{3}"'.format(d['realm'], | |
104 | d['nonce'], | |
105 | auth_response, | |
106 | d['opaque'])}) | |
107 | self.assertEqual(response.status_code, 401) | |
108 | self.assertTrue('WWW-Authenticate' in response.headers) | |
109 | self.assertTrue(re.match(r'^Digest realm="Authentication Required",' | |
110 | r'nonce="[0-9a-f]+",opaque="[0-9a-f]+"$', | |
111 | response.headers['WWW-Authenticate'])) | |
112 | ||
113 | def test_digest_auth_login_invalid2(self): | |
114 | response = self.client.get('/digest') | |
115 | self.assertEqual(response.status_code, 401) | |
116 | header = response.headers.get('WWW-Authenticate') | |
117 | auth_type, auth_info = header.split(None, 1) | |
118 | d = parse_dict_header(auth_info) | |
119 | ||
120 | a1 = 'david:' + 'Authentication Required' + ':bye' | |
121 | ha1 = md5(a1).hexdigest() | |
122 | a2 = 'GET:/digest' | |
123 | ha2 = md5(a2).hexdigest() | |
124 | a3 = ha1 + ':' + d['nonce'] + ':' + ha2 | |
125 | auth_response = md5(a3).hexdigest() | |
126 | ||
127 | response = self.client.get( | |
128 | '/digest', headers={ | |
129 | 'Authorization': 'Digest username="david",realm="{0}",' | |
130 | 'nonce="{1}",uri="/digest",response="{2}",' | |
131 | 'opaque="{3}"'.format(d['realm'], | |
132 | d['nonce'], | |
133 | auth_response, | |
134 | d['opaque'])}) | |
135 | self.assertEqual(response.status_code, 401) | |
136 | self.assertTrue('WWW-Authenticate' in response.headers) | |
137 | self.assertTrue(re.match(r'^Digest realm="Authentication Required",' | |
138 | r'nonce="[0-9a-f]+",opaque="[0-9a-f]+"$', | |
139 | response.headers['WWW-Authenticate'])) | |
140 | ||
141 | def test_digest_generate_ha1(self): | |
142 | ha1 = self.digest_auth.generate_ha1('pawel', 'test') | |
143 | ha1_expected = get_ha1('pawel', 'test', self.digest_auth.realm) | |
144 | self.assertEqual(ha1, ha1_expected) | |
145 | ||
146 | def test_digest_custom_nonce_checker(self): | |
147 | @self.digest_auth.generate_nonce | |
148 | def noncemaker(): | |
149 | return 'not a good nonce' | |
150 | ||
151 | @self.digest_auth.generate_opaque | |
152 | def opaquemaker(): | |
153 | return 'some opaque' | |
154 | ||
155 | verify_nonce_called = [] | |
156 | ||
157 | @self.digest_auth.verify_nonce | |
158 | def verify_nonce(provided_nonce): | |
159 | verify_nonce_called.append(provided_nonce) | |
160 | return True | |
161 | ||
162 | verify_opaque_called = [] | |
163 | ||
164 | @self.digest_auth.verify_opaque | |
165 | def verify_opaque(provided_opaque): | |
166 | verify_opaque_called.append(provided_opaque) | |
167 | return True | |
168 | ||
169 | response = self.client.get('/digest') | |
170 | self.assertEqual(response.status_code, 401) | |
171 | header = response.headers.get('WWW-Authenticate') | |
172 | auth_type, auth_info = header.split(None, 1) | |
173 | d = parse_dict_header(auth_info) | |
174 | ||
175 | self.assertEqual(d['nonce'], 'not a good nonce') | |
176 | self.assertEqual(d['opaque'], 'some opaque') | |
177 | ||
178 | a1 = 'john:' + d['realm'] + ':bye' | |
179 | ha1 = md5(a1).hexdigest() | |
180 | a2 = 'GET:/digest' | |
181 | ha2 = md5(a2).hexdigest() | |
182 | a3 = ha1 + ':' + d['nonce'] + ':' + ha2 | |
183 | auth_response = md5(a3).hexdigest() | |
184 | ||
185 | response = self.client.get( | |
186 | '/digest', headers={ | |
187 | 'Authorization': 'Digest username="john",realm="{0}",' | |
188 | 'nonce="{1}",uri="/digest",response="{2}",' | |
189 | 'opaque="{3}"'.format(d['realm'], | |
190 | d['nonce'], | |
191 | auth_response, | |
192 | d['opaque'])}) | |
193 | self.assertEqual(response.data, b'digest_auth:john') | |
194 | self.assertEqual(verify_nonce_called, ['not a good nonce'], | |
195 | "Should have verified the nonce.") | |
196 | self.assertEqual(verify_opaque_called, ['some opaque'], | |
197 | "Should have verified the opaque.") |
0 | 0 | [tox] |
1 | envlist=flake8,py36,py37,py38,py39,pypy3,docs | |
1 | envlist=flake8,py36,py37,py38,py39,py310pypy3,docs | |
2 | 2 | skip_missing_interpreters=True |
3 | 3 | |
4 | 4 | [gh-actions] |
5 | 5 | python = |
6 | 2.7: py27 | |
7 | 6 | 3.6: py36 |
8 | 7 | 3.7: py37 |
9 | 8 | 3.8: py38 |
10 | 9 | 3.9: py39 |
11 | pypy2: pypy2 | |
12 | pypy3: pypy3 | |
10 | 3.10: py310 | |
11 | pypy-3: pypy3 | |
13 | 12 | |
14 | 13 | [testenv] |
15 | 14 | commands= |