Skip to content

Commit 680f940

Browse files
authored
Change TOKEN_MAX_AGE to accept timedelta and default to 15 minutes. (#1233)
The config variable still accepts seconds (int) as well. This also (finally?) sets the default to something short - rather than 'never expire' - which out of the box is really bad.
2 parents 3a24949 + b4402b5 commit 680f940

8 files changed

Lines changed: 87 additions & 37 deletions

File tree

CHANGES.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ Released TBD
1111
Features & Improvements
1212
+++++++++++++++++++++++
1313
- (:issue:`1206`) Add support for refresh tokens. See :ref:`token_topic`
14+
- (:pr:`1233`) Change :py:data:`SECURITY_TOKEN_MAX_AGE` from an int to a timedelta.
15+
Also - change default from ``never expire`` to 15 minutes.
1416

1517
Fixes
1618
+++++
@@ -25,6 +27,10 @@ Backwards Compatibility Concerns
2527
+++++++++++++++++++++++++++++++++
2628
- The fix for the inverted `is_locked` logic will require any application using it
2729
to invert their logic.
30+
- The change to :py:data:`SECURITY_TOKEN_MAX_AGE` default improves a long-standing
31+
'insecure-out-of-the-box' issue. Applications will have to either change the
32+
value if they really want a long-lasting token or use the new refresh token
33+
feature.
2834

2935
Notes
3036
+++++

docs/configuration.rst

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,14 @@ These configuration keys are used globally across all features.
9898

9999
.. py:data:: SECURITY_TOKEN_MAX_AGE
100100
101-
Specifies the number of seconds before an authentication token expires.
101+
Specifies token expiration as a timedelta. Setting to 0 will mean the
102+
token never expires.
102103

103-
Default: ``None``, meaning the token never expires.
104+
Default: ``timedelta(minutes=15)``
105+
106+
.. versionchanged:: 5.9.0
107+
Change from int to a timedelta. If an int is provided, it will be
108+
changed to a timedelta as part of Flask-Security initialization.
104109

105110
.. py:data:: SECURITY_TOKEN_EXPIRE_TIMESTAMP
106111

flask_security/core.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@
293293
"TWO_FACTOR_RESCUE_MAIL": "no-reply@localhost",
294294
"TOKEN_AUTHENTICATION_KEY": "auth_token",
295295
"TOKEN_AUTHENTICATION_HEADER": "Authentication-Token",
296-
"TOKEN_MAX_AGE": None,
296+
"TOKEN_MAX_AGE": timedelta(minutes=15),
297297
"TOKEN_EXPIRE_TIMESTAMP": lambda user: 0,
298298
"REFRESH_TOKEN": False,
299299
"REFRESH_TOKEN_SALT": "refresh-token-salt",
@@ -1761,6 +1761,11 @@ def init_app(
17611761
" must have one and only one key."
17621762
)
17631763

1764+
if not isinstance(cv("TOKEN_MAX_AGE", app=app), timedelta):
1765+
app.config["SECURITY_TOKEN_MAX_AGE"] = timedelta(
1766+
seconds=cv("TOKEN_MAX_AGE", app=app)
1767+
)
1768+
17641769
self.login_manager = _get_login_manager(app, self)
17651770
self._phone_util = self._phone_util_cls(app)
17661771
self._mail_util = self._mail_util_cls(app)

flask_security/tokens.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
TODO
1616
- add ability for app to add/verify additional stuff in refresh_token
1717
- add endpoint to user can revoke refresh token?
18-
- change TOKEN_MAX_AGE to accept timedelta and change default to 30minutes or so.
1918
- implment rotation overlap period?:
2019
https://auth0.com/docs/secure/tokens/refresh-tokens/configure-refresh-token-rotation
2120

flask_security/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -566,7 +566,7 @@ def parse_auth_token(auth_token: str) -> dict[str, t.Any]:
566566

567567
# This can raise BadSignature or SignatureExpired exceptions from itsdangerous
568568
raw_data = _security.remember_token_serializer.loads(
569-
auth_token, max_age=config_value("TOKEN_MAX_AGE")
569+
auth_token, max_age=config_value("TOKEN_MAX_AGE").total_seconds()
570570
)
571571

572572
# Version 3.x generated tokens that map to data with 3 elements,

tests/test_basic.py

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

1111
import base64
12-
from datetime import datetime, timedelta, timezone
1312
import json
1413
import re
1514
import pytest
@@ -19,7 +18,6 @@
1918
from flask_security import uia_email_mapper
2019
from flask_security.decorators import auth_required
2120
from flask_principal import identity_loaded
22-
from freezegun import freeze_time
2321

2422
from tests.conftest import v2_param
2523
from tests.test_utils import (
@@ -729,35 +727,6 @@ def test_token_auth_invalid_for_session_auth(client):
729727
assert response.status_code == 401
730728

731729

732-
def test_per_user_expired_token(app, client_nc):
733-
# Test expiry in auth_token using callable
734-
with freeze_time("2024-01-01"):
735-
736-
def exp(user):
737-
assert user.email == "matt@lp.com"
738-
return int((datetime.now(timezone.utc) + timedelta(days=1)).timestamp())
739-
740-
app.config["SECURITY_TOKEN_EXPIRE_TIMESTAMP"] = exp
741-
742-
response = json_authenticate(client_nc)
743-
token = response.json["response"]["user"]["authentication_token"]
744-
745-
verify_token(client_nc, token, status=401)
746-
747-
748-
def test_per_user_not_expired_token(app, client_nc):
749-
# Test expiry in auth_token using callable
750-
def exp(user):
751-
assert user.email == "matt@lp.com"
752-
return int((datetime.now(timezone.utc) + timedelta(days=1)).timestamp())
753-
754-
app.config["SECURITY_TOKEN_EXPIRE_TIMESTAMP"] = exp
755-
756-
response = json_authenticate(client_nc)
757-
token = response.json["response"]["user"]["authentication_token"]
758-
verify_token(client_nc, token)
759-
760-
761730
def test_garbled_auth_token(app, client_nc):
762731
# garble token
763732
def augment_auth_token(tdata):

tests/test_tokens.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,17 @@
88
:license: MIT, see LICENSE for more details.
99
"""
1010

11-
from datetime import datetime, timedelta
11+
from datetime import datetime, timedelta, timezone
1212
import math
1313

1414
import pytest
15+
from freezegun import freeze_time
16+
from itsdangerous import SignatureExpired
1517
from sqlalchemy import Column, String
1618

1719
from flask_security.datastore import PeeweeDatastore
1820
from flask_security.tokens import RefreshTokenErrors
21+
from flask_security.utils import parse_auth_token
1922
from tests.test_csrf import _get_csrf_token
2023
from tests.test_utils import (
2124
check_signals,
@@ -371,3 +374,65 @@ def test_refresh_token_csrf(app, client, get_message):
371374
user = app.security.datastore.find_user(email="matt@lp.com")
372375
assert len(user.refresh_trackers) == 1
373376
assert user.refresh_trackers[0].revoked_at is not None
377+
378+
379+
@pytest.mark.settings(token_max_age=60)
380+
def test_auth_token_max_age_int(app, client_nc):
381+
with app.app_context():
382+
with freeze_time(datetime.now(timezone.utc) + timedelta(minutes=-3)):
383+
response = json_authenticate(client_nc)
384+
token = response.json["response"]["user"]["authentication_token"]
385+
with pytest.raises(SignatureExpired):
386+
parse_auth_token(token)
387+
388+
389+
@pytest.mark.settings(token_max_age=timedelta(days=3))
390+
def test_auth_token_max_age_delta(app, client_nc):
391+
with app.app_context():
392+
with freeze_time(datetime.now(timezone.utc) + timedelta(days=-5)):
393+
response = json_authenticate(client_nc)
394+
token = response.json["response"]["user"]["authentication_token"]
395+
with pytest.raises(SignatureExpired):
396+
parse_auth_token(token)
397+
398+
399+
@pytest.mark.settings(token_max_age=0)
400+
def test_auth_token_max_age_0(app, client_nc):
401+
with app.app_context():
402+
with freeze_time(datetime.now(timezone.utc) + timedelta(weeks=-1000)):
403+
response = json_authenticate(client_nc)
404+
token = response.json["response"]["user"]["authentication_token"]
405+
with pytest.raises(SignatureExpired):
406+
parse_auth_token(token)
407+
408+
409+
@pytest.mark.settings(token_max_age=timedelta(days=6))
410+
def test_per_user_expired_token(app, client_nc):
411+
# Test expiry in auth_token using callable
412+
with app.app_context():
413+
with freeze_time(datetime.now(timezone.utc) + timedelta(days=-3)):
414+
415+
def exp(user):
416+
assert user.email == "matt@lp.com"
417+
return int((datetime.now(timezone.utc) + timedelta(days=1)).timestamp())
418+
419+
app.config["SECURITY_TOKEN_EXPIRE_TIMESTAMP"] = exp
420+
421+
response = json_authenticate(client_nc)
422+
token = response.json["response"]["user"]["authentication_token"]
423+
with pytest.raises(SignatureExpired) as e:
424+
parse_auth_token(token)
425+
assert "token[exp] value expired" in e.value.message
426+
427+
428+
def test_per_user_not_expired_token(app, client_nc):
429+
# Test expiry in auth_token using callable
430+
def exp(user):
431+
assert user.email == "matt@lp.com"
432+
return int((datetime.now(timezone.utc) + timedelta(days=1)).timestamp())
433+
434+
app.config["SECURITY_TOKEN_EXPIRE_TIMESTAMP"] = exp
435+
436+
response = json_authenticate(client_nc)
437+
token = response.json["response"]["user"]["authentication_token"]
438+
verify_token(client_nc, token)

tests/test_two_factor.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -946,6 +946,7 @@ def pc(sender, user, method, **kwargs):
946946
assert response.json["response"]["tf_phone_number"] == "+442083661177"
947947

948948

949+
@pytest.mark.settings(token_max_age=timedelta(days=4))
949950
def test_opt_in_nc_expired(app, client_nc, get_message):
950951
"""
951952
Test tf-setup without cookies - expired token

0 commit comments

Comments
 (0)