diff --git a/docs/configuration.rst b/docs/configuration.rst index d4438bc3..82c216f4 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -537,7 +537,9 @@ These are used by the Two-Factor and Unified Signin features. - :py:data:`SECURITY_US_SETUP_URL` - :py:data:`SECURITY_TWO_FACTOR_SETUP_URL` - :py:data:`SECURITY_WAN_REGISTER_URL` + - :py:data:`SECURITY_WAN_DELETE_URL` - :py:data:`SECURITY_MULTI_FACTOR_RECOVERY_CODES` + - :py:data:`SECURITY_CHANGE_EMAIL_URL` Setting this to a negative number will disable any freshness checking and the endpoints: @@ -552,9 +554,8 @@ These are used by the Two-Factor and Unified Signin features. Please see :meth:`flask_security.check_and_update_authn_fresh` for details. .. note:: - This stores freshness information in the session - which must be presented - (usually via a Cookie) to the above endpoints. To disable this, set it - to ``timedelta(minutes=-1)`` + The timestamp of when the caller/user last successfully authenticated is + stored in the session as well as authentication token. Default: timedelta(hours=24) @@ -563,13 +564,11 @@ These are used by the Two-Factor and Unified Signin features. .. py:data:: SECURITY_FRESHNESS_GRACE_PERIOD A timedelta that provides a grace period when altering sensitive - information. - This is used to protect the endpoints: + information. This ensures that multi-step operations don't get denied + because the session/token happens to expire mid-step. - - :py:data:`SECURITY_US_SETUP_URL` - - :py:data:`SECURITY_TWO_FACTOR_SETUP_URL` - - :py:data:`SECURITY_WAN_REGISTER_URL` - - :py:data:`SECURITY_MULTI_FACTOR_RECOVERY_CODES` + Note that this is not implemented for freshness information carried in the + auth token. N.B. To avoid strange behavior, be sure to set the grace period less than the freshness period. @@ -579,6 +578,18 @@ These are used by the Two-Factor and Unified Signin features. .. versionadded:: 3.4.0 +.. py:data:: SECURITY_FRESHNESS_ALLOW_AUTH_TOKEN + + Controls whether the freshness data set in the auth token can be used to + satisfy freshness checks. Some applications might want to force freshness + protected endpoints to always use browser based access with sessions - they + should set this to ``False``. + + Default: ``True`` + + + .. versionadded:: 5.4.0 + Core - Compatibility --------------------- These are flags that change various backwards compatability functionality. @@ -1672,8 +1683,8 @@ WebAuthn Additional relevant configuration variables: - * :py:data:`SECURITY_FRESHNESS` - Used to protect /us-setup. - * :py:data:`SECURITY_FRESHNESS_GRACE_PERIOD` - Used to protect /us-setup. + * :py:data:`SECURITY_FRESHNESS` - Used to protect /wan-register and /wan-delete. + * :py:data:`SECURITY_FRESHNESS_GRACE_PERIOD` - Used to protect /wan-register and /wan-delete. Recovery Codes -------------- diff --git a/docs/features.rst b/docs/features.rst index 4d307465..58534318 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -77,7 +77,8 @@ Token Authentication -------------------- Token based authentication can be used by retrieving the user auth token from an -authentication endpoint (e.g. ``/login``, ``/us-signin``, ``/wan-signin``). +authentication endpoint (e.g. ``/login``, ``/us-signin``, ``/wan-signin``, ``/verify``, +``/us-verify``, ``/wan-verify``). Perform an HTTP POST with a query param of ``include_auth_token`` and the authentication details as JSON data. A successful call will return the authentication token. This token can be used in subsequent @@ -101,6 +102,10 @@ Authentication tokens have 2 options for specifying expiry time :data:`SECURITY_ is applied to ALL authentication tokens. Each authentication token can itself have an embedded expiry value (settable via the :data:`SECURITY_TOKEN_EXPIRE_TIMESTAMP` callable). +Authentication tokens also convey freshness by recording the time the token was generated. +This is used for endpoints protected with :func:`.auth_required` with a ``within`` +value set. + .. note:: While every Flask-Security endpoint will accept an authentication token header, there are some endpoints that require session information (e.g. a session cookie). diff --git a/docs/patterns.rst b/docs/patterns.rst index 36cb2f62..45d44cf5 100644 --- a/docs/patterns.rst +++ b/docs/patterns.rst @@ -101,13 +101,16 @@ Flask-Security itself uses this as part of securing the following endpoints: - .tf_setup ("/tf-setup") - .us_setup ("/us-setup") - .mf_recovery_codes ("/mf-recovery-codes") + - .change_email ("/change-email") Using the :py:data:`SECURITY_FRESHNESS` and :py:data:`SECURITY_FRESHNESS_GRACE_PERIOD` configuration variables. .. tip:: - Freshness requires a session (cookie) be sent as part of the request. Without - a session, freshness will fail. If your application doesn't/can't send session cookies - you can disable freshness by setting ``SECURITY_FRESHNESS`` to ``timedelta(minutes=-1)`` + The timestamp of the users last successful authentication is stored in the session + as well as in the authentication token. One of these must be presented or freshness + will fail. You can disallow using the value in the authentication token by setting + :py:data:`SECURITY_FRESHNESS_ALLOW_AUTH_TOKEN` to ``False``. + You can disable freshness by setting ``SECURITY_FRESHNESS`` to ``timedelta(minutes=-1)`` .. _redirect_topic: diff --git a/examples/unified_signin/client/client.py b/examples/unified_signin/client/client.py index dc606327..566de70e 100644 --- a/examples/unified_signin/client/client.py +++ b/examples/unified_signin/client/client.py @@ -1,5 +1,5 @@ """ -Copyright 2020-2021 by J. Christopher Wagner (jwag). All rights reserved. +Copyright 2020-2024 by J. Christopher Wagner (jwag). All rights reserved. :license: MIT, see LICENSE for more details. This relies on session/session cookie for continued authentication. @@ -84,6 +84,9 @@ def ussetup(server_url, session, password, phone): # unified sign in - setup sms with a phone number # Use the backdoor to grab verification SMS. + # reset freshness to show how that would work + resp = session.get(f"{server_url}/api/resetfresh") + csrf_token = session.cookies["XSRF-TOKEN"] resp = session.post( f"{server_url}/us-setup", diff --git a/examples/unified_signin/server/api.py b/examples/unified_signin/server/api.py index a8c5108a..93ad98d5 100644 --- a/examples/unified_signin/server/api.py +++ b/examples/unified_signin/server/api.py @@ -6,7 +6,7 @@ from datetime import datetime, timezone -from flask import Blueprint, abort, current_app, jsonify +from flask import Blueprint, abort, current_app, jsonify, session from flask_security import auth_required from app import SmsCaptureSender @@ -39,3 +39,16 @@ def popsms(): if msg: return jsonify(sms=msg) abort(400) + + +@api.route("/resetfresh", methods=["GET"]) +def resetfresh(): + # This resets the callers session freshness field - just for testing + old_paa = ( + session["fs_paa"] + - current_app.config["SECURITY_FRESHNESS"].total_seconds() + - 100 + ) + session["fs_paa"] = old_paa + session.pop("fs_gexp", None) + return jsonify() diff --git a/examples/unified_signin/server/app.py b/examples/unified_signin/server/app.py index 079c8161..103275c3 100644 --- a/examples/unified_signin/server/app.py +++ b/examples/unified_signin/server/app.py @@ -1,5 +1,5 @@ """ -Copyright 2020-2022 by J. Christopher Wagner (jwag). All rights reserved. +Copyright 2020-2024 by J. Christopher Wagner (jwag). All rights reserved. :license: MIT, see LICENSE for more details. A simple example of server and client utilizing unified sign in and other @@ -13,7 +13,6 @@ """ -import datetime import os from flask import Flask @@ -75,6 +74,8 @@ def create_app(): # We aren't interested in form-based APIs - so no need for flashing. app.config["SECURITY_FLASH_MESSAGES"] = False + app.config["SECURITY_AUTO_LOGIN_AFTER_CONFIRM"] = True + # Allow signing in with a phone number or email app.config["SECURITY_USER_IDENTITY_ATTRIBUTES"] = [ {"email": {"mapper": uia_email_mapper, "case_insensitive": True}}, @@ -108,12 +109,6 @@ def create_app(): app.config["REMEMBER_COOKIE_SAMESITE"] = "strict" app.config["SESSION_COOKIE_SAMESITE"] = "strict" - # This means the first 'fresh-required' endpoint after login will always require - # re-verification - but after that the grace period will kick in. - # This isn't likely something a normal app would need/want to do. - app.config["SECURITY_FRESHNESS"] = datetime.timedelta(minutes=0) - app.config["SECURITY_FRESHNESS_GRACE_PERIOD"] = datetime.timedelta(minutes=2) - # As of Flask-SQLAlchemy 2.4.0 it is easy to pass in options directly to the # underlying engine. This option makes sure that DB connections from the pool # are still valid. Important for entire application since many DBaaS options diff --git a/flask_security/core.py b/flask_security/core.py index bfd5e402..57c6cf7c 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -16,10 +16,11 @@ from datetime import datetime, timedelta from dataclasses import dataclass import importlib +import time import typing as t import warnings -from flask import current_app, g +from flask import current_app, g, session from flask_login import AnonymousUserMixin, LoginManager from flask_login import UserMixin as BaseUserMixin from flask_login import current_user @@ -292,6 +293,7 @@ "PHONE_REGION_DEFAULT": "US", "FRESHNESS": timedelta(hours=24), "FRESHNESS_GRACE_PERIOD": timedelta(hours=1), + "FRESHNESS_ALLOW_AUTH_TOKEN": True, "API_ENABLED_METHODS": ["session", "token"], "HASHING_SCHEMES": ["sha256_crypt", "hex_md5"], "DEPRECATED_HASHING_SCHEMES": ["hex_md5"], @@ -674,6 +676,7 @@ def _user_loader(user_id): user = _security.datastore.find_user(fs_uniquifier=str(user_id)) if user and user.active: set_request_attr("fs_authn_via", "session") + set_request_attr("fs_paa", session.get("fs_paa", 0)) return user return None @@ -707,6 +710,8 @@ def _request_loader(request): if user and user.active and user.verify_auth_token(tdata): set_request_attr("fs_authn_via", "token") + if cv("FRESHNESS_ALLOW_AUTH_TOKEN"): + set_request_attr("fs_paa", tdata.get("fs_paa", 0)) return user return None @@ -849,8 +854,12 @@ def get_auth_token(self) -> str | bytes: :raises ValueError: If ``fs_token_uniquifier`` is part of model but not set. - Optionally use a separate uniquifier so that changing password doesn't - invalidate auth tokens. + Uses ``fs_uniquifier`` or ``fs_token_uniquifier`` (if in the UserModel) + to identify this user. If ``fs_token_uniquifier`` is used then + changing password doesn't invalidate auth tokens. + + Calls :meth:`.UserMixin.augment_auth_token` which applications can override + to add any additional information. The returned value is securely signed using the ``remember_token_serializer`` @@ -860,6 +869,9 @@ def get_auth_token(self) -> str | bytes: .. versionchanged:: 5.4.0 New format - a dict with a version string. Add a token-based expiry option as well as a session id. + .. versionchanged:: 5.5.0 + Remove session id (never set or used); added fs_paa (last authentication + timestamp) """ tdata: dict[str, t.Any] = dict(ver=str(5)) @@ -869,7 +881,9 @@ def get_auth_token(self) -> str | bytes: tdata["uid"] = str(self.fs_token_uniquifier) else: tdata["uid"] = str(self.fs_uniquifier) - tdata["sid"] = 0 # session id + # Set the primary authenticated at variable. This is equivalent to + # what we set in the session. + tdata["fs_paa"] = time.time() # equivalent of session["fs_paa"] tdata["exp"] = int(cv("TOKEN_EXPIRE_TIMESTAMP")(self)) # if >0 then shorter of # :data:SECURITY_MAX_AGE and this. @@ -881,7 +895,8 @@ def get_auth_token(self) -> str | bytes: def augment_auth_token(self, tdata: dict[str, t.Any]) -> None: """Override this to add/modify parts of the auth token. - Additions to the dict can be made and verified in verify_auth_token() + Additions to the dict can be made here and verified in + :meth:`.UserMixin.verify_auth_token` .. versionadded:: 5.4.0 """ @@ -1032,7 +1047,7 @@ def get_user_mapping(self) -> dict[str, t.Any]: """ Return the filter needed by find_user() to get the user associated with this webauthn credential. - Note that this probably has to be overridden using mongoengine. + Note that this probably has to be overridden when using mongoengine. .. versionadded:: 5.0.0 """ diff --git a/flask_security/decorators.py b/flask_security/decorators.py index a8dd0298..0048e578 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -332,9 +332,7 @@ def dashboard(): timedelta.total_seconds() is used for the calculations: - If > 0, then the caller must have authenticated within the time specified - (as measured using the session cookie). - - If 0 and not within the grace period (see below) the caller will - always be redirected to re-authenticate. + (as measured using the session cookie or authentication token). - If < 0 (the default) no freshness check is performed. Note that Basic Auth, by definition, is always 'fresh' and will never result in @@ -422,8 +420,7 @@ def decorated_view( for method, mechanism in mechanisms: if mechanism and mechanism(): # successfully authenticated. Basic auth is by definition 'fresh'. - # Note that using token auth is ok - but caller still has to pass - # in a session cookie if freshness checking is required. + # If 'within' is set - check for freshness of authentication. if not check_and_update_authn_fresh(within, grace, method): return _security._reauthn_handler(within, grace) if eresponse := handle_csrf(method, _security._want_json(request)): diff --git a/flask_security/utils.py b/flask_security/utils.py index 9d466425..8c515f60 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -269,21 +269,21 @@ def check_and_update_authn_fresh( will set a grace period for which freshness won't be checked. The intent here is that the caller shouldn't get part-way though a set of operations and suddenly be required to authenticate again. + This is not supported for authentication tokens. :param method: Optional - if set and == "basic" then will always return True. (since basic-auth sends username/password on every request) If within.total_seconds() is negative, will always return True (always 'fresh'). This effectively just disables this entire mechanism. + within.total_seconds() == 0 results in undefined behavior. + If "fs_gexp" is in the session and the current timestamp is less than that, return True and extend grace time (i.e. set fs_gexp to current time + grace). - If not within the grace period, and within.total_seconds() is 0, - return False (not fresh). - - Be aware that for this to work, sessions and therefore session cookies - must be functioning and being sent as part of the request. If the required - state isn't in the session cookie then return False (not 'fresh'). + Be aware that for this to work, state is required to be sent from the client. + Flask security adds this state to the session (cookie) and the auth token. + Without this state, 'False' is always returned - (not fresh). .. warning:: Be sure the caller is already authenticated PRIOR to calling this method. @@ -292,6 +292,10 @@ def check_and_update_authn_fresh( .. versionchanged:: 4.0.0 Added `method` parameter. + + .. versionchanged:: 5.5.0 + Grab 'Primary Authenticated At' from request_attrs + which is set from either session or auth token """ if method == "basic": @@ -301,8 +305,8 @@ def check_and_update_authn_fresh( # this means 'always fresh' return True - if "fs_paa" not in session: - # No session, you can't play. + if not (paa := get_request_attr("fs_paa")): + # No recorded primary authenticated at time, you can't play. return False now = naive_utcnow() @@ -315,12 +319,7 @@ def check_and_update_authn_fresh( session["fs_gexp"] = grace_ts return True - # Special case 0 - return False always, but set grace period. - if within.total_seconds() == 0: - session["fs_gexp"] = grace_ts - return False - - authn_time = naive_utcfromtimestamp(session["fs_paa"]) + authn_time = naive_utcfromtimestamp(paa) # allow for some time drift where it's possible authn_time is in the future # but let's be cautious and not allow arbitrary future times delta = now - authn_time @@ -484,7 +483,7 @@ def parse_auth_token(auth_token: str) -> dict[str, t.Any]: # Version 5 and up are already a dict (with a version #) if isinstance(raw_data, dict): # new format - starting at ver=5 - if not all(k in raw_data for k in ["ver", "uid", "exp", "sid"]): + if not all(k in raw_data for k in ["ver", "uid", "exp"]): raise ValueError("Token missing keys") tdata = raw_data if ts := tdata.get("exp"): diff --git a/tests/test_misc.py b/tests/test_misc.py index 69d29759..ea90928c 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -1079,6 +1079,24 @@ def myspecialview(): def test_authn_freshness_nc(app, client_nc, get_message): + # By default, auth token carries the fs_paa time. + @auth_required(within=30) + def myview(): + return Response(status=200) + + app.add_url_rule("/myview", view_func=myview, methods=["GET"]) + + response = json_authenticate(client_nc) + token = response.json["response"]["user"]["authentication_token"] + h = {"Authentication-Token": token} + + # This should fail - should be a redirect + response = client_nc.get("/myview", headers=h, follow_redirects=False) + assert response.status_code == 200 + + +@pytest.mark.settings(freshness_allow_auth_token=False) +def test_authn_freshness_nc_no(app, client_nc, get_message): # If don't send session cookie - then freshness always fails @auth_required(within=30) def myview(): diff --git a/tests/test_two_factor.py b/tests/test_two_factor.py index 2a5cf669..f040a324 100644 --- a/tests/test_two_factor.py +++ b/tests/test_two_factor.py @@ -1407,29 +1407,24 @@ class User(db.Model, UserMixin): app.security = Security(app, datastore=ds) with app.app_context(): - client = app.test_client() - ds.create_user( email="trp@lp.com", password=hash_password("password"), ) ds.commit() - data = dict(email="trp@lp.com", password="password") - client.post("/login", data=data, follow_redirects=True) + client = app.test_client() + data = dict(email="trp@lp.com", password="password") + client.post("/login", data=data, follow_redirects=True) - response = client.post( - "/tf-setup", data=dict(setup="email"), follow_redirects=True - ) - msg = b"Enter code to complete setup" - assert msg in response.data + response = client.post("/tf-setup", data=dict(setup="email"), follow_redirects=True) + msg = b"Enter code to complete setup" + assert msg in response.data - code = app.mail.outbox[0].body.split()[-1] - # submit right token and show appropriate response - response = client.post( - "/tf-validate", data=dict(code=code), follow_redirects=True - ) - assert b"You successfully changed your two-factor method" in response.data + code = app.mail.outbox[0].body.split()[-1] + # submit right token and show appropriate response + response = client.post("/tf-validate", data=dict(code=code), follow_redirects=True) + assert b"You successfully changed your two-factor method" in response.data @pytest.mark.settings(two_factor_post_setup_view="/post_setup_view") diff --git a/tests/test_unified_signin.py b/tests/test_unified_signin.py index 9bc40829..f54e9b01 100644 --- a/tests/test_unified_signin.py +++ b/tests/test_unified_signin.py @@ -33,6 +33,7 @@ is_authenticated, logout, reset_fresh, + reset_fresh_auth_token, setup_tf_sms, ) from tests.test_webauthn import HackWebauthnUtil, reg_2_keys @@ -871,9 +872,11 @@ def pc(sender, user, methods, delete, **kwargs): user_identity_attributes=UIA_EMAIL_PHONE, ) def test_setup_json_no_session(app, client_nc, get_message): - # Test that with normal config freshness is required so must have session. + # Test that with normal config freshness is required and we can use auth_token + # for that set_email(app) - token = us_authenticate(client_nc) + us_authenticate(client_nc) + token = reset_fresh_auth_token(app, app.config["SECURITY_FRESHNESS"]) headers = { "Authentication-Token": token, "Accept": "application/json", @@ -884,6 +887,26 @@ def test_setup_json_no_session(app, client_nc, get_message): assert response.json["response"]["reauth_required"] assert "WWW-Authenticate" not in response.headers + # re-verify + client_nc.post( + "/us-verify/send-code", + json=dict(identity="matt@lp.com", chosen_method="email"), + headers=headers, + ) + outbox = app.mail.outbox + matcher = re.match(r".*Token:(\d+).*", outbox[1].body, re.IGNORECASE | re.DOTALL) + code = matcher.group(1) + response = client_nc.post( + "/us-verify?include_auth_token", json=dict(passcode=code), headers=headers + ) + assert response.status_code == 200 + token = response.json["response"]["user"]["authentication_token"] + headers["Authentication-Token"] = token + + # should work now + response = client_nc.get("/us-setup", headers=headers) + assert response.status_code == 200 + @pytest.mark.settings(api_enabled_methods=["basic"]) def test_setup_basic(app, client, get_message): @@ -1660,19 +1683,18 @@ def us_send_security_token(self, method, **kwargs): ds = SQLAlchemyUserDatastore(db, User, Role) app.security = Security(app, datastore=ds) - with app.app_context(): - client = app.test_client() - - # since we don't use client fixture - have to add user - data = dict(email="trp@lp.com", password="password") - response = client.post("/register", data=data, follow_redirects=True) - assert b"Welcome trp@lp.com" in response.data - logout(client) - - set_phone(app, email="trp@lp.com") - data = dict(identity="trp@lp.com", chosen_method="sms") - response = client.post("/us-signin/send-code", data=data, follow_redirects=True) - assert b"Code has been sent" in response.data + client = app.test_client() + + # since we don't use client fixture - have to add user + data = dict(email="trp@lp.com", password="password") + response = client.post("/register", data=data, follow_redirects=True) + assert b"Welcome trp@lp.com" in response.data + logout(client) + + set_phone(app, email="trp@lp.com") + data = dict(identity="trp@lp.com", chosen_method="sms") + response = client.post("/us-signin/send-code", data=data, follow_redirects=True) + assert b"Code has been sent" in response.data @pytest.mark.settings(us_enabled_methods=["password"]) diff --git a/tests/test_utils.py b/tests/test_utils.py index 6d3a578f..a5234488 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -12,6 +12,7 @@ from contextlib import contextmanager import re +import time from flask.json.tag import TaggedJSONSerializer from flask.signals import message_flashed @@ -173,6 +174,22 @@ def reset_fresh(client, within): return old_paa +def reset_fresh_auth_token(app, within, email="matt@lp.com"): + # Assumes client authenticated. + # Returns a new auth token that will force the NEXT request, + # if protected with a freshness check to require a fresh authentication + with app.test_request_context("/"): + user = app.security.datastore.find_user(email=email) + tdata = dict(ver=str(5)) + if hasattr(user, "fs_token_uniquifier"): + tdata["uid"] = str(user.fs_token_uniquifier) + else: + tdata["uid"] = str(user.fs_uniquifier) + tdata["fs_paa"] = time.time() - within.total_seconds() - 100 + tdata["exp"] = int(app.config.get("SECURITY_TOKEN_EXPIRE_TIMESTAMP")(user)) + return app.security.remember_token_serializer.dumps(tdata) + + def get_form_action(response, ordinal=0): # Return the URL that the form WOULD post to - this is useful to check # how our templates actually work (e.g. propagation of 'next')