Skip to content

Commit c1d8426

Browse files
authored
Fix more Basic Auth. (#371)
Basic auth didn't reject deactivated users. Basic Auth didn't return WWW-Authenticate when request was JSON. Introduced a new config variable SECURITY_API_ENABLED_METHODS which configures which of (basic, session, token) will be used to authenticate Flask-Security's endpoints that require it. change the @auth_required decorator to take a callable which can reference context variables (such as config). Change all endpoints that require authentication to use new config variable. Fixes: #368 Fixes: #369
1 parent 783dab3 commit c1d8426

File tree

10 files changed

+130
-19
lines changed

10 files changed

+130
-19
lines changed

docs/configuration.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,19 @@ These configuration keys are used globally across all features.
309309
.. deprecated:: 4.0.0
310310
Superseded by :py:data:`SECURITY_USER_IDENTITY_ATTRIBUTES`
311311

312+
.. py:data:: SECURITY_API_ENABLED_METHODS
313+
314+
Various endpoints of Flask-Security require the caller to be authenticated.
315+
This variable controls which of the methods - ``token``, ``session``, ``basic``
316+
will be allowed. The default does NOT include ``basic`` since if ``basic``
317+
is in the list, and if the user is NOT authenticated, then the standard/required
318+
response of 401 with the ``WWW-Authenticate`` header is returned. This is
319+
rarely what the client wants.
320+
321+
Default: ``["session", "token"]``.
322+
323+
.. versionadded:: 4.0.0
324+
312325
.. py:data:: SECURITY_DEFAULT_REMEMBER_ME
313326
314327
Specifies the default "remember me" value used when logging in a user.

docs/patterns.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ own code. Then use Flask's ``errorhandler`` to catch that exception and create t
5757
raise MyForbiddenException(msg='You can only update docs you own')
5858

5959

60+
A note about Basic Auth
61+
+++++++++++++++++++++++
62+
Basic Auth is supported in Flask-Security, using the @http_auth_required() decorator. If a request for an endpoint
63+
protected with @http_auth_required is received, and the request doesn't contain the appropriate HTTP Headers, a 401 is returned
64+
along with the required WWW-Authenticate header. In this case there won't be a usable session cookie returned so all future requests
65+
will also require credentials to be sent. Effectively the caller is temporarily 'logged in' at the beginning of each request and 'logged out' again
66+
at the end of the request. Most (all?) browsers intercept this response and pop up a login dialog box and remember, for the site, the entered credentials.
67+
This effectively bypasses any of the normal Flask-Security login forms. By default, the Flask-Security endpoints that require the caller be
68+
authenticated do NOT support ``basic`` - however the :py:data:`SECURITY_API_ENABLED_METHODS` can be used to override this.
69+
6070
Freshness
6171
++++++++++
6272
A common pattern for browser-based sites is to use sessions to manage identity. This is usually

flask_security/core.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@
213213
"PHONE_REGION_DEFAULT": "US",
214214
"FRESHNESS": timedelta(hours=24),
215215
"FRESHNESS_GRACE_PERIOD": timedelta(hours=1),
216+
"API_ENABLED_METHODS": ["session", "token"],
216217
"HASHING_SCHEMES": ["sha256_crypt", "hex_md5"],
217218
"DEPRECATED_HASHING_SCHEMES": ["hex_md5"],
218219
"DATETIME_FACTORY": datetime.utcnow,

flask_security/decorators.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,6 @@ def default_unauthn_handler(mechanisms, headers=None):
7676
if config_value("BACKWARDS_COMPAT_UNAUTHN"):
7777
return _get_unauthenticated_response(headers=headers)
7878
if _security._want_json(request):
79-
# Remove WWW-Authenticate from headers for JSON responses since
80-
# browsers will intercept that and pop up their own login form.
81-
headers.pop("WWW-Authenticate", None)
8279
payload = json_error_response(errors=msg)
8380
return _security._render_json(payload, 401, headers, None)
8481

@@ -168,6 +165,8 @@ def _check_http_auth():
168165
if not auth.username:
169166
return False
170167
user = find_user(auth.username)
168+
if user and not user.active:
169+
return False
171170

172171
if user and user.verify_and_update_password(auth.password):
173172
_security.datastore.commit()
@@ -283,7 +282,9 @@ def dashboard():
283282
return 'Dashboard'
284283
285284
:param auth_methods: Specified mechanisms (token, basic, session). If not specified
286-
then all current available mechanisms (except "basic") will be tried.
285+
then all current available mechanisms (except "basic") will be tried. A callable
286+
can also be passed (useful if you need app/request context). The callable
287+
must return a list.
287288
:kwparam within: Add 'freshness' check to authentication. Is either an int
288289
specifying # of minutes, or a callable that returns a timedelta. For timedeltas,
289290
timedelta.total_seconds() is used for the calculations:
@@ -330,6 +331,9 @@ def dashboard():
330331
.. versionchanged:: 3.4.4
331332
If ``auth_methods`` isn't specified try all mechanisms EXCEPT ``basic``.
332333
334+
.. versionchanged:: 4.0.0
335+
auth_methods can be passed as a callable.
336+
333337
"""
334338

335339
login_mechanisms = {
@@ -338,10 +342,7 @@ def dashboard():
338342
"basic": lambda: _check_http_auth(),
339343
}
340344
mechanisms_order = ["token", "session", "basic"]
341-
if not auth_methods:
342-
auth_methods = {"session", "token"}
343-
else:
344-
auth_methods = [am for am in auth_methods]
345+
auth_methods_arg = auth_methods
345346

346347
def wrapper(fn):
347348
@wraps(fn)
@@ -360,6 +361,16 @@ def decorated_view(*args, **dkwargs):
360361
else:
361362
grace = datetime.timedelta(minutes=grace)
362363

364+
if not auth_methods_arg:
365+
auth_methods = {"session", "token"}
366+
else:
367+
auth_methods = []
368+
for am in auth_methods_arg:
369+
if callable(am):
370+
auth_methods.extend(am())
371+
else:
372+
auth_methods.append(am)
373+
363374
h = {}
364375
if "basic" in auth_methods:
365376
r = _security.default_http_auth_realm

flask_security/unified_signin.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,7 @@ def us_signin_send_code():
404404
)
405405

406406

407-
@auth_required()
407+
@auth_required(lambda: config_value("API_ENABLED_METHODS"))
408408
def us_verify_send_code():
409409
"""
410410
Send code during verify.
@@ -557,7 +557,7 @@ def us_signin():
557557
)
558558

559559

560-
@auth_required()
560+
@auth_required(lambda: config_value("API_ENABLED_METHODS"))
561561
def us_verify():
562562
"""
563563
Re-authenticate to reset freshness time.
@@ -687,6 +687,7 @@ def us_verify_link():
687687

688688

689689
@auth_required(
690+
lambda: config_value("API_ENABLED_METHODS"),
690691
within=lambda: config_value("FRESHNESS"),
691692
grace=lambda: config_value("FRESHNESS_GRACE_PERIOD"),
692693
)
@@ -790,7 +791,7 @@ def us_setup():
790791
)
791792

792793

793-
@auth_required()
794+
@auth_required(lambda: config_value("API_ENABLED_METHODS"))
794795
def us_setup_validate(token):
795796
"""
796797
Validate new setup.
@@ -854,7 +855,7 @@ def us_setup_validate(token):
854855
return redirect(url_for_security("us_setup"))
855856

856857

857-
@auth_required()
858+
@auth_required(lambda: config_value("API_ENABLED_METHODS"))
858859
def us_qrcode(token):
859860

860861
if "authenticator" not in config_value("US_ENABLED_METHODS"):

flask_security/views.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ def login():
199199
)
200200

201201

202-
@auth_required()
202+
@auth_required(lambda: config_value("API_ENABLED_METHODS"))
203203
def verify():
204204
"""View function which handles a authentication verification request.
205205
"""
@@ -610,7 +610,7 @@ def reset_password(token):
610610
)
611611

612612

613-
@auth_required("basic", "token", "session")
613+
@auth_required(lambda: config_value("API_ENABLED_METHODS"))
614614
def change_password():
615615
"""View function which handles a change password request."""
616616

tests/test_changeable.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
:license: MIT, see LICENSE for more details.
99
"""
1010

11+
import base64
1112
import sys
1213

1314
import pytest
@@ -204,6 +205,32 @@ def test_token_change(app, client_nc):
204205
assert "authentication_token" in response.json["response"]["user"]
205206

206207

208+
@pytest.mark.settings(api_enabled_methods=["basic"])
209+
def test_basic_change(app, client_nc, get_message):
210+
# Verify can change password using basic auth
211+
data = dict(
212+
password="password",
213+
new_password="new strong password",
214+
new_password_confirm="new strong password",
215+
)
216+
response = client_nc.post("/change", data=data)
217+
assert b"You are not authenticated" in response.data
218+
assert "WWW-Authenticate" in response.headers
219+
220+
response = client_nc.post(
221+
"/change",
222+
data=data,
223+
headers={
224+
"Authorization": "Basic %s"
225+
% base64.b64encode(b"matt@lp.com:password").decode("utf-8")
226+
},
227+
follow_redirects=True,
228+
)
229+
assert response.status_code == 200
230+
# No session so no flashing
231+
assert b"Home Page" in response.data
232+
233+
207234
@pytest.mark.settings(password_complexity_checker="zxcvbn")
208235
def test_easy_password(app, client):
209236
authenticate(client)

tests/test_common.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,36 @@ def test_inactive_forbids_token(app, client_nc, get_message):
219219
assert response.status_code == 401
220220

221221

222+
def test_inactive_forbids_basic(app, client, get_message):
223+
""" Make sure that basic auth doesn't work if user deactivated
224+
"""
225+
226+
# Should properly work.
227+
response = client.get(
228+
"/multi_auth",
229+
headers={
230+
"Authorization": "Basic %s"
231+
% base64.b64encode(b"joe@lp.com:password").decode("utf-8")
232+
},
233+
)
234+
assert b"Session, Token, Basic" in response.data
235+
236+
# deactivate joe
237+
with app.test_request_context("/"):
238+
user = app.security.datastore.find_user(email="joe@lp.com")
239+
app.security.datastore.deactivate_user(user)
240+
app.security.datastore.commit()
241+
242+
response = client.get(
243+
"/multi_auth",
244+
headers={
245+
"Authorization": "Basic %s"
246+
% base64.b64encode(b"joe@lp.com:password").decode("utf-8")
247+
},
248+
)
249+
assert b"You are not authenticated" in response.data
250+
251+
222252
def test_unset_password(client, get_message):
223253
response = authenticate(client, "jess@lp.com", "password")
224254
assert get_message("PASSWORD_NOT_SET") in response.data
@@ -472,7 +502,6 @@ def test_http_auth_no_authentication_json(client, get_message):
472502
"UNAUTHENTICATED"
473503
)
474504
assert response.headers["Content-Type"] == "application/json"
475-
assert "WWW-Authenticate" not in response.headers
476505

477506

478507
@pytest.mark.settings(backwards_compat_unauthn=True)
@@ -491,9 +520,7 @@ def test_invalid_http_auth_invalid_username(client):
491520

492521
@pytest.mark.settings(backwards_compat_unauthn=False)
493522
def test_invalid_http_auth_invalid_username_json(client, get_message):
494-
# While Basic auth is allowed with JSON - we never expect a WWW-Authenticate
495-
# header - since that is captured by most browsers and they pop up a
496-
# login form.
523+
# Even with JSON - Basic Auth required a WWW-Authenticate header response.
497524
response = client.get(
498525
"/http",
499526
headers={
@@ -507,7 +534,7 @@ def test_invalid_http_auth_invalid_username_json(client, get_message):
507534
"UNAUTHENTICATED"
508535
)
509536
assert response.headers["Content-Type"] == "application/json"
510-
assert "WWW-Authenticate" not in response.headers
537+
assert "WWW-Authenticate" in response.headers
511538

512539

513540
@pytest.mark.settings(backwards_compat_unauthn=True)

tests/test_unified_signin.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
1010
"""
1111

12+
import base64
1213
from contextlib import contextmanager
1314
from datetime import timedelta
1415
from unittest.mock import Mock
@@ -663,6 +664,21 @@ def test_setup_json_no_session(app, client_nc, get_message):
663664
}
664665
response = client_nc.get("/us-setup", headers=headers)
665666
assert response.status_code == 401
667+
assert response.json["response"]["reauth_required"]
668+
assert "WWW-Authenticate" not in response.headers
669+
670+
671+
@pytest.mark.settings(api_enabled_methods=["basic"])
672+
def test_setup_basic(app, client, get_message):
673+
# If using Basic Auth - always fresh so should be able to setup (not sure the
674+
# use case but...)
675+
headers = {
676+
"Authorization": "Basic %s"
677+
% base64.b64encode(b"matt@lp.com:password").decode("utf-8")
678+
}
679+
response = client.get("/us-setup", headers=headers)
680+
assert response.status_code == 200
681+
assert b"Setup Unified Sign In options" in response.data
666682

667683

668684
def test_setup_bad_token(app, client, get_message):

tests/view_scaffold.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,11 @@ def home():
179179
def basic():
180180
return render_template_string("Basic auth success")
181181

182+
@app.route("/protected")
183+
@auth_required()
184+
def protected():
185+
return render_template_string("Protected endpoint")
186+
182187
return app
183188

184189

0 commit comments

Comments
 (0)