diff --git a/CHANGES.rst b/CHANGES.rst index 29201628..88cef66b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,11 +8,15 @@ Version 3.3.0 Released TBD +**There are several default behavior changes that might break existing applications. +Most have configuration variables that restore prior behavior**. + - (:pr:`120`) Native support for Permissions as part of Roles. Endpoints can be protected via permissions that are evaluated based on role(s) that the user has. - (:issue:`126`, :issue:`93`, :issue:`96`) Revamp entire CSRF handling. This adds support for Single Page Applications and having CSRF protection for browser(session) authentication but ignored for token based authentication. Add extensive documentation about all the options. +- (:issue:`156`) Token authentication is slow. Please see below for details on how to enable a new, fast implementation. - (:issue:`130`) Enable applications to provide their own :meth:`.render_json` method so that they can create unified API responses. - (:issue:`121`) Unauthorization callback not quite right. Split into 2 different callbacks - one for @@ -35,7 +39,8 @@ Released TBD - (:issue:`159`) The ``/register`` endpoint returned the Authentication Token even though confirmation was required. This was a huge security hole - it has been fixed. -Possible compatibility issues: +Possible compatibility issues ++++++++++++++++++++++++++++++ - (:pr:`120`) :class:`.RoleMixin` now has a method :meth:`.get_permissions` which is called as part each request to add Permissions to the authenticated user. It checks if the RoleModel @@ -55,8 +60,39 @@ Possible compatibility issues: on Flask-Security's blueprint by sending it as `json_encoder_cls` as part of initialization. Be aware that your JsonEncoder needs to handle LazyStrings (see speaklater). +- (:issue:`156`) Faster Authentication Token introduced 2 non-backwards compatible behavior changes - each can + be reverted using a configuration variable. + + * In prior releases, the Authentication Token was returned as part of the JSON response to each + successful call to `/login`, `/change`, or `/reset/{token}` API call. This is not a great idea since + for browser-based UIs that used JSON request/response, and used session based authentication - they would + be sent this token - even though it was likely ignored. Since these tokens by default have no expiration time + this exposed a needless security hole. The new default behavior is to ONLY return the Authentication Token from those APIs + if the query param ``include_auth_token`` is added to the request. Prior behavior can be restored by setting + the `BACKWARDS_COMPAT_AUTH_TOKEN` configuration variable. + * Since the old Authentication Token algorithm used the (hashed) user's password, those tokens would be invalidated + whenever the user changed their password. This is not likely to be what most users expect. Since the new + Authentication Token algorithm doesn't refer to the user's password, changing the user's password won't invalidate + outstanding Authentication Tokens. There is a new method and an administrator could use to force changing + of a user's ``fs_uniquifier`` - but nothing the user themselves can do to invalidate their Authentication Tokens. + Setting the `BACKWARDS_COMPAT_AUTH_TOKEN_INVALIDATE` configuration variable will cause the user's ``fs_uniquifier`` to + be changed when they change their password. + + +New fast authentication token implementation +++++++++++++++++++++++++++++++++++++++++++++ +Current auth tokens are slow because they use the user's password (hashed) as a uniquifier (the +user id isn't really enough since it might be reused). This requires checking the (hashed) password against +what is in the token on EVERY request - however hashing is (on purpose) slow. So this can add almost a whole second +to every request. + +To solve this a new attribute in the User model was added - ``fs_uniquifier``. If this is present in your +User model, then it will be used instead of the password for ensuring the token corresponds to the correct user. +This is very fast. If that attribute is NOT present - then the behavior falls back to existing (slow) method. + DB Migration +~~~~~~~~~~~~ To use the new UserModel mixins or to add the column ``user.fs_uniquifier`` to speed up token authentication, a schema AND data migration needs to happen. If you are using Alembic the schema migration is diff --git a/docs/configuration.rst b/docs/configuration.rst index 4beeab1a..22cccb82 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -11,6 +11,8 @@ Core ============================================== ============================================= ``SECRET_KEY`` This is actually part of Flask - but is used by Flask-Security to sign all tokens. + It is critical this is set to a strong value. For python3 + consider using: ``secrets.token_urlsafe()`` ``SECURITY_BLUEPRINT_NAME`` Specifies the name for the Flask-Security blueprint. Defaults to ``security``. @@ -373,106 +375,116 @@ Miscellaneous .. tabularcolumns:: |p{6.5cm}|p{8.5cm}| -============================================= ================================== -``SECURITY_USER_IDENTITY_ATTRIBUTES`` Specifies which attributes of the - user object can be used for login. - Defaults to ``['email']``. -``SECURITY_SEND_REGISTER_EMAIL`` Specifies whether registration - email is sent. Defaults to - ``True``. -``SECURITY_SEND_PASSWORD_CHANGE_EMAIL`` Specifies whether password change - email is sent. Defaults to - ``True``. -``SECURITY_SEND_PASSWORD_RESET_EMAIL`` Specifies whether password reset - email is sent. Defaults to - ``True``. -``SECURITY_SEND_PASSWORD_RESET_NOTICE_EMAIL`` Specifies whether password reset - notice email is sent. Defaults to - ``True``. +===================================================== ================================== +``SECURITY_USER_IDENTITY_ATTRIBUTES`` Specifies which attributes of the + user object can be used for login. + Defaults to ``['email']``. +``SECURITY_SEND_REGISTER_EMAIL`` Specifies whether registration + email is sent. Defaults to + ``True``. +``SECURITY_SEND_PASSWORD_CHANGE_EMAIL`` Specifies whether password change + email is sent. Defaults to + ``True``. +``SECURITY_SEND_PASSWORD_RESET_EMAIL`` Specifies whether password reset + email is sent. Defaults to + ``True``. +``SECURITY_SEND_PASSWORD_RESET_NOTICE_EMAIL`` Specifies whether password reset + notice email is sent. Defaults to + ``True``. -``SECURITY_CONFIRM_EMAIL_WITHIN`` Specifies the amount of time a - user has before their confirmation - link expires. Always pluralized - the time unit for this value. - Defaults to ``5 days``. -``SECURITY_RESET_PASSWORD_WITHIN`` Specifies the amount of time a - user has before their password - reset link expires. Always - pluralized the time unit for this - value. Defaults to ``5 days``. -``SECURITY_LOGIN_WITHIN`` Specifies the amount of time a - user has before a login link - expires. This is only used when - the passwordless login feature is - enabled. Always pluralize the - time unit for this value. - Defaults to ``1 days``. -``SECURITY_AUTO_LOGIN_AFTER_CONFIRM`` If ``False`` then on confirmation - the user will be required to login again. Note that the - confirmation token is not valid after being used once. - If ``True``, then the user corresponding to the - confirmation token will be automatically logged - in. - Defaults to ``True``. -``SECURITY_TWO_FACTOR_GOOGLE_AUTH_VALIDITY`` Specifies the number of seconds access token is - valid. Defaults to 2 minutes. -``SECURITY_TWO_FACTOR_MAIL_VALIDITY`` Specifies the number of seconds - access token is valid. Defaults to 5 minutes. -``SECURITY_TWO_FACTOR_SMS_VALIDITY`` Specifies the number of seconds access token is - valid. Defaults to 2 minutes. -``SECURITY_LOGIN_WITHOUT_CONFIRMATION`` Specifies if a user may login - before confirming their email when - the value of - ``SECURITY_CONFIRMABLE`` is set to - ``True``. Defaults to ``False``. -``SECURITY_CONFIRM_SALT`` Specifies the salt value when - generating confirmation - links/tokens. Defaults to - ``confirm-salt``. -``SECURITY_RESET_SALT`` Specifies the salt value when - generating password reset - links/tokens. Defaults to - ``reset-salt``. -``SECURITY_LOGIN_SALT`` Specifies the salt value when - generating login links/tokens. - Defaults to ``login-salt``. -``SECURITY_REMEMBER_SALT`` Specifies the salt value when - generating remember tokens. - Remember tokens are used instead - of user ID's as it is more - secure. Defaults to - ``remember-salt``. -``SECURITY_DEFAULT_REMEMBER_ME`` Specifies the default "remember - me" value used when logging in - a user. Defaults to ``False``. -``SECURITY_TWO_FACTOR_REQUIRED`` If set to ``True`` then all users will be - required to setup and use two factor authorization. - Defaults to ``False``. -``SECURITY_TWO_FACTOR_ENABLED_METHODS`` Specifies the default enabled - methods for two-factor - authentication. Defaults to - ``['mail', 'google_authenticator', - 'sms']`` which are the only - supported method at the moment. -``SECURITY_TWO_FACTOR_URI_SERVICE_NAME`` Specifies the name of the service - or application that the user is - authenticating to. Defaults to - ``service_name`` -``SECURITY_TWO_FACTOR_SMS_SERVICE`` Specifies the name of the sms - service provider. Defaults to - ``Dummy`` which does nothing. -``SECURITY_TWO_FACTOR_SMS_SERVICE_CONFIG`` Specifies a dictionary of basic - configurations needed for use of a - sms service. Defaults to - ``{'ACCOUNT_ID': NONE, 'AUTH_TOKEN - ':NONE, 'PHONE_NUMBER': NONE}`` -``SECURITY_DATETIME_FACTORY`` Specifies the default datetime - factory. Defaults to - ``datetime.datetime.utcnow``. -``SECURITY_BACKWARDS_COMPAT_UNAUTHN`` If set to ``True`` then the default behavior for authentication - failures from one of Flask-Security's decorators will be restored to - be compatibile with prior releases (return 401 and some static html) -============================================= ================================== +``SECURITY_CONFIRM_EMAIL_WITHIN`` Specifies the amount of time a + user has before their confirmation + link expires. Always pluralized + the time unit for this value. + Defaults to ``5 days``. +``SECURITY_RESET_PASSWORD_WITHIN`` Specifies the amount of time a + user has before their password + reset link expires. Always + pluralized the time unit for this + value. Defaults to ``5 days``. +``SECURITY_LOGIN_WITHIN`` Specifies the amount of time a + user has before a login link + expires. This is only used when + the passwordless login feature is + enabled. Always pluralize the + time unit for this value. + Defaults to ``1 days``. +``SECURITY_AUTO_LOGIN_AFTER_CONFIRM`` If ``False`` then on confirmation + the user will be required to login again. Note that the + confirmation token is not valid after being used once. + If ``True``, then the user corresponding to the + confirmation token will be automatically logged + in. + Defaults to ``True``. +``SECURITY_TWO_FACTOR_GOOGLE_AUTH_VALIDITY`` Specifies the number of seconds access token is + valid. Defaults to 2 minutes. +``SECURITY_TWO_FACTOR_MAIL_VALIDITY`` Specifies the number of seconds + access token is valid. Defaults to 5 minutes. +``SECURITY_TWO_FACTOR_SMS_VALIDITY`` Specifies the number of seconds access token is + valid. Defaults to 2 minutes. +``SECURITY_LOGIN_WITHOUT_CONFIRMATION`` Specifies if a user may login + before confirming their email when + the value of + ``SECURITY_CONFIRMABLE`` is set to + ``True``. Defaults to ``False``. +``SECURITY_CONFIRM_SALT`` Specifies the salt value when + generating confirmation + links/tokens. Defaults to + ``confirm-salt``. +``SECURITY_RESET_SALT`` Specifies the salt value when + generating password reset + links/tokens. Defaults to + ``reset-salt``. +``SECURITY_LOGIN_SALT`` Specifies the salt value when + generating login links/tokens. + Defaults to ``login-salt``. +``SECURITY_REMEMBER_SALT`` Specifies the salt value when + generating remember tokens. + Remember tokens are used instead + of user ID's as it is more + secure. Defaults to + ``remember-salt``. +``SECURITY_DEFAULT_REMEMBER_ME`` Specifies the default "remember + me" value used when logging in + a user. Defaults to ``False``. +``SECURITY_TWO_FACTOR_REQUIRED`` If set to ``True`` then all users will be + required to setup and use two factor authorization. + Defaults to ``False``. +``SECURITY_TWO_FACTOR_ENABLED_METHODS`` Specifies the default enabled + methods for two-factor + authentication. Defaults to + ``['mail', 'google_authenticator', + 'sms']`` which are the only + supported method at the moment. +``SECURITY_TWO_FACTOR_URI_SERVICE_NAME`` Specifies the name of the service + or application that the user is + authenticating to. Defaults to + ``service_name`` +``SECURITY_TWO_FACTOR_SMS_SERVICE`` Specifies the name of the sms + service provider. Defaults to + ``Dummy`` which does nothing. +``SECURITY_TWO_FACTOR_SMS_SERVICE_CONFIG`` Specifies a dictionary of basic + configurations needed for use of a + sms service. Defaults to + ``{'ACCOUNT_ID': NONE, 'AUTH_TOKEN + ':NONE, 'PHONE_NUMBER': NONE}`` +``SECURITY_DATETIME_FACTORY`` Specifies the default datetime + factory. Defaults to + ``datetime.datetime.utcnow``. +``SECURITY_BACKWARDS_COMPAT_UNAUTHN`` If set to ``True`` then the default behavior for authentication + failures from one of Flask-Security's decorators will be restored to + be compatible with releases prior to 3.3.0 (return 401 and some static html). + Defaults to ``False``. +``SECURITY_BACKWARDS_COMPAT_AUTH_TOKEN`` If set to ``True`` then an Authentication-Token will be returned + on every successful call to login, reset-password, change-password + as part of the JSON response. This was the default prior to release 3.3.0 + - however sending Authentication-Tokens (which by default don't expire) + to session based UIs is a bad security practice. + Defaults to ``False``. +``SECURITY_BACKWARDS_COMPAT_AUTH_TOKEN_INVALIDATE`` When ``True`` changing the user's password will also change the user's + ``fs_uniquifier`` (if it exists) such that existing authentication tokens + will be rendered invalid. This restores pre 3.3.0 behavior. +===================================================== ================================== Messages ------------- diff --git a/docs/customizing.rst b/docs/customizing.rst index d3e262d4..d82132b9 100644 --- a/docs/customizing.rst +++ b/docs/customizing.rst @@ -272,8 +272,8 @@ JSON Response +++++++++++++ Applications that support a JSON based API need to be able to have a uniform API response. Flask-Security has a default way to render its API responses - which can -be easily overridden by either providing a callback function via :meth:`.Security.render_json`. -As documents in :meth:`Security.render_json`, be aware that Flask-Security registers +be easily overridden by providing a callback function via :meth:`.Security.render_json`. +As documented in :meth:`Security.render_json`, be aware that Flask-Security registers its own JsonEncoder on its blueprint. 401, 403, Oh My @@ -281,14 +281,14 @@ its own JsonEncoder on its blueprint. For a very long read and discussion; look at `this`_. Out of the box, Flask-Security in tandem with Flask-Login, behaves as follows: - * If authentication fails as the result of a @login_required, @auth_required, - @http_auth_required, or @token_auth_required then if the request 'wants' a JSON + * If authentication fails as the result of a `@login_required`, `@auth_required`, + `@http_auth_required`, or `@token_auth_required` then if the request 'wants' a JSON response, :meth:`.Security.render_json` is called with a 401 status code. If not then flask_login.LoginManager.unauthorized() is called. By default THAT will redirect to a login view. - * If authorization fails as the result of @roles_required, @roles_accepted, - @permissions_required, or @permissions_accepted, then if the request 'wants' a JSON + * If authorization fails as the result of `@roles_required`, `@roles_accepted`, + `@permissions_required`, or `@permissions_accepted`, then if the request 'wants' a JSON response, :meth:`.Security.render_json` is called with a 403 status code. If not, then if ``SECURITY_UNAUTHORIZED_VIEW`` is defined, the response will redirected. If ``SECURITY_UNAUTHORIZED_VIEW`` is not defined, then ``abort(403)`` is called. diff --git a/docs/features.rst b/docs/features.rst index 339800e0..adf81d69 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -59,16 +59,23 @@ Token Authentication -------------------- Token based authentication is enabled by retrieving the user auth token by -performing an HTTP POST with the authentication details as JSON data against the +performing an HTTP POST with a query param of ``include_auth_token`` with the authentication details +as JSON data against the authentication endpoint. A successful call to this endpoint will return the user's ID and their authentication token. This token can be used in subsequent requests to protected resources. The auth token is supplied in the request through an HTTP header or query string parameter. By default the HTTP header name is `Authentication-Token` and the default query string parameter name is -`auth_token`. Authentication tokens are generated using the user's password. +`auth_token`. Authentication tokens are generated using a uniquifier field in the +user's UserModel. If that field is changed (via :meth:`.UserDatastore.set_uniqifier`) +then any existing authentication tokens will no longer be valid. Changing +the user's password will not affect tokens. + +Note that prior to release 3.3.0 or if the Usermodel doesn't contain the ``fs_uniquifier`` +attribute the authentication tokens are generated using the user's password. Thus if the user changes his or her password their existing authentication token will become invalid. A new token will need to be retrieved using the user's new -password. +password. Verifying tokens created in this way is very slow. Two-factor Authentication (experimental) ---------------------------------------- diff --git a/docs/models.rst b/docs/models.rst index cb5d39ae..7c3b8214 100644 --- a/docs/models.rst +++ b/docs/models.rst @@ -24,6 +24,7 @@ your `User` and `Role` model should include the following fields: * ``email`` * ``password`` * ``active`` +* ``fs_uniquifier`` **Role** diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 8c5e49b8..2d28dcfc 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -29,6 +29,8 @@ paths: /login: get: summary: Retrieve login form and/or user information + parameters: + - $ref: "#/components/parameters/include_auth_token" responses: 200: description: > @@ -60,6 +62,7 @@ paths: URL to redirect to on successful registration. Ignored for json request. schema: type: string + - $ref: "#/components/parameters/include_auth_token" requestBody: required: true content: @@ -278,6 +281,7 @@ paths: in: header schema: $ref: "#/components/headers/X-CSRF-Token" + - $ref: '#/components/parameters/include_auth_token' requestBody: required: true content: @@ -406,6 +410,8 @@ paths: value: redirect(cv('FORGOT_PASSWORD')) post: summary: Reset password + parameters: + - $ref: '#/components/parameters/include_auth_token' requestBody: required: true content: @@ -557,7 +563,7 @@ components: type: object required: [id] description: > - By default just 'id', and 'authentication_token' are returned. However by overriding _User::get_security_payload()_ any attributes of the User model can be returned. + By default just 'id'is returned. However by overriding _User::get_security_payload()_ any attributes of the User model can be returned. properties: id: type: integer @@ -565,7 +571,10 @@ components: description: Unique user id (primary key) authentication_token: type: string - description: Token to be used in future token-based API calls. + description: > + Token to be used in future token-based API calls. + Note this only returned from those APIs that accept a + 'include_auth_token' query param. csrf_token: type: string description: Session CSRF token @@ -660,6 +669,14 @@ components: Email address to send link email to. headers: X-CSRF-Token: + description: CSRF token + schema: + type: string + parameters: + include_auth_token: + name: include_auth_token + description: If set/sent, will return an Authentication Token for user + in: query schema: type: string diff --git a/flask_security/changeable.py b/flask_security/changeable.py index 45f97683..ce8bab87 100644 --- a/flask_security/changeable.py +++ b/flask_security/changeable.py @@ -39,6 +39,8 @@ def change_user_password(user, password): :param password: The unhashed new password """ user.password = hash_password(password) + if config_value("BACKWARDS_COMPAT_AUTH_TOKEN_INVALID"): + _datastore.set_uniquifier(user) _datastore.put(user) send_password_changed_notice(user) password_changed.send(current_app._get_current_object(), user=user) diff --git a/flask_security/core.py b/flask_security/core.py index f2c98e9b..0aa8d503 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -205,6 +205,8 @@ "CSRF_HEADER": "X-XSRF-Token", "CSRF_COOKIE_REFRESH_EACH_REQUEST": False, "BACKWARDS_COMPAT_UNAUTHN": False, + "BACKWARDS_COMPAT_AUTH_TOKEN": False, + "BACKWARDS_COMPAT_AUTH_TOKEN_INVALIDATE": False, } #: Default Flask-Security messages @@ -364,11 +366,11 @@ def _request_loader(request): local_cache.verify_hash_cache = cache if cache.has_verify_hash_cache(user): return user - if verify_hash(data[1], user.password): + if user.verify_auth_token(data): cache.set_cache(user) return user else: - if verify_hash(data[1], user.password): + if user.verify_auth_token(data): return user return _security.login_manager.anonymous_user() @@ -567,10 +569,37 @@ def is_active(self): return self.active def get_auth_token(self): - """Returns the user's authentication token.""" + """Constructs the user's authentication token. + + This data MUST be securely signed using the ``remember_token_serializer`` + """ data = [str(self.id), hash_data(self.password)] + if hasattr(self, "fs_uniquifier"): + data.append(self.fs_uniquifier) return _security.remember_token_serializer.dumps(data) + def verify_auth_token(self, data): + """ + Perform additional verification of contents of auth token. + Prior to this being called the token has been validated (via signing) + and has not expired. + + :param data: the data as formulated by :meth:`get_auth_token` + + .. versionadded:: 3.3.0 + """ + if len(data) > 2 and hasattr(self, "fs_uniquifier"): + # has uniquifier - use that + if data[2] == self.fs_uniquifier: + return True + # Don't even try old way - if they have defined a uniquifier + # we want that to be able to invalidate tokens if changed. + return False + # Fall back to old and very expensive check + if verify_hash(data[1], self.password): + return True + return False + def has_role(self, role): """Returns `True` if the user identifies with the specified role. @@ -961,6 +990,9 @@ def want_json(self, fn): :request: Werkzueg/Flask request + The default implementation returns True if either the Content-Type is + "application/json" or the best Accept header value is "application/json". + .. versionadded:: 3.3.0 """ self._state._want_json = fn @@ -968,8 +1000,9 @@ def want_json(self, fn): def unauthz_handler(self, cb): """ Callback for failed authorization. - This is called by the various decorators if a role or permission - is missing. + This is called by the :func:`roles_required`, :func:`roles_accepted`, + :func:`permissions_required`, or :func:`permissions_accepted` + if a role or permission is missing. :param cb: Callback function with signature (func, params) @@ -990,7 +1023,8 @@ def unauthz_handler(self, cb): def unauthn_handler(self, cb): """ Callback for failed authentication. - This is called by the various decorators if authentication fails. + This is called by :func:`auth_required`, :func:`auth_token_required` + or :func:`http_auth_required` if authentication fails. :param cb: Callback function with signature (mechanisms, headers=None) diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 2b774c40..3a680cdd 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -232,6 +232,25 @@ def activate_user(self, user): return True return False + def set_uniquifier(self, user, uniquifier=None): + """ Set user's authentication token uniquifier. + This will immediately render outstanding auth tokens invalid. + + :param user: User to modify + :param uniquifier: Unique value - if none then uuid.uuid4().hex is used + + This method is a no-op if the user model doesn't contain the attribute + ``fs_uniquifier`` + + .. versionadded:: 3.3.0 + """ + if not hasattr(user, "fs_uniquifier"): + return + if not uniquifier: + uniquifier = uuid.uuid4().hex + user.fs_uniquifier = uniquifier + self.put(user) + def create_role(self, **kwargs): """ Creates and returns a new role from the given parameters. diff --git a/flask_security/models/fsqla.py b/flask_security/models/fsqla.py index 360ecfff..46dcd702 100644 --- a/flask_security/models/fsqla.py +++ b/flask_security/models/fsqla.py @@ -4,6 +4,9 @@ Complete models for all features when using Flask-SqlAlchemy + +BE AWARE: Once any version of this is shipped no changes can be made - instead +a new version needs to be created. """ import datetime diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index ac08a9ae..780dbe27 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -100,6 +100,8 @@ def update_password(user, password): :param password: The unhashed new password """ user.password = hash_password(password) + if config_value("BACKWARDS_COMPAT_AUTH_TOKEN_INVALID"): + _datastore.set_uniquifier(user) _datastore.put(user) send_password_reset_notice(user) password_reset.send(app._get_current_object(), user=user) diff --git a/flask_security/utils.py b/flask_security/utils.py index 2e80d637..8dcde2e0 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -620,6 +620,8 @@ def default_want_json(req): class FsJsonEncoder(JSONEncoder): """ Flask-Security JSON encoder. Extends Flask's JSONencoder to handle lazy-text. + + .. versionadded:: 3.3.0 """ def default(self, obj): diff --git a/flask_security/views.py b/flask_security/views.py index dfef0c43..6646ce52 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -107,8 +107,13 @@ def _base_render_json( payload["user"] = user.get_security_payload() if include_auth_token: - token = user.get_auth_token() - payload["user"]["authentication_token"] = token + # view wants to return auth_token - check behavior config + if ( + config_value("BACKWARDS_COMPAT_AUTH_TOKEN") + or "include_auth_token" in request.args + ): + token = user.get_auth_token() + payload["user"]["authentication_token"] = token # Return csrf_token on each JSON response - just as every form # has it rendered. @@ -212,7 +217,7 @@ def logout(): logout_user() # No body is required - so if a POST and json - return OK - if request.method == "POST" and request.is_json: + if request.method == "POST" and _security._want_json(request): return _security._render_json({}, 200, headers=None, user=None) return redirect(get_post_logout_redirect()) diff --git a/tests/test_cache.py b/tests/test_cache.py index 74c92672..ae301a18 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -33,6 +33,9 @@ def __init__(self, id, password): self.password = password self.active = True + def verify_auth_token(self, data): + return True + class MockExtensionSecurity: @property @@ -119,6 +122,7 @@ def test_request_loader_not_using_cache(app): def test_request_loader_using_cache(app): with app.app_context(): app.config["SECURITY_USE_VERIFY_PASSWORD_CACHE"] = True + app.config["SECURITY_BACKWARDS_COMPAT_AUTH_TOKEN"] = True app.extensions["security"] = MockExtensionSecurity() _request_loader(MockRequest()) assert local_cache.verify_hash_cache is not None diff --git a/tests/test_changeable.py b/tests/test_changeable.py index b42f4fa8..1da94370 100644 --- a/tests/test_changeable.py +++ b/tests/test_changeable.py @@ -4,13 +4,16 @@ ~~~~~~~~~~~~~~~ Changeable tests + + :copyright: (c) 2019 by J. Christopher Wagner (jwag). + :license: MIT, see LICENSE for more details. """ import json import pytest from flask import Flask -from utils import authenticate, json_authenticate +from utils import authenticate, json_authenticate, verify_token from flask_security.core import UserMixin from flask_security.signals import password_changed @@ -194,9 +197,37 @@ def test_token_change(app, client_nc): new_password_confirm="newpassword", ) response = client_nc.post( - "/change", + "/change?include_auth_token=1", + data=json.dumps(data), + headers={"Content-Type": "application/json", "Authentication-Token": token}, + ) + assert response.status_code == 200 + assert "authentication_token" in response.jdata["response"]["user"] + + +@pytest.mark.settings(backwards_compat_auth_token_invalid=True) +def test_bc_password(app, client_nc): + # Test behavior of BACKWARDS_COMPAT_AUTH_TOKEN_INVALID + response = json_authenticate(client_nc) + token = response.jdata["response"]["user"]["authentication_token"] + verify_token(client_nc, token) + + data = dict( + password="password", + new_password="newpassword", + new_password_confirm="newpassword", + ) + response = client_nc.post( + "/change?include_auth_token=1", data=json.dumps(data), headers={"Content-Type": "application/json", "Authentication-Token": token}, ) assert response.status_code == 200 assert "authentication_token" in response.jdata["response"]["user"] + + # changing password should have rendered existing auth tokens invalid + verify_token(client_nc, token, status=401) + + # but new auth token should work + token = response.jdata["response"]["user"]["authentication_token"] + verify_token(client_nc, token) diff --git a/tests/test_common.py b/tests/test_common.py index 6eb89e96..d81ba955 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -4,13 +4,16 @@ ~~~~~~~~~~~ Test common functionality + + :copyright: (c) 2019 by J. Christopher Wagner (jwag). + :license: MIT, see LICENSE for more details. """ import base64 import json import pytest -from utils import authenticate, json_authenticate, logout +from utils import authenticate, json_authenticate, logout, verify_token try: from cookielib import Cookie @@ -490,3 +493,46 @@ def test_login_info(client): assert response.status_code == 200 assert response.jdata["response"]["user"]["id"] == "1" assert "last_update" in response.jdata["response"]["user"] + + +@pytest.mark.settings(security_hashing_schemes=["sha256_crypt"]) +@pytest.mark.skip +def test_auth_token_speed(app, client_nc): + # To run with old algorithm you have to comment out fs_uniquifier check in UserMixin + import timeit + + response = json_authenticate(client_nc) + token = response.jdata["response"]["user"]["authentication_token"] + + def time_get(): + rp = client_nc.get( + "/login", + data={}, + headers={"Content-Type": "application/json", "Authentication-Token": token}, + ) + assert rp.status_code == 200 + + t = timeit.timeit(time_get, number=50) + print("Time for 50 iterations: ", t) + + +def test_change_uniquifier(app, client_nc): + # make sure that existing token no longer works once we change the uniquifier + + response = json_authenticate(client_nc) + token = response.jdata["response"]["user"]["authentication_token"] + verify_token(client_nc, token) + + # now change uniquifier + # deactivate matt + with app.test_request_context("/"): + user = app.security.datastore.find_user(email="matt@lp.com") + app.security.datastore.set_uniquifier(user) + app.security.datastore.commit() + + verify_token(client_nc, token, status=401) + + # get new token and verify it works + response = json_authenticate(client_nc) + token = response.jdata["response"]["user"]["authentication_token"] + verify_token(client_nc, token) diff --git a/tests/test_confirmable.py b/tests/test_confirmable.py index 7a1ac71b..e6f7a245 100644 --- a/tests/test_confirmable.py +++ b/tests/test_confirmable.py @@ -177,7 +177,7 @@ def test_no_auth_token(client_nc): if user isn't confirmed. """ response = client_nc.post( - "/register", + "/register?include_auth_token", data='{"email": "dude@lp.com", "password": "password"}', headers={"Content-Type": "application/json"}, ) @@ -193,7 +193,7 @@ def test_auth_token_unconfirmed(client_nc): if user isn't confirmed, but the 'login_without_confirmation' flag is set. """ response = client_nc.post( - "/register", + "/register?include_auth_token", data='{"email": "dude@lp.com", "password": "password"}', headers={"Content-Type": "application/json"}, ) diff --git a/tests/test_csrf.py b/tests/test_csrf.py index 0fe44573..0a48fecb 100644 --- a/tests/test_csrf.py +++ b/tests/test_csrf.py @@ -80,7 +80,7 @@ def json_login( data["csrf_token"] = csrf_token response = client.post( - endpoint or "/login", + endpoint or "/login" + "?include_auth_token", content_type="application/json", data=json.dumps(data), headers=headers, diff --git a/tests/test_recoverable.py b/tests/test_recoverable.py index 88a30e70..08c0c2e6 100644 --- a/tests/test_recoverable.py +++ b/tests/test_recoverable.py @@ -6,11 +6,12 @@ Recoverable functionality tests """ +import json import time import pytest from flask import Flask -from utils import authenticate, logout +from utils import authenticate, json_authenticate, json_logout, logout, verify_token from flask_security.core import UserMixin from flask_security.forms import LoginForm @@ -173,7 +174,7 @@ def on_instructions_sent(app, user, token): # Test submitting a new password response = client.post( - "/reset/" + token, + "/reset/" + token + "?include_auth_token", data='{"password": "newpassword",\ "password_confirm": "newpassword"}', headers={"Content-Type": "application/json"}, @@ -189,7 +190,7 @@ def on_instructions_sent(app, user, token): # Test logging in with the new password response = client.post( - "/login", + "/login?include_auth_token", data='{"email": "joe@lp.com",\ "password": "newpassword"}', headers={"Content-Type": "application/json"}, @@ -460,3 +461,38 @@ def test_spa_get_bad_token(app, client, get_message): msg = get_message("INVALID_RESET_PASSWORD_TOKEN") assert msg == qparams["error"].encode("utf-8") assert len(flashes) == 0 + + +@pytest.mark.settings(backwards_compat_auth_token_invalid=True) +def test_bc_password(app, client_nc): + # Test behavior of BACKWARDS_COMPAT_AUTH_TOKEN_INVALID + response = json_authenticate(client_nc, email="joe@lp.com") + token = response.jdata["response"]["user"]["authentication_token"] + verify_token(client_nc, token) + json_logout(client_nc, token) + + with capture_reset_password_requests() as requests: + response = client_nc.post( + "/reset", + data='{"email": "joe@lp.com"}', + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 200 + + reset_token = requests[0]["token"] + + data = dict(password="newpassword", password_confirm="newpassword") + response = client_nc.post( + "/reset/" + reset_token + "?include_auth_token=1", + data=json.dumps(data), + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 200 + assert "authentication_token" in response.jdata["response"]["user"] + + # changing password should have rendered existing auth tokens invalid + verify_token(client_nc, token, status=401) + + # but new auth token should work + token = response.jdata["response"]["user"]["authentication_token"] + verify_token(client_nc, token) diff --git a/tests/test_response.py b/tests/test_response.py index 7b46a051..3f98a640 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ - test_respnse + test_response ~~~~~~~~~~~~~~~~~ Tests for validating default and plugable responses. diff --git a/tests/utils.py b/tests/utils.py index 2bd99d1d..0b9f31b2 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -4,6 +4,9 @@ ~~~~~ Test utils + + :copyright: (c) 2019 by J. Christopher Wagner (jwag). + :license: MIT, see LICENSE for more details. """ from flask import Response as BaseResponse @@ -37,13 +40,40 @@ def authenticate( def json_authenticate(client, email="matt@lp.com", password="password", endpoint=None): data = '{"email": "%s", "password": "%s"}' % (email, password) - return client.post(endpoint or "/login", content_type="application/json", data=data) + + # Get auth token always + ep = endpoint or "/login" + "?include_auth_token" + return client.post(ep, content_type="application/json", data=data) + + +def verify_token(client_nc, token, status=None): + # Use passed auth token in API that requires auth and verify status. + # Pass in a client_nc to get valid results. + response = client_nc.get( + "/token", + headers={"Content-Type": "application/json", "Authentication-Token": token}, + ) + if status: + assert response.status_code == status + else: + assert b"Token Authentication" in response.data def logout(client, endpoint=None, **kwargs): return client.get(endpoint or "/logout", **kwargs) +def json_logout(client, token, endpoint=None): + return client.post( + endpoint or "/logout", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "Authentication-Token": token, + }, + ) + + def get_session(response): """ Return session cookie contents. This a base64 encoded json.