diff -Nru flask-security-3.4.2/babel.ini flask-security-4.0.0/babel.ini
--- flask-security-3.4.2/babel.ini 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/babel.ini 2021-01-26 02:39:51.000000000 +0000
@@ -8,3 +8,6 @@
[jinja2: **/templates/**.html]
encoding = utf-8
extensions = jinja2.ext.autoescape, jinja2.ext.with_
+
+[jinja2: **/templates/**.txt]
+extensions = jinja2.ext.with_
diff -Nru flask-security-3.4.2/CHANGES.rst flask-security-4.0.0/CHANGES.rst
--- flask-security-3.4.2/CHANGES.rst 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/CHANGES.rst 2021-01-26 02:39:51.000000000 +0000
@@ -6,18 +6,234 @@
Version 4.0.0
-------------
-Release Target 2020
+Released January 26, 2021
+**PLEASE READ CHANGE NOTES CAREFULLY - THERE ARE LIKELY REQUIRED CHANGES YOU WILL HAVE TO MAKE TO EVEN START YOUR APPLICATION WITH 4.0**
+
+Start Here
++++++++++++
+- Your UserModel must contain ``fs_uniquifier``
+- Either uninstall Flask-BabelEx (if you don't need translations) or add either Flask-Babel (>=2.0) or Flask-BabelEx to your
+ dependencies AND be sure to initialize it in your app.
+- Add Flask-Mail to your dependencies.
+- If you have unicode emails or passwords read change notes below.
+
+Version 4.0.0rc2
+----------------
+
+Released January 18, 2021
+
+Features & Cleanup
++++++++++++++++++++
- Removal of python 2.7 and <3.6 support
-- Removal of token caching feature (a relatively new feature that has some systemic issues)
-- Other possible breaking changes tracked `here`_
+- Removal of token caching feature (a relatively new feature that had some systemic issues)
+- (:pr:`328`) Remove dependence on Flask-Mail and refactor.
+- (:pr:`335`) Remove two-factor `/tf-confirm` endpoint and use generic `freshness` mechanism.
+- (:pr:`336`) Remove ``SECURITY_BACKWARDS_COMPAT_AUTH_TOKEN_INVALID(ATE)``. In addition to
+ not making sense - the documentation has never been correct.
+- (:pr:`339`) Require ``fs_uniquifier`` in the UserModel and stop using/referencing the UserModel
+ primary key.
+- (:pr:`349`) Change ``SECURITY_USER_IDENTITY_ATTRIBUTES`` configuration variable semantics.
+- Remove (all?) requirements around having an 'email' column in the UserModel. API change -
+ JSON SPA redirects used to always include a query param 'email=xx'. While that is still sent
+ (if and only if) the UserModel contains an 'email' columns, a new query param 'identity' is returned
+ which returns the value of :meth:`.UserMixin.calc_username()`.
+- (:pr:`382`) Improvements and documentation for two-factor authentication.
+- (:pr:`394`) Add support for email validation and normalization (see :class:`.MailUtil`).
+- (:issue:`231`) Normalize unicode passwords (see :class:`.PasswordUtil`).
+- (:issue:`391`) Option to redirect to `/confirm` if user hits an endpoint that requires
+ confirmation. New option :py:data:`SECURITY_REQUIRES_CONFIRMATION_ERROR_VIEW` which if set and the user
+ hits the `/login`, `/reset`, or `/us-signin` endpoint, and they require confirmation the response will be a redirect. (SnaKyEyeS)
+- (:issue:`366`) Allow redirects on sub-domains. Please see :py:data:`SECURITY_REDIRECT_ALLOW_SUBDOMAINS`. (willcroft)
+- (:pr:`376`) Have POST redirects default to Flask's ``APPLICATION_ROOT``. Previously the default configuration was ``/``.
+ Now it first looks at Flask's `APPLICATION_ROOT` configuration and uses that (which also by default is ``/``. (tysonholub)
+- (:pr:`401`) Add 2FA Validity Window so an application can configure how often the second factor has to be entered. (baurt)
+- (:pr:`403`) Add HTML5 Email input types to email fields. This has some backwards compatibility concerns outlined below. (drola)
+- (:pr:`413`) Add hy_AM translations. (rudolfamirjanyan)
+- (:pr:`410`) Add Basque and fix Spanish translations. (mmozos)
+- (:pr:`408`) Polish translations. (kamil559)
+- (:pr:`390`) Update ru_RU translations. (TitaniumHocker)
+
+Fixed
++++++
+- (:issue:`389`) Fixes for translations. First - email subjects were never being translated. Second, converted
+ all templates to use _fsdomain(xx) rather than _(xx) so that they get translated regardless of the app's domain.
+- (:issue:`381`) Support Flask-Babel 2.0 which has backported Domain support. Flask-Security now supports
+ Flask-Babel (>=2.00), Flask-BabelEx, as well as no translation support. Please see backwards compatibility notes below.
+- (:pr:`352`) Fix issue with adding/deleting permissions - all mutating methods must be at the datastore layer so that
+ db.put() can be called. Added :meth:`.UserDatastore.add_permissions_to_role` and :meth:`.UserDatastore.remove_permissions_from_role`.
+ The methods :meth:`.RoleMixin.add_permissions` and :meth:`.RoleMixin.remove_permissions` have been deprecated.
+- (:issue:`395`) Provide ability to change table names for User and Role tables in the fsqla model.
+- (:issue:`338`) All sessions are invalidated when a user changes or resets their password. This is accomplished by
+ changing the user's `fs_uniquifier`. The user is automatically re-logged in (and a new session
+ created) after a successful change operation.
+- (:issue:`418`) Two-factor (and to a lesser extent unified sign in) QRcode fetching wasn't protected via CSRF. The
+ fix makes things secure and simpler (always good); however read below for compatibility concerns. In addition, the elements that make up the QRcode (key, username, issuer) area also made available to the form
+ and returned as part of the JSON return value - this allows for manual or other ways to initialize the authenticator
+ app.
+- (:issue:`421`) GET on `/login` and `/change` could return the callers authentication_token. This is a security
+ concern since GETs don't have CSRF protection. This bug was introduced in 3.3.0.
+
+Backwards Compatibility Concerns
++++++++++++++++++++++++++++++++++
+- (:pr:`328`) Remove dependence on Flask-Mail and refactor. The ``send_mail_task`` and
+ ``send_mail`` methods as part of Flask-Security initialization
+ have been removed and replaced with a new :class:`.MailUtil` class.
+ The utility method :func:`.send_mail` can still be used.
+ If your application didn't use either of the deprecated methods, then the only change required
+ is to add Flask-Mail to your package requirements (since Flask-Security no longer lists it).
+ Please see the :ref:`emails_topic` for updated examples.
+
+- (:pr:`335`) Convert two-factor setup flow to use the freshness feature rather than
+ its own verify password endpoint. This COMPLETELY removes the ``/tf-confirm`` endpoint
+ and associated form: ``two_factor_verify_password_form``. Now, when /tf-setup is invoked,
+ the :meth:`flask_security.check_and_update_authn_fresh` is invoked, and if the current session isn't 'fresh'
+ the caller will be redirected to a verify endpoint (either :py:data:`SECURITY_VERIFY_URL` or
+ :py:data:`SECURITY_US_VERIFY_URL`). The simplest change would be to call ``/verify`` everywhere
+ the application used to call ``/tf-confirm``.
+
+- (:pr:`339`) Require ``fs_uniquifier``. In 3.3 the ``fs_uniquifier`` was added in the UserModel to fix
+ the slow authentication token issue. In 3.4 the ``fs_uniquifier`` was used to implement Flask-Login's
+ `Alternative Token` feature - thus decoupling the primary key (id) from any security context.
+ All along, there have been a few issues with applications not wanting to use the name 'id' in their
+ model, or wanting a different type for their primary key. With this change, Flask-Security no longer
+ interprets or uses the UserModel primary key - just the ``fs_uniquifier`` field. See the changes section for 3.3
+ for information on how to do the schema and data upgrades required to add this field. There is also an API change -
+ the JSON response (via UserModel.get_security_payload()) returned the ``user.id`` field. With this change
+ the default is an empty directory - override :meth:`.UserMixin.get_security_payload()` to return any portion of the UserModel you need.
+
+- (:pr:`349`) :py:data:`SECURITY_USER_IDENTITY_ATTRIBUTES` has changed syntax and semantics. It now contains
+ the combined information from the old ``SECURITY_USER_IDENTITY_ATTRIBUTES`` and the newly introduced in 3.4 :py:data:`SECURITY_USER_IDENTITY_MAPPINGS`.
+ This enabled changing the underlying way we validate credentials in the login form and unified sign in form.
+ In prior releases we simply tried to look up the form value as the PK of the UserModel - this often failed and then
+ looped through the other ``SECURITY_USER_IDENTITY_ATTRIBUTES``. This had a history of issues, including many applications not
+ wanting to have a standard PK for the user model. Now, using the mapping configuration, the UserModel attribute/column the input
+ corresponds to is determined, then the UserModel is queried specifically for that *attribute:value* pair. If you application
+ didn't change the variable, no modifications are required.
+
+- (:pr:`354`) The :class:`flask_security.PhoneUtil` is now initialized as part of Flask-Security initialization rather than
+ ``@app.before_first_request`` (since that broke the CLI). Since it isn't called in an application context, the *app* being initialized is
+ passed as an argument to *__init__*.
+
+- (:issue:`381`) When using Flask-Babel (>= 2.0) it is required that the application initialize Flask-Babel (e.g. Babel(app)).
+ Flask-BabelEx would self-initialize so it didn't matter. Flask-Security will throw a run time error upon first request if Flask-Babel
+ OR FLask-BabelEx
+ is installed, but not initialized. Also, Flask-Security no longer has a dependency on either Flask-Babel or Flask-BabelEx - if neither
+ are installed, it falls back to a dummy translation. *If your application expects translation services, it must specify the appropriate*
+ *dependency AND initialize it.*
+
+- (:pr:`394`) Email input is now normalized prior to being stored in the DB. Previously, it was validated, but the raw input
+ was stored. Normalization and validation rely on the `email_validator `_ package.
+ The :class:`.MailUtil` class provides the interface for normalization and validation - allowing all this to be customized.
+ If you have unicode local or domain parts - existing users may have difficulties logging in. Administratively you need to
+ read each user record, normalize the email (see :class:`.MailUtil`), and write it back.
+
+- (:issue:`381`) Passwords are now, by default, normalized using Python's unicodedata.normalize() method.
+ The :py:data:`SECURITY_PASSWORD_NORMALIZE_FORM` defaults to "NKFD". This brings Flask-Security
+ in line with the NIST recommendations outlined in `Memorized Secret Verifiers `_
+ If your users have unicode passwords
+ they may have difficulty authenticating. You can turn off this normalization or have your users reset their passwords.
+ Password normalization and validation has been encapsulated in a new :class:`.PasswordUtil` class. This replaces
+ the method ``password_validator`` introduced in 3.4.0.
+
+- (:pr:`403`) By default all forms that have an email as input now use the wtforms html5 ``EmailField``. For most applications this will
+ make the user experience slightly nicer - especially for mobile devices. Some applications use the email form field for other
+ identity attributes (such as username). If your application does this you will probably need to subclass ``LoginForm`` and change
+ the email type back to StringField.
+
+- (:issue:`338`) By default, both passwords and authentication tokens use the same attribute ``fs_uniquifier`` to
+ uniquely identify the user. This means that if the user changes or resets their password, all authentication tokens
+ also become invalid. This could be viewed as a feature or a bug. If this behavior isn't desired, add another
+ uniquifier: ``fs_token_uniquifier`` to your UserModel and that will be used to generate authentication tokens.
+
+- (:issue:`418`) Fix CSRF vulnerability w.r.t. getting QRcodes. Both two-factor and unified-signup had a separate
+ GET endpoint to fetch the QRcode when setting up an authenticator app. GETS don't have any CSRF protection. Both
+ of those endpoints have been completely removed, and the QRcode is embedded in a successful POST of the setup form.
+ The changes to the templates are minimal and of course if you didn't override the template - there is no
+ compatibility concern.
+
+- (:issue:`421`) Fix CSRF vulnerability on `/login` and `/change` that could return the callers authentication token.
+ Now, callers can only get the authentication token on successful POST calls.
+
+Version 3.4.5
+--------------
+
+Released January 8, 2021
+
+Security Vulnerability Fix.
+
+Two CSRF vulnerabilities were reported: `qrcode`_ and `login`_. This release
+fixes the more severe of the 2 - the `/login` vulnerability. The QRcode issue
+has a much smaller risk profile since a) it is only for two-factor authentication
+using an authenticator app b) the qrcode is only available during the time
+the user is first setting up their authentication app.
+The QRcode issue has been fixed in 4.0.
+
+.. _qrcode: https://github.com/Flask-Middleware/flask-security/issues/418
+.. _login: https://github.com/Flask-Middleware/flask-security/issues/421
+
+Fixed
++++++
+
+- (:issue:`421`) GET on `/login` and `/change` could return the callers authentication_token. This is a security
+ concern since GETs don't have CSRF protection. This bug was introduced in 3.3.0.
+
+Backwards Compatibility Concerns
+++++++++++++++++++++++++++++++++
+
+- (:issue:`421`) Fix CSRF vulnerability on `/login` and `/change` that could return the callers authentication token.
+ Now, callers can only get the authentication token on successful POST calls.
+
+Version 3.4.4
+--------------
+
+Released July 27, 2020
+
+Bug/regression fixes.
+
+Fixed
++++++
+
+- (:issue:`359`) Basic Auth broken. When the unauthenticated handler was changed to provide a more
+ uniform/consistent response - it broke using Basic Auth from a browser, since it always redirected rather than
+ returning 401. Now, if the response headers contain ``WWW-Authenticate``
+ (which is set if ``basic`` @auth_required method is used), a 401 is returned. See below
+ for backwards compatibility concerns.
+
+- (:pr:`362`) As part of figuring out issue 359 - a redirect loop was found. In release 3.3.0 code was put
+ in to redirect to :py:data:`SECURITY_POST_LOGIN_VIEW` when GET or POST was called and the caller was already authenticated. The
+ method used would honor the request ``next`` query parameter. This could cause redirect loops. The pre-3.3.0 behavior
+ of redirecting to :py:data:`SECURITY_POST_LOGIN_VIEW` and ignoring the ``next`` parameter has been restored.
+
+- (:issue:`347`) Fix peewee. Turns out - due to lack of unit tests - peewee hasn't worked since
+ 'permissions' were added in 3.3. Furthermore, changes in 3.4 around get_id and alternative tokens also
+ didn't work since peewee defines its own `get_id` method.
+
+Compatibility Concerns
+++++++++++++++++++++++
+
+In 3.3.0, :meth:`flask_security.auth_required` was changed to add a default argument if none was given. The default
+include all current methods - ``session``, ``token``, and ``basic``. However ``basic`` really isn't like the others
+and requires that we send back a ``WWW-Authenticate`` header if authentication fails (and return a 401 and not redirect).
+``basic`` has been removed from the default set and must once again be explicitly requested.
+
+Version 3.4.3
+-------------
+
+Released June 12, 2020
+
+Minor fixes for a regression and a couple other minor changes
+
+Fixed
++++++
-.. _here: https://github.com/Flask-Middleware/flask-security/issues/85
+- (:issue:`340`) Fix regression where tf_phone_number was required, even if SMS wasn't configured.
+- (:pr:`342`) Pick up some small documentation fixes from 4.0.0.
Version 3.4.2
-------------
-Released May x, 2020
+Released May 2, 2020
Only change is to move repo to the Flask-Middleware github organization.
@@ -95,6 +311,8 @@
Other changes with possible backwards compatibility issues:
- ``/tf-setup`` never did any phone number validation. Now it does.
+- ``two_factor_setup.html`` template - the chosen_method check was changed to ``email``.
+ If you have your own custom template - be sure make that change.
Version 3.3.3
-------------
@@ -165,7 +383,7 @@
- (: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
+- (:issue:`121`) Unauthorized callback not quite right. Split into 2 different callbacks - one for
unauthorized and one for unauthenticated. Made default unauthenticated handler use Flask-Login's unauthenticated
method to make everything uniform. Extensive documentation added. `.Security.unauthorized_callback` has been deprecated.
- (:pr:`120`) Add complete User and Role model mixins that support all features. Modify tests and Quickstart documentation
@@ -280,7 +498,7 @@
Released June 26th 2019
-- (opr #839) Support caching of authentication token (eregnier).
+- (:pr:`80`) Support caching of authentication token (eregnier `opr #839 `_).
This adds a new configuration variable *SECURITY_USE_VERIFY_PASSWORD_CACHE*
which enables a cache (with configurable TTL) for authentication tokens.
This is a big performance boost for those accessing Flask-Security via token
@@ -301,23 +519,23 @@
Released never
-- (opr #487) Use Security.render_template in mails too (noirbizarre)
-- (opr #679) Optimize DB accesses by using an SQL JOIN when retrieving a user. (nfvs)
-- (opr #697) Add base template to security templates (grihabor)
-- (opr #633) datastore: get user by numeric identity attribute (jirikuncar)
-- (opr #703) bugfix: support application factory pattern (briancappello)
-- (opr #714) Make SECURITY_PASSWORD_SINGLE_HASH a list of scheme ignoring double hash (noirbizarre )
-- (opr #717) Allow custom login_manager to be passed in to Flask-Security (jaza)
-- (opr #727) Docs for OAauth2-based custom login manager (jaza)
-- (opr #779) core: make the User model check the password (mklassen)
-- (opr #730) Customizable send_mail (abulte)
-- (opr #726) core: fix default for UNAUTHORIZED_VIEW (jirijunkar)
+- (:pr:`53`) Use Security.render_template in mails too (noirbizarre `opr #487 `_)
+- (:pr:`56`) Optimize DB accesses by using an SQL JOIN when retrieving a user. (nfvs `opr #679 `_)
+- (:pr:`57`) Add base template to security templates (grihabor `opr #697 `_)
+- (:pr:`73`) datastore: get user by numeric identity attribute (jirikuncar `opr #633 `_)
+- (:pr:`58`) bugfix: support application factory pattern (briancappello `opr #703 `_)
+- (:pr:`60`) Make SECURITY_PASSWORD_SINGLE_HASH a list of scheme ignoring double hash (noirbizarre `opr #714 `_)
+- (:pr:`61`) Allow custom login_manager to be passed in to Flask-Security (jaza `opr #717 `_)
+- (:pr:`62`) Docs for OAauth2-based custom login manager (jaza `opr #727 `_)
+- (:pr:`63`) core: make the User model check the password (mklassen `opr #779 `_)
+- (:pr:`64`) Customizable send_mail (abulte `opr #730 `_)
+- (:pr:`68`) core: fix default for UNAUTHORIZED_VIEW (jirijunkar `opr #726 `_)
These should all be backwards compatible.
Possible compatibility issues:
-- #487 - prior to this, render_template() was overiddable for views, but not
+- #487 - prior to this, render_template() was overridable for views, but not
emails. If anyone actually relied on this behavior, this has changed.
- #703 - get factory pattern working again. There was a very complex dance between
Security() instantiation and init_app regarding kwargs. This has been rationalized (hopefully).
@@ -326,7 +544,7 @@
Got exception during processing: -
'User.roles' does not support object population - eager loading cannot be applied.
- This is likely solveable by removing ``lazy='dynamic'`` from your Role definition.
+ This is likely solvable by removing ``lazy='dynamic'`` from your Role definition.
Performance improvements:
@@ -399,7 +617,7 @@
and expiration causes confirmation email to resend. (see #556)
- Added support for I18N.
- Added options `SECURITY_EMAIL_PLAINTEXT` and `SECURITY_EMAIL_HTML`
- for sending respecively plaintext and HTML version of email.
+ for sending respectively plaintext and HTML version of email.
- Fixed validation when missing login information.
- Fixed condition for token extraction from JSON body.
- Better support for universal bdist wheel.
@@ -425,7 +643,7 @@
- Fixed failure of init_app to set self.datastore.
- Changed to new style flask imports.
- Added proper error code when returning JSON response.
-- Changed obsolette Required validator from WTForms to DataRequired. Bumped Flask-WTF to 0.13.
+- Changed obsolete Required validator from WTForms to DataRequired. Bumped Flask-WTF to 0.13.
- Fixed missing `SECURITY_SUBDOMAIN` in config docs.
- Added cascade delete in PeeweeDatastore.
- Added notes to docs about `SECURITY_USER_IDENTITY_ATTRIBUTES`.
@@ -462,7 +680,7 @@
Released October 13th 2014
- Fixed a bug related to changing existing passwords from plaintext to hashed
-- Fixed a bug in form validation that did not enforce case insensivitiy
+- Fixed a bug in form validation that did not enforce case insensitivity
- Fixed a bug with validating redirects
@@ -504,7 +722,7 @@
- Python 3.3 support!
- Dependency updates
- Fixed a bug when `SECURITY_LOGIN_WITHOUT_CONFIRMATION = True` did not allow users to log in
-- Added `SECURITY_SEND_PASSWORD_RESET_NOTICE_EMAIL` configuraiton option to optionally send password reset notice emails
+- Added `SECURITY_SEND_PASSWORD_RESET_NOTICE_EMAIL` configuration option to optionally send password reset notice emails
- Add documentation for `@security.send_mail_task`
- Move to `request.get_json` as `request.json` is now deprecated in Flask
- Fixed a bug when using AJAX to change a user's password
@@ -660,7 +878,7 @@
Released October 11th 2012
- Major release. Upgrading from previous versions will require a bit of work to
- accomodate API changes. See documentation for a list of new features and for
+ accommodate API changes. See documentation for a list of new features and for
help on how to upgrade.
Version 1.2.3
diff -Nru flask-security-3.4.2/CONTRIBUTING.rst flask-security-4.0.0/CONTRIBUTING.rst
--- flask-security-3.4.2/CONTRIBUTING.rst 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/CONTRIBUTING.rst 2021-01-26 02:39:51.000000000 +0000
@@ -44,10 +44,10 @@
#. (Optional, but recommended) Create a Python 3.6 (or greater) virtualenv to work in,
and activate it.
- #. Fork the repo `Flask-Security `_
+ #. Fork the repo `Flask-Security `_
(look for the "Fork" button).
- #. Clone your fork locally::
+ #. Clone your fork locally::
$ git clone https://github.com//flask-security
@@ -55,13 +55,17 @@
$ git checkout -b name-of-your-bugfix-or-feature
- #. Change directory to flask_security::
+ #. Change directory to flask_security::
$ cd flask_security
- #. Install the requirements::
+ #. Install the requirements::
- $ pip install -e .[tests]
+ $ pip install -r requirements/tests.txt
+
+ #. Install pre-commit hooks::
+
+ $ pre-commit install
#. Develop the Feature/Bug Fix and edit
@@ -71,9 +75,16 @@
#. When done, verify unit tests, syntax etc. all pass::
- $ python setup.py test
+ $ pytest tests
+ $ pre-commit run --all-files
$ python setup.py build_sphinx compile_catalog
+ #. Use tox::
+
+ $ tox # run everything CI does
+ $ tox -e py38-low # make sure works with older dependencies
+ $ tox -e style # run pre-commit/style checks
+
#. When the tests are successful, commit your changes
and push your branch to GitHub::
@@ -137,9 +148,9 @@
of course install the DB locally then::
# For postgres
- python setup.py test --realdburl postgres://@localhost/
+ pytest --realdburl postgres://@localhost/
# For mysql
- python setup.py test --realdburl "mysql+pymysql://root:@localhost/"
+ pytest --realdburl "mysql+pymysql://root:@localhost/"
Views
+++++
diff -Nru flask-security-3.4.2/debian/changelog flask-security-4.0.0/debian/changelog
--- flask-security-3.4.2/debian/changelog 2020-07-06 18:05:29.000000000 +0000
+++ flask-security-4.0.0/debian/changelog 2021-02-01 14:42:21.000000000 +0000
@@ -1,3 +1,23 @@
+flask-security (4.0.0-1) unstable; urgency=medium
+
+ * Team upload.
+
+ [ Debian Janitor ]
+ * Set upstream metadata fields: Bug-Database, Bug-Submit, Repository,
+ Repository-Browse.
+
+ [ Ondřej Nový ]
+ * d/control: Update Maintainer field with new Debian Python Team
+ contact address.
+ * d/control: Update Vcs-* fields with new Debian Python Team Salsa
+ layout.
+
+ [ Christoph Berg ]
+ * New upstream version 4.0.0.
+ + Fixes /login and /change vulnerability. (Closes: 980189, CVE-2021-21241)
+
+ -- Christoph Berg Mon, 01 Feb 2021 15:42:21 +0100
+
flask-security (3.4.2-2) unstable; urgency=medium
* Team upload.
diff -Nru flask-security-3.4.2/debian/control flask-security-4.0.0/debian/control
--- flask-security-3.4.2/debian/control 2020-07-06 18:05:29.000000000 +0000
+++ flask-security-4.0.0/debian/control 2021-02-01 14:41:21.000000000 +0000
@@ -1,5 +1,5 @@
Source: flask-security
-Maintainer: Debian Python Modules Team
+Maintainer: Debian Python Team
Uploaders: Adrian Vondendriesch
Section: python
Priority: optional
@@ -30,8 +30,8 @@
twine,
Standards-Version: 4.5.0
Homepage: https://github.com/mattupstate/flask-security
-Vcs-Git: https://salsa.debian.org/python-team/modules/flask-security.git
-Vcs-Browser: https://salsa.debian.org/python-team/modules/flask-security
+Vcs-Git: https://salsa.debian.org/python-team/packages/flask-security.git
+Vcs-Browser: https://salsa.debian.org/python-team/packages/flask-security
Package: python3-flask-security
Architecture: all
diff -Nru flask-security-3.4.2/debian/patches/flask-version flask-security-4.0.0/debian/patches/flask-version
--- flask-security-3.4.2/debian/patches/flask-version 2020-07-06 18:05:29.000000000 +0000
+++ flask-security-4.0.0/debian/patches/flask-version 1970-01-01 00:00:00.000000000 +0000
@@ -1,13 +0,0 @@
-Remove Flask version annotation so we can be installed on stretch and xenial/bionic
-
---- a/setup.py
-+++ b/setup.py
-@@ -57,7 +57,7 @@ for reqs in extras_require.values():
- setup_requires = ["Babel>=1.3", "pytest-runner>=2.6.2", "twine", "wheel"]
-
- install_requires = [
-- "Flask>=1.0.2",
-+ "Flask",
- "Flask-Login>=0.4.1",
- "Flask-Mail>=0.9.1",
- "Flask-Principal>=0.4.0",
diff -Nru flask-security-3.4.2/debian/patches/series flask-security-4.0.0/debian/patches/series
--- flask-security-3.4.2/debian/patches/series 2020-07-06 18:05:29.000000000 +0000
+++ flask-security-4.0.0/debian/patches/series 1970-01-01 00:00:00.000000000 +0000
@@ -1,2 +0,0 @@
-flask-version
-#xenial-pytest-runner
diff -Nru flask-security-3.4.2/debian/patches/xenial-pytest-runner flask-security-4.0.0/debian/patches/xenial-pytest-runner
--- flask-security-3.4.2/debian/patches/xenial-pytest-runner 2020-07-06 18:05:29.000000000 +0000
+++ flask-security-4.0.0/debian/patches/xenial-pytest-runner 1970-01-01 00:00:00.000000000 +0000
@@ -1,11 +0,0 @@
---- a/setup.py
-+++ b/setup.py
-@@ -54,7 +54,7 @@ extras_require["all"] = []
- for reqs in extras_require.values():
- extras_require["all"].extend(reqs)
-
--setup_requires = ["Babel>=1.3", "pytest-runner>=2.6.2", "twine", "wheel"]
-+setup_requires = ["Babel>=1.3", "twine", "wheel"]
-
- install_requires = [
- "Flask",
diff -Nru flask-security-3.4.2/debian/upstream/metadata flask-security-4.0.0/debian/upstream/metadata
--- flask-security-3.4.2/debian/upstream/metadata 1970-01-01 00:00:00.000000000 +0000
+++ flask-security-4.0.0/debian/upstream/metadata 2021-02-01 14:41:21.000000000 +0000
@@ -0,0 +1,5 @@
+---
+Bug-Database: https://github.com/Flask-Middleware/flask-security/issues
+Bug-Submit: https://github.com/Flask-Middleware/flask-security/issues/new
+Repository: https://github.com/Flask-Middleware/flask-security.git
+Repository-Browse: https://github.com/Flask-Middleware/flask-security
diff -Nru flask-security-3.4.2/docs/api.rst flask-security-4.0.0/docs/api.rst
--- flask-security-3.4.2/docs/api.rst 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/docs/api.rst 2021-01-26 02:39:51.000000000 +0000
@@ -96,6 +96,8 @@
.. autofunction:: flask_security.get_hmac
+.. autofunction:: flask_security.get_request_attr
+
.. autofunction:: flask_security.verify_password
.. autofunction:: flask_security.verify_and_update_password
@@ -126,6 +128,8 @@
.. autofunction:: flask_security.transform_url
+.. autofunction:: flask_security.unique_identity_attribute
+
.. autofunction:: flask_security.us_send_security_token
.. autofunction:: flask_security.tf_send_security_token
@@ -133,10 +137,19 @@
.. autoclass:: flask_security.FsJsonEncoder
.. autoclass:: flask_security.Totp
- :members: get_last_counter, set_last_counter
+ :members: get_last_counter, set_last_counter, generate_qrcode
.. autoclass:: flask_security.PhoneUtil
:members:
+ :special-members: __init__
+
+.. autoclass:: flask_security.MailUtil
+ :members:
+ :special-members: __init__
+
+.. autoclass:: flask_security.PasswordUtil
+ :members:
+ :special-members: __init__
.. autoclass:: flask_security.SmsSenderBaseClass
:members: send_sms
@@ -144,6 +157,8 @@
.. autoclass:: flask_security.SmsSenderFactory
:members: createSender
+.. _signals_topic:
+
Signals
-------
See the `Flask documentation on signals`_ for information on how to use these
diff -Nru flask-security-3.4.2/docs/configuration.rst flask-security-4.0.0/docs/configuration.rst
--- flask-security-3.4.2/docs/configuration.rst 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/docs/configuration.rst 2021-01-26 02:39:51.000000000 +0000
@@ -27,7 +27,8 @@
.. py:data:: SECURITY_SUBDOMAIN
- Specifies the subdomain for the Flask-Security blueprint.
+ Specifies the subdomain for the Flask-Security blueprint. If your authenticated
+ content is on a different subdomain, also enable :py:data:`SECURITY_REDIRECT_ALLOW_SUBDOMAINS`.
Default: ``None``.
.. py:data:: SECURITY_FLASH_MESSAGES
@@ -142,6 +143,18 @@
.. versionadded:: 3.4.0
+.. py:data:: SECURITY_PASSWORD_NORMALIZE_FORM
+
+ Passwords are normalized prior to changing or comparing. This satisfies
+ the NIST requirement: `5.1.1.2 Memorized Secret Verifiers`_.
+ Normalization is performed using the Python unicodedata.normalize() method.
+
+ Default: "NFKD"
+
+ .. versionadded:: 4.0.0
+
+.. _5.1.1.2 Memorized Secret Verifiers: https://pages.nist.gov/800-63-3/sp800-63b.html#sec5
+
.. py:data:: SECURITY_TOKEN_AUTHENTICATION_KEY
Specifies the query string parameter to read when using token authentication.
@@ -160,35 +173,22 @@
Default: ``None``, meaning the token never expires.
-.. py:data:: SECURITY_DEFAULT_HTTP_AUTH_REALM
-
- Specifies the default authentication realm when using basic HTTP auth.
-
- Default: ``Login Required``
-
-.. py:data:: SECURITY_USE_VERIFY_PASSWORD_CACHE
+.. py:data:: SECURITY_EMAIL_VALIDATOR_ARGS
- If ``True`` enables cache for token verification, which speeds up further
- calls to authenticated routes using authentication-token and slow hash algorithms (like bcrypt).
- If you set this - you must ensure that `cachetools`_ is installed.
- **Note: this will likely be deprecated and removed in 4.0. It**
- **has known limitations, and there is now a better/faster way to**
- **generate and verify auth tokens.**
+ Email address are validated using the `email_validator`_ package. Its methods
+ have some configurable options - these can be set here and will be passed in.
- Default: ``None``.
-
-.. py:data:: SECURITY_VERIFY_HASH_CACHE_MAX_SIZE
+ Default: ``None``, meaning use the defaults from email_validator package.
- Limitation for token validation cache size. Rules are the ones of TTLCache of
- cachetools package.
+ .. versionadded:: 4.0.0
- Default: ``500``
+.. _email_validator: https://pypi.org/project/email-validator/
-.. py:data:: SECURITY_VERIFY_HASH_CACHE_TTL
+.. py:data:: SECURITY_DEFAULT_HTTP_AUTH_REALM
- Time to live for password check cache entries.
+ Specifies the default authentication realm when using basic HTTP auth.
- Default: ``300`` (5 minutes)
+ Default: ``Login Required``
.. py:data:: SECURITY_REDIRECT_BEHAVIOR
@@ -213,6 +213,17 @@
.. versionadded:: 3.3.0
+.. py:data:: SECURITY_REDIRECT_ALLOW_SUBDOMAINS
+
+ If ``True`` then subdomains (and the root domain) of the top-level host set
+ by Flask's ``SERVER_NAME`` configuration will be allowed as post-login redirect targets.
+ This is beneficial if you wish to place your authentiation on one subdomain and
+ authenticated content on another, for example ``auth.domain.tld`` and ``app.domain.tld``.
+
+ Default: ``False``.
+
+ .. versionadded:: 4.0.0
+
.. py:data:: SECURITY_CSRF_PROTECT_MECHANISMS
Authentication mechanisms that require CSRF protection.
@@ -261,46 +272,78 @@
.. py:data:: SECURITY_USER_IDENTITY_ATTRIBUTES
- Specifies which attributes of the user object can be used for login.
+ Specifies which attributes of the user object can be used for credential validation.
+
+ Defines the order and matching that will be applied when validating login
+ credentials (either via standard login form or the unified sign in form).
+ The identity field in the form will be matched in order using this configuration
+ - the FIRST match will then be used to look up the user in the DB.
- Default: ``['email']``.
+ Mapping functions take a single argument - ``identity`` from the form
+ and should return ``None`` if the ``identity`` argument isn't in a format
+ suitable for the attribute. If the ``identity`` argument format matches, it
+ should be returned, optionally having had some canonicalization performed.
+ The returned result will be used to look up the identity in the UserDataStore
+ using the column name specified in the key.
+
+ The provided :meth:`flask_security.uia_phone_mapper` for example performs
+ phone number normalization using the ``phonenumbers`` package.
+
+ .. tip::
+ If your mapper performs any sort of canonicalization/normalization,
+ make sure you apply the exact same transformation in your form validator
+ when setting the field.
.. danger::
Make sure that any attributes listed here are marked Unique in your UserDataStore
model.
-.. py:data:: SECURITY_USER_IDENTITY_MAPPINGS
+ .. danger::
+ Make sure your mapper methods guard against malicious user input. For example,
+ if you allow ``username`` as an identity method you could use `bleach`_::
- Defines the order and matching that will be applied when validating the
- unified sign in form. This form has a single ``identity`` field
- that is parsed using the information below - the FIRST match will then be
- used to look up the user in the DB.
+ def uia_username_mapper(identity):
+ # we allow pretty much anything - but we bleach it.
+ return bleach.clean(identity, strip=True)
Default::
[
- {"email": uia_email_mapper},
- {"us_phone_number": uia_phone_mapper},
- ],
+ {"email": {"mapper": uia_email_mapper, "case_insensitive": True}},
+ ]
- Be aware that ONLY those attributes listed in :py:data:`SECURITY_USER_IDENTITY_ATTRIBUTES`
- will be considered - regardless of the setting of this variable.
+ If you enable :py:data:`SECURITY_UNIFIED_SIGNIN` and set ``sms`` as a :py:data:`SECURITY_US_ENABLED_METHODS`
+ the following would be necessary::
- Mapping functions take a single argument - ``identity`` from the form
- and should return ``None`` if the ``identity`` argument isn't in a format
- suitable for the attribute. If the ``identity`` argument format matches, it
- should be returned, optionally having had some canonicalization performed.
- The returned result will be used to look up the identity in the UserDataStore.
+ [
+ {"email": {"mapper": uia_email_mapper, "case_insensitive": True}},
+ {"us_phone_number": {"mapper": uia_phone_number}},
+ ]
- The provided :meth:`flask_security.uia_phone_mapper` for example performs
- phone number normalization using the ``phonenumbers`` package.
- .. tip::
- If your mapper performs any sort of canonicalization/normalization,
- make sure you apply the exact same transformation in your form validator
- when setting the field.
+ .. versionchanged:: 4.0.0
+ Changed from list to list of dict.
+
+.. _bleach: https://pypi.org/project/bleach/
+
+.. py:data:: SECURITY_USER_IDENTITY_MAPPINGS
.. versionadded:: 3.4.0
+ .. deprecated:: 4.0.0
+ Superseded by :py:data:`SECURITY_USER_IDENTITY_ATTRIBUTES`
+
+.. py:data:: SECURITY_API_ENABLED_METHODS
+
+ Various endpoints of Flask-Security require the caller to be authenticated.
+ This variable controls which of the methods - ``token``, ``session``, ``basic``
+ will be allowed. The default does NOT include ``basic`` since if ``basic``
+ is in the list, and if the user is NOT authenticated, then the standard/required
+ response of 401 with the ``WWW-Authenticate`` header is returned. This is
+ rarely what the client wants.
+
+ Default: ``["session", "token"]``.
+
+ .. versionadded:: 4.0.0
.. py:data:: SECURITY_DEFAULT_REMEMBER_ME
@@ -326,12 +369,6 @@
Default: ``False``.
-.. py:data:: 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.
-
Core - Multi-factor
-------------------
These are used by the Two-Factor and Unified Signin features.
@@ -388,12 +425,13 @@
.. py:data:: SECURITY_FRESHNESS
A timedelta used to protect endpoints that alter sensitive information.
- This is used to protect the endpoint: :py:data:`SECURITY_US_SETUP_URL`.
- Refer to :meth:`flask_security.auth_required` for details.
+ This is used to protect the endpoint: :py:data:`SECURITY_US_SETUP_URL`, and
+ :py:data:`SECURITY_TWO_FACTOR_SETUP_URL`.
Setting this to a negative number will disable any freshness checking and
the endpoints :py:data:`SECURITY_VERIFY_URL`, :py:data:`SECURITY_US_VERIFY_URL`
and :py:data:`SECURITY_US_VERIFY_SEND_CODE_URL` won't be registered.
Setting this to 0 results in undefined behavior.
+ Please see :meth:`flask_security.check_and_update_authn_fresh` for details.
Default: timedelta(hours=24)
@@ -403,8 +441,8 @@
A timedelta that provides a grace period when altering sensitive
information.
- This is used to protect the endpoint: :py:data:`SECURITY_US_SETUP_URL`.
- Refer to :meth:`flask_security.auth_required` for details.
+ This is used to protect the endpoint: :py:data:`SECURITY_US_SETUP_URL`, and
+ :py:data:`SECURITY_TWO_FACTOR_SETUP_URL`.
N.B. To avoid strange behavior, be sure to set the grace period less than
the freshness period.
Please see :meth:`flask_security.check_and_update_authn_fresh` for details.
@@ -447,6 +485,11 @@
Remember tokens are used instead of user ID's as it is more secure.
Default: ``"remember-salt"``.
+.. py:data:: SECURITY_TWO_FACTOR_VALIDITY_SALT
+
+ Specifies the salt value when generating two factor validity tokens.
+
+ Default: ``"tf-validity-salt"``.
.. py:data:: SECURITY_US_SETUP_SALT
Default: ``"us-setup-salt"``
@@ -489,7 +532,6 @@
.. _Totp: https://passlib.readthedocs.io/en/stable/narr/totp-tutorial.html#totp-encryption-setup
.. _set_cookie: https://flask.palletsprojects.com/en/1.1.x/api/?highlight=set_cookie#flask.Response.set_cookie
.. _axios: https://github.com/axios/axios
-.. _cachetools: https://pypi.org/project/cachetools/
.. _bcrypt: https://pypi.org/project/bcrypt/
.. _argon2: https://pypi.org/project/argon2-cffi/
@@ -519,16 +561,16 @@
.. py:data:: SECURITY_POST_LOGIN_VIEW
Specifies the default view to redirect to after a user logs in. This value can be set to a URL
- or an endpoint name.
+ or an endpoint name. Defaults to the Flask config ``APPLICATION_ROOT`` value which itself defaults to ``"/"``.
- Default: ``"/"``.
+ Default: ``APPLICATION_ROOT``.
.. py:data:: SECURITY_POST_LOGOUT_VIEW
- Specifies the default view to redirect to after a user logs out.
- This value can be set to a URL or an endpoint name.
+ Specifies the default view to redirect to after a user logs out. This value can be set to a URL
+ or an endpoint name. Defaults to the Flask config ``APPLICATION_ROOT`` value which itself defaults to ``"/"``.
- Default: ``"/"``.
+ Default: ``APPLICATION_ROOT``.
.. py:data:: SECURITY_UNAUTHORIZED_VIEW
@@ -543,7 +585,7 @@
Specifies the path to the template for the user login page.
- Default:``security/login_user.html``.
+ Default: ``"security/login_user.html"``.
.. py:data:: SECURITY_VERIFY_URL
@@ -552,6 +594,13 @@
Default: ``"/verify"``
+
+.. py:data:: SECURITY_VERIFY_TEMPLATE
+
+ Specifies the path to the template for the verify password page.
+
+ Default: ``"security/verify.html"``.
+
.. py:data:: SECURITY_POST_VERIFY_URL
Specifies the default view to redirect to after a user successfully re-authenticates either via
@@ -657,7 +706,15 @@
Specifies if a user may login before confirming their email when
the value of ``SECURITY_CONFIRMABLE`` is set to ``True``.
- Default:``False``.
+ Default: ``False``.
+.. py:data:: SECURITY_REQUIRES_CONFIRMATION_ERROR_VIEW
+
+ Specifies a redirect page if the users tries to login, reset password or us-signin with an unconfirmed account.
+ If an URL endpoint is specified, flashes an error messages and passes user email as an argument.
+ For us-signin, no argument is specified: it simply flashes the error message and redirects.
+ Default behavior is to reload the form with an error message without redirecting to an other page.
+
+ Default: ``None``.
Changeable
----------
@@ -866,12 +923,6 @@
Specifies the path to the template for the setup page for the two factor authentication process.
Default: ``security/two_factor_setup.html``.
-.. py:data:: SECURITY_TWO_FACTOR_VERIFY_PASSWORD_TEMPLATE
-
- Specifies the path to the template for the change method page for the two
- factor authentication process.
-
- Default: ``security/two_factor_verify_password.html``.
.. py:data:: SECURITY_TWO_FACTOR_SETUP_URL
@@ -883,21 +934,35 @@
Specifies the two factor token validation URL.
Default: ``"/tf-validate"``.
-.. py:data:: SECURITY_TWO_FACTOR_QRCODE_URL
-
- Specifies the two factor request QrCode URL.
- Default: ``/tf-qrcode``.
.. py:data:: SECURITY_TWO_FACTOR_RESCUE_URL
Specifies the two factor rescue URL.
Default: ``"/tf-rescue"``.
-.. py:data:: SECURITY_TWO_FACTOR_CONFIRM_URL
- Specifies the two factor password confirmation URL.
+.. py:data:: SECURITY_TWO_FACTOR_ALWAYS_VALIDATE
+
+ Specifies whether the application should require a two factor code upon every login.
+ If set to ``False`` then the 2 values below are used to determine when
+ a code is required. Note that this is cookie based - so a new browser
+ session will always require a fresh two-factor code.
+
+ Default: ``True``.
+.. py:data:: SECURITY_TWO_FACTOR_LOGIN_VALIDITY
+
+ Specifies the expiration of the two factor validity cookie and verification of the token.
+
+ Default: ``30 Days``.
+
+
+.. py:data:: SECURITY_TWO_FACTOR_VALIDITY_COOKIE
+
+ A dictionary containing the parameters of the two factor validity cookie.
+ The complete set of parameters is described in Flask's `set_cookie`_ documentation.
+
+ Default: ``{'httponly': True, 'secure': False, 'samesite': None}``.
- Default: ``"/tf-confirm"``.
Unified Signin
--------------
@@ -937,12 +1002,6 @@
Default: ``"/us-verify-link"``
-.. py:data:: SECURITY_US_QRCODE_URL
-
- Used to generate and return a QRcode that can be used to intialize an authenticator app.
-
- Default: ``"/us-qrcode"``
-
.. py:data:: SECURITY_US_VERIFY_URL
This endpoint handles re-authentication, the caller must be already authenticated
@@ -985,6 +1044,11 @@
Be aware that ``password`` only affects this ``SECURITY_US_SIGNIN_URL`` endpoint.
Removing it from here won't stop users from using the ``SECURITY_LOGIN_URL`` endpoint.
+ If you select ``sms`` then make sure you add this to :py:data:`SECURITY_USER_IDENTITY_ATTRIBUTES`::
+
+ {"us_phone_number": {"mapper": uia_phone_number}},
+
+
Default: ``["password", "email", "authenticator", "sms"]`` - which are the only supported options.
.. py:data:: SECURITY_US_MFA_REQUIRED
@@ -1004,6 +1068,8 @@
.. py:data:: SECURITY_US_EMAIL_SUBJECT
+ Sets the email subject when sending the verification code via email.
+
Default: ``_("Verification Code")``
.. py:data:: SECURITY_US_SETUP_WITHIN
@@ -1023,9 +1089,7 @@
Additional relevant configuration variables:
- * :py:data:`SECURITY_USER_IDENTITY_ATTRIBUTES` - Defines which user fields can be
- used for identity.
- * :py:data:`SECURITY_USER_IDENTITY_MAPPINGS` - Defines the order and methods for parsing identity.
+ * :py:data:`SECURITY_USER_IDENTITY_ATTRIBUTES` - Defines the order and methods for parsing and validating identity.
* :py:data:`SECURITY_DEFAULT_REMEMBER_ME`
* :py:data:`SECURITY_SMS_SERVICE` - When SMS is enabled in :py:data:`SECURITY_US_ENABLED_METHODS`.
* :py:data:`SECURITY_SMS_SERVICE_CONFIG`
@@ -1109,15 +1173,14 @@
* ``SECURITY_LOGIN_URL``
* ``SECURITY_LOGOUT_URL``
+* :py:data:`SECURITY_VERIFY_URL`
* ``SECURITY_REGISTER_URL``
* ``SECURITY_RESET_URL``
* ``SECURITY_CHANGE_URL``
* ``SECURITY_CONFIRM_URL``
* ``SECURITY_TWO_FACTOR_SETUP_URL``
* ``SECURITY_TWO_FACTOR_TOKEN_VALIDATION_URL``
-* ``SECURITY_TWO_FACTOR_QRCODE_URL``
* ``SECURITY_TWO_FACTOR_RESCUE_URL``
-* ``SECURITY_TWO_FACTOR_CONFIRM_URL``
* ``SECURITY_POST_LOGIN_VIEW``
* ``SECURITY_POST_LOGOUT_VIEW``
* ``SECURITY_CONFIRM_ERROR_VIEW``
@@ -1130,7 +1193,6 @@
* ``SECURITY_RESET_ERROR_VIEW``
* ``SECURITY_LOGIN_ERROR_VIEW``
* :py:data:`SECURITY_US_SIGNIN_URL`
-* :py:data:`SECURITY_US_QRCODE_URL`
* :py:data:`SECURITY_US_SETUP_URL`
* :py:data:`SECURITY_US_SIGNIN_SEND_CODE_URL`
* :py:data:`SECURITY_US_VERIFY_LINK_URL`
@@ -1144,6 +1206,7 @@
* ``SECURITY_FORGOT_PASSWORD_TEMPLATE``
* ``SECURITY_LOGIN_USER_TEMPLATE``
+* :py:data:`SECURITY_VERIFY_TEMPLATE`
* ``SECURITY_REGISTER_USER_TEMPLATE``
* ``SECURITY_RESET_PASSWORD_TEMPLATE``
* ``SECURITY_CHANGE_PASSWORD_TEMPLATE``
@@ -1151,7 +1214,6 @@
* ``SECURITY_SEND_LOGIN_TEMPLATE``
* ``SECURITY_TWO_FACTOR_VERIFY_CODE_TEMPLATE``
* ``SECURITY_TWO_FACTOR_SETUP_TEMPLATE``
-* ``SECURITY_TWO_FACTOR_VERIFY_PASSWORD_TEMPLATE``
* :py:data:`SECURITY_US_SIGNIN_TEMPLATE`
* :py:data:`SECURITY_US_SETUP_TEMPLATE`
* :py:data:`SECURITY_US_VERIFY_TEMPLATE`
@@ -1165,6 +1227,7 @@
The default messages and error levels can be found in ``core.py``.
* ``SECURITY_MSG_ALREADY_CONFIRMED``
+* ``SECURITY_MSG_API_ERROR``
* ``SECURITY_MSG_ANONYMOUS_USER_REQUIRED``
* ``SECURITY_MSG_CONFIRMATION_EXPIRED``
* ``SECURITY_MSG_CONFIRMATION_REQUEST``
@@ -1176,6 +1239,7 @@
* ``SECURITY_MSG_EMAIL_NOT_PROVIDED``
* ``SECURITY_MSG_FAILED_TO_SEND_CODE``
* ``SECURITY_MSG_FORGOT_PASSWORD``
+* ``SECURITY_MSG_IDENTITY_ALREADY_ASSOCIATED``
* ``SECURITY_MSG_INVALID_CODE``
* ``SECURITY_MSG_INVALID_CONFIRMATION_TOKEN``
* ``SECURITY_MSG_INVALID_EMAIL_ADDRESS``
@@ -1208,8 +1272,6 @@
* ``SECURITY_MSG_TWO_FACTOR_INVALID_TOKEN``
* ``SECURITY_MSG_TWO_FACTOR_LOGIN_SUCCESSFUL``
* ``SECURITY_MSG_TWO_FACTOR_CHANGE_METHOD_SUCCESSFUL``
-* ``SECURITY_MSG_TWO_FACTOR_PASSWORD_CONFIRMATION_DONE``
-* ``SECURITY_MSG_TWO_FACTOR_PASSWORD_CONFIRMATION_NEEDED``
* ``SECURITY_MSG_TWO_FACTOR_PERMISSION_DENIED``
* ``SECURITY_MSG_TWO_FACTOR_METHOD_NOT_AVAILABLE``
* ``SECURITY_MSG_TWO_FACTOR_DISABLED``
diff -Nru flask-security-3.4.2/docs/conf.py flask-security-4.0.0/docs/conf.py
--- flask-security-3.4.2/docs/conf.py 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/docs/conf.py 2021-01-26 02:39:51.000000000 +0000
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
#
# Flask-Security documentation build configuration file, created by
# sphinx-quickstart on Mon Mar 12 15:35:21 2012.
@@ -49,8 +48,8 @@
master_doc = "index"
# General information about the project.
-project = u"Flask-Security"
-copyright = u"2012-2020"
+project = "Flask-Security"
+copyright = "2012-2020"
author = "Matt Wright & Chris Wagner"
# The version info for the project you're documenting, acts as replacement for
@@ -58,7 +57,7 @@
# built documents.
#
# The short X.Y version.
-version = "3.4.2"
+version = "4.0.0"
# The full version, including alpha/beta/rc tags.
release = version
@@ -168,7 +167,7 @@
# (source start file, target name, title, author, documentclass
# [howto/manual]).
latex_documents = [
- ("index", "Flask-Security.tex", u"Flask-Security Documentation", author, "manual")
+ ("index", "Flask-Security.tex", "Flask-Security Documentation", author, "manual")
]
# The name of an image file (relative to this directory) to place at the top of
@@ -201,8 +200,8 @@
(
"index",
"Flask-Security",
- u"Flask-Security Documentation",
- u"Matt Wright",
+ "Flask-Security Documentation",
+ "Matt Wright",
"Flask-Security",
"One line description of project.",
"Miscellaneous",
@@ -222,10 +221,10 @@
# -- Options for Epub output ---------------------------------------------
# Bibliographic Dublin Core info.
-epub_title = u"Flask-Security"
-epub_author = u"Matt Wright"
-epub_publisher = u"J. Christopher Wagner"
-epub_copyright = u"2012-2019"
+epub_title = "Flask-Security"
+epub_author = "Matt Wright"
+epub_publisher = "J. Christopher Wagner"
+epub_copyright = "2012-2020"
# The language of the text. It defaults to the language option
# or en if the language is not set.
diff -Nru flask-security-3.4.2/docs/contributing.rst flask-security-4.0.0/docs/contributing.rst
--- flask-security-3.4.2/docs/contributing.rst 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/docs/contributing.rst 2021-01-26 02:39:51.000000000 +0000
@@ -1 +1 @@
-.. include:: ../CONTRIBUTING.rst
\ No newline at end of file
+.. include:: ../CONTRIBUTING.rst
diff -Nru flask-security-3.4.2/docs/customizing.rst flask-security-4.0.0/docs/customizing.rst
--- flask-security-3.4.2/docs/customizing.rst 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/docs/customizing.rst 2021-01-26 02:39:51.000000000 +0000
@@ -22,7 +22,6 @@
* `security/send_confirmation.html`
* `security/send_login.html`
* `security/verify.html`
-* `security/two_factor_verify_password.html`
* `security/two_factor_setup.html`
* `security/two_factor_verify_code.html`
* `security/us_signin.html`
@@ -71,7 +70,6 @@
* ``mail_context_processor``: Whenever an email will be sent
* ``tf_setup_context_processor``: Two factor setup view
* ``tf_token_validation_context_processor``: Two factor token validation view
-* ``tf_verify_password_context_processor``: Two factor password re-verify view
* ``us_signin_context_processor``: Unified sign in view
* ``us_setup_context_processor``: Unified sign in setup view
@@ -118,7 +116,6 @@
* ``passwordless_login_form``: Passwordless login form
* ``two_factor_verify_code_form``: Two-factor verify code form
* ``two_factor_setup_form``: Two-factor setup form
-* ``two_factor_verify_password_form``: Two-factor verify password form
* ``two_factor_rescue_form``: Two-factor help user form
* ``us_signin_form``: Unified sign in form
* ``us_setup_form``: Unified sign in setup form
@@ -131,17 +128,26 @@
Localization
------------
All messages, form labels, and form strings are localizable. Flask-Security uses
+`Flask-Babel `_ or
`Flask-BabelEx `_ to manage its messages.
All translations are tagged with a domain, as specified by the configuration variable
``SECURITY_I18N_DOMAIN`` (default: "security"). For messages and labels all this
works seamlessly. For strings inside templates it is necessary to explicitly ask for
the "security" domain, since your application itself might have its own domain.
-Flask-Security places the method ``_fsdomain`` in jinja2's global environment.
+Flask-Security places the method ``_fsdomain`` in jinja2's global environment and
+uses that in all templates.
In order to reference a Flask-Security translation from ANY template (such as if you copied and
modified an existing security template) just use that method::
{{ _fsdomain("Login") }}
+Be aware that Flask-Security will validate and normalize email input using the
+`email_validator `_ package.
+The normalized form is stored in the DB.
+
+
+.. _emails_topic:
+
Emails
------
@@ -156,9 +162,9 @@
* `security/email/reset_instructions.html`
* `security/email/reset_instructions.txt`
* `security/email/reset_notice.html`
+* `security/email/reset_notice.txt`
* `security/email/change_notice.txt`
* `security/email/change_notice.html`
-* `security/email/reset_notice.txt`
* `security/email/welcome.html`
* `security/email/welcome.txt`
* `security/email/two_factor_instructions.html`
@@ -187,103 +193,89 @@
return dict(hello="world")
-Emails with Celery
-------------------
+There are many configuration variables associated with emails, and each template
+will receive a slightly different context. The ``Gate Config`` column are configuration variables that if set
+to ``False`` will bypass sending of the email (they all default to ``True``).
+In most cases, in addition to an email being sent, a :ref:`Signal ` is sent.
+The table below summarizes all this:
-Sometimes it makes sense to send emails via a task queue, such as `Celery`_.
-To delay the sending of emails, you can use the ``@security.send_mail_task``
-decorator like so::
+============================= ================================ ====================================== ====================== ===============================
+**Template Name** **Gate Config** **Subject Config** **Context Vars** **Signal Sent**
+----------------------------- -------------------------------- -------------------------------------- ---------------------- -------------------------------
+confirmation_instructions N/A EMAIL_SUBJECT_CONFIRM - user confirm_instructions_sent
+ - confirmation_link
+login_instructions N/A EMAIL_SUBJECT_PASSWORDLESS - user login_instructions_sent
+ - login_link
- from flask_mail import Message
-
- # Setup the task
- @celery.task
- def send_flask_mail(**kwargs):
- # Use the Flask-Mail extension instance to send the incoming ``msg`` parameter
- # which is an instance of `flask_mail.Message`
- mail.send(Message(**kwargs))
- @security.send_mail_task
- def delay_flask_security_mail(msg):
- send_flask_mail.delay(
- subject=msg.subject,
- sender=msg.sender,
- recipients=msg.recipients,
- body=msg.body,
- html=msg.html,
- )
-
-If factory method is going to be used for initialization, use ``_SecurityState``
-object returned by ``init_app`` method to initialize Celery tasks instead of using
-``security.send_mail_task`` directly like so::
+reset_instructions SEND_PASSWORD_RESET_EMAIL EMAIL_SUBJECT_PASSWORD_RESET - user reset_password_instructions_sent
+ - reset_link
- from flask import Flask
- from flask_mail import Mail, Message
- from flask_security import Security, SQLAlchemyUserDatastore
- from celery import Celery
- mail = Mail()
- security = Security()
- celery = Celery()
+reset_notice SEND_PASSWORD_RESET_NOTICE_EMAIL EMAIL_SUBJECT_PASSWORD_NOTICE - user password_reset
- def create_app(config):
- """Initialize Flask instance."""
+change_notice SEND_PASSWORD_CHANGE_EMAIL EMAIL_SUBJECT_PASSWORD_CHANGE_NOTICE - user password_changed
- app = Flask(__name__)
- app.config.from_object(config)
+welcome
- @celery.task
- def send_flask_mail(**kwargs):
- mail.send(Message(**kwargs))
+two_factor_instructions N/A EMAIL_SUBJECT_TWO_FACTOR - user tf_security_token_sent
+ - token
+ - username
- mail.init_app(app)
- datastore = SQLAlchemyUserDatastore(db, User, Role)
- security_ctx = security.init_app(app, datastore)
+two_factor_rescue N/A EMAIL_SUBJECT_TWO_FACTOR_RESCUE - user N/A
- # Flexible way for defining custom mail sending task.
- @security_ctx.send_mail_task
- def delay_flask_security_mail(msg):
- send_flask_mail.delay(
- subject=msg.subject,
- sender=msg.sender,
- recipients=msg.recipients,
- body=msg.body,
- html=msg.html,
- )
+us_instructions N/A US_EMAIL_SUBJECT - user us_security_token_sent
+ - token
+ - login_link
+ - username
+============================= ================================ ====================================== ====================== ===============================
- # A shortcut.
- security_ctx.send_mail_task(send_flask_mail.delay)
+When sending an email, Flask-Security goes through the following steps:
- return app
+ #. Calls the email context processor as described above
-Note that ``flask_mail.Message`` may not be serialized as an argument passed to
-Celery. The practical way with custom serialization may look like so::
+ #. Calls ``render_template`` (as configured at Flask-Security initialization time) with the
+ context and template to produce a text and/or html version of the message
- @celery.task
- def send_flask_mail(**kwargs):
- mail.send(Message(**kwargs))
+ #. Calls :meth:`.MailUtil.send_mail` with all the required parameters.
- @security_ctx.send_mail_task
- def delay_flask_security_mail(msg):
- send_flask_mail.delay(subject=msg.subject, sender=msg.sender,
- recipients=msg.recipients, body=msg.body,
- html=msg.html)
+The default implementation of ``MailUtil.send_mail`` uses Flask-Mail to create and send the message.
+By providing your own implementation, you can use any available python email handling package.
-.. _Celery: http://www.celeryproject.org/
+Emails with Celery
+++++++++++++++++++
+Sometimes it makes sense to send emails via a task queue, such as `Celery`_.
+This is supported by providing your own implementation of the :class:`.MailUtil` class::
-Custom send_mail method
------------------------
+ from flask_security import MailUtil
+ class MyMailUtil(MailUtil):
-It's also possible to completely override the ``security.send_mail`` method to
-implement your own logic.
+ def send_mail(self, template, subject, recipient, sender, body, html, user, **kwargs):
+ send_flask_mail.delay(
+ subject=subject,
+ sender=sender,
+ recipients=recipients,
+ body=body,
+ html=html,
+ )
-For example, you might want to use an alternative email library like `Flask-Emails`::
+Then register your class as part of Flask-Security initialization::
from flask import Flask
+ from flask_mail import Mail, Message
from flask_security import Security, SQLAlchemyUserDatastore
- from flask_emails import Message
+ from celery import Celery
+
+ mail = Mail()
+ security = Security()
+ celery = Celery()
+
+
+ @celery.task
+ def send_flask_mail(**kwargs):
+ mail.send(Message(**kwargs))
def create_app(config):
"""Initialize Flask instance."""
@@ -291,22 +283,14 @@
app = Flask(__name__)
app.config.from_object(config)
- def custom_send_mail(subject, recipient, template, **context):
- ctx = ('security/email', template)
- message = Message(
- subject=subject,
- html=_security.render_template('%s/%s.html' % ctx, **context))
- message.send(mail_to=[recipient])
-
+ mail.init_app(app)
datastore = SQLAlchemyUserDatastore(db, User, Role)
- Security(app, datastore, send_mail=custom_send_mail)
+ security.init_app(app, datastore, mail_util_cls=MyMailUtil)
return app
-.. note::
+.. _Celery: http://www.celeryproject.org/
- The above ``security.send_mail_task`` override will be useless if you
- override the entire ``send_mail`` method.
.. _responsetopic:
@@ -320,25 +304,29 @@
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 providing a callback function via :meth:`.Security.render_json`.
-As documented in :meth:`.Security.render_json`, be aware that Flask-Security registers
+Be aware that Flask-Security registers
its own JsonEncoder on its blueprint.
401, 403, Oh My
+++++++++++++++
For a very long read and discussion; look at `this`_. Out of the box, Flask-Security in
-tandem with Flask-Login, behaves as follows:
+tandem with Flask-Login, behave 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("session", "token")`,
+ 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 authentication fails as the result of a `@http_auth_required` or `@auth_required("basic")`
+ then a 401 is returned along with the http header ``WWW-Authenticate`` set to
+ ``Basic realm="xxxx"``. The realm name is defined by :py:data:`SECURITY_DEFAULT_HTTP_AUTH_REALM`.
+
* 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.
+ then if :py:data:`SECURITY_UNAUTHORIZED_VIEW` is defined, the response will redirected.
+ If :py:data:`SECURITY_UNAUTHORIZED_VIEW` is not defined, then ``abort(403)`` is called.
All this can be easily changed by registering any or all of :meth:`.Security.render_json`,
:meth:`.Security.unauthn_handler` and :meth:`.Security.unauthz_handler`.
diff -Nru flask-security-3.4.2/docs/features.rst flask-security-4.0.0/docs/features.rst
--- flask-security-3.4.2/docs/features.rst 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/docs/features.rst 2021-01-26 02:39:51.000000000 +0000
@@ -11,11 +11,11 @@
Session based authentication is fulfilled entirely by the `Flask-Login`_
extension. Flask-Security handles the configuration of Flask-Login automatically
based on a few of its own configuration values and uses Flask-Login's
-`alternative token`_ feature for remembering users when their session has
-expired. Flask-Security uses ``fs_uniquifier`` from its Token Authentication
-Feature (see below) to implement Flask-Login's `alternative token`_. `Flask-WTF`_
+`alternative token`_ feature to associate the value of ``fs_uniquifier`` with the user.
+(This enables easily invalidating all existing sessions for a given user without
+having to change their user id). `Flask-WTF`_
integrates with the session as well to provide out of the box CSRF support.
-Flask-Security extends that to support requiring CSRF for requests that are
+Flask-Security extends that to support configurations that would require CSRF for requests that are
authenticated via session cookies, but not for requests authenticated using tokens.
@@ -69,27 +69,33 @@
Token Authentication
--------------------
-Token based authentication is enabled by retrieving the user auth token by
-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
+Token based authentication can be used by retrieving the user auth token from an
+authentication endpoint (e.g. ``/login``, ``/us-signin``).
+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
+requests to protected resources. The auth token should be 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 a uniquifier field in the
-user's UserModel. If that field is changed (via :meth:`.UserDatastore.set_uniquifier`)
-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. Verifying tokens created in this way is very slow.
+`auth_token`.
-Two-factor Authentication (alpha)
+Authentication tokens are generated using a uniquifier field in the
+user's UserModel. By default that field is ``fs_uniquifier``. This means that
+if that field is changed (via :meth:`.UserDatastore.set_uniquifier`)
+then any existing authentication tokens will no longer be valid. This value is changed
+whenever a user changes their password. If this is not the desired behavior then you can add an additional
+attribute to the UserModel: ``fs_token_uniquifier`` and that will be used instead, thus
+isolating password changes from authentication tokens. That attribute can be changed via
+:meth:`.UserDatastore.set_token_uniquifier`. This attribute should have ``unique=True``.
+Unlike ``fs_uniquifier``, it can be set to ``nullable`` - it will automatically be generated
+at first use if null.
+
+.. _two-factor:
+
+Two-factor Authentication
----------------------------------------
+**This feature is in Beta - mostly due to it being brand new and little to no production soak time**
+
Two-factor authentication is enabled by generating time-based one time passwords
(Tokens). The tokens are generated using the users `totp secret`_, which is unique
per user, and is generated both on first login, and when changing the two-factor
@@ -100,12 +106,7 @@
valid for 2 minutes, tokens sent by mail for up to 5 minute and tokens sent by
sms for up to 2 minutes. The QR code used to supply the authenticator app with
the secret is generated using the PyQRCode library.
-This feature is marked alpha meaning that backwards incompatible changes
-might occur during minor releases. While the feature is operational, it has these
-known limitations:
-
- * Limited and incomplete JSON support
- * Not enough documentation to use w/o looking at code
+Please read :ref:`2fa_theory_of_operation` for more details.
.. _unified-sign-in:
@@ -132,7 +133,7 @@
Using SMS or an authenticator app means you are providing "something you have" (the mobile device)
and either "something you know" (passcode to unlock your device)
-or "something you are" (biometric passcode to unlock your device).
+or "something you are" (biometric quality to unlock your device).
This effectively means that using a one-time code to sign in, is in fact already two-factor (if using
SMS or authenticator app). Many large authentication providers already offer this - here is
`Microsoft's`_ version.
@@ -173,12 +174,15 @@
Password Reset/Recovery
-----------------------
-Password reset and recovery is available for when a user forgets his or her
+Password reset and recovery is available for when a user forgets their
password. Flask-Security sends an email to the user with a link to a view which
they can reset their password. Once the password is reset they are automatically
-logged in and can use the new password from then on. Password reset links can
+logged in and can use the new password from then on. Password reset links can
be configured to expire after a specified amount of time.
+As with password change - this will update the the user's ``fs_uniquifier`` attribute
+which will invalidate all existing sessions AND (by default) all authentication tokens.
+
User Registration
-----------------
@@ -186,6 +190,22 @@
Flask-Security comes packaged with a basic user registration view. This view is
very simple and new users need only supply an email address and their password.
This view can be overridden if your registration process requires more fields.
+User email is validated and normalized using the
+`email_validator `_ package.
+
+Password Change
+---------------
+Flask-Security comes packaged with a basic change user password view. Unlike password
+recovery, this endpoint is used when the user is already authenticated. The result
+of a successful password change is not only a new password, but a new value for ``fs_uniquifier``.
+This has the effect is immediately invalidating all existing sessions. The change request
+itself effectively re-logs in the user so a new session is created. Note that since the user
+is effectively re-logged in, the same signals are sent as when the user normally authenticates.
+
+*NOTE* The ``fs_uniquifier`` by default, controls both sessions and authenticated tokens.
+Thus changing the password also invalidates all authentication tokens. This may not be desirable
+behavior, so if the UserModel contains an attribute ``fs_token_uniquifier``, then that will be used
+when generating authentication tokens and so won't be affected by password changes.
Login Tracking
diff -Nru flask-security-3.4.2/docs/.gitignore flask-security-4.0.0/docs/.gitignore
--- flask-security-3.4.2/docs/.gitignore 1970-01-01 00:00:00.000000000 +0000
+++ flask-security-4.0.0/docs/.gitignore 2021-01-26 02:39:51.000000000 +0000
@@ -0,0 +1 @@
+_build
diff -Nru flask-security-3.4.2/docs/index.rst flask-security-4.0.0/docs/index.rst
--- flask-security-3.4.2/docs/index.rst 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/docs/index.rst 2021-01-26 02:39:51.000000000 +0000
@@ -30,13 +30,13 @@
Many of these features are made possible by integrating various Flask extensions
and libraries. They include:
-1. `Flask-Login `_
-2. `Flask-Mail `_
-3. `Flask-Principal `_
-4. `Flask-WTF `_
-5. `itsdangerous `_
-6. `passlib `_
-7. `PyQRCode `_
+* `Flask-Login `_
+* `Flask-Mail `_
+* `Flask-Principal `_
+* `Flask-WTF `_
+* `itsdangerous `_
+* `passlib `_
+* `PyQRCode `_
Additionally, it assumes you'll be using a common library for your database
connections and model definitions. Flask-Security supports the following Flask
diff -Nru flask-security-3.4.2/docs/models.rst flask-security-4.0.0/docs/models.rst
--- flask-security-3.4.2/docs/models.rst 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/docs/models.rst 2021-01-26 02:39:51.000000000 +0000
@@ -19,12 +19,15 @@
changes require a schema migration (and perhaps a data migration). Applications
must specifically import the version they want (and handle any required migration).
-At the bare minimum
-your `User` and `Role` model should include the following fields:
+Your `User` model needs a Primary Key - Flask-Security doesn't actually reference
+this - so it can be any name or type your application needs. It should be used in the
+foreign relationship between `User` and `Role`.
+
+At the bare minimum your `User` and `Role` model should include the following fields:
**User**
-* ``id`` (primary key - integer, string, or uuid)
+* primary key
* ``email`` (for most features - unique, non-nullable)
* ``password`` (non-nullable)
* ``active`` (boolean, non-nullable)
@@ -33,7 +36,7 @@
**Role**
-* ``id`` (primary key - integer)
+* primary key
* ``name`` (unique, non-nullable)
* ``description`` (string)
@@ -95,6 +98,13 @@
* ``us_phone_number`` (string)
+Separate Identity Domains
+~~~~~~~~~~~~~~~~~~~~~~~~~
+If you want authentication tokens to not be invalidated when the user changes their
+password add the following to your `User` model:
+
+* ``fs_token_uniquifier`` (unique, non-nullable)
+
Permissions
^^^^^^^^^^^
If you want to protect endpoints with permissions, and assign permissions to roles
@@ -121,9 +131,8 @@
# Custom User Payload
def get_security_payload(self):
- return {
- 'id': self.id,
- 'name': self.name,
- 'email': self.email
- }
-
+ rv = super().get_security_payload()
+ # :meth:`User.calc_username`
+ rv["username"] = self.calc_username()
+ rv["confirmation_needed"] = self.confirmed_at is None
+ return rv
diff -Nru flask-security-3.4.2/docs/openapi.yaml flask-security-4.0.0/docs/openapi.yaml
--- flask-security-3.4.2/docs/openapi.yaml 1970-01-01 00:00:00.000000000 +0000
+++ flask-security-4.0.0/docs/openapi.yaml 2021-01-26 02:39:51.000000000 +0000
@@ -0,0 +1,1532 @@
+openapi: 3.0.0
+info:
+ description: |
+ Default API for Flask-Security.
+
+ __N.B. This is preliminary.__
+
+ Since Flask-Security is middleware, with many possible configurations this is a
+ guide to how the APIs will behave using standard defaults.
+
+ By default, all POST requests require a CSRF token. This is handled automatically
+ if you render the form from your Flask application. If you send JSON, then you must include a request header (configured via __SECURITY_CSRF_HEADER__).
+ Please read the online documentation to find out details on how CSRF can be configured.
+
+ You can download the latest spec from: https://github.com/Flask-Middleware/flask-security/blob/master/docs/openapi.yaml
+ version: 1.0.0
+ title: "Flask-Security External API"
+ contact:
+ name: Flask-Security-Too
+ url: https://github.com/Flask-Middleware/flask-security
+ license:
+ name: MIT
+ url: https://github.com/Flask-Middleware/flask-security/blob/master/LICENSE
+paths:
+ /login:
+ get:
+ summary: Retrieve login form and/or user information
+ responses:
+ 200:
+ description: >
+ Login form or user information. The JSON response will always
+ carry the csrf_token information. If the caller is logged in, then
+ additional information is returned. This can be very useful for single-page applications where during a force refresh, all state is lost.
+ By performing this GET, the session cookie will authenticate the user and the response will contain user information.
+ content:
+ text/html:
+ schema:
+ example: render_template(SECURITY_LOGIN_USER_TEMPLATE)
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonResponse"
+ 302:
+ description: Response when already logged in (non-JSON request)
+ headers:
+ Location:
+ description: Redirect to ``SECURITY_POST_LOGIN_VIEW``
+ schema:
+ type: string
+ format: uri
+ post:
+ summary: Login to application
+ description: Supports both json and form request types. If the caller is already logged in, then in the form case, they are redirected to SECURITY_POST_LOGIN_VIEW, for a json request, a 400 is returned.
+ parameters:
+ - name: next
+ in: query
+ description: >
+ URL to redirect to on successful login. Ignored for json request.
+ schema:
+ type: string
+ - $ref: "#/components/parameters/include_auth_token"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Login"
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: "#/components/schemas/Login"
+ responses:
+ 200:
+ description: Login response
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LoginJsonResponse"
+ text/html:
+ schema:
+ description: Unsuccessful login
+ type: string
+ example: render_template(SECURITY_LOGIN_USER_TEMPLATE) with error values
+ 302:
+ description: >
+ If the caller already authenticated, the form contents is ignored and a
+ redirect is done: redirect(next) or redirect(SECURITY_POST_LOGIN_VIEW).
+
+ If the caller is NOT already authenticated, and the form contents are
+ validated the caller will be redirected to:
+ redirect(next) or redirect(SECURITY_POST_LOGIN_VIEW)
+ headers:
+ Location:
+ description: Redirect to ``SECURITY_POST_LOGIN_VIEW``
+ schema:
+ type: string
+ 400:
+ description: Errors while validating login, or caller already authenticated/logged in.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonErrorResponse"
+ /login(passwordless):
+ get:
+ summary: Return passwordless login form
+ responses:
+ 200:
+ description: Passwordless login form
+ content:
+ text/html:
+ schema:
+ type: string
+ example: render_template(SECURITY_SEND_LOGIN_TEMPLATE)
+ post:
+ summary: Send passwordless login instructions email
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EmailLink"
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: "#/components/schemas/EmailLink"
+ responses:
+ 200:
+ description: >
+ Passwordless login response. For forms both success and validation errors.
+ content:
+ text/html:
+ schema:
+ description: Passwordless login form - with errors.
+ type: string
+ example: render_template(SECURITY_SEND_LOGIN_TEMPLATE)
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonResponse"
+ 400:
+ description: Errors while validating form
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonErrorResponse"
+ /login(passwordless)/{token}:
+ parameters:
+ - name: token
+ in: path
+ required: true
+ schema:
+ type: string
+ get:
+ summary: Login via token
+ description: >
+ This is the result of getting a passwordless login token and is usually
+ the result of clicking the link from a passwordless email.
+ This ALWAYS results in a 302 redirect.
+ responses:
+ 302:
+ description: >
+ Redirects depending on success/error and whether
+ __SECURITY_REDIRECT_BEHAVIOR__ == 'spa'.
+ headers:
+ Location:
+ description: |
+ On spa-success: SECURITY_POST_LOGIN_VIEW?identity={identity}&email={email}
+
+ On spa-error-expired: SECURITY_LOGIN_ERROR_VIEW?error={msg}&identity={identity}&email={email}
+
+ On spa-error-invalid-token: SECURITY_LOGIN_ERROR_VIEW?error={msg}
+
+ On form-success: SECURITY_POST_LOGIN_VIEW
+
+ On form-error-expired: SECURITY_LOGIN_VIEW
+
+ On form-error-invalid-token: SECURITY_LOGIN_VIEW
+ schema:
+ type: string
+ /logout:
+ get:
+ summary: Log out current user
+ responses:
+ 302:
+ description: Successful logout
+ headers:
+ Location:
+ description: Redirect to ``SECURITY_POST_LOGOUT_VIEW``
+ schema:
+ type: string
+ post:
+ summary: Log out current user
+ responses:
+ 200:
+ description: Successful logout
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [meta]
+ properties:
+ meta:
+ type: object
+ required: [code]
+ properties:
+ code:
+ type: integer
+ example: 200
+ description: Http status code
+ /register:
+ get:
+ summary: Return register form
+ responses:
+ 200:
+ description: Register form
+ content:
+ text/html:
+ schema:
+ type: string
+ example: render_template(SECURITY_REGISTER_USER_TEMPLATE)
+ 302:
+ description: Response when already logged in
+ headers:
+ Location:
+ description: Redirect to ``SECURITY_POST_LOGIN_VIEW``
+ schema:
+ type: string
+ post:
+ summary: Register new user with application
+ parameters:
+ - name: next
+ in: query
+ description: >
+ URL to redirect to on successful registration. Ignored for json request.
+ schema:
+ type: string
+
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Register"
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: "#/components/schemas/RegisterForm"
+ responses:
+ 200:
+ description: Register response
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonResponse"
+ text/html:
+ schema:
+ description: Unsuccessful registration
+ type: string
+ example: render_template(SECURITY_REGISTER_USER_TEMPLATE) with error values
+ 302:
+ description: >
+ Successful registration with form data body.
+ headers:
+ Location:
+ description: redirect to ``next`` or ``SECURITY_POST_REGISTER_VIEW``
+ schema:
+ type: string
+ 400:
+ description: Errors while validating registration form
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonErrorResponse"
+ /change:
+ get:
+ summary: Return change password form
+ responses:
+ 200:
+ description: change password form
+ content:
+ text/html:
+ schema:
+ example: render_template(SECURITY_CHANGE_PASSWORD_TEMPLATE)
+ post:
+ summary: Change password
+ parameters:
+ - name: X-XSRF-Token
+ in: header
+ schema:
+ $ref: "#/components/headers/X-CSRF-Token"
+ - $ref: "#/components/parameters/include_auth_token"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ChangePassword"
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: "#/components/schemas/ChangePassword"
+ responses:
+ 200:
+ description: Change password response.
+ content:
+ text/html:
+ schema:
+ description: Change form validation error.
+ type: string
+ example: render_template(SECURITY_CHANGE_PASSWORD_TEMPLATE) with error values
+ application/json:
+ schema:
+ $ref: "#/components/schemas/JsonResponseWithToken"
+ 302:
+ description: Password has been changed (non-json)
+ headers:
+ Location:
+ description: |
+ On success: Redirect to ``SECURITY_POST_CHANGE_VIEW`` or
+ ``SECURITY_POST_LOGIN_VIEW``
+ schema:
+ type: string
+ 400:
+ description: Errors while validating form
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonErrorResponse"
+ /reset:
+ get:
+ summary: Return reset password form
+ responses:
+ 200:
+ description: Reset password form
+ content:
+ text/html:
+ schema:
+ type: string
+ example: render_template(SECURITY_FORGOT_PASSWORD_TEMPLATE)
+ 302:
+ description: Response when already logged in
+ headers:
+ Location:
+ description: Redirect to ``SECURITY_POST_LOGIN_VIEW``
+ schema:
+ type: string
+ post:
+ summary: Send reset password instructions email
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EmailLink"
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: "#/components/schemas/EmailLink"
+ responses:
+ 200:
+ description: >
+ Reset password response. For forms both success and validation errors.
+ content:
+ text/html:
+ schema:
+ description: Forgot password form - with errors.
+ type: string
+ example: render_template(SECURITY_FORGOT_PASSWORD_TEMPLATE)
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonResponseNoUser"
+ 400:
+ description: Errors while validating form
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonErrorResponse"
+ /reset/{token}:
+ parameters:
+ - name: token
+ in: path
+ required: true
+ schema:
+ type: string
+ get:
+ summary: Request to reset password
+ description: >
+ This is the result of getting a reset-password token and is usually
+ the result of clicking the link from a reset-password email.
+ If __SECURITY_REDIRECT_BEHAVIOR__ == 'spa' then a 302 is always returned.
+ responses:
+ 200:
+ description: Reset password form
+ content:
+ text/html:
+ schema:
+ type: string
+ example: render_template(SECURITY_RESET_PASSWORD_TEMPLATE)
+ 302:
+ description: >
+ Redirects depending on success/error and whether
+ __SECURITY_REDIRECT_BEHAVIOR__ == 'spa'.
+ headers:
+ Location:
+ description: |
+ On spa-success: SECURITY_RESET_VIEW?token={token}&identity={identity}&email={email}
+
+ On spa-error-expired: SECURITY_RESET_ERROR_VIEW?error={msg}&identity={identity}&email={email}
+
+ On spa-error-invalid-token: SECURITY_RESET_ERROR_VIEW?error={msg}
+
+ On default-error: redirect(SECURITY_FORGOT_PASSWORD)
+ schema:
+ type: string
+ post:
+ summary: Reset password
+ parameters:
+ - $ref: "#/components/parameters/include_auth_token"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ResetPassword"
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: "#/components/schemas/ResetPassword"
+ responses:
+ 200:
+ description: Reset response
+ content:
+ text/html:
+ schema:
+ description: Reset form validation error.
+ type: string
+ example: render_template(SECURITY_RESET_PASSWORD_TEMPLATE) with error values
+ application/json:
+ schema:
+ $ref: "#/components/schemas/JsonResponseWithToken"
+ 302:
+ description: Password has been reset or validation error (non-json)
+ headers:
+ Location:
+ description: |
+ On success: redirect(SECURITY_POST_RESET_VIEW) or
+ redirect(SECURITY_POST_LOGIN_VIEW)
+
+ On invalid/expired token: redirect(SECURITY_FORGOT_PASSWORD)
+ schema:
+ type: string
+ 400:
+ description: Errors while validating form
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonErrorResponse"
+ /confirm:
+ get:
+ summary: Return send confirmation form
+ responses:
+ 200:
+ description: Confirmation form
+ content:
+ text/html:
+ schema:
+ example: render_template(SECURITY_SEND_CONFIRMATION_TEMPLATE)
+ post:
+ summary: Send confirmation email
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EmailLink"
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: "#/components/schemas/EmailLink"
+ responses:
+ 200:
+ description: >
+ Confirmation response. For forms both success and validation errors.
+ content:
+ text/html:
+ schema:
+ description: Confirmation form - with errors.
+ type: string
+ example: render_template(SECURITY_SEND_CONFIRMATION_TEMPLATE)
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonResponse"
+ 400:
+ description: Errors while validating form
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonErrorResponse"
+ /confirm/{token}:
+ parameters:
+ - name: token
+ in: path
+ required: true
+ schema:
+ type: string
+ get:
+ summary: Request to confirm account
+ description: >
+ This is the result of getting a confirmation token and is usually
+ the result of clicking the link from a confirmation email.
+ This ALWAYS results in a 302 redirect.
+ By default (unless __SECURITY_AUTO_LOGIN_AFTER_CONFIRM__ == False), the user
+ denoted by the token is logged in as a side-effect.
+ responses:
+ 302:
+ description: >
+ Redirects depending on success/error and whether
+ __SECURITY_REDIRECT_BEHAVIOR__ == 'spa'.
+ headers:
+ Location:
+ description: |
+ On spa-success: SECURITY_POST_CONFIRM_VIEW?identity={identity}&email={email}&{level}={msg}
+
+ On spa-error-expired: SECURITY_CONFIRM_ERROR_VIEW?error={msg}&identity={identity}&email={email}
+
+ On spa-error-invalid-token: SECURITY_CONFIRM_ERROR_VIEW?error={msg}
+
+ On form-success: SECURITY_POST_CONFIRM_VIEW or
+ SECURITY_POST_LOGIN_VIEW
+
+ On form-success (no auto-login): SECURITY_POST_CONFIRM_VIEW or
+ SECURITY_LOGIN_URL
+
+ On form-error-expired: SECURITY_CONFIRM_ERROR_VIEW or
+ SECURITY_CONFIRM_URL
+
+ On form-error-invalid-token: SECURITY_CONFIRM_ERROR_VIEW or
+ SECURITY_CONFIRM_URL
+ schema:
+ type: string
+ /us-signin:
+ get:
+ summary: Unified Sign In.
+ responses:
+ 200:
+ description: Sign in form
+ content:
+ text/html:
+ schema:
+ example: render_template(SECURITY_US_SIGNIN_TEMPLATE)
+ application/json:
+ schema:
+ type: object
+ properties:
+ available_methods:
+ type: string
+ description: Config setting SECURITY_US_ENABLED_METHODS
+ code_methods:
+ type: string
+ description: All SECURITY_US_ENABLED_METHODS that require a code to be generated and sent.
+ identity_attributes:
+ type: string
+ description: Configuration setting SECURITY_USER_IDENTITY_ATTRIBUTES
+ post:
+ summary: Unified Sign In.
+ parameters:
+ - $ref: "#/components/parameters/include_auth_token"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UsSignin"
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: "#/components/schemas/UsSignin"
+ responses:
+ 200:
+ description: Unified Sign In response
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UsSigninJsonResponse"
+ text/html:
+ schema:
+ description: Unsuccessful sign in
+ type: string
+ example: render_template(SECURITY_US_SIGNIN_TEMPLATE) with error values
+ 302:
+ description: >
+ If the caller already authenticated, the form contents is ignored and a
+ redirect is done: redirect(next) or redirect(SECURITY_POST_LOGIN_VIEW).
+
+ If the caller is NOT already authenticated, and the form contents are
+ validated the caller will be redirected to:
+ redirect(next) or redirect(SECURITY_POST_LOGIN_VIEW)
+ headers:
+ Location:
+ description: Redirect to ``SECURITY_POST_LOGIN_VIEW``
+ schema:
+ type: string
+ 400:
+ description: Errors while validating attributes, or caller already authenticated/logged in.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonErrorResponse"
+ /us-signin/send-code:
+ get:
+ summary: Unified Sign In send authentication code
+ responses:
+ 200:
+ description: Send Code form
+ content:
+ text/html:
+ schema:
+ example: render_template(SECURITY_US_SIGNIN_TEMPLATE)
+ application/json:
+ schema:
+ type: object
+ properties:
+ methods:
+ type: string
+ description: Config setting SECURITY_US_ENABLED_METHODS
+ code_methods:
+ type: string
+ description: All SECURITY_US_ENABLED_METHODS that require a code to be generated and sent.
+ identity_attributes:
+ type: string
+ description: Configuration setting SECURITY_USER_IDENTITY_ATTRIBUTES
+ post:
+ summary: Send Code for unified sign in.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UsSigninSendCode"
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: "#/components/schemas/UsSigninSendCode"
+ responses:
+ 200:
+ description: Send code response
+ content:
+ application/json:
+ schema:
+ description: Code successfully sent
+ text/html:
+ schema:
+ description: Validation error, code send error, or code successfully sent
+ type: string
+ example: render_template(SECURITY_US_SIGNIN_TEMPLATE) with error values
+ 400:
+ description: Errors while validating attributes.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonErrorResponse"
+ 500:
+ description: Error when trying to send code.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonErrorResponse"
+
+ /us-verify:
+ get:
+ summary: Unified sign in re-authentication.
+ description: >
+ If an endpoint is protected with @auth_required() with a freshness declaration
+ this endpoint will be called to request an already signed in user to re-authenticate.
+ responses:
+ 200:
+ description: Verify/re-authenticate form
+ content:
+ text/html:
+ schema:
+ example: render_template(SECURITY_US_VERIFY_TEMPLATE)
+ application/json:
+ schema:
+ type: object
+ properties:
+ available_methods:
+ type: string
+ description: Config setting SECURITY_US_ENABLED_METHODS
+ code_methods:
+ type: string
+ description: All SECURITY_US_ENABLED_METHODS that require a code to be generated and sent.
+ post:
+ summary: Unified sign in re-authentication
+ parameters:
+ - $ref: "#/components/parameters/include_auth_token"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UsSigninVerify"
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: "#/components/schemas/UsSigninVerify"
+ responses:
+ 200:
+ description: Verify/re-authenticate response.
+ content:
+ application/json:
+ schema:
+ allOf:
+ - description: >
+ The user successfully re-authenticated.
+ - $ref: "#/components/schemas/JsonResponseWithToken"
+ text/html:
+ schema:
+ description: Unsuccessful re-authentication.
+ type: string
+ example: render_template(SECURITY_US_VERIFY_TEMPLATE) with error values
+ 302:
+ description: User successfully re-authenticated when using form based request.
+ headers:
+ Location:
+ description: Redirect to ``next`` or ``SECURITY_POST_VERIFY_VIEW``
+ schema:
+ type: string
+ 400:
+ description: Errors while validating attributes.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonErrorResponse"
+ /us-verify/send-code:
+ get:
+ summary: Unified sign in verify/re-authenticate send authentication code
+ responses:
+ 200:
+ description: Send Code form
+ content:
+ text/html:
+ schema:
+ example: render_template(SECURITY_US_VERIFY_TEMPLATE)
+ application/json:
+ schema:
+ type: object
+ properties:
+ methods:
+ type: string
+ description: Config setting SECURITY_US_ENABLED_METHODS
+ code_methods:
+ type: string
+ description: All SECURITY_US_ENABLED_METHODS that require a code to be generated and sent.
+ post:
+ summary: Send Code for unified sign in verify.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UsSigninVerifySendCode"
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: "#/components/schemas/UsSigninVerifySendCode"
+ responses:
+ 200:
+ description: Send code response
+ content:
+ application/json:
+ schema:
+ description: Code successfully sent
+ text/html:
+ schema:
+ description: Validation error, code send error, or code successfully sent
+ type: string
+ example: render_template(SECURITY_US_VERIFY_TEMPLATE) with error values
+ 400:
+ description: Errors while validating attributes.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonErrorResponse"
+ 500:
+ description: Error when trying to send code.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonErrorResponse"
+ /us-setup:
+ get:
+ summary: Unified sign in setup passcode options.
+ responses:
+ 200:
+ description: Setup form
+ content:
+ text/html:
+ schema:
+ example: render_template(SECURITY_US_SETUP_TEMPLATE)
+ application/json:
+ schema:
+ type: object
+ properties:
+ available_methods:
+ type: string
+ description: Config setting SECURITY_US_ENABLED_METHODS
+ active_methods:
+ type: string
+ description: Methods that have already been setup.
+ setup_methods:
+ type: string
+ description: All SECURITY_US_ENABLED_METHODS that require setup.
+ identity_attributes:
+ type: string
+ description: Configuration setting SECURITY_USER_IDENTITY_ATTRIBUTES
+ phone:
+ type: string
+ description: existing configured phone number
+ post:
+ summary: Unified sign in setup.
+ description: >
+ An authenticated user can call this endpoint to update or add additional methods for authenticating (e.g. sms, authenticator app). This is controlled by application configuration settings SECURITY_US_ENABLED_METHODS. This endpoint is protected by a 'freshness' check - meaning the caller will be required to have authenticated recently. In addition, to ensure correctness, the newly setup method must be verified by sending and entering a code prior to it being permanently stored. This verification process is also time-limited.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UsSetup"
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: "#/components/schemas/UsSetup"
+ responses:
+ 200:
+ description: Unified sign in setup response.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UsSetupJsonResponse"
+ text/html:
+ schema:
+ description: Invalid form values or verification code sent successfully and should be entered into the form.
+ type: string
+ example: render_template(SECURITY_US_SETUP_TEMPLATE) with error values
+ 400:
+ description: Errors while validating attributes.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonErrorResponse"
+ 500:
+ description: Error when trying to send code.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonErrorResponse"
+ /us-setup/{token}:
+ parameters:
+ - name: token
+ in: path
+ required: true
+ schema:
+ type: string
+ get:
+ summary: Validate unified sign in setup request.
+ description: >
+ This does nothing but redirect back to the setup form.
+ responses:
+ 200:
+ description: Get form.
+ content:
+ text/html:
+ schema:
+ example: render_template(SECURITY_US_SETUP_TEMPLATE)
+
+ post:
+ summary: Validate passcode sent and store setup method.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UsSetupValidateRequest"
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: "#/components/schemas/UsSetupValidateRequest"
+ responses:
+ 200:
+ description: Successfully validated and persisted sign in method.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UsSetupValidateJsonResponse"
+ 302:
+ description: Successfuly validated and persisted sign in method.
+ headers:
+ Location:
+ description: |
+ On form-success: SECURITY_POST_SETUP_VIEW or
+ SECURITY_POST_LOGIN_VIEW
+ schema:
+ type: string
+ 400:
+ description: Validation failed.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonErrorResponse"
+ /us-verify-link:
+ parameters:
+ - name: email
+ in: query
+ required: true
+ schema:
+ type: string
+ - name: code
+ in: query
+ required: true
+ schema:
+ type: string
+ get:
+ summary: A magic link to authenticate (instead of manually entering a code).
+ description: >
+ This is the result of getting a passcode link and is usually
+ the result of clicking the link from an email.
+ This ALWAYS results in a 302 redirect.
+ N.B. Magic link with 2FA enabled does not work and the SPA will get a redirect to the login error page with tf_required. Must use code option instead.
+ responses:
+ 302:
+ description: >
+ Redirects depending on success/error and whether
+ __SECURITY_REDIRECT_BEHAVIOR__ == 'spa'. Also, if Two-Factor authentication has been enabled, further authentication/redirects might be required.
+ headers:
+ Location:
+ description: |
+ On spa-success: SECURITY_POST_LOGIN_VIEW?identity={identity}&email={email}
+
+ On spa-error-expired: SECURITY_LOGIN_ERROR_VIEW?error={msg}
+
+ On spa-error-invalid-token: SECURITY_LOGIN_ERROR_VIEW?error={msg}
+
+ On spa-two-factor-required: SECURITY_LOGIN_ERROR_VIEW?tf_required=1
+
+ On form-success: SECURITY_POST_LOGIN_VIEW
+
+ On form-error-expired: SECURITY_US_SIGNIN_URL
+
+ On form-error-invalid-token: SECURITY_US_SIGNIN_URL
+
+ On form-success and two-factor: SECURITY_TWO_FACTOR_TOKEN_VALIDATION_URL or SECURITY_TWO_FACTOR_SETUP_URL
+ schema:
+ type: string
+
+ /tf-setup:
+ get:
+ summary: Two-factor authentication setup.
+ responses:
+ 200:
+ description: Setup form
+ content:
+ text/html:
+ schema:
+ example: render_template(SECURITY_TWO_FACTOR_SETUP_TEMPLATE)
+ application/json:
+ schema:
+ type: object
+ properties:
+ tf_required:
+ type: string
+ description: Config setting SECURITY_TWO_FACTOR_REQUIRED.
+ tf_primary_method:
+ type: string
+ description: Current (if any) setup method.
+ tf_available_methods:
+ type: string
+ description: Config setting SECURITY_TWO_FACTOR_ENABLED_METHODS. If SECURITY_TWO_FACTOR_REQUIRED is false then 'disable' will be part of the set.
+ tf_phone_number:
+ type: string
+ description: Currently configured (if any) phone number.
+ post:
+ summary: Two factor setup.
+ description: >
+ Two-factor setup can be used in three cases:
+
+ 1) Initial login and application requires 2FA
+
+ 2) An authenticated user wishing to change their 2FA configuration
+
+ 3) An authenticated user wishes to enable or disable 2FA (assuming SECURITY_TWO_FACTOR_REQUIRED is False).
+
+
+ Allowed 2FA methods are controlled via the configuration SECURITY_TWO_FACTOR_ENABLED_METHODS.
+
+
+ This endpoint is protected by a 'freshness' check - meaning the caller will be required to have authenticated recently. In addition, to ensure correctness, the newly setup method must be verified by sending and entering a code prior to it being permanently stored.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TfSetup"
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: "#/components/schemas/TfSetup"
+ responses:
+ 200:
+ description: >
+ Two factor setup response. Please note that the newly setup method must be validated PRIOR to it being stored permanently.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TfSetupJsonResponse"
+ text/html:
+ schema:
+ description: Invalid form values or verification code sent successfully and should be entered into the form.
+ type: string
+ example: render_template(SECURITY_TWO_FACTOR_SETUP_TEMPLATE) with error values
+ 400:
+ description: Errors while validating attributes.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonErrorResponse"
+ 500:
+ description: Error when trying to send code.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonErrorResponse"
+ /tf-validate:
+ get:
+ summary: Retrieve form based on current two-factor state.
+ responses:
+ 200:
+ description: Code validation
+ content:
+ text/html:
+ schema:
+ description: >
+ If this is a normal, already setup method, then render_template(SECURITY_TWO_FACTOR_VERIFY_CODE_TEMPLATE) is returned;
+ if this is validating a new method then render_template(SECURITY_TWO_FACTOR_SETUP_TEMPLATE) is returned.
+ type: string
+ post:
+ summary: Send two-factor code.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ code:
+ description: The code sent via the configured method (e.g. SMS, email, authenticator).
+ type: string
+ example: 12345
+ application/x-www-form-urlencoded:
+ schema:
+ type: object
+ properties:
+ code:
+ description: The code sent via the configured method (e.g. SMS, email, authenticator).
+ type: string
+ example: 12345
+ responses:
+ 200:
+ description: Two factor code validation response.
+ content:
+ application/json:
+ schema:
+ allOf:
+ - description: >
+ The code was correct, the caller is now signed in.
+ - $ref: "#/components/schemas/TfValidateJsonResponse"
+ text/html:
+ schema:
+ description:
+ Unsuccessfully processed code. As above, which form is
+ rendered depends on the state of the user's two factor configuration.
+ type: string
+ 302:
+ description: User successfully sent code when using form based request. The caller is not logged in.
+ headers:
+ Location:
+ description: Redirect to either ``next`` or ``SECURITY_POST_LOGIN_VIEW``
+ schema:
+ type: string
+ 400:
+ description: Errors while validating attributes.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonErrorResponse"
+
+ /tf-rescue:
+ get:
+ summary: Help user that has lost authenticator or SMS device.
+ responses:
+ 200:
+ description: Return form.
+ content:
+ text/html:
+ schema:
+ description: >
+ render_template(SECURITY_TWO_FACTOR_VERIFY_CODE_TEMPLATE).
+ type: string
+ post:
+ summary: Request help.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ help-setup:
+ description: Either 'lost_device' or 'no_mail_access'.
+ type: string
+ example: "lost_device"
+ application/x-www-form-urlencoded:
+ schema:
+ type: object
+ properties:
+ help_setup:
+ description: Either 'lost_device' or 'no_mail_access'.
+ type: string
+ example: "lost_device"
+ responses:
+ 200:
+ description: >
+ If 'lost_device' was specified, then an authentication code was sent to the email
+ on record for the user. If 'no_mail_access' then an email was sent to administrator address
+ specified by SECURITY_TWO_FACTOR_RESCUE_MAIL.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonResponseNoUser"
+ text/html:
+ schema:
+ description: Invalid form values or verification code sent successfully and should be entered into the form.
+ type: string
+ example: render_template(SECURITY_TWO_FACTOR_VERIFY_CODE_TEMPLATE) with error values
+ 400:
+ description: Failed to send code
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DefaultJsonErrorResponse"
+
+components:
+ schemas:
+ Login:
+ type: object
+ required: [email, password]
+ properties:
+ email:
+ type: string
+ description: |
+ user identifier. This is by default an email address, but can be any (unique)
+ field that is part of the User model and is defined in the __SECURITY_USER_IDENTITY_ATTRIBUTES__ configuration variable. It will also match against numeric User model fields.
+ password:
+ type: string
+ description: Password
+ remember_me:
+ type: boolean
+ description: >
+ If true, will remember userid as part of cookie. There is a configuration variable DEFAULT_REMEMBER_ME that can be set. This field will override that.
+ tf_validity_token:
+ type: string
+ description: Code verifying the user has successfully verfied 2FA in the past. If verified, the user is able to skip validation of the second factor. Only used when SECURITY_TWO_FACTOR_ALWAYS_VALIDATE is False.
+ LoginJsonResponse:
+ type: object
+ description: >
+ The user successfully signed in. Note that depending on SECURITY_TWO_FACTOR configuration variables, a second form of authentication might be required.
+ Note that if 2FA is not configured, none of the ``tf_`` properties will be returned.
+ required: [meta, response]
+ properties:
+ meta:
+ type: object
+ required: [code]
+ properties:
+ code:
+ type: integer
+ example: 200
+ description: Http status code
+ response:
+ type: object
+ properties:
+ authentication_token:
+ type: string
+ description: >
+ Token to be used in future token-based API calls. Only returned if "include_auth_token" parameter is set.
+ tf_required:
+ type: boolean
+ description: If two-factor authentication is required for caller.
+ tf_state:
+ type: string
+ description: if "setup_from_login" then the caller must go through two-factor setup endpoint. If "ready" then a code has been sent and should be supplied to SECURITY_TWO_FACTOR_TOKEN_VALIDATION_URL.
+ tf_primary_method:
+ type: string
+ description: Which method was used to send code.
+ DefaultJsonResponse:
+ type: object
+ properties:
+ user:
+ type: object
+ description: >
+ By default an empty dictionary is returned. However by overriding _User::get_security_payload()_ any attributes of the User model can be returned.
+ csrf_token:
+ type: string
+ description: Session CSRF token
+ DefaultJsonResponseNoUser:
+ type: object
+ properties:
+ csrf_token:
+ type: string
+ description: Session CSRF token
+ JsonResponseWithToken:
+ type: object
+ properties:
+ user:
+ type: object
+ description: >
+ By default an empty dictionary is returned. However by overriding _User::get_security_payload()_ any attributes of the User model can be returned.
+ properties:
+ authentication_token:
+ type: string
+ 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
+ DefaultJsonErrorResponse:
+ type: object
+ required: [meta, response]
+ properties:
+ meta:
+ type: object
+ required: [code]
+ properties:
+ code:
+ type: integer
+ example: 400
+ description: Http status code
+ response:
+ type: object
+ description: >
+ For form validation errors, the 'errors' key will be set with a list of errors per
+ invalid form input field. For non-form related errors, the 'error' key will be set
+ with a single (localized) error string.
+ properties:
+ errors:
+ type: object
+ description: >
+ Errors per input/form field ('email' below is just an example)
+ properties:
+ email:
+ type: array
+ items:
+ type: string
+ example: Email issues.
+ description: Error message (localized)
+ error:
+ type: string
+ example: "Unauthenticated"
+ description: Error message (localized)
+ Register:
+ type: object
+ required: [email, password]
+ properties:
+ email:
+ type: string
+ description: >
+ user identifier. This is by default an email address, but can be any (unique)
+ field that is part of the User model and is defined in the __SECURITY_USER_IDENTITY_ATTRIBUTES__ configuration variable. It will also match against numeric User model fields.
+ password:
+ type: string
+ description: Password
+ RegisterForm:
+ type: object
+ required: [email, password]
+ properties:
+ email:
+ type: string
+ description: >
+ user identifier. This is by default an email address, but can be any (unique)
+ field that is part of the User model and is defined in the __SECURITY_USER_IDENTITY_ATTRIBUTES__ configuration variable. It will also match against numeric User model fields.
+ password:
+ type: string
+ description: Password
+ password_confirm:
+ type: string
+ description: >
+ If present, must re-type in password. This will not be present if the __SECURITY_CONFIRM__ configuration is true.
+ next:
+ type: string
+ description: >
+ Redirect URL. Overrides __SECURITY_POST_REGISTER_VIEW__.
+ ResetPassword:
+ type: object
+ required: [password, password_confirm]
+ properties:
+ password:
+ type: string
+ description: Password
+ password_confirm:
+ type: string
+ description: Password - again
+ ChangePassword:
+ type: object
+ required: [password, new_password, new_password_confirm]
+ properties:
+ password:
+ type: string
+ description: Password
+ new_password:
+ type: string
+ description: New password
+ new_password_confirm:
+ type: string
+ description: New password - again
+ EmailLink:
+ type: object
+ required: [email]
+ properties:
+ email:
+ type: string
+ description: >
+ Email address to send link email to.
+ UsSignin:
+ type: object
+ required: [identity, passcode]
+ properties:
+ identity:
+ type: string
+ description: Configured by SECURITY_USER_IDENTITY_ATTRIBUTES
+ example: me@you.com, +16505551212
+ passcode:
+ type: string
+ description: password or code
+ remember_me:
+ type: boolean
+ tf_validity_token:
+ type: string
+ description: Code verifying the user has successfully verfied 2FA in the past. If verified, the user is able to skip validation of the second factor. Only used when SECURITY_TWO_FACTOR_ALWAYS_VALIDATE is False.
+ UsSigninJsonResponse:
+ type: object
+ description: >
+ The user successfully signed in. Note that depending on SECURITY_TWO_FACTOR and SECURITY_US_MFA_REQUIRED configuration variables, a second form of authentication might be required.
+ required: [meta, response]
+ properties:
+ meta:
+ type: object
+ required: [code]
+ properties:
+ code:
+ type: integer
+ example: 200
+ description: Http status code
+ response:
+ type: object
+ properties:
+ authentication_token:
+ type: string
+ description: >
+ Token to be used in future token-based API calls. Only returned if "include_auth_token" parameter is set.
+ tf_required:
+ type: boolean
+ description: If two-factor authentication is required for caller.
+ tf_state:
+ type: string
+ description: if "setup_from_login" then the caller must go through two-factor setup endpoint. If "ready" then a code has been sent and should be supplied to SECURITY_TWO_FACTOR_TOKEN_VALIDATION_URL.
+ tf_primary_method:
+ type: string
+ description: Which method was used to send code.
+ UsSigninSendCode:
+ type: object
+ required: [identity, chosen_method]
+ properties:
+ identity:
+ type: string
+ description: Configured by SECURITY_USER_IDENTITY_ATTRIBUTES
+ example: me@you.com, +16505551212
+ chosen_method:
+ type: string
+ description: which method should be used to send the code, as configured with SECURITY_US_ENABLED_METHODS
+ UsSigninVerify:
+ type: object
+ required: [passcode]
+ properties:
+ passcode:
+ type: string
+ description: password or code
+ UsSigninVerifySendCode:
+ type: object
+ required: [chosen_method]
+ properties:
+ chosen_method:
+ type: string
+ description: which method should be used to send the code, as configured with SECURITY_US_ENABLED_METHODS
+ UsSetup:
+ type: object
+ required: [chosen_method]
+ properties:
+ chosen_method:
+ type: string
+ description: which method should be used to send the code, as configured with SECURITY_US_ENABLED_METHODS
+ phone:
+ type: string
+ description: phone number (this will be normalized). Required if chosen_method == "sms".
+ UsSetupJsonResponse:
+ type: object
+ required: [meta, response]
+ properties:
+ meta:
+ type: object
+ required: [code]
+ properties:
+ code:
+ type: integer
+ example: 200
+ description: Http status code
+ response:
+ type: object
+ properties:
+ chosen_method:
+ type: string
+ description: The chosen_method as passed into API.
+ phone:
+ type: string
+ description: The canonicalized phone number if setting up SMS
+ authr_key:
+ type: string
+ description: TOTP key for setting up authenticator (if chosen_method == 'authenticator')
+ authr_issuer:
+ type: string
+ description: Issuer as configured with TOTP_ISSUER (same as used in QRcode) (if chosen_method == 'authenticator')
+ authr_username:
+ type: string
+ description: Username (same as used in QRcode) (if chosen_method == 'authenticator')
+ state:
+ type: string
+ description: Opaque blob that must be pass to /us-setup/. This is a signed, timed token.
+ UsSetupValidateRequest:
+ type: object
+ required: [passcode]
+ properties:
+ passcode:
+ type: string
+ description: Code/Passcode as received from method being setup.
+ UsSetupValidateJsonResponse:
+ type: object
+ required: [meta, response]
+ properties:
+ meta:
+ type: object
+ required: [code]
+ properties:
+ code:
+ type: integer
+ example: 200
+ description: Http status code
+ response:
+ type: object
+ properties:
+ chosen_method:
+ type: string
+ description: The chosen_method as passed into API.
+ phone:
+ type: string
+ description: Phone number if set.
+ TfSetup:
+ type: object
+ required: [setup]
+ properties:
+ setup:
+ type: string
+ description: >
+ Which method should be used to send the code, as configured with SECURITY_TWO_FACTOR_ENABLED_METHODS.
+ If SECURITY_TWO_FACTOR_REQUIRED is False, the additional method 'disable' is available.
+ example: sms
+ phone:
+ type: string
+ description: phone number (this will be validated for format). Required if setup == "sms".
+ example: 650-555-1212
+ TfSetupJsonResponse:
+ type: object
+ required: [meta, response]
+ properties:
+ meta:
+ type: object
+ required: [code]
+ properties:
+ code:
+ type: integer
+ example: 200
+ description: Http status code
+ response:
+ type: object
+ properties:
+ tf_state:
+ type: string
+ description: >
+ Current state of Two Factor configuration. Not present when disabling 2FA. This will be set to 'validating_profile'
+ indicating the caller needs to call '/tf-validate' with the correct code.
+ example: validating_profile
+ tf_primary_method:
+ type: string
+ description: Current method being congfigured.
+ example: sms
+ tf_authr_key:
+ type: string
+ description: TOTP key for setting up authenticator (if tf_primary_method == 'authenticator')
+ tf_authr_issuer:
+ type: string
+ description: Issuer as configured with TOTP_ISSUER (same as used in QRcode) (if tf_primary_method == 'authenticator')
+ tf_authr_username:
+ type: string
+ description: Username (same as used in QRcode) (if tf_primary_method == 'authenticator')
+ TfValidateJsonResponse:
+ type: object
+ properties:
+ user:
+ type: object
+ description: >
+ By default an empty dictionary is returned. However by overriding _User::get_security_payload()_ any attributes of the User model can be returned.
+ csrf_token:
+ type: string
+ description: Session CSRF token
+ tf_validity_token:
+ type: string
+ description: A timed token that verifies that the user has successfully completed 2FA. Only sent if SECURITY_TWO_FACTOR_ALWAYS_VALIDATE is False and remember_me (from /login POST) is True
+
+ 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 -Nru flask-security-3.4.2/docs/patterns.rst flask-security-4.0.0/docs/patterns.rst
--- flask-security-3.4.2/docs/patterns.rst 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/docs/patterns.rst 2021-01-26 02:39:51.000000000 +0000
@@ -57,11 +57,21 @@
raise MyForbiddenException(msg='You can only update docs you own')
+A note about Basic Auth
++++++++++++++++++++++++
+Basic Auth is supported in Flask-Security, using the @http_auth_required() decorator. If a request for an endpoint
+protected with @http_auth_required is received, and the request doesn't contain the appropriate HTTP Headers, a 401 is returned
+along with the required WWW-Authenticate header. In this case there won't be a usable session cookie returned so all future requests
+will also require credentials to be sent. Effectively the caller is temporarily 'logged in' at the beginning of each request and 'logged out' again
+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.
+This effectively bypasses any of the normal Flask-Security login forms. By default, the Flask-Security endpoints that require the caller be
+authenticated do NOT support ``basic`` - however the :py:data:`SECURITY_API_ENABLED_METHODS` can be used to override this.
+
Freshness
++++++++++
A common pattern for browser-based sites is to use sessions to manage identity. This is usually
implemented using session cookies. These cookies expire once the session (browser tab) is closed. This is very
-convenient, and keep the users from having to constantly re-authenticate. The downside is that sessions can easily be
+convenient, and keeps the users from having to constantly re-authenticate. The downside is that sessions can easily be
open for days or weeks. This adds to the security risk that some bad-actor or XSS gets control of the browser and then can
do anything the user can. To mitigate that, operations that change fundamental identity characteristics (such as email, password, etc.)
can be protected by requiring a 'fresh' or recent authentication. Flask-Security supports this with the following:
@@ -69,10 +79,10 @@
- :func:`.auth_required` takes parameters that define how recent the authentication must have happened. In addition a grace
period can be specified so that multiple step operations don't require re-authentication in the middle.
- A default :meth:`.Security.reauthn_handler` that is called when a request fails the recent authentication check.
- - :py:data:`SECURITY_VERIFY_URL` and :py:data:`SECURITY_US_VERIFY_URL` endpoints that request the user to re-authenticate
- - VerifyForm and UsVerifyForm forms that can be extended.
+ - :py:data:`SECURITY_VERIFY_URL` and :py:data:`SECURITY_US_VERIFY_URL` endpoints that request the user to re-authenticate.
+ - ``VerifyForm`` and ``UsVerifyForm`` forms that can be extended.
-Flask-Security itself uses this as part of securing the :ref:`unified-sign-in` setup endpoint.
+Flask-Security itself uses this as part of securing the :ref:`unified-sign-in` and :ref:`two-factor` setup endpoints.
.. _pass_validation_topic:
@@ -92,12 +102,19 @@
Be aware that ``zxcvbn`` is not actively being maintained, and has localization issues.
-The entire validator can be easily changed by supplying a :meth:`.Security.password_validator`.
-This enables application to e.g. use any piece of the UserModel (which is a parameter) as part of validation.
+In addition to validation, unicode passwords should be normalized as specified
+by NIST requirement: `5.1.1.2 Memorized Secret Verifiers`_. Normalization can
+be disabled by setting the :py:data:`SECURITY_PASSWORD_NORMALIZE_FORM` to ``None``.
+Validation and normalization is encapsulated in :class:`.PasswordUtil`.
+This can be overridden by passing your class at app initialization time.
+The :meth:`.PasswordUtil.validate` is passed additional kwargs to allow custom
+validators more flexibility.
A custom validator can still call the underlying methods where appropriate:
:func:`flask_security.password_length_validator`, :func:`flask_security.password_complexity_validator`,
and :func:`flask_security.password_breached_validator`.
+.. _5.1.1.2 Memorized Secret Verifiers: https://pages.nist.gov/800-63-3/sp800-63b.html#sec5
+
.. _csrftopic:
CSRF
diff -Nru flask-security-3.4.2/docs/quickstart.rst flask-security-4.0.0/docs/quickstart.rst
--- flask-security-3.4.2/docs/quickstart.rst 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/docs/quickstart.rst 2021-01-26 02:39:51.000000000 +0000
@@ -4,6 +4,12 @@
There are some complete (but simple) examples available in the *examples* directory of the
`Flask-Security repo`_.
+.. note::
+ The below quickstarts are just that - they don't enable most of the features (such as registration, reset, etc.).
+ They basically create a single user, and you can login as that user... that's it.
+ As you add more features, additional packages (e.g. Flask-Mail, Flask-Babel, pyqrcode) might be required
+ and will need to be added to your requirements.txt (or equivalent) file.
+
.. danger::
The examples below place secrets in source files. Never do this for your application
especially if your source code is placed in a public repo. How you pass in secrets
@@ -87,7 +93,8 @@
@app.before_first_request
def create_user():
db.create_all()
- user_datastore.create_user(email="test@me.com", password=hash_password("password"))
+ if not user_datastore.find_user(email="test@me.com"):
+ user_datastore.create_user(email="test@me.com", password=hash_password("password"))
db.session.commit()
# Views
@@ -120,7 +127,7 @@
The following code sample illustrates how to get started as quickly as
possible using `SQLAlchemy in a declarative way
-`_:
+`_:
We are gonna split the application at least in three files: app.py, database.py
and models.py. You can also do the models a folder and spread your tables there.
@@ -153,7 +160,8 @@
@app.before_first_request
def create_user():
init_db()
- user_datastore.create_user(email="test@me.com", password=hash_password("password"))
+ if not user_datastore.find_user(email="test@me.com"):
+ user_datastore.create_user(email="test@me.com", password=hash_password("password"))
db_session.commit()
# Views
@@ -171,8 +179,7 @@
from sqlalchemy.orm import scoped_session, sessionmaker
from sqlalchemy.ext.declarative import declarative_base
- engine = create_engine('sqlite:////tmp/test.db', \
- convert_unicode=True)
+ engine = create_engine('sqlite:////tmp/test.db')
db_session = scoped_session(sessionmaker(autocommit=False,
autoflush=False,
bind=engine))
@@ -211,15 +218,15 @@
__tablename__ = 'user'
id = Column(Integer, primary_key=True)
email = Column(String(255), unique=True)
- username = Column(String(255))
- password = Column(String(255))
+ username = Column(String(255), unique=True, nullable=True)
+ password = Column(String(255), nullable=False)
last_login_at = Column(DateTime())
current_login_at = Column(DateTime())
last_login_ip = Column(String(100))
current_login_ip = Column(String(100))
login_count = Column(Integer)
active = Column(Boolean())
- fs_uniquifier = Column(String(255))
+ fs_uniquifier = Column(String(255), unique=True, nullable=False)
confirmed_at = Column(DateTime())
roles = relationship('Role', secondary='roles_users',
backref=backref('users', lazy='dynamic'))
@@ -273,12 +280,13 @@
class Role(db.Document, RoleMixin):
name = db.StringField(max_length=80, unique=True)
description = db.StringField(max_length=255)
+ permissions = db.StringField(max_length=255)
class User(db.Document, UserMixin):
email = db.StringField(max_length=255)
password = db.StringField(max_length=255)
active = db.BooleanField(default=True)
- fs_uniquifier = db.StringField(max_length=255)
+ fs_uniquifier = db.StringField(max_length=64, unique=True)
confirmed_at = db.DateTimeField()
roles = db.ListField(db.ReferenceField(Role), default=[])
@@ -289,7 +297,8 @@
# Create a user to test with
@app.before_first_request
def create_user():
- user_datastore.create_user(email="admin@me.com", password=hash_password("password"))
+ if not user_datastore.find_user(email="test@me.com"):
+ user_datastore.create_user(email="test@me.com", password=hash_password("password"))
# Views
@app.route("/")
@@ -348,15 +357,18 @@
# Create database connection object
db = FlaskDB(app)
- class Role(db.Model, RoleMixin):
+ class Role(RoleMixin, db.Model):
name = CharField(unique=True)
description = TextField(null=True)
+ permissions = TextField(null=True)
- class User(db.Model, UserMixin):
+ # N.B. order is important since db.Model also contains a get_id() -
+ # we need the one from UserMixin.
+ class User(UserMixin, db.Model):
email = TextField()
password = TextField()
active = BooleanField(default=True)
- fs_uniquifier = TextField()
+ fs_uniquifier = TextField(null=False)
confirmed_at = DateTimeField(null=True)
class UserRoles(db.Model):
@@ -368,6 +380,9 @@
name = property(lambda self: self.role.name)
description = property(lambda self: self.role.description)
+ def get_permissions(self):
+ return self.role.get_permissions()
+
# Setup Flask-Security
user_datastore = PeeweeUserDatastore(db, User, Role, UserRoles)
security = Security(app, user_datastore)
@@ -378,7 +393,8 @@
for Model in (Role, User, UserRoles):
Model.drop_table(fail_silently=True)
Model.create_table(fail_silently=True)
- user_datastore.create_user(email="test@me.com", password=hash_password("password"))
+ if not user_datastore.find_user(email="test@me.com"):
+ user_datastore.create_user(email="test@me.com", password=hash_password("password"))
# Views
@app.route('/')
@@ -395,10 +411,10 @@
Mail Configuration
------------------
-Flask-Security integrates with Flask-Mail to handle all email
-communications between user and site, so it's important to configure
-Flask-Mail with your email server details so Flask-Security can talk
-with Flask-Mail correctly.
+Flask-Security integrates with an outgoing mail service via the ``mail_util_cls`` which
+is part of initial configuration. The default class :class:`flask_security.MailUtil` utilizes the
+`Flask-Mail `_ package. Be sure to add flask_mail to
+your requirements.txt.
The following code illustrates a basic setup, which could be added to
the basic application code in the previous section::
@@ -409,7 +425,8 @@
# After 'Create app'
app.config['MAIL_SERVER'] = 'smtp.example.com'
app.config['MAIL_PORT'] = 465
- app.config['MAIL_USE_SSL'] = True
+ app.config['MAIL_USE_SSL'] = False
+ app.config['MAIL_USE_TLS'] = True
app.config['MAIL_USERNAME'] = 'username'
app.config['MAIL_PASSWORD'] = 'password'
mail = Mail(app)
diff -Nru flask-security-3.4.2/docs/_static/openapi_view.html flask-security-4.0.0/docs/_static/openapi_view.html
--- flask-security-3.4.2/docs/_static/openapi_view.html 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/docs/_static/openapi_view.html 2021-01-26 02:39:51.000000000 +0000
@@ -10,6 +10,8 @@
allow-try="false"
allow-spec-url-load="false"
allow-spec-file-load="false"
+ show-components="true"
+ schema-description-expanded="true"
heading-text="Flask Security External API">
`_
-
Basic SQLAlchemy Two-Factor Application
+++++++++++++++++++++++++++++++++++++++
@@ -67,6 +65,9 @@
app.config['SECURITY_TWO_FACTOR'] = True
app.config['SECURITY_TWO_FACTOR_RESCUE_MAIL'] = 'put_your_mail@gmail.com'
+ app.config['SECURITY_TWO_FACTOR_ALWAYS_VALIDATE']=False
+ app.config['SECURITY_TWO_FACTOR_LOGIN_VALIDITY']='1 week'
+
# Generate a good totp secret using: passlib.totp.generate_secret()
app.config['SECURITY_TOTP_SECRETS'] = {"1": "TjQ9Qa31VOrfEzuPy4VHQWPCTmRzCnFzMKLxXYiZu9B"}
app.config['SECURITY_TOTP_ISSUER'] = 'put_your_app_name'
@@ -118,3 +119,58 @@
if __name__ == '__main__':
app.run()
+
+.. _2fa_theory_of_operation:
+
+Theory of Operation
++++++++++++++++++++++
+
+.. note::
+ The Two-factor feature requires that session cookies be received and sent as part of the API.
+ This is true regardless of if the application uses forms or JSON.
+
+The Two-factor (2FA) API has four paths:
+
+ - Normal login once everything set up
+ - Changing 2FA setup
+ - Initial login/registration when 2FA is required
+ - Rescue
+
+When using forms, the flow from one state to the next is handled by the forms themselves. When using JSON
+the application must of course explicitly access the appropriate endpoints. The descriptions below describe the JSON access pattern.
+
+Normal Login
+~~~~~~~~~~~~
+In the normal case, when the user has already setup their preferred 2FA method (e.g. email, SMS, authenticator app),
+then the flow starts with the authentication process using the ``/login`` or ``/us-signin`` endpoints, providing
+their identity and password. If 2FA is required, the response will indicate that. Then, the application must POST to the ``/tf-validate``
+with the correct code.
+
+Changing 2FA Setup
+~~~~~~~~~~~~~~~~~~~
+An authenticated user can change their 2FA configuration (primary_method, phone number, etc.). In order to prevent a user from being
+locked out, the new configuration must be validated before it is stored permanently. The user starts with a GET on ``/tf-setup``. This will return
+a list of configured 2FA methods the user can choose from, and the existing configuration. This must be followed with a POST on ``/tf-setup`` with the new primary
+method (and phone number if SMS). In the case of SMS, a code will be sent to the phone/device and again use ``/tf-validate`` to confirm code.
+In the case of setting up an authenticator app, the response to the POST will contain the QRcode image as well
+as the required information for manual entry.
+Once the code has been successfully
+entered, the new configuration will be permanently stored.
+
+Initial login/registration
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+This is basically a combination of the above two - initial POST to ``/login`` will return indicating that 2FA is required. The user must then POST to ``/tf_setup`` to setup
+the desired 2FA method, and finally have the user enter the code and POST to ``/tf-validate``.
+
+Rescue
+~~~~~~
+Life happens - if the user doesn't have their mobile devices (SMS) or authenticator app, then they can request using ``/tf-rescue`` endpoint to have the code sent to their email.
+If they have lost access to their email, they can request an email be sent to the application administrators.
+
+Validity
+~~~~~~~~
+Sometimes it can be preferrable to enter the 2FA code once a day/week/month, especially if a user logs in and out of a website multiple times. This allows the
+security of a two factor authentication but with a slightly better user experience. This can be achevied by setting ``SECURITY_TWO_FACTOR_ALWAYS_VALIDATE`` to ``False``,
+and clicking the 'Remember' button on the login form. Once the two factor code is validated, a cookie is set to allow skipping the validation step. The cookie is named
+``tf_validity`` and contains the signed token containing the user's ``fs_uniquifier``. The cookie and token are both set to expire after the time delta given in
+``SECURITY_TWO_FACTOR_LOGIN_VALIDITY``. Note that setting ``SECURITY_TWO_FACTOR_LOGIN_VALIDITY`` to 0 is equivalent to ``SECURITY_TWO_FACTOR_ALWAYS_VALIDATE`` being ``True``.
diff -Nru flask-security-3.4.2/flask_security/async_compat.py flask-security-4.0.0/flask_security/async_compat.py
--- flask-security-3.4.2/flask_security/async_compat.py 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/async_compat.py 1970-01-01 00:00:00.000000000 +0000
@@ -1,18 +0,0 @@
-"""
- Temporary workaround while we still support p2.7
-
- :copyright: (c) 2019 by J. Christopher Wagner (jwag).
- :license: MIT, see LICENSE for more details.
-"""
-
-from flask import current_app
-from werkzeug.local import LocalProxy
-
-_security = LocalProxy(lambda: current_app.extensions["security"])
-
-_datastore = LocalProxy(lambda: _security.datastore)
-
-
-async def _commit(response=None): # pragma: no cover
- _datastore.commit()
- return response
diff -Nru flask-security-3.4.2/flask_security/babel.py flask-security-4.0.0/flask_security/babel.py
--- flask-security-3.4.2/flask_security/babel.py 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/babel.py 2021-01-26 02:39:51.000000000 +0000
@@ -1,22 +1,100 @@
-# -*- coding: utf-8 -*-
"""
flask_security.babel
~~~~~~~~~~~~~~~~~~~~
I18N support for Flask-Security.
+
+ As of Flask-Babel 2.0.0 - it supports the Flask-BabelEx Domain extension - and it
+ is maintained. (Flask-BabelEx is no longer maintained). So we start with that,
+ then fall back to Flask-BabelEx, then fall back to a Null Domain
+ (just as Flask-Admin).
"""
-from flask_babelex import Domain
+# flake8: noqa: F811
+
from wtforms.i18n import messages_path
-wtforms_domain = Domain(messages_path(), domain="wtforms")
+from .utils import config_value as cv
+
+_domain_cls = None
+try:
+ from flask_babel import Domain
+
+ _domain_cls = Domain
+ _dir_keyword = "translation_directories"
+except ImportError: # pragma: no cover
+ try:
+ from flask_babelex import Domain
+
+ _domain_cls = Domain
+ _dir_keyword = "dirname"
+ except ImportError:
+ # Fake up just enough
+ class Domain:
+ @staticmethod
+ def gettext(string, **variables):
+ return string % variables
+
+ @staticmethod
+ def ngettext(singular, plural, num, **variables):
+ variables.setdefault("num", num)
+ return (singular if num == 1 else plural) % variables
+
+ @staticmethod
+ def lazy_gettext(string, **variables):
+ return Domain.gettext(string, **variables)
+
+ class Translations:
+ """ dummy Translations class for WTForms, no translation support """
+
+ def gettext(self, string):
+ return string
+
+ def ngettext(self, singular, plural, n):
+ return singular if n == 1 else plural
+
+ def get_i18n_domain(app):
+ return Domain()
+
+ def have_babel():
+ return False
+
+ def is_lazy_string(obj):
+ return False
+
+ def make_lazy_string(__func, msg):
+ return msg
+
+
+if _domain_cls:
+ # Have either Flask-Babel or Flask-BabelEx
+ from babel.support import LazyProxy
+
+ wtforms_domain = _domain_cls(messages_path(), domain="wtforms")
+
+ def get_i18n_domain(app):
+ kwargs = {
+ _dir_keyword: cv("I18N_DIRNAME", app=app),
+ "domain": cv("I18N_DOMAIN", app=app),
+ }
+ return _domain_cls(**kwargs)
+
+ def have_babel():
+ return True
+
+ def is_lazy_string(obj):
+ """Checks if the given object is a lazy string."""
+ return isinstance(obj, LazyProxy)
+ def make_lazy_string(__func, msg):
+ """Creates a lazy string by invoking func with args."""
+ return LazyProxy(__func, msg, enable_cache=False)
-class Translations(object):
- """Fixes WTForms translation support and uses wtforms translations."""
+ class Translations:
+ """Fixes WTForms translation support and uses wtforms translations."""
- def gettext(self, string):
- return wtforms_domain.gettext(string)
+ def gettext(self, string):
+ return wtforms_domain.gettext(string)
- def ngettext(self, singular, plural, n):
- return wtforms_domain.ngettext(singular, plural, n)
+ def ngettext(self, singular, plural, n):
+ return wtforms_domain.ngettext(singular, plural, n)
diff -Nru flask-security-3.4.2/flask_security/cache.py flask-security-4.0.0/flask_security/cache.py
--- flask-security-3.4.2/flask_security/cache.py 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/cache.py 1970-01-01 00:00:00.000000000 +0000
@@ -1,43 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
- flask_security.cache
- ~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
- Flask-Security token cache module
-
- :copyright: (c) 2019.
- :license: MIT, see LICENSE for more details.
-"""
-
-from .utils import config_value
-
-
-class VerifyHashCache:
- """Cache handler to make it quick password check by bypassing
- already checked passwords against exact same couple of token/password.
- This cache handler is more efficient on small apps that
- run on few processes as cache is only shared between threads."""
-
- def __init__(self):
- ttl = config_value("VERIFY_HASH_CACHE_TTL", default=(60 * 5))
- max_size = config_value("VERIFY_HASH_CACHE_MAX_SIZE", default=500)
-
- try:
- from cachetools import TTLCache
-
- self._cache = TTLCache(max_size, ttl)
- except ImportError:
- # this should have been checked at app init.
- raise
-
- def has_verify_hash_cache(self, user):
- """Check given user id is in cache."""
- return self._cache.get(user.id)
-
- def set_cache(self, user):
- """When a password is checked, then result is put in cache."""
- self._cache[user.id] = True
-
- def clear(self):
- """Clear cache"""
- self._cache.clear()
diff -Nru flask-security-3.4.2/flask_security/changeable.py flask-security-4.0.0/flask_security/changeable.py
--- flask-security-3.4.2/flask_security/changeable.py 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/changeable.py 2021-01-26 02:39:51.000000000 +0000
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""
flask_security.changeable
~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -6,15 +5,17 @@
Flask-Security recoverable module
:copyright: (c) 2012 by Matt Wright.
+ :copyright: (c) 2019-2020 by J. Christopher Wagner (jwag).
:author: Eskil Heyn Olsen
:license: MIT, see LICENSE for more details.
"""
-from flask import current_app
+from flask import current_app, request, session
+from flask_login import COOKIE_NAME as REMEMBER_COOKIE_NAME
from werkzeug.local import LocalProxy
from .signals import password_changed
-from .utils import config_value, hash_password
+from .utils import config_value, hash_password, login_user, send_mail
# Convenient references
_security = LocalProxy(lambda: current_app.extensions["security"])
@@ -29,7 +30,7 @@
"""
if config_value("SEND_PASSWORD_CHANGE_EMAIL"):
subject = config_value("EMAIL_SUBJECT_PASSWORD_CHANGE_NOTICE")
- _security._send_mail(subject, user.email, "change_notice", user=user)
+ send_mail(subject, user.email, "change_notice", user=user)
def change_user_password(user, password):
@@ -39,8 +40,17 @@
:param password: The unhashed new password
"""
user.password = hash_password(password)
- if config_value("BACKWARDS_COMPAT_AUTH_TOKEN_INVALID"):
- _datastore.set_uniquifier(user)
+ # Change uniquifier - this will cause ALL sessions to be invalidated.
+ _datastore.set_uniquifier(user)
_datastore.put(user)
+
+ # re-login user - this will update session, optional remember etc.
+ remember_cookie_name = current_app.config.get(
+ "REMEMBER_COOKIE_NAME", REMEMBER_COOKIE_NAME
+ )
+ has_remember_cookie = (
+ remember_cookie_name in request.cookies and session.get("remember") != "clear"
+ )
+ login_user(user, remember=has_remember_cookie, authn_via=["change"])
send_password_changed_notice(user)
password_changed.send(current_app._get_current_object(), user=user)
diff -Nru flask-security-3.4.2/flask_security/cli.py flask-security-4.0.0/flask_security/cli.py
--- flask-security-3.4.2/flask_security/cli.py 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/cli.py 2021-01-26 02:39:51.000000000 +0000
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""
flask_security.cli
~~~~~~~~~~~~~~~~~~
@@ -6,11 +5,10 @@
Command Line Interface for managing accounts and roles.
:copyright: (c) 2016 by CERN.
- :copyright: (c) 2019 by J. Christopher Wagner
+ :copyright: (c) 2019-2020 by J. Christopher Wagner
:license: MIT, see LICENSE for more details.
"""
-from __future__ import absolute_import, print_function
from functools import wraps
@@ -20,7 +18,12 @@
from werkzeug.local import LocalProxy
from .quart_compat import get_quart_status
-from .utils import hash_password
+from .utils import (
+ find_user,
+ get_identity_attributes,
+ get_identity_attribute,
+ hash_password,
+)
if get_quart_status(): # pragma: no cover
import quart.cli
@@ -65,7 +68,10 @@
@click.group()
def users():
- """User commands."""
+ """User commands.
+
+ For commands that require a USER - pass in any identity attribute.
+ """
@click.group()
@@ -73,22 +79,56 @@
"""Role commands."""
-@users.command("create")
-@click.argument("identity")
+@users.command(
+ "create",
+ help="Create a new user with one or more attributes using the syntax:"
+ " attr:value. If attr isn't set 'email' is presumed."
+ " Identity attribute values will be validated using the configured"
+ " confirm_register_form;"
+ " however, any ADDITIONAL attribute:value pairs will be sent to"
+ " datastore.create_user",
+)
+@click.argument(
+ "attributes",
+ nargs=-1,
+)
@click.password_option()
@click.option("-a", "--active", default=False, is_flag=True)
@with_appcontext
@commit
-def users_create(identity, password, active):
+def users_create(attributes, password, active):
"""Create a user."""
- kwargs = {attr: identity for attr in _security.user_identity_attributes}
+ kwargs = {}
+
+ identity_attributes = get_identity_attributes()
+ for attrarg in attributes:
+ # If given identity is an identity_attribute - do a bit of pre-validating
+ # to provide nicer errors.
+ attr = "email"
+ if ":" in attrarg:
+ attr, attrarg = attrarg.split(":")
+ if attr in identity_attributes:
+ details = get_identity_attribute(attr)
+ idata = details["mapper"](attrarg)
+ if not idata:
+ raise click.UsageError(
+ f"Attr {attr} with value {attrarg} wasn't accepted by mapper"
+ )
+
+ kwargs[attr] = attrarg
kwargs.update(**{"password": password})
form = _security.confirm_register_form(MultiDict(kwargs), meta={"csrf": False})
if form.validate():
- kwargs["password"] = hash_password(kwargs["password"])
+ # We don't use the form directly to provide values so that this CLI can actually
+ # set any usermodel attribute. We do grab email and password from the form
+ # so that we get any normalization results.
+ kwargs["password"] = hash_password(form.password.data)
kwargs["active"] = active
+ # echo normalized email...
+ if "email" in kwargs:
+ kwargs["email"] = form.email.data
_datastore.create_user(**kwargs)
click.secho("User created successfully.", fg="green")
kwargs["password"] = "****"
@@ -100,13 +140,13 @@
@roles.command("create")
@click.argument("name")
@click.option("-d", "--description", default=None)
-@click.option("-p", "--permissions")
+@click.option("-p", "--permissions", help="A comma separated list")
@with_appcontext
@commit
def roles_create(**kwargs):
"""Create a role."""
- # For some reaosn Click puts arguments in kwargs - even if they weren't specified.
+ # For some reason Click puts arguments in kwargs - even if they weren't specified.
if "permissions" in kwargs and not kwargs["permissions"]:
del kwargs["permissions"]
if "permissions" in kwargs and not hasattr(_datastore.role_model, "permissions"):
@@ -122,14 +162,16 @@
@commit
def roles_add(user, role):
"""Add user to role."""
- user, role = _datastore._prepare_role_modify_args(user, role)
- if user is None:
- raise click.UsageError("Cannot find user.")
+ user_obj = find_user(user)
+ if user_obj is None:
+ raise click.UsageError("User not found.")
+
+ role = _datastore._prepare_role_modify_args(role)
if role is None:
raise click.UsageError("Cannot find role.")
- if _datastore.add_role_to_user(user, role):
+ if _datastore.add_role_to_user(user_obj, role):
click.secho(
- 'Role "{0}" added to user "{1}" ' "successfully.".format(role, user),
+ f'Role "{role.name}" added to user "{user}" successfully.',
fg="green",
)
else:
@@ -143,33 +185,82 @@
@commit
def roles_remove(user, role):
"""Remove user from role."""
- user, role = _datastore._prepare_role_modify_args(user, role)
- if user is None:
- raise click.UsageError("Cannot find user.")
+ user_obj = find_user(user)
+ if user_obj is None:
+ raise click.UsageError("User not found.")
+
+ role = _datastore._prepare_role_modify_args(role)
if role is None:
raise click.UsageError("Cannot find role.")
- if _datastore.remove_role_from_user(user, role):
+ if _datastore.remove_role_from_user(user_obj, role):
click.secho(
- 'Role "{0}" removed from user "{1}" ' "successfully.".format(role, user),
+ f'Role "{role.name}" removed from user "{user}" successfully.',
fg="green",
)
else:
raise click.UsageError("Cannot remove role from user.")
+@roles.command("add_permissions")
+@click.argument("role")
+@click.argument("permissions")
+@with_appcontext
+@commit
+def roles_add_permissions(role, permissions):
+ """Add permissions to role.
+
+ Role is an existing role name.
+ Permissions are a comma separated list.
+ """
+ role = _datastore._prepare_role_modify_args(role)
+ if role is None:
+ raise click.UsageError("Cannot find role.")
+ if _datastore.add_permissions_to_role(role, permissions):
+ click.secho(
+ f'Permission(s) "{permissions}" added to role "{role.name}" successfully.',
+ fg="green",
+ )
+ else: # pragma: no cover
+ raise click.UsageError("Cannot add permission(s) to role.")
+
+
+@roles.command("remove_permissions")
+@click.argument("role")
+@click.argument("permissions")
+@with_appcontext
+@commit
+def roles_remove_permissions(role, permissions):
+ """Remove permissions from role.
+
+ Role is an existing role name.
+ Permissions are a comma separated list.
+ """
+ role = _datastore._prepare_role_modify_args(role)
+ if role is None:
+ raise click.UsageError("Cannot find role.")
+ if _datastore.remove_permissions_from_role(role, permissions):
+ click.secho(
+ f'Permission(s) "{permissions}" removed from role'
+ f' "{role.name}" successfully.',
+ fg="green",
+ )
+ else: # pragma: no cover
+ raise click.UsageError("Cannot remove permission(s) from role.")
+
+
@users.command("activate")
@click.argument("user")
@with_appcontext
@commit
def users_activate(user):
"""Activate a user."""
- user_obj = _datastore.get_user(user)
+ user_obj = find_user(user)
if user_obj is None:
- raise click.UsageError("ERROR: User not found.")
+ raise click.UsageError("User not found.")
if _datastore.activate_user(user_obj):
- click.secho('User "{0}" has been activated.'.format(user), fg="green")
+ click.secho(f'User "{user}" has been activated.', fg="green")
else:
- click.secho('User "{0}" was already activated.'.format(user), fg="yellow")
+ click.secho(f'User "{user}" was already activated.', fg="yellow")
@users.command("deactivate")
@@ -178,10 +269,30 @@
@commit
def users_deactivate(user):
"""Deactivate a user."""
- user_obj = _datastore.get_user(user)
+ user_obj = find_user(user)
if user_obj is None:
- raise click.UsageError("ERROR: User not found.")
+ raise click.UsageError("User not found.")
if _datastore.deactivate_user(user_obj):
- click.secho('User "{0}" has been deactivated.'.format(user), fg="green")
+ click.secho(f'User "{user}" has been deactivated.', fg="green")
else:
- click.secho('User "{0}" was already deactivated.'.format(user), fg="yellow")
+ click.secho(f'User "{user}" was already deactivated.', fg="yellow")
+
+
+@users.command(
+ "reset_access",
+ help="Reset all authentication credentials for user."
+ " This includes sessions, authentication tokens, two-factor"
+ " and unified sign in secrets. ",
+)
+@click.argument("user")
+@with_appcontext
+@commit
+def users_reset_access(user):
+ """ Reset all authentication tokens etc."""
+ user_obj = find_user(user)
+ if user_obj is None:
+ raise click.UsageError("User not found.")
+ _datastore.reset_user_access(user_obj)
+ click.secho(
+ f'User "{user}" authentication credentials have been reset.', fg="green"
+ )
diff -Nru flask-security-3.4.2/flask_security/confirmable.py flask-security-4.0.0/flask_security/confirmable.py
--- flask-security-3.4.2/flask_security/confirmable.py 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/confirmable.py 2021-01-26 02:39:51.000000000 +0000
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""
flask_security.confirmable
~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -18,6 +17,7 @@
config_value,
get_token_status,
hash_data,
+ send_mail,
url_for_security,
verify_hash,
)
@@ -41,7 +41,7 @@
confirmation_link, token = generate_confirmation_link(user)
- _security._send_mail(
+ send_mail(
config_value("EMAIL_SUBJECT_CONFIRM"),
user.email,
"confirmation_instructions",
@@ -57,7 +57,7 @@
:param user: The user to work with
"""
- data = [str(user.id), hash_data(user.email)]
+ data = [str(user.fs_uniquifier), hash_data(user.email)]
return _security.confirm_serializer.dumps(data)
diff -Nru flask-security-3.4.2/flask_security/core.py flask-security-4.0.0/flask_security/core.py
--- flask-security-3.4.2/flask_security/core.py 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/core.py 2021-01-26 02:39:51.000000000 +0000
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""
flask_security.core
~~~~~~~~~~~~~~~~~~~
@@ -14,11 +13,9 @@
from datetime import datetime, timedelta
import warnings
-import sys
import pkg_resources
from flask import _request_ctx_stack, current_app, render_template
-from flask_babelex import Domain
from flask_login import AnonymousUserMixin, LoginManager
from flask_login import UserMixin as BaseUserMixin
from flask_login import current_user
@@ -26,8 +23,9 @@
from itsdangerous import URLSafeTimedSerializer
from passlib.context import CryptContext
from werkzeug.datastructures import ImmutableList
-from werkzeug.local import LocalProxy, Local
+from werkzeug.local import LocalProxy
+from .babel import get_i18n_domain, have_babel
from .decorators import (
default_reauthn_handler,
default_unauthn_handler,
@@ -44,10 +42,11 @@
SendConfirmationForm,
TwoFactorVerifyCodeForm,
TwoFactorSetupForm,
- TwoFactorVerifyPasswordForm,
TwoFactorRescueForm,
VerifyForm,
)
+from .mail_util import MailUtil
+from .password_util import PasswordUtil
from .phone_util import PhoneUtil
from .twofactor import tf_send_security_token
from .unified_signin import (
@@ -65,27 +64,21 @@
FsPermNeed,
csrf_cookie_handler,
default_want_json,
- default_password_validator,
get_config,
+ get_identity_attribute,
get_identity_attributes,
get_message,
- hash_data,
localize_callback,
- send_mail,
- string_types,
+ set_request_attr,
uia_email_mapper,
- uia_phone_mapper,
url_for_security,
verify_and_update_password,
- verify_hash,
)
from .views import create_blueprint, default_render_json
-from .cache import VerifyHashCache
# Convenient references
_security = LocalProxy(lambda: current_app.extensions["security"])
_datastore = LocalProxy(lambda: _security.datastore)
-local_cache = Local()
# List of authentication mechanisms supported.
AUTHN_MECHANISMS = ("basic", "session", "token")
@@ -101,6 +94,7 @@
"FLASH_MESSAGES": True,
"I18N_DOMAIN": "flask_security",
"I18N_DIRNAME": pkg_resources.resource_filename("flask_security", "translations"),
+ "EMAIL_VALIDATOR_ARGS": None,
"PASSWORD_HASH": "bcrypt",
"PASSWORD_SALT": None,
"PASSWORD_SINGLE_HASH": {
@@ -133,6 +127,7 @@
"PASSWORD_COMPLEXITY_CHECKER": None,
"PASSWORD_CHECK_BREACHED": False,
"PASSWORD_BREACHED_COUNT": 1,
+ "PASSWORD_NORMALIZE_FORM": "NFKD",
"DEPRECATED_PASSWORD_SCHEMES": ["auto"],
"LOGIN_URL": "/login",
"LOGOUT_URL": "/logout",
@@ -143,9 +138,7 @@
"VERIFY_URL": "/verify",
"TWO_FACTOR_SETUP_URL": "/tf-setup",
"TWO_FACTOR_TOKEN_VALIDATION_URL": "/tf-validate",
- "TWO_FACTOR_QRCODE_URL": "/tf-qrcode",
"TWO_FACTOR_RESCUE_URL": "/tf-rescue",
- "TWO_FACTOR_CONFIRM_URL": "/tf-confirm",
"LOGOUT_METHODS": ["GET", "POST"],
"POST_LOGIN_VIEW": "/",
"POST_LOGOUT_VIEW": "/",
@@ -159,8 +152,10 @@
"RESET_ERROR_VIEW": None,
"RESET_VIEW": None,
"LOGIN_ERROR_VIEW": None,
+ "REQUIRES_CONFIRMATION_ERROR_VIEW": None,
"REDIRECT_HOST": None,
"REDIRECT_BEHAVIOR": None,
+ "REDIRECT_ALLOW_SUBDOMAINS": False,
"FORGOT_PASSWORD_TEMPLATE": "security/forgot_password.html",
"LOGIN_USER_TEMPLATE": "security/login_user.html",
"REGISTER_USER_TEMPLATE": "security/register_user.html",
@@ -171,7 +166,6 @@
"VERIFY_TEMPLATE": "security/verify.html",
"TWO_FACTOR_VERIFY_CODE_TEMPLATE": "security/two_factor_verify_code.html",
"TWO_FACTOR_SETUP_TEMPLATE": "security/two_factor_setup.html",
- "TWO_FACTOR_VERIFY_PASSWORD_TEMPLATE": "security/two_factor_verify_password.html",
"CONFIRMABLE": False,
"REGISTERABLE": False,
"RECOVERABLE": False,
@@ -187,6 +181,14 @@
"TWO_FACTOR_AUTHENTICATOR_VALIDITY": 120,
"TWO_FACTOR_MAIL_VALIDITY": 300,
"TWO_FACTOR_SMS_VALIDITY": 120,
+ "TWO_FACTOR_ALWAYS_VALIDATE": True,
+ "TWO_FACTOR_LOGIN_VALIDITY": "30 days",
+ "TWO_FACTOR_VALIDITY_SALT": "tf-validity-salt",
+ "TWO_FACTOR_VALIDITY_COOKIE": {
+ "httponly": True,
+ "secure": False,
+ "samesite": None,
+ },
"CONFIRM_EMAIL_WITHIN": "5 days",
"RESET_PASSWORD_WITHIN": "5 days",
"LOGIN_WITHOUT_CONFIRMATION": False,
@@ -215,20 +217,16 @@
"EMAIL_HTML": True,
"EMAIL_SUBJECT_TWO_FACTOR": _("Two-factor Login"),
"EMAIL_SUBJECT_TWO_FACTOR_RESCUE": _("Two-factor Rescue"),
- "USER_IDENTITY_ATTRIBUTES": ["email"],
- "USER_IDENTITY_MAPPINGS": [
- {"email": uia_email_mapper},
- {"us_phone_number": uia_phone_mapper},
+ "USER_IDENTITY_ATTRIBUTES": [
+ {"email": {"mapper": uia_email_mapper, "case_insensitive": True}}
],
"PHONE_REGION_DEFAULT": "US",
"FRESHNESS": timedelta(hours=24),
"FRESHNESS_GRACE_PERIOD": timedelta(hours=1),
+ "API_ENABLED_METHODS": ["session", "token"],
"HASHING_SCHEMES": ["sha256_crypt", "hex_md5"],
"DEPRECATED_HASHING_SCHEMES": ["hex_md5"],
"DATETIME_FACTORY": datetime.utcnow,
- "USE_VERIFY_PASSWORD_CACHE": False,
- "VERIFY_HASH_CACHE_TTL": 60 * 5,
- "VERIFY_HASH_CACHE_MAX_SIZE": 500,
"TOTP_SECRETS": None,
"TOTP_ISSUER": None,
"SMS_SERVICE": "Dummy",
@@ -255,7 +253,6 @@
"US_VERIFY_URL": "/us-verify",
"US_VERIFY_SEND_CODE_URL": "/us-verify/send-code",
"US_VERIFY_LINK_URL": "/us-verify-link",
- "US_QRCODE_URL": "/us-qrcode",
"US_POST_SETUP_VIEW": None,
"US_SIGNIN_TEMPLATE": "security/us_signin.html",
"US_SETUP_TEMPLATE": "security/us_setup.html",
@@ -273,7 +270,6 @@
"CSRF_COOKIE_REFRESH_EACH_REQUEST": False,
"BACKWARDS_COMPAT_UNAUTHN": False,
"BACKWARDS_COMPAT_AUTH_TOKEN": False,
- "BACKWARDS_COMPAT_AUTH_TOKEN_INVALIDATE": False,
"JOIN_USER_ROLES": True,
}
@@ -300,6 +296,13 @@
_("%(email)s is already associated with an account."),
"error",
),
+ "IDENTITY_ALREADY_ASSOCIATED": (
+ _(
+ "Identity attribute '%(attr)s' with value '%(value)s' is already"
+ " associated with an account."
+ ),
+ "error",
+ ),
"PASSWORD_MISMATCH": (_("Password does not match"), "error"),
"RETYPE_PASSWORD_MISMATCH": (_("Passwords do not match"), "error"),
"INVALID_REDIRECT": (_("Redirections outside the domain are forbidden"), "error"),
@@ -388,14 +391,6 @@
_("You successfully changed your two-factor method."),
"success",
),
- "TWO_FACTOR_PASSWORD_CONFIRMATION_DONE": (
- _("You successfully confirmed password"),
- "success",
- ),
- "TWO_FACTOR_PASSWORD_CONFIRMATION_NEEDED": (
- _("Password confirmation is needed in order to access page"),
- "error",
- ),
"TWO_FACTOR_PERMISSION_DENIED": (
_("You currently do not have permissions to access this page"),
"error",
@@ -427,7 +422,6 @@
"passwordless_login_form": PasswordlessLoginForm,
"two_factor_verify_code_form": TwoFactorVerifyCodeForm,
"two_factor_setup_form": TwoFactorSetupForm,
- "two_factor_verify_password_form": TwoFactorVerifyPasswordForm,
"two_factor_rescue_form": TwoFactorRescueForm,
"us_signin_form": UnifiedSigninForm,
"us_setup_form": UnifiedSigninSetupForm,
@@ -437,23 +431,10 @@
def _user_loader(user_id):
- """ Try to load based on fs_uniquifier (alternative_id) if available.
-
- Note that we don't try, and fall back to the other - primarily because some DBs
- and drivers (psycopg2) really really hate getting mismatched types during queries.
- They hate it enough that they abort the 'transaction' and refuse to do anything
- in the future until the transaction is rolled-back. But we don't really control
- that and there doesn't seem to be any way to catch the actual offensive query -
- just next time and forever, things fail.
- This assumes that if the app has fs_uniquifier, it is non-nullable as we specify
- so we use that and only that.
- """
- if hasattr(_datastore.user_model, "fs_uniquifier"):
- selector = dict(fs_uniquifier=str(user_id))
- else:
- selector = dict(id=user_id)
- user = _security.datastore.find_user(**selector)
+ """Load based on fs_uniquifier (alternative_id)."""
+ user = _security.datastore.find_user(fs_uniquifier=str(user_id))
if user and user.active:
+ set_request_attr("fs_authn_via", "session")
return user
return None
@@ -477,49 +458,35 @@
if isinstance(data, dict):
token = data.get(args_key, token)
- use_cache = cv("USE_VERIFY_PASSWORD_CACHE")
-
try:
data = _security.remember_token_serializer.loads(
token, max_age=_security.token_max_age
)
- user = _security.datastore.find_user(id=data[0])
+ if hasattr(_security.datastore.user_model, "fs_token_uniquifier"):
+ user = _security.datastore.find_user(fs_token_uniquifier=data[0])
+ else:
+ user = _security.datastore.find_user(fs_uniquifier=data[0])
if not user.active:
user = None
except Exception:
user = None
- if not user:
- return _security.login_manager.anonymous_user()
- if use_cache:
- cache = getattr(local_cache, "verify_hash_cache", None)
- if cache is None:
- cache = VerifyHashCache()
- local_cache.verify_hash_cache = cache
- if cache.has_verify_hash_cache(user):
- _request_ctx_stack.top.fs_authn_via = "token"
- return user
- if user.verify_auth_token(data):
- _request_ctx_stack.top.fs_authn_via = "token"
- cache.set_cache(user)
- return user
- else:
- if user.verify_auth_token(data):
- _request_ctx_stack.top.fs_authn_via = "token"
- return user
+ if user and user.verify_auth_token(data):
+ set_request_attr("fs_authn_via", "token")
+ return user
return _security.login_manager.anonymous_user()
def _identity_loader():
if not isinstance(current_user._get_current_object(), AnonymousUserMixin):
- identity = Identity(current_user.id)
+ identity = Identity(current_user.fs_uniquifier)
return identity
def _on_identity_loaded(sender, identity):
- if hasattr(current_user, "id"):
- identity.provides.add(UserNeed(current_user.id))
+ if hasattr(current_user, "fs_uniquifier"):
+ identity.provides.add(UserNeed(current_user.fs_uniquifier))
for role in getattr(current_user, "roles", []):
identity.provides.add(RoleNeed(role.name))
@@ -570,17 +537,11 @@
schemes=schemes,
default=pw_hash,
deprecated=deprecated,
- **cv("PASSWORD_HASH_PASSLIB_OPTIONS", app=app)
+ **cv("PASSWORD_HASH_PASSLIB_OPTIONS", app=app),
)
return cc
-def _get_i18n_domain(app):
- return Domain(
- dirname=cv("I18N_DIRNAME", app=app), domain=cv("I18N_DOMAIN", app=app)
- )
-
-
def _get_hashing_context(app):
schemes = cv("HASHING_SCHEMES", app=app)
deprecated = cv("DEPRECATED_HASHING_SCHEMES", app=app)
@@ -604,22 +565,20 @@
principal=_get_principal(app),
pwd_context=_get_pwd_context(app),
hashing_context=_get_hashing_context(app),
- i18n_domain=_get_i18n_domain(app),
+ i18n_domain=get_i18n_domain(app),
remember_token_serializer=_get_serializer(app, "remember"),
login_serializer=_get_serializer(app, "login"),
reset_serializer=_get_serializer(app, "reset"),
confirm_serializer=_get_serializer(app, "confirm"),
us_setup_serializer=_get_serializer(app, "us_setup"),
+ tf_validity_serializer=_get_serializer(app, "two_factor_validity"),
_context_processors={},
- _send_mail_task=None,
- _send_mail=kwargs.get("send_mail", send_mail),
_unauthorized_callback=None,
_render_json=default_render_json,
_want_json=default_want_json,
_unauthn_handler=default_unauthn_handler,
_reauthn_handler=default_reauthn_handler,
_unauthz_handler=default_unauthz_handler,
- _password_validator=default_password_validator,
)
)
@@ -637,7 +596,7 @@
return dict(url_for_security=url_for_security, security=_security)
-class RoleMixin(object):
+class RoleMixin:
"""Mixin for `Role` model definitions"""
def __eq__(self, other):
@@ -653,9 +612,8 @@
"""
Return set of permissions associated with role.
- Either takes a comma separated string of permissions or
- an interable of strings if permissions are in their own
- table.
+ Supports permissions being a comma separated string, an iterable, or a set
+ based on how the underlying DB model was built.
.. versionadded:: 3.3.0
"""
@@ -667,7 +625,7 @@
else:
# Assume this is a comma separated list
return set(self.permissions.split(","))
- return set([])
+ return set()
def add_permissions(self, permissions):
"""
@@ -675,9 +633,10 @@
:param permissions: a set, list, or single string.
- Caller must commit to DB.
-
.. versionadded:: 3.3.0
+
+ .. deprecated:: 3.4.4
+ Use :meth:`.UserDatastore.add_permissions_to_role`
"""
if hasattr(self, "permissions"):
current_perms = self.get_permissions()
@@ -688,7 +647,7 @@
else:
perms = {permissions}
self.permissions = ",".join(current_perms.union(perms))
- else:
+ else: # pragma: no cover
raise NotImplementedError("Role model doesn't have permissions")
def remove_permissions(self, permissions):
@@ -697,9 +656,10 @@
:param permissions: a set, list, or single string.
- Caller must commit to DB.
-
.. versionadded:: 3.3.0
+
+ .. deprecated:: 3.4.4
+ Use :meth:`.UserDatastore.remove_permissions_from_role`
"""
if hasattr(self, "permissions"):
current_perms = self.get_permissions()
@@ -710,7 +670,7 @@
else:
perms = {permissions}
self.permissions = ",".join(current_perms.difference(perms))
- else:
+ else: # pragma: no cover
raise NotImplementedError("Role model doesn't have permissions")
@@ -718,22 +678,12 @@
"""Mixin for `User` model definitions"""
def get_id(self):
- """Returns the user identification attribute.
-
- This will be `fs_uniquifier` if that is available, else base class id
- (which is via Flask-Login and is user.id).
+ """Returns the user identification attribute. 'Alternative-token' for
+ Flask-Login. This is always ``fs_uniquifier``.
.. versionadded:: 3.4.0
"""
- if hasattr(self, "fs_uniquifier") and self.fs_uniquifier is not None:
- # Use fs_uniquifier as alternative_id if available and not None
- alternative_id = str(self.fs_uniquifier)
- if len(alternative_id) > 0:
- # Return only if alternative_id is a valid value
- return alternative_id
-
- # Use upstream value if alternative_id is unavailable
- return BaseUserMixin.get_id(self)
+ return str(self.fs_uniquifier)
@property
def is_active(self):
@@ -743,11 +693,24 @@
def get_auth_token(self):
"""Constructs the user's authentication token.
+ :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.
+
This data MUST be securely signed using the ``remember_token_serializer``
+
+ .. versionchanged:: 4.0.0
+ If user model has ``fs_token_uniquifier`` - use that (raise ValueError
+ if not set). Otherwise fallback to using ``fs_uniqifier``.
"""
- data = [str(self.id), hash_data(self.password)]
- if hasattr(self, "fs_uniquifier"):
- data.append(self.fs_uniquifier)
+
+ if hasattr(self, "fs_token_uniquifier"):
+ if not self.fs_token_uniquifier:
+ raise ValueError()
+ data = [str(self.fs_token_uniquifier)]
+ else:
+ data = [str(self.fs_uniquifier)]
return _security.remember_token_serializer.dumps(data)
def verify_auth_token(self, data):
@@ -759,24 +722,22 @@
:param data: the data as formulated by :meth:`get_auth_token`
.. versionadded:: 3.3.0
+
+ .. versionchanged:: 4.0.0
+ If user model has ``fs_token_uniquifier`` - use that otherwise
+ use ``fs_uniquifier``.
"""
- 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
+
+ if hasattr(self, "fs_token_uniquifier"):
+ return data[0] == self.fs_token_uniquifier
+
+ return data[0] == self.fs_uniquifier
def has_role(self, role):
"""Returns `True` if the user identifies with the specified role.
:param role: A role name or `Role` instance"""
- if isinstance(role, string_types):
+ if isinstance(role, str):
return role in (role.name for role in self.roles)
else:
return role in self.roles
@@ -791,14 +752,16 @@
"""
for role in self.roles:
- if hasattr(role, "permissions"):
- if permission in role.get_permissions():
- return True
+ if permission in role.get_permissions():
+ return True
return False
def get_security_payload(self):
- """Serialize user object as response payload."""
- return {"id": str(self.id)}
+ """Serialize user object as response payload.
+ Override this to return any/all of the user object in JSON responses.
+ Return a dict.
+ """
+ return {}
def get_redirect_qparams(self, existing=None):
"""Return user info that will be added to redirect query params.
@@ -807,10 +770,15 @@
:return: A dict whose keys will be query params and values will be query values.
.. versionadded:: 3.2.0
+
+ .. versionchanged:: 4.0.0
+ Add 'identity' using UserMixin.calc_username() - email is optional.
"""
if not existing:
existing = {}
- existing.update({"email": self.email})
+ if hasattr(self, "email"):
+ existing.update({"email": self.email})
+ existing.update({"identity": self.calc_username()})
return existing
def verify_and_update_password(self, password):
@@ -831,7 +799,7 @@
return verify_and_update_password(password, self)
def calc_username(self):
- """ Come up with the best 'username' based on how the app
+ """Come up with the best 'username' based on how the app
is configured (via :py:data:`SECURITY_USER_IDENTITY_ATTRIBUTES`).
Returns the first non-null match (and converts to string).
In theory this should NEVER be the empty string unless the user
@@ -847,7 +815,7 @@
return str(cusername) if cusername is not None else ""
def us_send_security_token(self, method, **kwargs):
- """ Generate and send the security code for unified sign in.
+ """Generate and send the security code for unified sign in.
:param method: The method in which the code will be sent
:param kwargs: Opaque parameters that are subject to change at any time
@@ -865,7 +833,7 @@
return None
def tf_send_security_token(self, method, **kwargs):
- """ Generate and send the security code for two-factor.
+ """Generate and send the security code for two-factor.
:param method: The method in which the code will be sent
:param kwargs: Opaque parameters that are subject to change at any time
@@ -894,7 +862,7 @@
return False
-class _SecurityState(object):
+class _SecurityState:
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key.lower(), value)
@@ -940,9 +908,6 @@
def mail_context_processor(self, fn):
self._add_ctx_processor("mail", fn)
- def tf_verify_password_context_processor(self, fn):
- self._add_ctx_processor("tf_verify_password", fn)
-
def tf_setup_context_processor(self, fn):
self._add_ctx_processor("tf_setup", fn)
@@ -958,12 +923,6 @@
def us_verify_context_processor(self, fn):
self._add_ctx_processor("us_verify", fn)
- def send_mail_task(self, fn):
- self._send_mail_task = fn
-
- def send_mail(self, fn):
- self._send_mail = fn
-
def unauthorized_handler(self, fn):
warnings.warn(
"'unauthorized_handler' has been replaced with"
@@ -990,11 +949,8 @@
def reauthn_handler(self, cb):
self._reauthn_handler = cb
- def password_validator(self, cb):
- self._password_validator = cb
-
-class Security(object):
+class Security:
"""The :class:`Security` class initializes the Flask-Security extension.
:param app: The application.
@@ -1014,7 +970,6 @@
:param two_factor_setup_form: set form for the 2FA setup view
:param two_factor_verify_code_form: set form the the 2FA verify code view
:param two_factor_rescue_form: set form for the 2FA rescue view
- :param two_factor_verify_password_form: set form for the 2FA verify password view
:param us_signin_form: set form for the unified sign in view
:param us_setup_form: set form for the unified sign in setup view
:param us_setup_validate_form: set form for the unified sign in setup validate view
@@ -1022,12 +977,14 @@
:param anonymous_user: class to use for anonymous user
:param render_template: function to use to render templates. The default is Flask's
render_template() function.
- :param send_mail: function to use to send email. Defaults to :func:`send_mail`
:param json_encoder_cls: Class to use as blueprint.json_encoder.
Defaults to :class:`FsJsonEncoder`
:param totp_cls: Class to use as TOTP factory. Defaults to :class:`Totp`
:param phone_util_cls: Class to use for phone number utilities.
Defaults to :class:`PhoneUtil`
+ :param mail_util_cls: Class to use for sending emails. Defaults to :class:`MailUtil`
+ :param password_util_cls: Class to use for password normalization/validation.
+ Defaults to :class:`PasswordUtil`
.. versionadded:: 3.4.0
``verify_form`` added as part of freshness/re-authentication
@@ -1043,6 +1000,16 @@
.. versionadded:: 3.4.0
``phone_util_cls`` added to allow different phone number
parsing implementations - see :py:class:`PhoneUtil`
+
+ .. versionadded:: 4.0.0
+ ``mail_util_cls`` added to isolate mailing handling.
+ ``password_util_cls`` added to encapsulate password validation/normalization.
+
+ .. deprecated:: 4.0.0
+ ``send_mail`` and ``send_mail_task``. Replaced with ``mail_util_cls``.
+ ``two_factor_verify_password_form`` removed.
+ ``password_validator`` removed in favor of the new ``password_util_cls``.
+
"""
def __init__(self, app=None, datastore=None, register_blueprint=True, **kwargs):
@@ -1085,6 +1052,18 @@
kwargs.setdefault("totp_cls", Totp)
if "phone_util_cls" not in kwargs:
kwargs.setdefault("phone_util_cls", PhoneUtil)
+ if "mail_util_cls" not in kwargs:
+ kwargs.setdefault("mail_util_cls", MailUtil)
+ if "password_util_cls" not in kwargs:
+ kwargs.setdefault("password_util_cls", PasswordUtil)
+
+ # default post redirects to APPLICATION_ROOT, which itself defaults to "/"
+ app.config.setdefault(
+ "SECURITY_POST_LOGIN_VIEW", app.config.get("APPLICATION_ROOT", "/")
+ )
+ app.config.setdefault(
+ "SECURITY_POST_LOGOUT_VIEW", app.config.get("APPLICATION_ROOT", "/")
+ )
for key, value in _default_config.items():
app.config.setdefault("SECURITY_" + key, value)
@@ -1095,6 +1074,10 @@
identity_loaded.connect_via(app)(_on_identity_loaded)
self._state = state = _get_state(app, datastore, **kwargs)
+ if hasattr(datastore, "user_model") and not hasattr(
+ datastore.user_model, "fs_uniquifier"
+ ): # pragma: no cover
+ raise ValueError("User model must contain fs_uniquifier as of 4.0.0")
if register_blueprint:
bp = create_blueprint(
@@ -1105,8 +1088,7 @@
@app.before_first_request
def _register_i18n():
- # N.B. as of jinja 2.9 '_' is always registered
- # http://jinja.pocoo.org/docs/2.10/extensions/#i18n-extension
+ # This is only not registered if Flask-Babel isn't installed...
if "_" not in app.jinja_env.globals:
current_app.jinja_env.globals["_"] = state.i18n_domain.gettext
# Register so other packages can reference our translations.
@@ -1166,8 +1148,17 @@
current_app.config["WTF_CSRF_HEADERS"].append(cv("CSRF_HEADER"))
@app.before_first_request
- def _init_phone_util():
- state._phone_util = state.phone_util_cls()
+ def check_babel():
+ # Verify that if Flask-Babel or Flask-BabelEx is installed
+ # it has been initialized
+ if have_babel() and "babel" not in app.extensions:
+ raise ValueError(
+ "Flask-Babel or Flask-BabelEx is installed but not initialized"
+ )
+
+ state._phone_util = state.phone_util_cls(app)
+ state._mail_util = state.mail_util_cls(app)
+ state._password_util = state.password_util_cls(app)
app.extensions["security"] = state
@@ -1189,12 +1180,33 @@
if not app.config.get(newc, None):
app.config[newc] = app.config.get(oldc, None)
+ # Check for pre-4.0 SECURITY_USER_IDENTITY_ATTRIBUTES format
+ for uia in cv("USER_IDENTITY_ATTRIBUTES", app=app): # pragma: no cover
+ if not isinstance(uia, dict):
+ raise ValueError(
+ "SECURITY_USER_IDENTITY_ATTRIBUTES changed semantics"
+ " in 4.0 - please see release notes."
+ )
+ if len(list(uia.keys())) != 1:
+ raise ValueError(
+ "Each element in SECURITY_USER_IDENTITY_ATTRIBUTES"
+ " must have one and only one key."
+ )
+
# Two factor configuration checks and setup
multi_factor = False
if cv("UNIFIED_SIGNIN", app=app):
multi_factor = True
if len(cv("US_ENABLED_METHODS", app=app)) < 1:
raise ValueError("Must configure some US_ENABLED_METHODS")
+ if "sms" in cv(
+ "US_ENABLED_METHODS", app=app
+ ) and not get_identity_attribute("us_phone_number", app=app):
+ warnings.warn(
+ "'sms' was enabled in SECURITY_US_ENABLED_METHODS;"
+ " however 'us_phone_number' not configured in"
+ " SECURITY_USER_IDENTITY_ATTRIBUTES"
+ )
if cv("TWO_FACTOR", app=app):
multi_factor = True
if len(cv("TWO_FACTOR_ENABLED_METHODS", app=app)) < 1:
@@ -1224,50 +1236,24 @@
raise ValueError("Both TOTP_SECRETS and TOTP_ISSUER must be set")
state.totp_factory(state.totp_cls(secrets, issuer))
- if cv("USE_VERIFY_PASSWORD_CACHE", app=app):
- self._check_modules("cachetools", "USE_VERIFY_PASSWORD_CACHE")
-
if cv("PASSWORD_COMPLEXITY_CHECKER", app=app) == "zxcvbn":
self._check_modules("zxcvbn", "PASSWORD_COMPLEXITY_CHECKER")
return state
def _check_modules(self, module, config_name): # pragma: no cover
- PY3 = sys.version_info[0] == 3
- if PY3:
- from importlib.util import find_spec
-
- module_exists = find_spec(module)
-
- else:
- import imp
-
- try:
- imp.find_module(module)
- module_exists = True
- except ImportError:
- module_exists = False
+ from importlib.util import find_spec
+ module_exists = find_spec(module)
if not module_exists:
- raise ValueError("{} is required for {}".format(module, config_name))
+ raise ValueError(f"{module} is required for {config_name}")
return module_exists
def render_template(self, *args, **kwargs):
return render_template(*args, **kwargs)
- def send_mail(self, fn):
- """ Function used to send emails.
-
- :param fn: Function with signature(subject, recipient, template, context)
-
- See :meth:`send_mail` for details.
-
- .. versionadded:: 3.1.0
- """
- self._state._send_mail = fn
-
def render_json(self, cb):
- """ Callback to render response payload as JSON.
+ """Callback to render response payload as JSON.
:param cb: Callback function with
signature (payload, code, headers=None, user=None)
@@ -1301,7 +1287,7 @@
self._state._render_json = cb
def want_json(self, fn):
- """ Function that returns True if response should be JSON (based on the request)
+ """Function that returns True if response should be JSON (based on the request)
:param fn: Function with the following signature (request)
@@ -1385,27 +1371,5 @@
"""
self._state._reauthn_handler = cb
- def password_validator(self, cb):
- """
- Callback for validating a user password.
- This is called on registration as well as change and reset password.
- For registration, ``kwargs`` will be all the form input fields that are
- attributes of the user model.
- For reset/change, ``kwargs`` will be user=UserModel
-
- :param cb: Callback function with signature (password, is_register, kwargs)
-
- :password: desired new plain text password
- :is_register: True if called as part of initial registration
- :kwargs: user info
-
- Returns: None if password passes all validations. A list of (localized) messages
- if not.
-
- .. versionadded:: 3.4.0
- Refer to :ref:`pass_validation_topic` for more information.
- """
- self._state._password_validator = cb
-
def __getattr__(self, name):
return getattr(self._state, name, None)
diff -Nru flask-security-3.4.2/flask_security/datastore.py flask-security-4.0.0/flask_security/datastore.py
--- flask-security-3.4.2/flask_security/datastore.py 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/datastore.py 2021-01-26 02:39:51.000000000 +0000
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""
flask_security.datastore
~~~~~~~~~~~~~~~~~~~~~~~~
@@ -12,10 +11,10 @@
import json
import uuid
-from .utils import config_value, get_identity_attributes, string_types
+from .utils import config_value
-class Datastore(object):
+class Datastore:
def __init__(self, db):
self.db = db
@@ -115,7 +114,7 @@
model.delete()
-class UserDatastore(object):
+class UserDatastore:
"""Abstracted user datastore.
:param user_model: A user model class definition
@@ -132,12 +131,10 @@
self.user_model = user_model
self.role_model = role_model
- def _prepare_role_modify_args(self, user, role):
- if isinstance(user, string_types):
- user = self.find_user(email=user)
- if isinstance(role, string_types):
+ def _prepare_role_modify_args(self, role):
+ if isinstance(role, str):
role = self.find_role(role)
- return user, role
+ return role
def _prepare_create_user_args(self, **kwargs):
kwargs.setdefault("active", True)
@@ -147,23 +144,11 @@
# see if the role exists
roles[i] = self.find_role(rn)
kwargs["roles"] = roles
- if hasattr(self.user_model, "fs_uniquifier"):
- kwargs.setdefault("fs_uniquifier", uuid.uuid4().hex)
- return kwargs
-
- def _is_numeric(self, value):
- try:
- int(value)
- except (TypeError, ValueError):
- return False
- return True
-
- def _is_uuid(self, value):
- return isinstance(value, uuid.UUID)
+ kwargs.setdefault("fs_uniquifier", uuid.uuid4().hex)
+ if hasattr(self.user_model, "fs_token_uniquifier"):
+ kwargs.setdefault("fs_token_uniquifier", uuid.uuid4().hex)
- def get_user(self, id_or_email):
- """Returns a user matching the specified ID or email address."""
- raise NotImplementedError
+ return kwargs
def find_user(self, *args, **kwargs):
"""Returns a user matching the provided parameters."""
@@ -179,8 +164,9 @@
:param user: The user to manipulate. Can be an User object or email
:param role: The role to add to the user. Can be a Role object or
string role name
+ :return: True is role was added, False if role already existed.
"""
- user, role = self._prepare_role_modify_args(user, role)
+ role = self._prepare_role_modify_args(role)
if role not in user.roles:
user.roles.append(role)
self.put(user)
@@ -193,15 +179,59 @@
:param user: The user to manipulate. Can be an User object or email
:param role: The role to remove from the user. Can be a Role object or
string role name
+ :return: True if role was removed, False if role doesn't exist or user didn't
+ have role.
"""
rv = False
- user, role = self._prepare_role_modify_args(user, role)
+ role = self._prepare_role_modify_args(role)
if role in user.roles:
rv = True
user.roles.remove(role)
self.put(user)
return rv
+ def add_permissions_to_role(self, role, permissions):
+ """Add one or more permissions to role.
+
+ :param role: The role to modify. Can be a Role object or
+ string role name
+ :param permissions: a set, list, or single string.
+ :return: True if permissions added, False if role doesn't exist.
+
+ Caller must commit to DB.
+
+ .. versionadded:: 4.0.0
+ """
+
+ rv = False
+ role = self._prepare_role_modify_args(role)
+ if role:
+ rv = True
+ role.add_permissions(permissions)
+ self.put(role)
+ return rv
+
+ def remove_permissions_from_role(self, role, permissions):
+ """Remove one or more permissions from a role.
+
+ :param role: The role to modify. Can be a Role object or
+ string role name
+ :param permissions: a set, list, or single string.
+ :return: True if permissions removed, False if role doesn't exist.
+
+ Caller must commit to DB.
+
+ .. versionadded:: 4.0.0
+ """
+
+ rv = False
+ role = self._prepare_role_modify_args(role)
+ if role:
+ rv = True
+ role.remove_permissions(permissions)
+ self.put(role)
+ return rv
+
def toggle_active(self, user):
"""Toggles a user's active status. Always returns True."""
user.active = not user.active
@@ -235,25 +265,38 @@
return False
def set_uniquifier(self, user, uniquifier=None):
- """ Set user's authentication token uniquifier.
+ """Set user's Flask-Security identity key.
This will immediately render outstanding auth tokens,
session cookies and remember cookies 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 set_token_uniquifier(self, user, uniquifier=None):
+ """Set user's auth token identity key.
+ 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_token_uniquifier``
+
+ .. versionadded:: 4.0.0
+ """
+ if not uniquifier:
+ uniquifier = uuid.uuid4().hex
+ if hasattr(user, "fs_token_uniquifier"):
+ user.fs_token_uniquifier = uniquifier
+ self.put(user)
+
def create_role(self, **kwargs):
"""
Creates and returns a new role from the given parameters.
@@ -274,7 +317,7 @@
perms = kwargs["permissions"]
if isinstance(perms, list) or isinstance(perms, set):
perms = ",".join(perms)
- elif isinstance(perms, string_types):
+ elif isinstance(perms, str):
# squash spaces.
perms = ",".join([p.strip() for p in perms.split(",")])
kwargs["permissions"] = perms
@@ -297,16 +340,27 @@
:kwparam roles: list of roles to be added to user.
Can be Role objects or strings
+ .. note::
+ No normalization is done on email - it is assumed the caller has already
+ done that.
+
+ .. note::
+ The roles kwparam is modified as part of the call - it will, if necessary
+ be converted from names to role instances.
+
.. danger::
Be aware that whatever `password` is passed in will
be stored directly in the DB. Do NOT pass in a plaintext password!
Best practice is to pass in ``hash_password(plaintext_password)``.
- Furthermore, no validation is done on the password (e.g for minimum length).
- Best practice is to call
- ``app.security._password_validator(plaintext_password, True)``
- and look for a ``None`` return meaning the password conforms to the
- configured validations.
+ Furthermore, no validation nor normalization is done on the password
+ (e.g for minimum length).
+
+ Best practice is::
+ pbad, pnorm = app.security._password_util.validate(password, True)
+
+ Look for `pbad` being None. Pass the normalized password `pnorm` to this
+ method.
The new user's ``active`` property will be set to ``True``
unless explicitly set to ``False`` in `kwargs`.
@@ -329,28 +383,30 @@
* reset fs_uniquifier - which causes session cookie, remember cookie, auth
tokens to be unusable
+ * reset fs_token_uniquifier (if present) - cause auth tokens to be unusable
* remove all unified signin TOTP secrets so those can't be used
* remove all two-factor secrets so those can't be used
- Note that if using unified sign in and allow 'email' as a way to receive a code
+ Note that if using unified sign in and allow 'email' as a way to receive a code;
if the email is compromised - login is still possible. To handle this - it
is better to deactivate the user.
Note - this method isn't used directly by Flask-Security - it is provided
- as a helper for an applications administrative needs.
+ as a helper for an application's administrative needs.
Remember to call commit on DB if needed.
.. versionadded:: 3.4.1
"""
self.set_uniquifier(user)
+ self.set_token_uniquifier(user)
if hasattr(user, "us_totp_secrets"):
self.us_reset(user)
if hasattr(user, "tf_primary_method"):
self.tf_reset(user)
def tf_set(self, user, primary_method, totp_secret=None, phone=None):
- """ Set two-factor info into user record.
+ """Set two-factor info into user record.
This carefully only changes things if different.
If totp_secret isn't provided - existing one won't be changed.
@@ -378,7 +434,7 @@
self.put(user)
def tf_reset(self, user):
- """ Disable two-factor auth for user
+ """Disable two-factor auth for user
.. versionadded: 3.4.1
"""
@@ -388,7 +444,7 @@
self.put(user)
def us_get_totp_secrets(self, user):
- """ Return totp secrets.
+ """Return totp secrets.
These are json encoded in the DB.
Returns a dict with methods as keys and secrets as values.
@@ -400,7 +456,7 @@
return json.loads(user.us_totp_secrets)
def us_put_totp_secrets(self, user, secrets):
- """ Save secrets. Assume to be a dict (or None)
+ """Save secrets. Assume to be a dict (or None)
with keys as methods, and values as (encrypted) secrets.
.. versionadded:: 3.4.0
@@ -409,7 +465,7 @@
self.put(user)
def us_set(self, user, method, totp_secret=None, phone=None):
- """ Set unified sign in info into user record.
+ """Set unified sign in info into user record.
If totp_secret isn't provided - existing one won't be changed.
If phone isn't provided, the existing phone number won't be changed.
@@ -431,7 +487,7 @@
self.put(user)
def us_reset(self, user):
- """ Disable unified sign in for user.
+ """Disable unified sign in for user.
Be aware that if "email" is an allowed way to receive codes, they
will still work (as totp secrets are generated on the fly).
This will disable authenticator app and SMS.
@@ -452,64 +508,28 @@
SQLAlchemyDatastore.__init__(self, db)
UserDatastore.__init__(self, user_model, role_model)
- def get_user(self, identifier):
+ def find_user(self, case_insensitive=False, **kwargs):
from sqlalchemy import func as alchemyFn
- from sqlalchemy import inspect
- from sqlalchemy.sql import sqltypes
- from sqlalchemy.dialects.postgresql import UUID as PSQL_UUID
-
- user_model_query = self.user_model.query
- if config_value("JOIN_USER_ROLES") and hasattr(self.user_model, "roles"):
- from sqlalchemy.orm import joinedload
-
- user_model_query = user_model_query.options(joinedload("roles"))
-
- # To support both numeric, string, and UUID primary keys, and support
- # calling this routine with either a numeric value or a string or a UUID
- # we need to make sure the types basically match.
- # psycopg2 for example will complain if we attempt to 'get' a
- # numeric primary key with a string value.
- # TODO: other datastores don't support this - they assume the only
- # PK is user.id. That makes things easier but for backwards compat...
- ins = inspect(self.user_model)
- pk_type = ins.primary_key[0].type
- pk_isnumeric = isinstance(pk_type, sqltypes.Integer)
- pk_isuuid = isinstance(pk_type, PSQL_UUID)
- # Are they the same or NOT numeric nor UUID
- if (
- (pk_isnumeric and self._is_numeric(identifier))
- or (pk_isuuid and self._is_uuid(identifier))
- or (not pk_isnumeric and not pk_isuuid)
- ):
- rv = self.user_model.query.get(identifier)
- if rv is not None:
- return rv
-
- # Not PK - iterate through other attributes and look for 'identifier'
- for attr in get_identity_attributes():
- column = getattr(self.user_model, attr)
- attr_isnumeric = isinstance(column.type, sqltypes.Integer)
-
- query = None
- if attr_isnumeric and self._is_numeric(identifier):
- query = column == identifier
- elif not attr_isnumeric and not self._is_numeric(identifier):
- # Look for exact case-insensitive match - 'ilike' honors
- # wild cards which isn't what we want.
- query = alchemyFn.lower(column) == alchemyFn.lower(identifier)
- if query is not None:
- rv = user_model_query.filter(query).first()
- if rv is not None:
- return rv
- def find_user(self, **kwargs):
query = self.user_model.query
if config_value("JOIN_USER_ROLES") and hasattr(self.user_model, "roles"):
from sqlalchemy.orm import joinedload
query = query.options(joinedload("roles"))
- return query.filter_by(**kwargs).first()
+ if case_insensitive:
+ # While it is of course possible to pass in multiple keys to filter on
+ # that isn't the normal use case. If caller asks for case_insensitive
+ # AND gives multiple keys - throw an error.
+ if len(kwargs) > 1:
+ raise ValueError("Case insensitive option only supports single key")
+ attr, identifier = kwargs.popitem()
+ subquery = alchemyFn.lower(
+ getattr(self.user_model, attr)
+ ) == alchemyFn.lower(identifier)
+ return query.filter(subquery).first()
+ else:
+ return query.filter_by(**kwargs).first()
def find_role(self, role):
return self.role_model.query.filter_by(name=role).first()
@@ -521,9 +541,8 @@
"""
def __init__(self, session, user_model, role_model):
- class PretendFlaskSQLAlchemyDb(object):
- """ This is a pretend db object, so we can just pass in a session.
- """
+ class PretendFlaskSQLAlchemyDb:
+ """This is a pretend db object, so we can just pass in a session."""
def __init__(self, session):
self.session = session
@@ -533,7 +552,7 @@
)
def commit(self):
- super(SQLAlchemySessionUserDatastore, self).commit()
+ super().commit()
class MongoEngineUserDatastore(MongoEngineDatastore, UserDatastore):
@@ -545,39 +564,24 @@
MongoEngineDatastore.__init__(self, db)
UserDatastore.__init__(self, user_model, role_model)
- def get_user(self, identifier):
- from mongoengine import ValidationError
-
- try:
- return self.user_model.objects(id=identifier).first()
- except (ValidationError, ValueError):
- pass
-
- is_numeric = self._is_numeric(identifier)
-
- for attr in get_identity_attributes():
- query_key = attr if is_numeric else "%s__iexact" % attr
- query = {query_key: identifier}
- try:
- rv = self.user_model.objects(**query).first()
- if rv is not None:
- return rv
- except (ValidationError, ValueError):
- # This can happen if identifier is a string but attribute is
- # an int.
- pass
-
- def find_user(self, **kwargs):
- try:
- from mongoengine.queryset import Q, QCombination
- except ImportError:
- from mongoengine.queryset.visitor import Q, QCombination
+ def find_user(self, case_insensitive=False, **kwargs):
+ from mongoengine.queryset.visitor import Q, QCombination
from mongoengine.errors import ValidationError
- queries = map(lambda i: Q(**{i[0]: i[1]}), kwargs.items())
- query = QCombination(QCombination.AND, queries)
try:
- return self.user_model.objects(query).first()
+ if case_insensitive:
+ # While it is of course possible to pass in multiple keys to filter on
+ # that isn't the normal use case. If caller asks for case_insensitive
+ # AND gives multiple keys - throw an error.
+ if len(kwargs) > 1:
+ raise ValueError("Case insensitive option only supports single key")
+ attr, identifier = kwargs.popitem()
+ query = {f"{attr}__iexact": identifier}
+ return self.user_model.objects(**query).first()
+ else:
+ queries = map(lambda i: Q(**{i[0]: i[1]}), kwargs.items())
+ query = QCombination(QCombination.AND, queries)
+ return self.user_model.objects(query).first()
except ValidationError: # pragma: no cover
return None
@@ -599,34 +603,23 @@
UserDatastore.__init__(self, user_model, role_model)
self.UserRole = role_link
- def get_user(self, identifier):
+ def find_user(self, case_insensitive=False, **kwargs):
from peewee import fn as peeweeFn
- from peewee import IntegerField
- # For peewee we only (currently) support numeric primary keys.
- if self._is_numeric(identifier):
- try:
- return self.user_model.get(self.user_model.id == identifier)
- except (self.user_model.DoesNotExist, ValueError):
- pass
-
- for attr in get_identity_attributes():
- # Read above (SQLAlchemy store) for why we are checking types.
- column = getattr(self.user_model, attr)
- attr_isnumeric = isinstance(column, IntegerField)
- try:
- if attr_isnumeric and self._is_numeric(identifier):
- return self.user_model.get(column == identifier)
- elif not attr_isnumeric and not self._is_numeric(identifier):
- return self.user_model.get(
- peeweeFn.Lower(column) == peeweeFn.Lower(identifier)
- )
- except (self.user_model.DoesNotExist, ValueError):
- pass
-
- def find_user(self, **kwargs):
try:
- return self.user_model.filter(**kwargs).get()
+ if case_insensitive:
+ # While it is of course possible to pass in multiple keys to filter on
+ # that isn't the normal use case. If caller asks for case_insensitive
+ # AND gives multiple keys - throw an error.
+ if len(kwargs) > 1:
+ raise ValueError("Case insensitive option only supports single key")
+ attr, identifier = kwargs.popitem()
+ return self.user_model.get(
+ peeweeFn.lower(getattr(self.user_model, attr))
+ == peeweeFn.lower(identifier)
+ )
+ else:
+ return self.user_model.filter(**kwargs).get()
except self.user_model.DoesNotExist:
return None
@@ -652,7 +645,7 @@
:param user: The user to manipulate
:param role: The role to add to the user
"""
- user, role = self._prepare_role_modify_args(user, role)
+ role = self._prepare_role_modify_args(role)
result = self.UserRole.select().where(
self.UserRole.user == user.id, self.UserRole.role == role.id
)
@@ -668,7 +661,7 @@
:param user: The user to manipulate
:param role: The role to remove from the user
"""
- user, role = self._prepare_role_modify_args(user, role)
+ role = self._prepare_role_modify_args(role)
result = self.UserRole.select().where(
self.UserRole.user == user, self.UserRole.role == role
)
@@ -694,26 +687,15 @@
UserDatastore.__init__(self, user_model, role_model)
@with_pony_session
- def get_user(self, identifier):
- from pony.orm.core import ObjectNotFound
+ def find_user(self, case_insensitive=False, **kwargs):
+ if case_insensitive:
+ # While it is of course possible to pass in multiple keys to filter on
+ # that isn't the normal use case. If caller asks for case_insensitive
+ # AND gives multiple keys - throw an error.
+ if len(kwargs) > 1:
+ raise ValueError("Case insensitive option only supports single key")
+ # TODO - implement case insensitive look ups.
- try:
- return self.user_model[identifier]
- except (ObjectNotFound, ValueError):
- pass
-
- for attr in get_identity_attributes():
- # this is a nightmare, tl;dr we need to get the thing that
- # corresponds to email (usually)
- try:
- user = self.user_model.get(**{attr: identifier})
- if user is not None:
- return user
- except (TypeError, ValueError):
- pass
-
- @with_pony_session
- def find_user(self, **kwargs):
return self.user_model.get(**kwargs)
@with_pony_session
@@ -722,12 +704,12 @@
@with_pony_session
def add_role_to_user(self, *args, **kwargs):
- return super(PonyUserDatastore, self).add_role_to_user(*args, **kwargs)
+ return super().add_role_to_user(*args, **kwargs)
@with_pony_session
def create_user(self, **kwargs):
- return super(PonyUserDatastore, self).create_user(**kwargs)
+ return super().create_user(**kwargs)
@with_pony_session
def create_role(self, **kwargs):
- return super(PonyUserDatastore, self).create_role(**kwargs)
+ return super().create_role(**kwargs)
diff -Nru flask-security-3.4.2/flask_security/decorators.py flask-security-4.0.0/flask_security/decorators.py
--- flask-security-3.4.2/flask_security/decorators.py 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/decorators.py 2021-01-26 02:39:51.000000000 +0000
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""
flask_security.decorators
~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -25,10 +24,12 @@
FsPermNeed,
config_value,
do_flash,
+ find_user,
get_message,
get_url,
check_and_update_authn_fresh,
json_error_response,
+ set_request_attr,
)
# Convenient references
@@ -61,28 +62,33 @@
def default_unauthn_handler(mechanisms, headers=None):
- """ Default callback for failures to authenticate
+ """Default callback for failures to authenticate
- If caller wants JSON - return 401
+ If caller wants JSON - return 401.
+ If caller wants BasicAuth - return 401 (the WWW-Authenticate header is set).
Otherwise - assume caller is html and redirect if possible to a login view.
We let Flask-Login handle this.
"""
+ headers = headers or {}
msg = get_message("UNAUTHENTICATED")[0]
if config_value("BACKWARDS_COMPAT_UNAUTHN"):
return _get_unauthenticated_response(headers=headers)
if _security._want_json(request):
- # Ignore headers since today, the only thing in there might be WWW-Authenticate
- # and we never want to send that in a JSON response (browsers will intercept
- # that and pop up their own login form).
payload = json_error_response(errors=msg)
- return _security._render_json(payload, 401, None, None)
+ return _security._render_json(payload, 401, headers, None)
+
+ # Basic-Auth is often used to provide a browser based login form and then the
+ # browser will always add the BasicAuth credentials. For that to work we need to
+ # return 401 and not redirect to our login view.
+ if "WWW-Authenticate" in headers:
+ return Response(msg, 401, headers)
return _security.login_manager.unauthorized()
def default_reauthn_handler(within, grace):
- """ Default callback for 'freshness' related authn failures.
+ """Default callback for 'freshness' related authn failures.
If caller wants JSON - return 401
Otherwise - assume caller is html and redirect if possible to configured view.
@@ -148,7 +154,7 @@
if user and user.is_authenticated:
app = current_app._get_current_object()
_request_ctx_stack.top.user = user
- identity_changed.send(app, identity=Identity(user.id))
+ identity_changed.send(app, identity=Identity(user.fs_uniquifier))
return True
return False
@@ -158,20 +164,22 @@
auth = request.authorization or BasicAuth(username=None, password=None)
if not auth.username:
return False
- user = _security.datastore.get_user(auth.username)
+ user = find_user(auth.username)
+ if user and not user.active:
+ return False
if user and user.verify_and_update_password(auth.password):
_security.datastore.commit()
app = current_app._get_current_object()
_request_ctx_stack.top.user = user
- identity_changed.send(app, identity=Identity(user.id))
+ identity_changed.send(app, identity=Identity(user.fs_uniquifier))
return True
return False
def handle_csrf(method):
- """ Invoke CSRF protection based on authentication method.
+ """Invoke CSRF protection based on authentication method.
Usually this is called as part of a decorator, but if that isn't
appropriate, endpoint code can call this directly.
@@ -212,6 +220,9 @@
:param realm: optional realm name
+ If authentication fails, then a 401 with the 'WWW-Authenticate' header set will be
+ returned.
+
Once authenticated, if so configured, CSRF protection will be tested.
"""
@@ -220,6 +231,7 @@
def wrapper(*args, **kwargs):
if _check_http_auth():
handle_csrf("basic")
+ set_request_attr("fs_authn_via", "basic")
return fn(*args, **kwargs)
if _security._unauthorized_callback:
return _security._unauthorized_callback()
@@ -249,6 +261,7 @@
def decorated(*args, **kwargs):
if _check_token():
handle_csrf("token")
+ set_request_attr("fs_authn_via", "token")
return fn(*args, **kwargs)
if _security._unauthorized_callback:
return _security._unauthorized_callback()
@@ -269,7 +282,9 @@
return 'Dashboard'
:param auth_methods: Specified mechanisms (token, basic, session). If not specified
- then all current available mechanisms will be tried.
+ then all current available mechanisms (except "basic") will be tried. A callable
+ can also be passed (useful if you need app/request context). The callable
+ must return a list.
:kwparam within: Add 'freshness' check to authentication. Is either an int
specifying # of minutes, or a callable that returns a timedelta. For timedeltas,
timedelta.total_seconds() is used for the calculations:
@@ -297,6 +312,14 @@
On authentication failure `.Security.unauthorized_callback` (deprecated)
or :meth:`.Security.unauthn_handler` will be called.
+ As a side effect, upon successful authentication, the request global
+ ``fs_authn_via`` will be set to the method ("basic", "token", "session")
+
+ .. note::
+ If "basic" is specified in addition to other methods, then if authentication
+ fails, a 401 with the "WWW-Authenticate" header will be returned - rather than
+ being redirected to the login view.
+
.. versionchanged:: 3.3.0
If ``auth_methods`` isn't specified, then all will be tried. Authentication
mechanisms will always be tried in order of ``token``, ``session``, ``basic``
@@ -305,6 +328,12 @@
.. versionchanged:: 3.4.0
Added ``within`` and ``grace`` parameters to enforce a freshness check.
+ .. versionchanged:: 3.4.4
+ If ``auth_methods`` isn't specified try all mechanisms EXCEPT ``basic``.
+
+ .. versionchanged:: 4.0.0
+ auth_methods can be passed as a callable.
+
"""
login_mechanisms = {
@@ -313,10 +342,7 @@
"basic": lambda: _check_http_auth(),
}
mechanisms_order = ["token", "session", "basic"]
- if not auth_methods:
- auth_methods = {"basic", "session", "token"}
- else:
- auth_methods = [am for am in auth_methods]
+ auth_methods_arg = auth_methods
def wrapper(fn):
@wraps(fn)
@@ -335,6 +361,16 @@
else:
grace = datetime.timedelta(minutes=grace)
+ if not auth_methods_arg:
+ auth_methods = {"session", "token"}
+ else:
+ auth_methods = []
+ for am in auth_methods_arg:
+ if callable(am):
+ auth_methods.extend(am())
+ else:
+ auth_methods.append(am)
+
h = {}
if "basic" in auth_methods:
r = _security.default_http_auth_realm
@@ -349,11 +385,10 @@
# 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 method != "basic" and not check_and_update_authn_fresh(
- within, grace
- ):
+ if not check_and_update_authn_fresh(within, grace, method):
return _security._reauthn_handler(within, grace)
handle_csrf(method)
+ set_request_attr("fs_authn_via", method)
return fn(*args, **dkwargs)
if _security._unauthorized_callback:
return _security._unauthorized_callback()
diff -Nru flask-security-3.4.2/flask_security/forms.py flask-security-4.0.0/flask_security/forms.py
--- flask-security-3.4.2/flask_security/forms.py 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/forms.py 2021-01-26 02:39:51.000000000 +0000
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""
flask_security.forms
~~~~~~~~~~~~~~~~~~~~
@@ -16,7 +15,6 @@
from flask import Markup, current_app, request
from flask_login import current_user
from flask_wtf import FlaskForm as BaseForm
-from speaklater import is_lazy_string, make_lazy_string
from werkzeug.local import LocalProxy
from wtforms import (
BooleanField,
@@ -29,13 +27,18 @@
ValidationError,
validators,
)
+from wtforms.fields.html5 import EmailField
+from wtforms.validators import StopValidation
+from .babel import is_lazy_string, make_lazy_string
from .confirmable import requires_confirmation
from .utils import (
_,
_datastore,
config_value,
do_flash,
+ find_user,
+ get_identity_attribute,
get_message,
hash_password,
localize_callback,
@@ -73,7 +76,7 @@
}
-class ValidatorMixin(object):
+class ValidatorMixin:
"""
This is called at import time - so there is no app context.
Validators have state - namely self.message - but we need that
@@ -89,7 +92,7 @@
del kwargs["message"]
else:
self._original_message = None
- super(ValidatorMixin, self).__init__(*args, **kwargs)
+ super().__init__(*args, **kwargs)
def __call__(self, form, field):
if self._original_message and (
@@ -101,7 +104,7 @@
self.message = make_lazy_string(_local_xlate, cv[0])
else:
self.message = self._original_message
- return super(ValidatorMixin, self).__call__(form, field)
+ return super().__call__(form, field)
class EqualTo(ValidatorMixin, validators.EqualTo):
@@ -112,28 +115,42 @@
pass
-class Email(ValidatorMixin, validators.Email):
+class Length(ValidatorMixin, validators.Length):
pass
-class Length(ValidatorMixin, validators.Length):
- pass
+class EmailValidation:
+ """Simple interface to email_validator."""
+
+ def __call__(self, form, field):
+ if field.data is None: # pragma: no cover
+ raise ValidationError(get_message("EMAIL_NOT_PROVIDED")[0])
+
+ try:
+ field.data = _security._mail_util.validate(field.data)
+ except ValueError:
+ msg = get_message("INVALID_EMAIL_ADDRESS")[0]
+ # we stop further validators if email isn't valid.
+ # TODO: email_validator provides some really nice error messages - however
+ # they aren't localized. And there isn't an easy way to add multiple
+ # errors at once.
+ raise StopValidation(msg)
email_required = Required(message="EMAIL_NOT_PROVIDED")
-email_validator = Email(message="INVALID_EMAIL_ADDRESS")
+email_validator = EmailValidation()
password_required = Required(message="PASSWORD_NOT_PROVIDED")
def _local_xlate(text):
- """ LazyStrings need to be evaluated in the context of a request
+ """LazyStrings need to be evaluated in the context of a request
where _security.i18_domain is available.
"""
return localize_callback(text)
def get_form_field_label(key):
- """ This is called during import since form fields are declared as part of
+ """This is called during import since form fields are declared as part of
class. Thus can't call 'localize_callback' until we need to actually
translate/render form.
"""
@@ -141,13 +158,52 @@
def unique_user_email(form, field):
- if _datastore.get_user(field.data) is not None:
- msg = get_message("EMAIL_ALREADY_ASSOCIATED", email=field.data)[0]
+ uia_email = get_identity_attribute("email")
+ norm_email = _security._mail_util.normalize(field.data)
+ if (
+ _datastore.find_user(
+ case_insensitive=uia_email.get("case_insensitive", False), email=norm_email
+ )
+ is not None
+ ):
+ msg = get_message("EMAIL_ALREADY_ASSOCIATED", email=norm_email)[0]
raise ValidationError(msg)
+def unique_identity_attribute(form, field):
+ """A validator that checks the field data against all configured
+ SECURITY_USER_IDENTITY_ATTRIBUTES.
+ This can be used as part of registration.
+
+ Be aware that the "mapper" function likely also nornalizes the input in addition
+ to validating it.
+
+ :param form:
+ :param field:
+ :return: Nothing; if field data corresponds to an existing User, ValidationError
+ is raised.
+ """
+ for mapping in config_value("USER_IDENTITY_ATTRIBUTES"):
+ attr = list(mapping.keys())[0]
+ details = mapping[attr]
+ idata = details["mapper"](field.data)
+ if idata:
+ if _datastore.find_user(
+ case_insensitive=details.get("case_insensitive", False), **{attr: idata}
+ ):
+ msg = get_message(
+ "IDENTITY_ALREADY_ASSOCIATED", attr=attr, value=idata
+ )[0]
+ raise ValidationError(msg)
+
+
def valid_user_email(form, field):
- form.user = _datastore.get_user(field.data)
+ # Verify email exists in DB - be sure to normalize first.
+ uia_email = get_identity_attribute("email")
+ norm_email = _security._mail_util.normalize(field.data)
+ form.user = _datastore.find_user(
+ case_insensitive=uia_email.get("case_insensitive", False), email=norm_email
+ )
if form.user is None:
raise ValidationError(get_message("USER_DOES_NOT_EXIST")[0])
@@ -156,25 +212,25 @@
def __init__(self, *args, **kwargs):
if current_app.testing:
self.TIME_LIMIT = None
- super(Form, self).__init__(*args, **kwargs)
+ super().__init__(*args, **kwargs)
class EmailFormMixin:
- email = StringField(
+ email = EmailField(
get_form_field_label("email"), validators=[email_required, email_validator]
)
class UserEmailFormMixin:
user = None
- email = StringField(
+ email = EmailField(
get_form_field_label("email"),
validators=[email_required, email_validator, valid_user_email],
)
class UniqueEmailFormMixin:
- email = StringField(
+ email = EmailField(
get_form_field_label("email"),
validators=[email_required, email_validator, unique_user_email],
)
@@ -235,7 +291,7 @@
return True
fields = inspect.getmembers(self, is_field_and_user_attr)
- return dict((key, value.data) for key, value in fields)
+ return {key: value.data for key, value in fields}
class SendConfirmationForm(Form, UserEmailFormMixin):
@@ -244,12 +300,12 @@
submit = SubmitField(get_form_field_label("send_confirmation"))
def __init__(self, *args, **kwargs):
- super(SendConfirmationForm, self).__init__(*args, **kwargs)
+ super().__init__(*args, **kwargs)
if request.method == "GET":
self.email.data = request.args.get("email", None)
def validate(self):
- if not super(SendConfirmationForm, self).validate():
+ if not super().validate():
return False
if self.user.confirmed_at is not None:
self.email.errors.append(get_message("ALREADY_CONFIRMED")[0])
@@ -262,13 +318,18 @@
submit = SubmitField(get_form_field_label("recover_password"))
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.requires_confirmation = False
+
def validate(self):
- if not super(ForgotPasswordForm, self).validate():
+ if not super().validate():
return False
if not self.user.is_active:
self.email.errors.append(get_message("DISABLED_ACCOUNT")[0])
return False
- if requires_confirmation(self.user):
+ self.requires_confirmation = requires_confirmation(self.user)
+ if self.requires_confirmation:
self.email.errors.append(get_message("CONFIRMATION_REQUIRED")[0])
return False
return True
@@ -280,10 +341,10 @@
submit = SubmitField(get_form_field_label("send_login_link"))
def __init__(self, *args, **kwargs):
- super(PasswordlessLoginForm, self).__init__(*args, **kwargs)
+ super().__init__(*args, **kwargs)
def validate(self):
- if not super(PasswordlessLoginForm, self).validate():
+ if not super().validate():
return False
if not self.user.is_active:
self.email.errors.append(get_message("DISABLED_ACCOUNT")[0])
@@ -294,7 +355,7 @@
class LoginForm(Form, NextFormMixin):
"""The default login form"""
- email = StringField(get_form_field_label("email"), validators=[email_required])
+ email = EmailField(get_form_field_label("email"), validators=[email_required])
password = PasswordField(
get_form_field_label("password"), validators=[password_required]
)
@@ -302,7 +363,7 @@
submit = SubmitField(get_form_field_label("login"))
def __init__(self, *args, **kwargs):
- super(LoginForm, self).__init__(*args, **kwargs)
+ super().__init__(*args, **kwargs)
if not self.next.data:
self.next.data = request.args.get("next", "")
self.remember.default = config_value("DEFAULT_REMEMBER_ME")
@@ -311,18 +372,22 @@
and not self.password.description
):
html = Markup(
- u'{message}'.format(
+ '{message}'.format(
url=url_for_security("forgot_password"),
message=get_message("FORGOT_PASSWORD")[0],
)
)
self.password.description = html
+ self.requires_confirmation = False
def validate(self):
- if not super(LoginForm, self).validate():
+ if not super().validate():
return False
- self.user = _datastore.get_user(self.email.data)
+ # Historically, this used get_user() which would look at all
+ # USER_IDENTITY_ATTRIBUTES - even though the field name is 'email'
+ # We keep that behavior (for now) as we transition to find_user.
+ self.user = find_user(self.email.data)
if self.user is None:
self.email.errors.append(get_message("USER_DOES_NOT_EXIST")[0])
@@ -334,10 +399,12 @@
# Reduce timing variation between existing and non-existing users
hash_password(self.password.data)
return False
+ self.password.data = _security._password_util.normalize(self.password.data)
if not self.user.verify_and_update_password(self.password.data):
self.password.errors.append(get_message("INVALID_PASSWORD")[0])
return False
- if requires_confirmation(self.user):
+ self.requires_confirmation = requires_confirmation(self.user)
+ if self.requires_confirmation:
self.email.errors.append(get_message("CONFIRMATION_REQUIRED")[0])
return False
if not self.user.is_active:
@@ -353,10 +420,11 @@
submit = SubmitField(get_form_field_label("verify_password"))
def validate(self):
- if not super(VerifyForm, self).validate():
+ if not super().validate():
return False
self.user = current_user
+ self.password.data = _security._password_util.normalize(self.password.data)
if not self.user.verify_and_update_password(self.password.data):
self.password.errors.append(get_message("INVALID_PASSWORD")[0])
return False
@@ -364,7 +432,7 @@
class ConfirmRegisterForm(Form, RegisterFormMixin, UniqueEmailFormMixin):
- """ This form is used for registering when 'confirmable' is set.
+ """This form is used for registering when 'confirmable' is set.
The only difference between this and the other RegisterForm is that
this one doesn't require re-typing in the password...
"""
@@ -375,7 +443,7 @@
)
def validate(self):
- if not super(ConfirmRegisterForm, self).validate():
+ if not super().validate():
return False
# To support unified sign in - we permit registering with no password.
@@ -397,7 +465,9 @@
if hasattr(_datastore.user_model, k):
rfields[k] = v
del rfields["password"]
- pbad = _security._password_validator(self.password.data, True, **rfields)
+ pbad, self.password.data = _security._password_util.validate(
+ self.password.data, True, **rfields
+ )
if pbad:
self.password.errors.extend(pbad)
return False
@@ -416,7 +486,7 @@
)
def validate(self):
- if not super(RegisterForm, self).validate():
+ if not super().validate():
return False
if not config_value("UNIFIED_SIGNIN"):
# password_confirm required
@@ -428,7 +498,7 @@
return True
def __init__(self, *args, **kwargs):
- super(RegisterForm, self).__init__(*args, **kwargs)
+ super().__init__(*args, **kwargs)
if not self.next.data:
self.next.data = request.args.get("next", "")
@@ -439,10 +509,10 @@
submit = SubmitField(get_form_field_label("reset_password"))
def validate(self):
- if not super(ResetPasswordForm, self).validate():
+ if not super().validate():
return False
- pbad = _security._password_validator(
+ pbad, self.password.data = _security._password_util.validate(
self.password.data, False, user=current_user
)
if pbad:
@@ -469,16 +539,17 @@
submit = SubmitField(get_form_field_label("change_password"))
def validate(self):
- if not super(ChangePasswordForm, self).validate():
+ if not super().validate():
return False
+ self.password.data = _security._password_util.normalize(self.password.data)
if not current_user.verify_and_update_password(self.password.data):
self.password.errors.append(get_message("INVALID_PASSWORD")[0])
return False
if self.password.data == self.new_password.data:
self.password.errors.append(get_message("PASSWORD_IS_THE_SAME")[0])
return False
- pbad = _security._password_validator(
+ pbad, self.new_password.data = _security._password_util.validate(
self.new_password.data, False, user=current_user
)
if pbad:
@@ -506,7 +577,7 @@
submit = SubmitField(get_form_field_label("submit"))
def __init__(self, *args, **kwargs):
- super(TwoFactorSetupForm, self).__init__(*args, **kwargs)
+ super().__init__(*args, **kwargs)
def validate(self):
# TODO: the super class validate is never called - thus we have to
@@ -542,7 +613,7 @@
submit = SubmitField(get_form_field_label("submitcode"))
def __init__(self, *args, **kwargs):
- super(TwoFactorVerifyCodeForm, self).__init__(*args, **kwargs)
+ super().__init__(*args, **kwargs)
def validate(self):
# codes sent by sms or mail will be valid for another window cycle
@@ -573,23 +644,6 @@
return True
-class TwoFactorVerifyPasswordForm(Form, PasswordFormMixin):
- """The verify password form"""
-
- submit = SubmitField(get_form_field_label("verify_password"))
-
- def validate(self):
- if not super(TwoFactorVerifyPasswordForm, self).validate():
- return False
-
- self.user = current_user
- if not self.user.verify_and_update_password(self.password.data):
- self.password.errors.append(get_message("INVALID_PASSWORD")[0])
- return False
-
- return True
-
-
class TwoFactorRescueForm(Form):
"""The Two-factor Rescue validation form """
@@ -603,9 +657,9 @@
submit = SubmitField(get_form_field_label("submit"))
def __init__(self, *args, **kwargs):
- super(TwoFactorRescueForm, self).__init__(*args, **kwargs)
+ super().__init__(*args, **kwargs)
def validate(self):
- if not super(TwoFactorRescueForm, self).validate():
+ if not super().validate():
return False
return True
diff -Nru flask-security-3.4.2/flask_security/__init__.py flask-security-4.0.0/flask_security/__init__.py
--- flask-security-3.4.2/flask_security/__init__.py 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/__init__.py 2021-01-26 02:39:51.000000000 +0000
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""
flask_security
~~~~~~~~~~~~~~
@@ -47,9 +46,11 @@
TwoFactorRescueForm,
TwoFactorSetupForm,
TwoFactorVerifyCodeForm,
- TwoFactorVerifyPasswordForm,
VerifyForm,
+ unique_identity_attribute,
)
+from .mail_util import MailUtil
+from .password_util import PasswordUtil
from .phone_util import PhoneUtil
from .signals import (
confirm_instructions_sent,
@@ -82,6 +83,7 @@
SmsSenderFactory,
check_and_get_token_status,
get_hmac,
+ get_request_attr,
get_token_status,
get_url,
hash_password,
@@ -101,4 +103,4 @@
verify_and_update_password,
)
-__version__ = "3.4.2"
+__version__ = "4.0.0"
diff -Nru flask-security-3.4.2/flask_security/mail_util.py flask-security-4.0.0/flask_security/mail_util.py
--- flask-security-3.4.2/flask_security/mail_util.py 1970-01-01 00:00:00.000000000 +0000
+++ flask-security-4.0.0/flask_security/mail_util.py 2021-01-26 02:39:51.000000000 +0000
@@ -0,0 +1,87 @@
+"""
+ flask_security.mail_util
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ Utility class providing methods for validating, normalizing and sending emails.
+
+ :copyright: (c) 2020 by J. Christopher Wagner (jwag).
+ :license: MIT, see LICENSE for more details.
+
+ While this default implementation uses FlaskMail - we want to make sure that
+ FlaskMail isn't REQUIRED (if this implementation isn't used).
+"""
+
+import email_validator
+from flask import current_app
+
+from .utils import config_value
+
+
+class MailUtil:
+ """
+ Utility class providing methods for validating, normalizing and sending emails.
+
+ This default class uses the email_validator package to handle validation and
+ normalization, and the flask_mail package to send emails.
+
+ To provide your own implementation, pass in the class as ``mail_util_cls``
+ at init time. Your class will be instantiated once as part of app initialization.
+
+ .. versionadded:: 4.0.0
+ """
+
+ def __init__(self, app):
+ """Instantiate class.
+
+ :param app: The Flask application being initialized.
+ """
+ pass
+
+ def send_mail(
+ self, template, subject, recipient, sender, body, html, user, **kwargs
+ ):
+ """Send an email via the Flask-Mail extension.
+
+ :param template: the Template name. The message has already been rendered
+ however this might be useful to differentiate why the email is being sent.
+ :param subject: Email subject
+ :param recipient: Email recipient
+ :param sender: who to send email as (see :py:data:`SECURITY_EMAIL_SENDER`)
+ :param body: the rendered body (text)
+ :param html: the rendered body (html)
+ :param user: the user model
+ """
+
+ from flask_mail import Message
+
+ msg = Message(subject, sender=sender, recipients=[recipient])
+ msg.body = body
+ msg.html = html
+
+ mail = current_app.extensions.get("mail")
+ mail.send(msg)
+
+ def normalize(self, email):
+ """
+ Given an input email - return a normalized version.
+ Must be called in app context and uses :py:data:`SECURITY_EMAIL_VALIDATOR_ARGS`
+ config variable to pass any relevant arguments to
+ email_validator.validate_email() method.
+
+ Will throw email_validator.EmailNotValidError if email isn't even valid.
+ """
+ validator_args = config_value("EMAIL_VALIDATOR_ARGS") or {}
+ valid = email_validator.validate_email(email, **validator_args)
+ return valid.email
+
+ def validate(self, email):
+ """
+ Validate the given email.
+ If valid, the normalized version is returned.
+
+ ValueError is thrown if not valid.
+ """
+
+ validator_args = config_value("EMAIL_VALIDATOR_ARGS") or {}
+ valid = email_validator.validate_email(email, **validator_args)
+ return valid.email
diff -Nru flask-security-3.4.2/flask_security/models/fsqla.py flask-security-4.0.0/flask_security/models/fsqla.py
--- flask-security-3.4.2/flask_security/models/fsqla.py 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/models/fsqla.py 2021-01-26 02:39:51.000000000 +0000
@@ -1,9 +1,11 @@
"""
-Copyright 2019 by J. Christopher Wagner (jwag). All rights reserved.
+Copyright 2019-2020 by J. Christopher Wagner (jwag). All rights reserved.
:license: MIT, see LICENSE for more details.
-Complete models for all features when using Flask-SqlAlchemy
+Complete models for all features when using Flask-SqlAlchemy.
+
+You can change the table names by passing them in to the set_db_info() method.
BE AWARE: Once any version of this is shipped no changes can be made - instead
a new version needs to be created.
@@ -26,7 +28,7 @@
from flask_security import RoleMixin, UserMixin
-class FsModels(object):
+class FsModels:
"""
Helper class for model mixins.
This records the ``db`` (which is a Flask-SqlAlchemy object) for use in
@@ -36,18 +38,22 @@
roles_users = None
db = None
fs_model_version = 1
+ user_table_name = "user"
+ role_table_name = "role"
@classmethod
- def set_db_info(cls, appdb):
- """ Initialize Model.
+ def set_db_info(cls, appdb, user_table_name="user", role_table_name="role"):
+ """Initialize Model.
This needs to be called after the DB object has been created
(e.g. db = Sqlalchemy())
"""
cls.db = appdb
+ cls.user_table_name = user_table_name
+ cls.role_table_name = role_table_name
cls.roles_users = appdb.Table(
"roles_users",
- Column("user_id", Integer(), ForeignKey("user.id")),
- Column("role_id", Integer(), ForeignKey("role.id")),
+ Column("user_id", Integer(), ForeignKey(f"{cls.user_table_name}.id")),
+ Column("role_id", Integer(), ForeignKey(f"{cls.role_table_name}.id")),
)
@@ -66,8 +72,7 @@
class FsUserMixin(UserMixin):
- """ User information
- """
+ """User information"""
# flask_security basic fields
id = Column(Integer, primary_key=True)
@@ -77,7 +82,7 @@
password = Column(String(255), nullable=False)
active = Column(Boolean(), nullable=False)
- # Faster token checking
+ # Flask-Security user identifier
fs_uniquifier = Column(String(64), unique=True, nullable=False)
# confirmable
@@ -97,6 +102,7 @@
@declared_attr
def roles(cls):
+ # The first arg is a class name, the backref is a column name
return FsModels.db.relationship(
"Role",
secondary=FsModels.roles_users,
@@ -117,7 +123,7 @@
"""
-class FsOauth2ClientMixin(object):
+class FsOauth2ClientMixin:
""" Oauth2 client """
id = Column(String(64), primary_key=True)
@@ -138,7 +144,7 @@
redirect_uris = Column(UnicodeText())
-class FsTokenMixin(object):
+class FsTokenMixin:
""" (Bearer) Tokens that have been given out """
id = Column(Integer, primary_key=True)
diff -Nru flask-security-3.4.2/flask_security/models/fsqla_v2.py flask-security-4.0.0/flask_security/models/fsqla_v2.py
--- flask-security-3.4.2/flask_security/models/fsqla_v2.py 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/models/fsqla_v2.py 2021-01-26 02:39:51.000000000 +0000
@@ -32,8 +32,7 @@
class FsUserMixin(FsUserMixinV1):
- """ User information
- """
+ """User information"""
# Make username unique but not required.
username = Column(String(255), unique=True, nullable=True)
diff -Nru flask-security-3.4.2/flask_security/passwordless.py flask-security-4.0.0/flask_security/passwordless.py
--- flask-security-3.4.2/flask_security/passwordless.py 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/passwordless.py 2021-01-26 02:39:51.000000000 +0000
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""
flask_security.passwordless
~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -13,7 +12,7 @@
from werkzeug.local import LocalProxy
from .signals import login_instructions_sent
-from .utils import config_value, get_token_status, url_for_security
+from .utils import config_value, get_token_status, send_mail, url_for_security
# Convenient references
_security = LocalProxy(lambda: app.extensions["security"])
@@ -25,12 +24,11 @@
"""Sends the login instructions email for the specified user.
:param user: The user to send the instructions to
- :param token: The login token
"""
token = generate_login_token(user)
login_link = url_for_security("token_login", token=token, _external=True)
- _security._send_mail(
+ send_mail(
config_value("EMAIL_SUBJECT_PASSWORDLESS"),
user.email,
"login_instructions",
@@ -48,7 +46,7 @@
:param user: The user the token belongs to
"""
- return _security.login_serializer.dumps([str(user.id)])
+ return _security.login_serializer.dumps([str(user.fs_uniquifier)])
def login_token_status(token):
diff -Nru flask-security-3.4.2/flask_security/password_util.py flask-security-4.0.0/flask_security/password_util.py
--- flask-security-3.4.2/flask_security/password_util.py 1970-01-01 00:00:00.000000000 +0000
+++ flask-security-4.0.0/flask_security/password_util.py 2021-01-26 02:39:51.000000000 +0000
@@ -0,0 +1,77 @@
+"""
+ flask_security.password_util
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ Utility class providing methods for validating and normalizing passwords.
+
+ :copyright: (c) 2020 by J. Christopher Wagner (jwag).
+ :license: MIT, see LICENSE for more details.
+
+"""
+import unicodedata
+
+from .utils import (
+ config_value,
+ password_length_validator,
+ password_breached_validator,
+ password_complexity_validator,
+)
+
+
+class PasswordUtil:
+ """
+ Utility class providing methods for validating and normalizing passwords.
+
+ To provide your own implementation, pass in the class as ``password_util_cls``
+ at init time. Your class will be instantiated once as part of app initialization.
+
+ .. versionadded:: 4.0.0
+ """
+
+ def __init__(self, app):
+ """Instantiate class.
+
+ :param app: The Flask application being initialized.
+ """
+ pass
+
+ def normalize(self, password):
+ """
+ Given an input password - return a normalized version (using Python's
+ unicodedata.normalize()).
+ Must be called in app context and uses
+ :py:data:`SECURITY_PASSWORD_NORMALIZE_FORM` config variable.
+ """
+ cf = config_value("PASSWORD_NORMALIZE_FORM")
+ if cf:
+ return unicodedata.normalize(cf, password)
+ return password
+
+ def validate(self, password, is_register, **kwargs):
+ """
+ Password validation.
+ Called in app/request context.
+
+ If is_register is True then kwargs will be the contents of the register form.
+ If is_register is False, then there is a single kwarg "user" which has the
+ current user data model.
+
+ The password is first normalized then validated.
+ Return value is a tuple ([msgs], normalized_password)
+ """
+
+ cf = config_value("PASSWORD_NORMALIZE_FORM")
+ if cf:
+ pnorm = unicodedata.normalize(cf, password)
+ else:
+ pnorm = password
+
+ notok = password_length_validator(pnorm)
+ if notok:
+ return notok, pnorm
+
+ notok = password_breached_validator(pnorm)
+ if notok:
+ return notok, pnorm
+
+ return password_complexity_validator(pnorm, is_register, **kwargs), pnorm
diff -Nru flask-security-3.4.2/flask_security/phone_util.py flask-security-4.0.0/flask_security/phone_util.py
--- flask-security-3.4.2/flask_security/phone_util.py 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/phone_util.py 2021-01-26 02:39:51.000000000 +0000
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""
flask_security.phone_util
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -14,20 +13,32 @@
from .utils import config_value, get_message
-class PhoneUtil(object):
+class PhoneUtil:
"""
Provide parsing and validation for user inputted phone numbers.
Subclass this to use a different underlying phone number parsing library.
To provide your own implementation, pass in the class as ``phone_util_cls``
- at init time. Your class will be instantiated once prior to the first
- request being handled.
+ at init time. Your class will be instantiated once as part of
+ Flask-Security initialization.
.. versionadded:: 3.4.0
+
+ .. versionchanged:: 4.0.0
+ __init__ takes app argument, and is instantiated at Flask-Security
+ initialization time rather than at first request.
"""
+ def __init__(self, app):
+ """Instantiate class.
+
+ :param app: The Flask application being initialized.
+ """
+ pass
+
def validate_phone_number(self, input_data):
- """ Return ``None`` if a valid phone number else an error message. """
+ """Return ``None`` if a valid phone number else
+ the ``PHONE_INVALID`` error message."""
import phonenumbers
try:
@@ -41,7 +52,7 @@
return get_message("PHONE_INVALID")[0]
def get_canonical_form(self, input_data):
- """ Validate and return a canonical form to be stored in DB
+ """Validate and return a canonical form to be stored in DB
and compared against.
Returns ``None`` if input isn't a valid phone number.
"""
diff -Nru flask-security-3.4.2/flask_security/quart_compat.py flask-security-4.0.0/flask_security/quart_compat.py
--- flask-security-3.4.2/flask_security/quart_compat.py 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/quart_compat.py 2021-01-26 02:39:51.000000000 +0000
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""
flask_security.quart_compat
~~~~~~~~~~~~~~~~~~~~
diff -Nru flask-security-3.4.2/flask_security/recoverable.py flask-security-4.0.0/flask_security/recoverable.py
--- flask-security-3.4.2/flask_security/recoverable.py 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/recoverable.py 2021-01-26 02:39:51.000000000 +0000
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""
flask_security.recoverable
~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -6,6 +5,7 @@
Flask-Security recoverable module
:copyright: (c) 2012 by Matt Wright.
+ :copyright: (c) 2019-2020 by J. Christopher Wagner (jwag).
:license: MIT, see LICENSE for more details.
"""
@@ -18,6 +18,7 @@
get_token_status,
hash_data,
hash_password,
+ send_mail,
url_for_security,
verify_hash,
)
@@ -37,7 +38,7 @@
reset_link = url_for_security("reset_password", token=token, _external=True)
if config_value("SEND_PASSWORD_RESET_EMAIL"):
- _security._send_mail(
+ send_mail(
config_value("EMAIL_SUBJECT_PASSWORD_RESET"),
user.email,
"reset_instructions",
@@ -56,7 +57,7 @@
:param user: The user to send the notice to
"""
if config_value("SEND_PASSWORD_RESET_NOTICE_EMAIL"):
- _security._send_mail(
+ send_mail(
config_value("EMAIL_SUBJECT_PASSWORD_NOTICE"),
user.email,
"reset_notice",
@@ -70,7 +71,7 @@
:param user: The user to work with
"""
password_hash = hash_data(user.password) if user.password else None
- data = [str(user.id), password_hash]
+ data = [str(user.fs_uniquifier), password_hash]
return _security.reset_serializer.dumps(data)
@@ -85,6 +86,11 @@
expired, invalid, user, data = get_token_status(
token, "reset", "RESET_PASSWORD", return_data=True
)
+ # This check looks to see if the password has been changed since the reset token
+ # was created. As of #338 - we reset the fs_uniquifier on each password change
+ # so the token would have been marked invalid above.
+ # This made sure that the token couldn't be used twice.
+ # TODO - look at removing this entire check.
if not invalid and user:
if user.password:
if not verify_hash(data[1], user.password):
@@ -100,8 +106,8 @@
:param password: The unhashed new password
"""
user.password = hash_password(password)
- if config_value("BACKWARDS_COMPAT_AUTH_TOKEN_INVALID"):
- _datastore.set_uniquifier(user)
+ # Change uniquifier - this will cause ALL sessions to be invalidated.
+ _datastore.set_uniquifier(user)
_datastore.put(user)
send_password_reset_notice(user)
password_reset.send(app._get_current_object(), user=user)
diff -Nru flask-security-3.4.2/flask_security/registerable.py flask-security-4.0.0/flask_security/registerable.py
--- flask-security-3.4.2/flask_security/registerable.py 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/registerable.py 2021-01-26 02:39:51.000000000 +0000
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""
flask_security.registerable
~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -17,7 +16,7 @@
from .confirmable import generate_confirmation_link
from .signals import user_registered
-from .utils import config_value, do_flash, get_message, hash_password
+from .utils import config_value, do_flash, get_message, hash_password, send_mail
# Convenient references
_security = LocalProxy(lambda: app.extensions["security"])
@@ -61,7 +60,7 @@
)
if config_value("SEND_REGISTER_EMAIL"):
- _security._send_mail(
+ send_mail(
config_value("EMAIL_SUBJECT_REGISTER"),
user.email,
"welcome",
diff -Nru flask-security-3.4.2/flask_security/signals.py flask-security-4.0.0/flask_security/signals.py
--- flask-security-3.4.2/flask_security/signals.py 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/signals.py 2021-01-26 02:39:51.000000000 +0000
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""
flask_security.signals
~~~~~~~~~~~~~~~~~~~~~~
diff -Nru flask-security-3.4.2/flask_security/templates/security/change_password.html flask-security-4.0.0/flask_security/templates/security/change_password.html
--- flask-security-3.4.2/flask_security/templates/security/change_password.html 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/templates/security/change_password.html 2021-01-26 02:39:51.000000000 +0000
@@ -3,7 +3,7 @@
{% block content %}
{% include "security/_messages.html" %}
-
-
-{% endblock %}
diff -Nru flask-security-3.4.2/flask_security/templates/security/us_setup.html flask-security-4.0.0/flask_security/templates/security/us_setup.html
--- flask-security-3.4.2/flask_security/templates/security/us_setup.html 2020-05-03 01:41:32.000000000 +0000
+++ flask-security-4.0.0/flask_security/templates/security/us_setup.html 2021-01-26 02:39:51.000000000 +0000
@@ -1,9 +1,39 @@
+{#
+ This template receives the following pieces of context in addition to the form:
+ On GET:
+ available_methods: Value of SECURITY_US_ENABLED_METHODS
+ active_methods: Which methods user has already set up
+ setup_methods: Which methods require a setup (e.g. password doesn't require any setup)
+
+ On successful POST:
+ available_methods: Value of SECURITY_US_ENABLED_METHODS
+ active_methods: Which methods user has already set up
+ setup_methods: Which methods require a setup (e.g. password doesn't require any setup)
+ chosen_method: which identity method was chosen (e.g. sms, authenticator)
+ code_sent: Was a code sent?
+ state: a signed state token used to validate the code.
+
+ If chosen method is 'authenticator' then additionally:
+ authr_qrcode: the image source for the qrcode
+ authr_key: same key as in qrcode - for possible manual entry
+ authr_username: same username as in qrcode
+ authr_issuer: same issuer as in qrcode
+#}
+
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_errors %}
+{% block styles %}
+ {{ super() }}
+
+{% endblock %}
+
{% block content %}
{% include "security/_messages.html" %}
-