From 915b1b49ee86dd955c588b035bf5a6588a2886bb Mon Sep 17 00:00:00 2001 From: Galstat Date: Wed, 1 Jun 2016 17:35:27 +0300 Subject: [PATCH 01/32] Security two factor authentication feature * supporting two factor authentication: * Support Mail, SMS, or Google Authenticator second factor authentication. * Ability to change second factor authentication to existing users * Provide rescue mail in case of lost phone * update docs with two factor authentication changes * updating requirements, authors * adding two_factor test * improving tests coverage --- .gitignore | 3 - AUTHORS | 1 + docs/api.rst | 23 ++ docs/configuration.rst | 133 ++++++++-- docs/contents.rst.inc | 1 + docs/customizing.rst | 12 +- docs/features.rst | 18 +- docs/index.rst | 9 +- docs/models.rst | 15 ++ docs/two_factor_configurations.rst | 112 ++++++++ flask_security/core.py | 58 ++++- flask_security/forms.py | 105 ++++++++ flask_security/signals.py | 7 +- .../email/two_factor_instructions.html | 4 + .../email/two_factor_instructions.txt | 4 + .../security/email/two_factor_rescue.html | 1 + .../security/email/two_factor_rescue.txt | 2 + ...r_change_method_password_confimration.html | 9 + .../security/two_factor_choose_method.html | 32 +++ .../security/two_factor_verify_code.html | 21 ++ flask_security/twofactor.py | 147 +++++++++++ flask_security/utils.py | 50 +++- flask_security/views.py | 245 +++++++++++++++++- requirements.txt | 10 +- tests/conftest.py | 12 +- tests/test_changeable.py | 1 - tests/test_misc.py | 8 + tests/test_two_factor.py | 234 +++++++++++++++++ tests/utils.py | 24 +- 29 files changed, 1242 insertions(+), 59 deletions(-) create mode 100644 docs/two_factor_configurations.rst create mode 100644 flask_security/templates/security/email/two_factor_instructions.html create mode 100644 flask_security/templates/security/email/two_factor_instructions.txt create mode 100644 flask_security/templates/security/email/two_factor_rescue.html create mode 100644 flask_security/templates/security/email/two_factor_rescue.txt create mode 100644 flask_security/templates/security/two_factor_change_method_password_confimration.html create mode 100644 flask_security/templates/security/two_factor_choose_method.html create mode 100644 flask_security/templates/security/two_factor_verify_code.html create mode 100644 flask_security/twofactor.py create mode 100644 tests/test_two_factor.py diff --git a/.gitignore b/.gitignore index 29a5b5cf..b04ba2f6 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,3 @@ Session.vim *~ .eggs/README.txt - -# Pycharm files -.idea/ diff --git a/AUTHORS b/AUTHORS index ce653c86..881576e8 100644 --- a/AUTHORS +++ b/AUTHORS @@ -39,3 +39,4 @@ Tristan Escalada Vadim Kotov Walt Askew John Paraskevopoulos +Gal Stainfeld diff --git a/docs/api.rst b/docs/api.rst index b519a882..7d7a5888 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -81,6 +81,20 @@ Utils .. autofunction:: flask_security.utils.get_token_status +.. autoclass:: SmsSenderBaseClass + ..method:: init(self) + ..method:: send_sms(self, from_number, to_number, msg) + +.. autoclass:: DummySmsSender + ..method:: send_sms(self, from_number, to_number, msg) + +.. autoclass:: SmsSenderFactory + :members: senders + +.. autoclass:: TwilioSmsSender(SmsSenderBaseClass) + ..method:: init(self) + ..method:: send_sms(self, from_number, to_number, msg) + Signals ------- See the `Flask documentation on signals`_ for information on how to use these @@ -125,5 +139,14 @@ sends the following signals. Sent when a user requests a password reset. In addition to the app (which is the sender), it is passed `user` and `token` arguments. +.. data:: user_two_factored + + Sent when a user performs two factor authentication login on the site. In + addition to the app (which is the sender), it is passed `user` argument + +.. data:: two_factor_method_changed + + Sent when two factor is used and user logs in. In addition to the app + (which is the sender), it is passed `user` argument. .. _Flask documentation on signals: http://flask.pocoo.org/docs/signals/ diff --git a/docs/configuration.rst b/docs/configuration.rst index bc72984f..571475b4 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -69,6 +69,10 @@ Core to ``MAIL_DEFAULT_SENDER`` if Flask-Mail is used otherwise ``no-reply@localhost``. +``SECURITY_TWO_FACTOR_RESCUE_MAIL`` Specifies the email address users send + mail to when they can't complete the + two factor authentication login. + Defaults to ``no-reply@localhost``. ``SECURITY_TOKEN_AUTHENTICATION_KEY`` Specifies the query string parameter to read when using token authentication. Defaults to ``auth_token``. @@ -151,31 +155,48 @@ Template Paths .. tabularcolumns:: |p{6.5cm}|p{8.5cm}| -======================================== ======================================= -``SECURITY_FORGOT_PASSWORD_TEMPLATE`` Specifies the path to the template for - the forgot password page. Defaults to - ``security/forgot_password.html``. -``SECURITY_LOGIN_USER_TEMPLATE`` Specifies the path to the template for - the user login page. Defaults to - ``security/login_user.html``. -``SECURITY_REGISTER_USER_TEMPLATE`` Specifies the path to the template for - the user registration page. Defaults to - ``security/register_user.html``. -``SECURITY_RESET_PASSWORD_TEMPLATE`` Specifies the path to the template for - the reset password page. Defaults to - ``security/reset_password.html``. -``SECURITY_CHANGE_PASSWORD_TEMPLATE`` Specifies the path to the template for - the change password page. Defaults to - ``security/change_password.html``. -``SECURITY_SEND_CONFIRMATION_TEMPLATE`` Specifies the path to the template for - the resend confirmation instructions - page. Defaults to - ``security/send_confirmation.html``. -``SECURITY_SEND_LOGIN_TEMPLATE`` Specifies the path to the template for - the send login instructions page for - passwordless logins. Defaults to - ``security/send_login.html``. -======================================== ======================================= +============================================== ======================================= +``SECURITY_FORGOT_PASSWORD_TEMPLATE`` Specifies the path to the template for + the forgot password page. Defaults to + ``security/forgot_password.html``. +``SECURITY_LOGIN_USER_TEMPLATE`` Specifies the path to the template for + the user login page. Defaults to + ``security/login_user.html``. +``SECURITY_REGISTER_USER_TEMPLATE`` Specifies the path to the template for + the user registration page. Defaults to + ``security/register_user.html``. +``SECURITY_RESET_PASSWORD_TEMPLATE`` Specifies the path to the template for + the reset password page. Defaults to + ``security/reset_password.html``. +``SECURITY_CHANGE_PASSWORD_TEMPLATE`` Specifies the path to the template for + the change password page. Defaults to + ``security/change_password.html``. +``SECURITY_SEND_CONFIRMATION_TEMPLATE`` Specifies the path to the template for + the resend confirmation instructions + page. Defaults to + ``security/send_confirmation.html``. +``SECURITY_SEND_LOGIN_TEMPLATE`` Specifies the path to the template for + the send login instructions page for + passwordless logins. Defaults to + ``security/send_login.html``. +``SECURITY_TWO_FACTOR_VERIFY_CODE_TEMPLATE`` Specifies the path to the template for + the verify code page for the two factor + authentication process. Defaults to + ``security/two_factor_verify_code + .html``. + +``SECURITY_TWO_FACTOR_CHOOSE_METHOD_TEMPLT`` Specifies the path to the template for + the choose method page for the two + factor authentication process. Defaults + to ``security/two_factor_choose_method + .html`` +``SECURITY_TWO_FACTOR_CHANGE_METHOD_TEMPLATE`` Specifies the path to the template for + the change method page for the two + factor authentication process. Defaults + to ``security/two_factor_change_method_ + password_confimration.html``. + +============================================== ======================================= Feature Flags @@ -214,6 +235,15 @@ Feature Flags change password endpoint. The URL for this endpoint is specified by the ``SECURITY_CHANGE_URL`` configuration option. Defaults to ``False``. +``SECURITY_TWO_FACTOR`` Specifies if Flask-Security should enable the + two factor login feature. If set to ``True``, in + addition to their passwords, users will be required to + enter a code that is sent to them. The added feature + includes the ability to send it either via email, sms + message, or Google Authenticator. Default time of + validity is 30 seconds in Google Authenticator and up + to 60 seconds if sent by mail or sms. + Defaults to ``False``. ========================= ====================================================== Email @@ -249,6 +279,12 @@ Email ``SECURITY_EMAIL_HTML`` Sends email as HTML using ``*.html`` template. Defaults to ``True``. +``SECURITY_EMAIL_SUBJECT_TWO_FACTOR`` Sets the subject for the two + factor feature. Defaults to + ``Two Factor Login`` +``SECURITY_EMAIL_SUBJECT_TWO_FACTOR_RESCUE`` Sets the subject for the two + factor help function. Defaults + to ``Two Factor Rescue`` ================================================= ============================== Miscellaneous @@ -290,6 +326,28 @@ Miscellaneous enabled. Always pluralized the time unit for this value. Defaults to ``1 days``. +``SECURITY_TWO_FACTOR_GOOGLE_AUTH_VALIDITY`` Specifies the number of time + windows user has before the token + generated for him using google + authenticator is valid. time + windows specifies the amount of + time, which is 30 seconds for each + window. Default to 0, which is up + to 30 seconds. +``SECURITY_TWO_FACTOR_MAIL_VALIDITY`` Specifies the number of time + windows user has before the token + sent to him using mail is valid. + time windows specifies the amount + of time, which is 30 seconds for + each window. Default to 1, which + is up to 60 seconds. +``SECURITY_TWO_FACTOR_SMS_VALIDITY`` Specifies the number of time + windows user has before the token + sent to him using sms is valid. + time windows specifies the amount + of time, which is 30 seconds for + each window. Default to 5, which + is up to 3 minutes. . ``SECURITY_LOGIN_WITHOUT_CONFIRMATION`` Specifies if a user may login before confirming their email when the value of @@ -315,6 +373,24 @@ Miscellaneous ``SECURITY_DEFAULT_REMEMBER_ME`` Specifies the default "remember me" value used when logging in a user. Defaults to ``False``. +``SECURITY_TWO_FACTOR_ENABLED_METHODS`` Specifies the default enabled + methods for two factor + authentication. defaults to + ``['mail', 'google_authenticator', + 'sms']`` which are the only + supported method at the moment. +``SECURITY_TWO_FACTOR_URI_SERVICE_NAME`` Specifies the name of the service + or application that the user is + authenticating to. Defaults to + ``service_name`` +``SECURITY_TWO_FACTOR_SMS_SERVICE`` Specifies the name of the sms + service provider. Defaults to + ``Dummy`` which does nothing. +``SECURITY_TWO_FACTOR_SMS_SERVICE_CONFIG`` Specifies a dictionary of basic + configurations needed for use of a + sms service. Defaults to + ``{'ACCOUNT_ID': NONE, 'AUTH_TOKEN + ':NONE, 'PHONE_NUMBER': NONE}`` ``SECURITY_DATETIME_FACTORY`` Specifies the default datetime factory. Defaults to ``datetime.datetime.utcnow``. @@ -359,5 +435,12 @@ The default messages and error levels can be found in ``core.py``. * ``SECURITY_MSG_PASSWORD_RESET_REQUEST`` * ``SECURITY_MSG_REFRESH`` * ``SECURITY_MSG_RETYPE_PASSWORD_MISMATCH`` +* ``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_UNAUTHORIZED`` * ``SECURITY_MSG_USER_DOES_NOT_EXIST`` diff --git a/docs/contents.rst.inc b/docs/contents.rst.inc index c1cb8114..df6e9032 100644 --- a/docs/contents.rst.inc +++ b/docs/contents.rst.inc @@ -9,6 +9,7 @@ Contents quickstart models customizing + two_factor_configurations api changelog authors diff --git a/docs/customizing.rst b/docs/customizing.rst index 9ff5f8dd..f3073dd0 100644 --- a/docs/customizing.rst +++ b/docs/customizing.rst @@ -21,6 +21,9 @@ following is a list of view templates: * `security/change_password.html` * `security/send_confirmation.html` * `security/send_login.html` +* `security/two_factor_change_method_password_confimration.html` +* `security/two_factor_choose_method.html` +* `security/two_factor_verify_code.html` Overriding these templates is simple: @@ -103,7 +106,10 @@ The following is a list of all the available form overrides: * ``change_password_form``: Change password form * ``send_confirmation_form``: Send confirmation form * ``passwordless_login_form``: Passwordless login form - +* ``two_factor_verify_code_form``: Two factor code form +* ``two_factor_setup_form``: Two factor setup form +* ``two_factor_change_method_verify_password_form``: Two factor password form +* ``two_factor_rescue_form``: Two factor help user form Emails ------ @@ -124,6 +130,10 @@ The following is a list of email templates: * `security/email/reset_notice.txt` * `security/email/welcome.html` * `security/email/welcome.txt` +* `security/email/two_factor_instructions.html` +* `security/email/two_factor_instructions.txt` +* `security/email/two_factor_rescue.html` +* `security/email/two_factor_rescue.txt` Overriding these templates is simple: diff --git a/docs/features.rst b/docs/features.rst index 9bdd9cd7..c998f49c 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -61,6 +61,17 @@ 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. +Two Factor Authentication +------------------------- +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 +method.(Doing this causes the previous totp secret to become invalid) The token +is provided by one of 3 methods - email, sms (service is not provided), or +Google Authenticator. By default, tokens provided by google authenticator are +valid for 30 seconds, tokens sent by mail for up to 1 minute and tokens sent by +sms for up to 3 minutes. The QR code used to supply Google Authenticator with +the secret is generated using the PyQRCode library. Email Confirmation ------------------ @@ -118,6 +129,8 @@ JSON is supported for the following operations: * Confirmation requests * Forgot password requests * Passwordless login requests +* Two factor login requests +* Change two factor method requests Command Line Interface @@ -131,7 +144,10 @@ Run ``flask --help`` and look for users and roles. .. _Click: http://click.pocoo.org/ .. _Flask-Login: https://flask-login.readthedocs.org/en/latest/ .. _alternative token: https://flask-login.readthedocs.io/en/latest/#alternative-tokens +.. _Flask-Login: http://packages.python.org/Flask-Login/ +.. _alternative token: http://packages.python.org/Flask-Login/#alternative-tokens .. _Flask-Principal: http://packages.python.org/Flask-Principal/ .. _documentation on this topic: http://packages.python.org/Flask-Principal/#granular-resource-protection .. _passlib: http://packages.python.org/passlib/ -.. _bcrypt: https://en.wikipedia.org/wiki/Bcrypt +.. _onetimepass: https://pypi.python.org/pypi/onetimepass/ +.. _PyQRCode: https://pypi.python.org/pypi/PyQRCode/ diff --git a/docs/index.rst b/docs/index.rst index 585908fa..0f5a4ce2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,9 +11,10 @@ Flask application. They include: 5. Token based authentication 6. Token based account activation (optional) 7. Token based password recovery / resetting (optional) -8. User registration (optional) -9. Login tracking (optional) -10. JSON/Ajax Support +8. Two factor authentication (optional) +9. User registration (optional) +10. Login tracking (optional) +11. JSON/Ajax Support Many of these features are made possible by integrating various Flask extensions and libraries. They include: @@ -24,6 +25,8 @@ and libraries. They include: 4. `Flask-WTF `_ 5. `itsdangerous `_ 6. `passlib `_ +8. `onetimepass `_ +9. `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 --git a/docs/models.rst b/docs/models.rst index d6d96033..a848bd1c 100644 --- a/docs/models.rst +++ b/docs/models.rst @@ -15,6 +15,7 @@ your `User` and `Role` model should include the following fields: * ``password`` * ``active`` + **Role** * ``id`` @@ -74,3 +75,17 @@ serializable object: 'name': self.name, 'email': self.email } + +Two_Factor +^^^^^^^^^^ + +If you enable two factor by setting your application's `TWO_FACTOR` +configuration value to `True`, your `User` model will require the following +additional fields: + +* ``totp_secret`` +* ``two_factor_primary_method`` + +If you include 'sms' in SECURITY_TWO_FACTOR_ENABLED_METHOD, your `User` model +will require the following additional field: +* ``phone_number`` diff --git a/docs/two_factor_configurations.rst b/docs/two_factor_configurations.rst new file mode 100644 index 00000000..9207b52a --- /dev/null +++ b/docs/two_factor_configurations.rst @@ -0,0 +1,112 @@ +Two Factor Configurations +========================= + +Two factor authentication provides a second layer of security to any type of +login, requiring extra information or a secondary device to log in, in addition +to ones login credentials. The added feature includes the ability to add a +secondary authentication method using either via email, sms message, or Google +Authenticator. + +The following code sample illustrates how to get started as quickly as +possible using SQLAlchemy and two factor feature: + +- `Basic SQLAlchemy Application <#basic-sqlalchemy-application>`_ + +Basic SQLAlchemy Application +============================= + +SQLAlchemy Install requirements +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + $ mkvirtualenv + $ pip install flask-security flask-sqlalchemy + + +Two Factor Application +~~~~~~~~~~~~~~~~~~~~~~ + +The following code sample illustrates how to get started as quickly as +possible using SQLAlchemy: + +:: + + from flask import Flask, current_app, render_template + from flask.ext.sqlalchemy import SQLAlchemy + from flask.ext.security import Security, SQLAlchemyUserDatastore, \ + UserMixin, RoleMixin, login_required + + + # At top of file + from flask_mail import Mail + + + # Convenient references + from werkzeug.datastructures import MultiDict + from werkzeug.local import LocalProxy + + + _security = LocalProxy(lambda: current_app.extensions['security']) + + _datastore = LocalProxy(lambda: _security.datastore) + + # Create app + app = Flask(__name__) + app.config['DEBUG'] = True + app.config['SECRET_KEY'] = 'super-secret' + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' + + app.config['SECURITY_TWO_FACTOR_ENABLED_METHODS'] = ['mail', + 'google_authenticator'] # 'sms' also valid but requires an sms provider + app.config["SECURITY_TWO_FACTOR"] = True + app.config['SECURITY_TWO_FACTOR_RESCUE_MAIL'] = 'put_your_mail@gmail.com' + app.config['SECURITY_TWO_FACTOR_URI_SERVICE_NAME'] = 'put_your_app_name' + + # Create database connection object + db = SQLAlchemy(app) + + # Define models + roles_users = db.Table('roles_users', + db.Column('user_id', db.Integer(), db.ForeignKey('user.id')), + db.Column('role_id', db.Integer(), db.ForeignKey('role.id'))) + + class Role(db.Model, RoleMixin): + id = db.Column(db.Integer(), primary_key=True) + name = db.Column(db.String(80), unique=True) + description = db.Column(db.String(255)) + + class User(db.Model, UserMixin): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), unique=True) + password = db.Column(db.String(255)) + active = db.Column(db.Boolean()) + confirmed_at = db.Column(db.DateTime()) + roles = db.relationship('Role', secondary=roles_users, + backref=db.backref('users', lazy='dynamic')) + phone_number = db.Column(db.String(15)) + two_factor_primary_method = db.Column(db.String(140)) + totp_secret = db.Column(db.String(16)) + + # Setup Flask-Security + user_datastore = SQLAlchemyUserDatastore(db, User, Role) + security = Security(app, user_datastore) + + mail = Mail(app) + + # Create a user to test with + @app.before_first_request + def create_user(): + db.create_all() + user_datastore.create_user(email='gal@lp.com', password='password', username='gal', + totp_secret=None, two_factor_primary_method=None) + db.session.commit() + + # Views + @app.route('/') + @login_required + def home(): + return render_template('index.html') + + if __name__ == '__main__': + app.run() diff --git a/flask_security/core.py b/flask_security/core.py index 597aa00e..47a299de 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -34,6 +34,10 @@ from .utils import get_config, hash_data, localize_callback, send_mail, \ string_types, url_for_security, verify_and_update_password, verify_hash from .views import create_blueprint +from .forms import LoginForm, ConfirmRegisterForm, RegisterForm, \ + ForgotPasswordForm, ChangePasswordForm, ResetPasswordForm, \ + SendConfirmationForm, PasswordlessLoginForm, TwoFactorVerifyCodeForm, \ + TwoFactorSetupForm, TwoFactorChangeMethodVerifyPasswordForm, TwoFactorRescueForm # Convenient references _security = LocalProxy(lambda: current_app.extensions['security']) @@ -84,23 +88,32 @@ 'CHANGE_PASSWORD_TEMPLATE': 'security/change_password.html', 'SEND_CONFIRMATION_TEMPLATE': 'security/send_confirmation.html', 'SEND_LOGIN_TEMPLATE': 'security/send_login.html', + 'TWO_FACTOR_VERIFY_CODE_TEMPLATE': 'security/two_factor_verify_code.html', + 'TWO_FACTOR_CHOOSE_METHOD_TEMPLATE': 'security/two_factor_choose_method.html', + 'TWO_FACTOR_CHANGE_METHOD_PASSWORD_CONFIRMATION_TEMPLATE': + 'security/two_factor_change_method_password_confimration.html', 'CONFIRMABLE': False, 'REGISTERABLE': False, 'RECOVERABLE': False, 'TRACKABLE': False, 'PASSWORDLESS': False, 'CHANGEABLE': False, + 'TWO_FACTOR': False, 'SEND_REGISTER_EMAIL': True, 'SEND_PASSWORD_CHANGE_EMAIL': True, 'SEND_PASSWORD_RESET_EMAIL': True, 'SEND_PASSWORD_RESET_NOTICE_EMAIL': True, 'LOGIN_WITHIN': '1 days', + 'TWO_FACTOR_GOOGLE_AUTH_VALIDITY': 0, + 'TWO_FACTOR_MAIL_VALIDITY': 1, + 'TWO_FACTOR_SMS_VALIDITY': 5, 'CONFIRM_EMAIL_WITHIN': '5 days', 'RESET_PASSWORD_WITHIN': '5 days', 'LOGIN_WITHOUT_CONFIRMATION': False, 'EMAIL_SENDER': LocalProxy(lambda: current_app.config.get( 'MAIL_DEFAULT_SENDER', 'no-reply@localhost' )), + 'TWO_FACTOR_RESCUE_MAIL': 'no-reply@localhost', 'TOKEN_AUTHENTICATION_KEY': 'auth_token', 'TOKEN_AUTHENTICATION_HEADER': 'Authentication-Token', 'TOKEN_MAX_AGE': None, @@ -120,6 +133,8 @@ 'EMAIL_SUBJECT_PASSWORD_RESET': _('Password reset instructions'), 'EMAIL_PLAINTEXT': True, 'EMAIL_HTML': True, + 'EMAIL_SUBJECT_TWO_FACTOR': 'Two Factor Login', + 'EMAIL_SUBJECT_TWO_FACTOR_RESCUE': 'Two Factor Rescue', 'USER_IDENTITY_ATTRIBUTES': ['email'], 'PASSWORD_SCHEMES': [ 'bcrypt', @@ -138,6 +153,14 @@ ], 'DEPRECATED_HASHING_SCHEMES': ['hex_md5'], 'DATETIME_FACTORY': datetime.utcnow, + 'TWO_FACTOR_ENABLED_METHODS': ['mail', 'google_authenticator', 'sms'], + 'TWO_FACTOR_URI_SERVICE_NAME': 'service_name', + 'TWO_FACTOR_SMS_SERVICE': 'Dummy', + 'TWO_FACTOR_SMS_SERVICE_CONFIG': { + 'ACCOUNT_SID': None, + 'AUTH_TOKEN': None, + 'PHONE_NUMBER': None, + } } #: Default Flask-Security messages @@ -216,7 +239,21 @@ 'LOGIN': ( _('Please log in to access this page.'), 'info'), 'REFRESH': ( - _('Please reauthenticate to access this page.'), 'info'), + 'Please reauthenticate to access this page.', 'info'), + 'TWO_FACTOR_INVALID_TOKEN': ( + 'Invalid Token', 'error'), + 'TWO_FACTOR_LOGIN_SUCCESSFUL': ( + 'Your token has been confirmed', 'success'), + 'TWO_FACTOR_CHANGE_METHOD_SUCCESSFUL': ( + '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'), + 'TWO_FACTOR_METHOD_NOT_AVAILABLE': ( + 'Marked method is not valid', 'error'), } _default_forms = { @@ -228,6 +265,10 @@ 'change_password_form': ChangePasswordForm, 'send_confirmation_form': SendConfirmationForm, 'passwordless_login_form': PasswordlessLoginForm, + 'two_factor_verify_code_form': TwoFactorVerifyCodeForm, + 'two_factor_setup_form': TwoFactorSetupForm, + 'two_factor_change_method_verify_password_form': TwoFactorChangeMethodVerifyPasswordForm, + 'two_factor_rescue_form': TwoFactorRescueForm } @@ -557,6 +598,21 @@ def _register_i18n(): if state.cli_roles_name: app.cli.add_command(roles, state.cli_roles_name) + # configuration mismatch check + if cv('TWO_FACTOR', app=app) is True and len(cv('TWO_FACTOR_ENABLED_METHODS', app=app))\ + < 1: + raise ValueError() + + flag = False + try: + from twilio.rest import TwilioRestClient + flag = True + except: + pass + + if flag is False and cv('TWO_FACTOR_SMS_SERVICE', app=app) == 'Twilio': + raise ValueError() + return state def render_template(self, *args, **kwargs): diff --git a/flask_security/forms.py b/flask_security/forms.py index 5e974ac0..b2a6de00 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -11,8 +11,13 @@ """ import inspect +import os from flask import Markup, current_app, flash, request +from flask import request, current_app, flash, session +from flask_wtf import Form as BaseForm +from wtforms import StringField, PasswordField, validators, \ + SubmitField, HiddenField, BooleanField, ValidationError, Field, RadioField from flask_login import current_user from flask_wtf import FlaskForm as BaseForm from speaklater import make_lazy_gettext @@ -22,6 +27,7 @@ from .confirmable import requires_confirmation from .utils import _, _datastore, config_value, get_message, hash_password, \ localize_callback, url_for_security, validate_redirect_url +from .twofactor import verify_totp lazy_gettext = make_lazy_gettext(lambda: localize_callback) @@ -38,6 +44,9 @@ 'new_password': _('New Password'), 'change_password': _('Change Password'), 'send_login_link': _('Send Login Link') + 'verify_password': 'Verify Method' + 'change_method': 'Change Method', + 'phone': 'Phone Number', } @@ -298,3 +307,99 @@ def validate(self): self.password.errors.append(get_message('PASSWORD_IS_THE_SAME')[0]) return False return True + + +class TwoFactorSetupForm(Form, UserEmailFormMixin): + """The Two Factor token validation form""" + + setup = RadioField('Available Methods', choices=[('mail', 'Set Up Using Mail'), + ('google_authenticator', + 'Set Up Using Google Authenticator'), + ('sms', 'Set Up Using SMS')]) + phone = StringField(get_form_field_label('phone')) + submit = SubmitField(get_form_field_label('sumbit')) + + def __init__(self, *args, **kwargs): + super(TwoFactorSetupForm, self).__init__(*args, **kwargs) + + def validate(self): + if 'setup' not in self.data or self.data['setup']\ + not in config_value('TWO_FACTOR_ENABLED_METHODS'): + do_flash(*get_message('TWO_FACTOR_METHOD_NOT_AVAILABLE')) + return False + + return True + + +class TwoFactorVerifyCodeForm(Form, UserEmailFormMixin): + """The Two Factor token validation form""" + + code = StringField(get_form_field_label('code')) + submit = SubmitField(get_form_field_label('submit code')) + + def __init__(self, *args, **kwargs): + super(TwoFactorVerifyCodeForm, self).__init__(*args, **kwargs) + + def validate(self): + if 'email' in session: + self.user = _datastore.find_user(email=session['email']) + elif 'password_confirmed' in session: + self.user = current_user + else: + os.abort() + # codes sent by sms or mail will be valid for another window cycle + if session['primary_method'] == 'google_authenticator': + self.window = config_value('TWO_FACTOR_GOOGLE_AUTH_VALIDITY') + elif session['primary_method'] == 'mail': + self.window = config_value('TWO_FACTOR_MAIL_VALIDITY') + elif session['primary_method'] == 'sms': + self.window = config_value('TWO_FACTOR_SMS_VALIDITY') + else: + return False + + # verify entered token with user's totp secret + if not verify_totp(token=self.code.data, totp_secret=session['totp_secret'], + window=self.window): + do_flash(*get_message('TWO_FACTOR_INVALID_TOKEN')) + return False + + return True + + +class TwoFactorChangeMethodVerifyPasswordForm(Form, PasswordFormMixin): + """The default change password form""" + + submit = SubmitField(get_form_field_label('verify_password')) + + def validate(self): + if not super(TwoFactorChangeMethodVerifyPasswordForm, self).validate(): + do_flash(*get_message('INVALID_PASSWORD')) + return False + + if not verify_and_update_password(self.password.data, current_user): + self.password.errors.append(get_message('INVALID_PASSWORD')[0]) + return False + + return True + + +class TwoFactorRescueForm(Form, UserEmailFormMixin): + """The Two Factor Rescue validation form""" + + help_setup = RadioField('Trouble Accessing Your Account?', + choices=[('lost_device', 'Can not access mobile device?'), + ('no_mail_access', 'Can not access mail account?')]) + submit = SubmitField(get_form_field_label('submit')) + + def __init__(self, *args, **kwargs): + super(TwoFactorRescueForm, self).__init__(*args, **kwargs) + + def validate(self): + + self.user = _datastore.find_user(email=session['email']) + + if 'primary_method' not in session or 'totp_secret' not in session: + do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) + return False + + return True diff --git a/flask_security/signals.py b/flask_security/signals.py index 53fdf1b2..25b23f29 100644 --- a/flask_security/signals.py +++ b/flask_security/signals.py @@ -17,6 +17,8 @@ user_confirmed = signals.signal("user-confirmed") +user_two_factored = signals.signal("user-two-factored") + confirm_instructions_sent = signals.signal("confirm-instructions-sent") login_instructions_sent = signals.signal("login-instructions-sent") @@ -25,5 +27,6 @@ password_changed = signals.signal("password-changed") -reset_password_instructions_sent = signals.signal( - "password-reset-instructions-sent") +two_factor_method_changed = signals.signal("two-factor-method-changed") + +reset_password_instructions_sent = signals.signal("password-reset-instructions-sent") diff --git a/flask_security/templates/security/email/two_factor_instructions.html b/flask_security/templates/security/email/two_factor_instructions.html new file mode 100644 index 00000000..c9ad7cb6 --- /dev/null +++ b/flask_security/templates/security/email/two_factor_instructions.html @@ -0,0 +1,4 @@ +

Welcome {{ user.username }}!

+ +

You can log into your account using the following code: {{ token }}

+ diff --git a/flask_security/templates/security/email/two_factor_instructions.txt b/flask_security/templates/security/email/two_factor_instructions.txt new file mode 100644 index 00000000..e177828e --- /dev/null +++ b/flask_security/templates/security/email/two_factor_instructions.txt @@ -0,0 +1,4 @@ +Welcome {{ user.username }}! + +You can log into your account using the following code: {{ token }} + diff --git a/flask_security/templates/security/email/two_factor_rescue.html b/flask_security/templates/security/email/two_factor_rescue.html new file mode 100644 index 00000000..238dc403 --- /dev/null +++ b/flask_security/templates/security/email/two_factor_rescue.html @@ -0,0 +1 @@ +

{{ user.email }} can not access mail account

diff --git a/flask_security/templates/security/email/two_factor_rescue.txt b/flask_security/templates/security/email/two_factor_rescue.txt new file mode 100644 index 00000000..8b5a6158 --- /dev/null +++ b/flask_security/templates/security/email/two_factor_rescue.txt @@ -0,0 +1,2 @@ +{{ user.email }} can not access mail account + diff --git a/flask_security/templates/security/two_factor_change_method_password_confimration.html b/flask_security/templates/security/two_factor_change_method_password_confimration.html new file mode 100644 index 00000000..c34e72c7 --- /dev/null +++ b/flask_security/templates/security/two_factor_change_method_password_confimration.html @@ -0,0 +1,9 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% include "security/_messages.html" %} +

Please Enter Your Password

+
+ {{ two_factor_change_method_verify_password_form.hidden_tag() }} + {{ render_field_with_errors(two_factor_change_method_verify_password_form.password, placeholder='enter password') }} + {{ render_field(two_factor_change_method_verify_password_form.submit, value='verify password') }} +
+ diff --git a/flask_security/templates/security/two_factor_choose_method.html b/flask_security/templates/security/two_factor_choose_method.html new file mode 100644 index 00000000..f0cab396 --- /dev/null +++ b/flask_security/templates/security/two_factor_choose_method.html @@ -0,0 +1,32 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_no_label %} +{% include "security/_messages.html" %} +

Two-factor authentication adds an extra layer of security to your account

+

In addition to your username and password, you'll need to use a code that we will send you

+
+ {{ two_factor_setup_form.hidden_tag() }} + {% for subfield in two_factor_setup_form.setup %} + {% if subfield.data in choices %} + {{ render_field_with_errors(subfield) }} + {% endif %} + {% endfor %} + {{ render_field(two_factor_setup_form.submit, value='submit choice') }} + {% if chosen_method=='mail' and chosen_method in choices %} +

To complete logging in, please enter the code sent to your mail

+ {% endif %} + {% if chosen_method=='google_authenticator' and chosen_method in choices %} +

Open Google Authenticator on your device and scan the following qrcode to start receiving codes:

+

+ {% endif %} + {% if chosen_method=='sms' and chosen_method in choices %} +

To Which Phone Number Should We Send Code To?

+ {{ two_factor_setup_form.hidden_tag() }} + {{ render_field_with_errors(two_factor_setup_form.phone, placeholder="enter phone number") }} + {{ render_field(two_factor_setup_form.submit, value='submit phone') }} + {% endif %} +
+
+ {{ two_factor_verify_code_form.hidden_tag() }} + {{ render_field_with_errors(two_factor_verify_code_form.code) }} + {{ render_field(two_factor_verify_code_form.submit, value='submit code') }} +
+{% include "security/_menu.html" %} \ No newline at end of file diff --git a/flask_security/templates/security/two_factor_verify_code.html b/flask_security/templates/security/two_factor_verify_code.html new file mode 100644 index 00000000..c544bbad --- /dev/null +++ b/flask_security/templates/security/two_factor_verify_code.html @@ -0,0 +1,21 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% include "security/_messages.html" %} +

Two Factor Authentication

+

Please enter your authentication code

+
+ {{ two_factor_verify_code_form.hidden_tag() }} + {{ render_field_with_errors(two_factor_verify_code_form.code, placeholder="enter code") }} + {{ render_field(two_factor_verify_code_form.submit, value='submit code') }} +
+
+ {{ two_factor_rescue_form.hidden_tag() }} + {{ render_field_with_errors(two_factor_rescue_form.help_setup) }} + {% if problem=='lost_device' %} +

The code for authentication was sent to your email address

+ {% endif %} + {% if problem=='no_mail_access' %} +

A mail was sent to us in order to reset your application account

+ {% endif %} + {{ render_field(two_factor_rescue_form.submit, value='submit') }} +
+{% include "security/_menu.html" %} \ No newline at end of file diff --git a/flask_security/twofactor.py b/flask_security/twofactor.py new file mode 100644 index 00000000..241a8261 --- /dev/null +++ b/flask_security/twofactor.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +""" + flask_security.two_factor + ~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security two_factor module + + :copyright: (c) 2016 by Gal Stainfeld, at Emedgene +""" + +import os +import base64 +import pyqrcode +import onetimepass + +from flask import current_app as app, session, abort +from flask_login import current_user +from werkzeug.local import LocalProxy + +from .utils import send_mail, config_value, get_message, do_flash, SmsSenderFactory, login_user +from .signals import user_two_factored, two_factor_method_changed + +# Convenient references +_security = LocalProxy(lambda: app.extensions['security']) + +_datastore = LocalProxy(lambda: _security.datastore) + + +def send_security_token(user, method, totp_secret): + """Sends the security token via email for the specified user. + :param user: The user to send the code to + :param method: The method in which the code will be sent ('mail' or 'sms') at the moment + :param totp_secret: a unique shared secret of the user + """ + token_to_be_sent = get_totp_password(totp_secret) + if method == 'mail': + send_mail(config_value('EMAIL_SUBJECT_TWO_FACTOR'), user.email, + 'two_factor_instructions', user=user, token=token_to_be_sent) + elif method == 'sms': + msg = "Use this code to log in: %s" % token_to_be_sent + from_number = config_value('TWO_FACTOR_SMS_SERVICE_CONFIG')['PHONE_NUMBER'] + if 'phone_number' in session: + to_number = session['phone_number'] + else: + to_number = user.phone_number + sms_sender = SmsSenderFactory.createSender(config_value('TWO_FACTOR_SMS_SERVICE')) + sms_sender.send_sms(from_number=from_number, to_number=to_number, msg=msg) + + elif method == 'google_authenticator': + # password are generated automatically in the google authenticator app + pass + + +def get_totp_uri(username, totp_secret): + """ Generate provisioning url for use with the qrcode scanner built into the app + :param username: username of the current user + :param totp_secret: a unique shared secret of the user + :return: + """ + service_name = config_value('TWO_FACTOR_URI_SERVICE_NAME') + return 'otpauth://totp/{0}:{1}?secret={2}&issuer={0}'.format(service_name, username, + totp_secret) + + +def verify_totp(token, totp_secret, window=0): + """ Verifies token for specific user_totp + :param token - token to be check against user's secret + :param totp_secret - a unique shared secret of the user + :param window - optional, compensate for clock skew, number of intervals to check on + each side of the current time. (default is 0 - only check the current clock time) + :return: + """ + return onetimepass.valid_totp(token, totp_secret, window=window) + + +def get_totp_password(totp_secret): + """Get time-based one-time password on the basis of given secret and time + :param totp_secret - a unique shared secret of the user + """ + return onetimepass.get_totp(totp_secret) + + +def generate_totp(): + return base64.b32encode(os.urandom(10)).decode('utf-8') + + +def generate_qrcode(): + """generate the qrcode for the two factor authentication process""" + if 'google_authenticator' not in config_value('TWO_FACTOR_ENABLED_METHODS'): + return abort(404) + if 'primary_method' not in session or session['primary_method'] != 'google_authenticator' \ + or 'totp_secret' not in session: + return abort(404) + + if 'email' in session: + email = session['email'] + elif 'password_confirmed' in session: + email = current_user.email + else: + return abort(404) + + name = email.split('@')[0] + totp = session['totp_secret'] + url = pyqrcode.create(get_totp_uri(name, totp)) + from StringIO import StringIO + stream = StringIO() + url.svg(stream, scale=3) + return stream.getvalue().encode('utf-8'), 200, { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0'} + + +def complete_two_factor_process(user): + """clean session according to process (login or changing two factor method) + and perform action accordingly + :param user - user's to update in database and log in if necessary + """ + totp_secret_changed = user.totp_secret != session['totp_secret'] + if totp_secret_changed or user.two_factor_primary_method != session['primary_method']: + user.totp_secret = session['totp_secret'] + user.two_factor_primary_method = session['primary_method'] + + if 'phone_number' in session: + user.phone_number = session['phone_number'] + del session['phone_number'] + + _datastore.put(user) + + del session['primary_method'] + del session['totp_secret'] + + # if we are changing two factor method + if 'password_confirmed' in session: + del session['password_confirmed'] + do_flash(*get_message('TWO_FACTOR_CHANGE_METHOD_SUCCESSFUL')) + two_factor_method_changed.send(app._get_current_object(), user=user) + + # if we are logging in for the first time + else: + del session['email'] + del session['has_two_factor'] + do_flash(*get_message('TWO_FACTOR_LOGIN_SUCCESSFUL')) + user_two_factored.send(app._get_current_object(), user=user) + login_user(user) + return diff --git a/flask_security/utils.py b/flask_security/utils.py index c56174e7..7f624acc 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -8,7 +8,7 @@ :copyright: (c) 2012 by Matt Wright. :license: MIT, see LICENSE for more details. """ - +import abc import base64 import hashlib import hmac @@ -515,3 +515,51 @@ def _on(app, **data): yield reset_requests finally: reset_password_instructions_sent.disconnect(_on) + + +class SmsSenderBaseClass(object): + __metaclass__ = abc.ABCMeta + + def __init__(self): + pass + + @abc.abstractmethod + def send_sms(self, from_number, to_number, msg): + """ Abstract method for sensing sms messages""" + return + + +class DummySmsSender(SmsSenderBaseClass): + def send_sms(self, from_number, to_number, msg): + return + + +class SmsSenderFactory(object): + senders = { + + 'Dummy': DummySmsSender + } + + @classmethod + def createSender(cls, name, *args, **kwargs): + return cls.senders[name](*args, **kwargs) + + +try: + from twilio.rest import TwilioRestClient + + class TwilioSmsSender(SmsSenderBaseClass): + def __init__(self): + self.account_sid = config_value('TWO_FACTOR_SMS_SERVICE_CONFIG')['ACCOUNT_SID'] + self.auth_token = config_value('TWO_FACTOR_SMS_SERVICE_CONFIG')['AUTH_TOKEN'] + + def send_sms(self, from_number, to_number, msg): + client = TwilioRestClient(self.account_sid, self.auth_token) + client.messages.create( + to=to_number, + from_=from_number, + body=msg,) + + SmsSenderFactory.senders['Twilio'] = TwilioSmsSender +except: + pass diff --git a/flask_security/views.py b/flask_security/views.py index 25807fbf..dd93b4c1 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -9,8 +9,8 @@ :license: MIT, see LICENSE for more details. """ -from flask import Blueprint, after_this_request, current_app, jsonify, \ - redirect, request +from flask import current_app, redirect, request, jsonify, \ + after_this_request, Blueprint, session, abort from flask_login import current_user from werkzeug.datastructures import MultiDict from werkzeug.local import LocalProxy @@ -23,11 +23,11 @@ from .recoverable import reset_password_token_status, \ send_reset_password_instructions, update_password from .registerable import register_user -from .utils import url_for_security as url_for -from .utils import config_value, do_flash, get_message, \ - get_post_login_redirect, get_post_logout_redirect, \ - get_post_register_redirect, get_url, login_user, logout_user, \ - slash_url_suffix +from .utils import config_value, do_flash, get_url, get_post_login_redirect, \ + get_post_register_redirect, get_message, login_user, logout_user, \ + url_for_security as url_for, slash_url_suffix, send_mail +from .twofactor import send_security_token, generate_totp, generate_qrcode, \ + complete_two_factor_process # Convenient references _security = LocalProxy(lambda: current_app.extensions['security']) @@ -91,6 +91,8 @@ def login(): def logout(): """View function which handles a logout request.""" + if config_value('TWO_FACTOR') is True and 'password_confirmed' in session: + del session['password_confirmed'] if current_user.is_authenticated: logout_user() @@ -334,6 +336,214 @@ def change_password(): ) +@anonymous_user_required +def two_factor_login(): + """View function for two factor authentication login""" + # if we already validated email&password, there is no need to do it again + form_class = _security.login_form + + if request.json: + form = form_class(MultiDict(request.json)) + else: + form = form_class() + + # if user's email&password approved + if form.validate_on_submit(): + user = form.user + session['email'] = user.email + # if user's two factor properties are not configured + if user.two_factor_primary_method is None or user.totp_secret is None: + session['has_two_factor'] = False + return redirect(url_for('two_factor_setup_function')) + # if user's two factor properties are configured + else: + session['has_two_factor'] = True + session['primary_method'] = user.two_factor_primary_method + session['totp_secret'] = user.totp_secret + send_security_token(user=user, method=user.two_factor_primary_method, + totp_secret=user.totp_secret) + return redirect(url_for('two_factor_token_validation')) + + if request.json: + form.user = current_user + return _render_json(form) + + return _security.render_template(config_value('LOGIN_USER_TEMPLATE'), + login_user_form=form, + **_ctx('login')) + + +def two_factor_setup_function(): + """View function for two factor setup during login process""" + # user's email&password not approved or we are logged in and didn't validate password + if 'password_confirmed' not in session: + if 'email' not in session or 'has_two_factor' not in session: + do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) + return redirect(get_post_login_redirect()) + + # user's email&password approved and two factor properties were configured before + if session['has_two_factor'] is True: + do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) + return redirect(url_for('two_factor_token_validation')) + + user = _datastore.find_user(email=session['email']) + else: + user = current_user + form_class = _security.two_factor_setup_form + + if request.json: + form = form_class(MultiDict(request.json)) + else: + form = form_class() + + if form.validate_on_submit(): + # totp and primarty_method are added to session to flag the user's temporary choice + session['totp_secret'] = generate_totp() + session['primary_method'] = form['setup'].data + if len(form.data['phone']) > 0: + session['phone_number'] = form.data['phone'] + send_security_token(user=user, method=session['primary_method'], + totp_secret=session['totp_secret']) + code_form = _security.two_factor_verify_code_form() + return _security.render_template(config_value('TWO_FACTOR_CHOOSE_METHOD_TEMPLATE'), + two_factor_setup_form=form, + two_factor_verify_code_form=code_form, + choices=config_value('TWO_FACTOR_ENABLED_METHODS'), + chosen_method=session['primary_method'], + **_ctx('two_factor_setup_function')) + + if request.json: + return _render_json(form, include_user=False) + + code_form = _security.two_factor_verify_code_form() + return _security.render_template(config_value('TWO_FACTOR_CHOOSE_METHOD_TEMPLATE'), + two_factor_setup_form=form, + two_factor_verify_code_form=code_form, + choices=config_value('TWO_FACTOR_ENABLED_METHODS'), + **_ctx('two_factor_setup_function')) + + +def two_factor_token_validation(): + """View function for two factor token validation during login process""" + # if we are in login process and not changing current two factor method + if 'password_confirmed' not in session: + # user's email&password not approved or we are logged in and didn't validate password + if 'has_two_factor' not in session: + do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) + return redirect(get_post_login_redirect()) + + # make sure user has or has chosen a two factor method before we try to validate + if 'totp_secret' not in session or 'primary_method' not in session: + do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) + return redirect(url_for('two_factor_setup_function')) + + form_class = _security.two_factor_verify_code_form + + if request.json: + form = form_class(MultiDict(request.json)) + else: + form = form_class() + + if form.validate_on_submit(): + complete_two_factor_process(form.user) + after_this_request(_commit) + return redirect(get_post_login_redirect()) + + if request.json: + return _render_json(form, include_user=False) + + # if we were trying to validate a new method + if 'password_confirmed' in session or session['has_two_factor'] is False: + setup_form = _security.two_factor_setup_form() + return _security.render_template(config_value('TWO_FACTOR_CHOOSE_METHOD_TEMPLATE'), + two_factor_setup_form=setup_form, + two_factor_verify_code_form=form, + choices=config_value('TWO_FACTOR_ENABLED_METHODS'), + **_ctx('two_factor_setup_function')) + # if we were trying to validate an existing method + else: + rescue_form = _security.two_factor_rescue_form() + return _security.render_template(config_value('TWO_FACTOR_VERIFY_CODE_TEMPLATE'), + two_factor_rescue_form=rescue_form, + two_factor_verify_code_form=form, + problem=None, + **_ctx('two_factor_token_validaion')) + + +@anonymous_user_required +def two_factor_rescue_function(): + """ Function that handles a situation where user can't enter his two factor validation code""" + # user's email&password yet to be approved + if 'email' not in session: + do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) + return abort(404) + + # user's email&password approved and two factor properties were not configured + if 'totp_secret' not in session or 'primary_method' not in session: + do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) + return abort(404) + + form_class = _security.two_factor_rescue_form + + if request.json: + form = form_class(MultiDict(request.json)) + else: + form = form_class() + + problem = None + if form.validate_on_submit(): + problem = form.data['help_setup'] + # if the problem is that user can't access his device, we send him code through mail + if problem == 'lost_device': + send_security_token(user=form.user, method='mail', totp_secret=form.user.totp_secret) + # send app provider a mail message regarding trouble + elif problem == 'no_mail_access': + send_mail(config_value('EMAIL_SUBJECT_TWO_FACTOR_RESCUE'), + config_value('TWO_FACTOR_RESCUE_MAIL'), 'two_factor_rescue', user=form.user) + else: + return "", 404 + + if request.json: + return _render_json(form, include_user=False) + + code_form = _security.two_factor_verify_code_form() + return _security.render_template(config_value('TWO_FACTOR_VERIFY_CODE_TEMPLATE'), + two_factor_verify_code_form=code_form, + two_factor_rescue_form=form, + rescue_mail=config_value('TWO_FACTOR_RESCUE_MAIL'), + problem=str(problem), + **_ctx('two_factor_token_validation')) + + +@login_required +def two_factor_password_confirmation(): + """View function which handles a change two factor method request.""" + form_class = _security.two_factor_change_method_verify_password_form + + if request.json: + form = form_class(MultiDict(request.json)) + else: + form = form_class() + + if form.validate_on_submit(): + session['password_confirmed'] = True + do_flash(get_message('TWO_FACTOR_PASSWORD_CONFIRMATION_DONE')) + return redirect(url_for('two_factor_setup_function')) + + if request.json: + form.user = current_user + return _render_json(form) + + return _security.render_template(config_value + ('TWO_FACTOR_CHANGE_METHOD_PASSWORD_CONFIRMATION_TEMPLATE'), + two_factor_change_method_verify_password_form=form, + **_ctx('two_factor_change_method_password_confirmation')) + + +def two_factor_qrcode(): + return generate_qrcode() + + def create_blueprint(state, import_name): """Creates the security extension blueprint""" @@ -351,6 +561,27 @@ def create_blueprint(state, import_name): bp.route(state.login_url + slash_url_suffix(state.login_url, ''), endpoint='token_login')(token_login) + + elif state.two_factor: + bp.route(state.login_url, + methods=['GET', 'POST'], + endpoint='login')(two_factor_login) + bp.route('/' + slash_url_suffix('/', 'two_factor_setup_function'), + methods=['GET', 'POST'], + endpoint='two_factor_setup_function')(two_factor_setup_function) + bp.route('/' + slash_url_suffix('/', 'two_factor_token_validation'), + methods=['GET', 'POST'], + endpoint='two_factor_token_validation')(two_factor_token_validation) + bp.route('/' + slash_url_suffix('/', 'two_factor_qrcode'), + endpoint='two_factor_qrcode')(two_factor_qrcode) + bp.route('/' + slash_url_suffix('/', 'two_factor_rescue_function'), + methods=['GET', 'POST'], + endpoint='two_factor_rescue_function')(two_factor_rescue_function) + bp.route(state.change_url + slash_url_suffix( + state.change_url, 'two_factor_password_confirmation'), + methods=['GET', 'POST'], + endpoint='two_factor_password_confirmation')(two_factor_password_confirmation) + else: bp.route(state.login_url, methods=['GET', 'POST'], diff --git a/requirements.txt b/requirements.txt index 0e9ba454..3a666c6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,11 @@ -# Trick for ReadTheDocs to install all requirements: +Flask>=0.9 +Flask-Login>=0.3.0,<0.4 +Flask-Mail>=0.7.3 +Flask-Principal>=0.3.3 +Flask-WTF>=0.8 +itsdangerous>=0.17 +passlib>=1.6.4 +onetimepass==1.0.1 +PyQRCode==1.1.1 -e .[all] diff --git a/tests/conftest.py b/tests/conftest.py index 3c0f11a9..5c053c8a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -46,12 +46,13 @@ def app(request): app.config['TESTING'] = True app.config['LOGIN_DISABLED'] = False app.config['WTF_CSRF_ENABLED'] = False + app.config['SECURITY_TWO_FACTOR_SMS_SERVICE'] = 'test' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SECURITY_PASSWORD_SALT'] = 'salty' for opt in ['changeable', 'recoverable', 'registerable', - 'trackable', 'passwordless', 'confirmable']: + 'trackable', 'passwordless', 'confirmable', 'two_factor']: app.config['SECURITY_' + opt.upper()] = opt in request.keywords if 'settings' in request.keywords: @@ -168,6 +169,9 @@ class User(db.Document, UserMixin): password = db.StringField(required=False, max_length=255) last_login_at = db.DateTimeField() current_login_at = db.DateTimeField() + two_factor_primary_method = db.StringField(max_length=255) + totp_secret = db.StringField(max_length=255) + phone_number = db.StringField(max_length=255) last_login_ip = db.StringField(max_length=100) current_login_ip = db.StringField(max_length=100) login_count = db.IntField() @@ -208,6 +212,9 @@ class User(db.Model, UserMixin): username = db.Column(db.String(255)) password = db.Column(db.String(255)) last_login_at = db.Column(db.DateTime()) + two_factor_primary_method = db.Column(db.String(255)) + totp_secret = db.Column(db.String(255)) + phone_number = db.Column(db.String(255)) current_login_at = db.Column(db.DateTime()) last_login_ip = db.Column(db.String(100)) current_login_ip = db.Column(db.String(100)) @@ -316,6 +323,9 @@ class User(db.Model, UserMixin): password = TextField(null=True) last_login_at = DateTimeField(null=True) current_login_at = DateTimeField(null=True) + two_factor_primary_method = TextField(null=True) + totp_secret = TextField(null=True) + phone_number = TextField(null=True) last_login_ip = TextField(null=True) current_login_ip = TextField(null=True) login_count = IntegerField(null=True) diff --git a/tests/test_changeable.py b/tests/test_changeable.py index 89482f45..f2f09f0e 100644 --- a/tests/test_changeable.py +++ b/tests/test_changeable.py @@ -82,7 +82,6 @@ def on_password_changed(app, user): assert get_message('PASSWORD_CHANGE') in response.data assert b'Home Page' in response.data assert len(recorded) == 1 - assert len(outbox) == 1 assert "Your password has been changed" in outbox[0].html # Test leading & trailing whitespace not stripped diff --git a/tests/test_misc.py b/tests/test_misc.py index 81dc6fce..df7ae1f4 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -147,6 +147,14 @@ def test_addition_identity_attributes(app, sqlalchemy_datastore): assert b'Hello matt@lp.com' in response.data +def test_passwordless_and_two_factor_configuration_mismatch(app, sqlalchemy_datastore): + with pytest.raises(ValueError): + init_app_with_options(app, sqlalchemy_datastore, **{ + 'SECURITY_TWO_FACTOR': True, + 'SECURITY_TWO_FACTOR_ENABLED_METHODS': [] + }) + + def test_flash_messages_off(app, sqlalchemy_datastore, get_message): init_app_with_options(app, sqlalchemy_datastore, **{ 'SECURITY_FLASH_MESSAGES': False diff --git a/tests/test_two_factor.py b/tests/test_two_factor.py new file mode 100644 index 00000000..3fd1e4f2 --- /dev/null +++ b/tests/test_two_factor.py @@ -0,0 +1,234 @@ +# -*- coding: utf-8 -*- +""" + test_two_factor + ~~~~~~~~~~~~~~~~~ + + two_factor tests +""" + +import onetimepass +import pytest + +from utils import logout +from flask_security.utils import SmsSenderBaseClass, SmsSenderFactory + +pytestmark = pytest.mark.two_factor() + + +class SmsTestSender(SmsSenderBaseClass): + SmsSenderBaseClass.messages = [] + SmsSenderBaseClass.count = 0 + + def __init__(self): + super(SmsSenderBaseClass, self).__init__() + + def send_sms(self, from_number, to_number, msg): + SmsSenderBaseClass.messages.append(msg) + SmsSenderBaseClass.count += 1 + return + + def get_count(self): + return SmsSenderBaseClass.count + +SmsSenderFactory.senders['test'] = SmsTestSender + + +class TestMail(): + + def __init__(self): + self.count = 0 + self.msg = "" + + def send(self, msg): + self.msg = msg + self.count += 1 + + +def assert_flashes(client, expected_message, expected_category='message'): + with client.session_transaction() as session: + try: + category, message = session['_flashes'][0] + except KeyError: + raise AssertionError('nothing flashed') + assert expected_message in message + assert expected_category == category + + +def test_two_factor_two_factor_setup_function_anonymous(app, client): + + # trying to pick method without doing earlier stage + data = dict(setup="mail") + response = client.post('/two_factor_setup_function/', data=data) + assert response.status_code == 302 + flash_message = 'You currently do not have permissions to access this page' + assert_flashes(client, flash_message, expected_category='error') + + +def test_two_factor_flag(app, client): + # trying to verify code without going through two factor first login function + wrong_code = '000000' + response = client.post('/two_factor_token_validation/', data=dict(code=wrong_code), + follow_redirects=True) + assert 'You currently do not have permissions to access this page' in response.data + + # Test login using invalid email + data = dict(email="nobody@lp.com", password="password") + response = client.post('/login', data=data, follow_redirects=True) + assert 'Specified user does not exist' in response.data + json_data = '{"email": "nobody@lp.com", "password": "password"}' + response = client.post('/login', data=json_data, headers={'Content-Type': 'application/json'}, + follow_redirects=True) + assert 'Specified user does not exist' in response.data + + # Test login using valid email and invalid password + data = dict(email="gal@lp.com", password="wrong_pass") + response = client.post('/login', data=data, follow_redirects=True) + assert 'Invalid password' in response.data + json_data = '{"email": "gal@lp.com", "password": "wrong_pass"}' + response = client.post('/login', data=json_data, headers={'Content-Type': 'application/json'}, + follow_redirects=True) + assert 'Invalid password' in response.data + + # Test two factor authentication first login + data = dict(email="matt@lp.com", password="password") + response = client.post('/login', data=data, follow_redirects=True) + assert 'Two-factor authentication adds an extra layer of security' in response.data + response = client.post('/two_factor_setup_function/', data=dict(setup="not_a_method"), + follow_redirects=True) + assert 'Marked method is not valid' in response.data + + # try non-existing setup on setup page (using json) + json_data = '{"setup": "not_a_method"}' + response = client.post('/two_factor_setup_function/', data=json_data, + headers={'Content-Type': 'application/json'}, follow_redirects=True) + assert '"response": {}' in response.data + + json_data = '{"setup": "mail"}' + response = client.post('/two_factor_setup_function/', data=json_data, + headers={'Content-Type': 'application/json'}, follow_redirects=True) + + # Test for sms in process of valid login + sms_sender = SmsSenderFactory.createSender('test') + json_data = '{"email": "gal@lp.com", "password": "password"}' + response = client.post('/login', data=json_data, headers={'Content-Type': 'application/json'}, + follow_redirects=True) + assert 'Please enter your authentication code' in response.data + assert sms_sender.get_count() == 1 + + code = sms_sender.messages[0].split()[-1] + + # submit bad token to two_factor_token_validation + response = client.post('/two_factor_token_validation/', data=dict(code=wrong_code)) + assert 'Invalid Token' in response.data + + # sumbit right token and show appropriate response + response = client.post('/two_factor_token_validation/', data=dict(code=code), + follow_redirects=True) + assert 'Your token has been confirmed' in response.data + + # try confirming password with a wrong one + response = client.post('/change/two_factor_password_confirmation', data=dict(password=""), + follow_redirects=True) + assert 'Invalid password' in response.data + + # try confirming password with a wrong one + json + json_data = '{"password": "wrong_password"}' + response = client.post('/change/two_factor_password_confirmation', + data=json_data, headers={'Content-Type': 'application/json'}, + follow_redirects=True) + assert 'Invalid password' in response.data + + # Test change two_factor password confirmation view to mail + password = 'password' + response = client.post('/change/two_factor_password_confirmation', + data=dict(password=password), follow_redirects=True) + assert 'You successfully confirmed password' in response.data + assert 'Two-factor authentication adds an extra layer of security' in response.data + + # change method (from sms to mail) + setup_data = dict(setup='mail') + testMail = TestMail() + app.extensions['mail'] = testMail + response = client.post('/two_factor_setup_function/', data=setup_data, follow_redirects=True) + assert 'To complete logging in, please enter the code sent to your mail' in response.data + + code = testMail.msg.body.split()[-1] + # sumbit right token and show appropriate response + response = client.post('/two_factor_token_validation/', data=dict(code=code), + follow_redirects=True) + assert 'You successfully changed your two factor method' in response.data + + # Test change two_factor password confirmation view to google authenticator + password = 'password' + response = client.post('/change/two_factor_password_confirmation', + data=dict(password=password), follow_redirects=True) + assert 'You successfully confirmed password' in response.data + assert 'Two-factor authentication adds an extra layer of security' in response.data + setup_data = dict(setup='google_authenticator') + response = client.post('/two_factor_setup_function/', data=setup_data, follow_redirects=True) + assert 'Open Google Authenticator on your device' in response.data + qrcode_page_response = client.get('/two_factor_qrcode/', data=setup_data, + follow_redirects=True) + assert 'svg' in qrcode_page_response.data + + logout(client) + + # Test for google_authenticator (test) + json_data = '{"email": "gal2@lp.com", "password": "password"}' + response = client.post('/login', data=json_data, headers={'Content-Type': 'application/json'}, + follow_redirects=True) + totp_secret = u'RCTE75AP2GWLZIFR' + code = str(onetimepass.get_totp(totp_secret)) + response = client.post('/two_factor_token_validation/', data=dict(code=code), + follow_redirects=True) + assert 'Your token has been confirmed' in response.data + + logout(client) + + # Test two factor authentication first login + data = dict(email="matt@lp.com", password="password") + response = client.post('/login', data=data, follow_redirects=True) + assert 'Two-factor authentication adds an extra layer of security' in response.data + + # check availability of qrcode page when this option is not picked + qrcode_page_response = client.get('/two_factor_qrcode/', follow_redirects=False) + assert qrcode_page_response.status_code == 404 + + # check availability of qrcode page when this option is picked + setup_data = dict(setup='google_authenticator') + response = client.post('/two_factor_setup_function/', data=setup_data, follow_redirects=True) + assert 'Open Google Authenticator on your device' in response.data + qrcode_page_response = client.get('/two_factor_qrcode/', data=setup_data, + follow_redirects=True) + assert 'svg' in qrcode_page_response.data + + # check appearence of setup page when sms picked and phone number entered + sms_sender = SmsSenderFactory.createSender('test') + data = dict(setup='sms', phone="+111111111111") + response = client.post('/two_factor_setup_function/', data=data, follow_redirects=True) + assert 'To Which Phone Number Should We Send Code To' in response.data + assert sms_sender.get_count() == 2 + code = sms_sender.messages[1].split()[-1] + + response = client.post('/two_factor_token_validation/', data=dict(code=code), + follow_redirects=True) + assert 'Your token has been confirmed' in response.data + + logout(client) + + # check when two_factor_rescue function should not appear + rescue_data_json = '{"help_setup": "lost_device"}' + response = client.post('/two_factor_rescue_function/', data=rescue_data_json, + headers={'Content-Type': 'application/json'}) + assert response.status_code == 404 + + # check when two_factor_rescue function should appear + data = dict(email="gal2@lp.com", password="password") + response = client.post('/login', data=data, follow_redirects=True) + assert 'Please enter your authentication code' in response.data + rescue_data = dict(help_setup='lost_device') + response = client.post('/two_factor_rescue_function/', data=rescue_data, follow_redirects=True) + assert 'The code for authentication was sent to your email address' in response.data + rescue_data = dict(help_setup='no_mail_access') + response = client.post('/two_factor_rescue_function/', data=rescue_data, follow_redirects=True) + assert 'A mail was sent to us in order to reset your application account' in response.data diff --git a/tests/utils.py b/tests/utils.py index b2726393..7420b8c0 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -48,13 +48,16 @@ def create_roles(ds): def create_users(ds, count=None): - users = [('matt@lp.com', 'matt', 'password', ['admin'], True), - ('joe@lp.com', 'joe', 'password', ['editor'], True), - ('dave@lp.com', 'dave', 'password', ['admin', 'editor'], True), - ('jill@lp.com', 'jill', 'password', ['author'], True), - ('tiya@lp.com', 'tiya', 'password', [], False), - ('gene@lp.com', 'gene', 'password', [], True), - ('jess@lp.com', 'jess', None, [], True)] + users = [('matt@lp.com', 'matt', 'password', ['admin'], True, None, None), + ('joe@lp.com', 'joe', 'password', ['editor'], True, None, None), + ('dave@lp.com', 'dave', 'password', ['admin', 'editor'], True, None, None), + ('jill@lp.com', 'jill', 'password', ['author'], True, None, None), + ('tiya@lp.com', 'tiya', 'password', [], False, None, None), + ('jess@lp.com', 'jess', None, [], True, None, None), + ('gal@lp.com', 'gal', 'password', ['admin'], True, 'sms', u'RCTE75AP2GWLZIFR'), + ('gal2@lp.com', 'gal2', 'password', ['admin'], True, 'google_authenticator', + u'RCTE75AP2GWLZIFR'), + ('gal3@lp.com', 'gal3', 'password', ['admin'], True, 'mail', u'RCTE75AP2GWLZIFR')] count = count or len(users) for u in users[:count]: @@ -63,11 +66,8 @@ def create_users(ds, count=None): pw = encrypt_password(pw) roles = [ds.find_or_create_role(rn) for rn in u[3]] ds.commit() - user = ds.create_user( - email=u[0], - username=u[1], - password=pw, - active=u[4]) + user = ds.create_user(email=u[0], username=u[1], password=pw, active=u[4], + two_factor_primary_method=u[5], totp_secret=u[6]) ds.commit() for role in roles: ds.add_role_to_user(user, role) From eb6e673ff062d51f094b94ec4e435982abc4dd92 Mon Sep 17 00:00:00 2001 From: Tyler Date: Tue, 7 May 2019 16:40:04 -0500 Subject: [PATCH 02/32] fix double import and unused import, revert get_user --- .gitignore | 6 ++++++ AUTHORS | 2 +- flask_security/core.py | 19 ++++++++----------- flask_security/datastore.py | 1 + 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index b04ba2f6..a34f1898 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,9 @@ Session.vim *~ .eggs/README.txt + +# Pycharm files +.idea/ + +# VScode +.vscode/ \ No newline at end of file diff --git a/AUTHORS b/AUTHORS index 881576e8..99396f74 100644 --- a/AUTHORS +++ b/AUTHORS @@ -39,4 +39,4 @@ Tristan Escalada Vadim Kotov Walt Askew John Paraskevopoulos -Gal Stainfeld +Tyler Baur diff --git a/flask_security/core.py b/flask_security/core.py index 47a299de..da3a6a0f 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -26,18 +26,15 @@ from werkzeug.datastructures import ImmutableList from werkzeug.local import LocalProxy -from .forms import ChangePasswordForm, ConfirmRegisterForm, \ - ForgotPasswordForm, LoginForm, PasswordlessLoginForm, RegisterForm, \ - ResetPasswordForm, SendConfirmationForm -from .utils import _ -from .utils import config_value as cv -from .utils import get_config, hash_data, localize_callback, send_mail, \ - string_types, url_for_security, verify_and_update_password, verify_hash -from .views import create_blueprint from .forms import LoginForm, ConfirmRegisterForm, RegisterForm, \ ForgotPasswordForm, ChangePasswordForm, ResetPasswordForm, \ SendConfirmationForm, PasswordlessLoginForm, TwoFactorVerifyCodeForm, \ TwoFactorSetupForm, TwoFactorChangeMethodVerifyPasswordForm, TwoFactorRescueForm +from .utils import config_value as cv +from .utils import _, get_config, hash_data, localize_callback, string_types, \ + url_for_security, verify_hash, send_mail +from .views import create_blueprint + # Convenient references _security = LocalProxy(lambda: current_app.extensions['security']) @@ -605,12 +602,12 @@ def _register_i18n(): flag = False try: - from twilio.rest import TwilioRestClient - flag = True + import importlib.util as import_util + flag = import_util.find_spec('twilio') except: pass - if flag is False and cv('TWO_FACTOR_SMS_SERVICE', app=app) == 'Twilio': + if not flag and cv('TWO_FACTOR_SMS_SERVICE', app=app) == 'Twilio': raise ValueError() return state diff --git a/flask_security/datastore.py b/flask_security/datastore.py index c8109eb8..e2b932c9 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -267,6 +267,7 @@ def find_role(self, role): return self.role_model.query.filter_by(name=role).first() + class SQLAlchemySessionUserDatastore(SQLAlchemyUserDatastore, SQLAlchemyDatastore): """A SQLAlchemy datastore implementation for Flask-Security that assumes the From 794faea1930eb2d12992dac67b309a57af03b6e6 Mon Sep 17 00:00:00 2001 From: tbaur Date: Wed, 8 May 2019 09:13:22 -0500 Subject: [PATCH 03/32] syntax error --- flask_security/forms.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flask_security/forms.py b/flask_security/forms.py index b2a6de00..a2e60a14 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -43,10 +43,10 @@ 'retype_password': _('Retype Password'), 'new_password': _('New Password'), 'change_password': _('Change Password'), - 'send_login_link': _('Send Login Link') - 'verify_password': 'Verify Method' - 'change_method': 'Change Method', - 'phone': 'Phone Number', + 'send_login_link': _('Send Login Link'), + 'verify_password': _('Verify Method'), + 'change_method': _('Change Method'), + 'phone': _('Phone Number'), } From 3eebb5ffed8173c063d28cc11c8072c44d6e29a9 Mon Sep 17 00:00:00 2001 From: tbaur Date: Wed, 8 May 2019 09:29:06 -0500 Subject: [PATCH 04/32] fix missing setup.py install_requires --- setup.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.py b/setup.py index 03c171da..b0a19bdb 100755 --- a/setup.py +++ b/setup.py @@ -26,6 +26,7 @@ 'pytest-translations>=2.0.0', 'pytest>=3.3.0', 'sqlalchemy>=0.8.0', + ] extras_require = { @@ -54,6 +55,8 @@ 'Flask-BabelEx>=0.9.3', 'itsdangerous>=0.21', 'passlib>=1.7', + 'pyqrcode>=1.2', + 'onetimepass>=1.0.1', ] packages = find_packages() From ab07db1a3fd7332599c4e7ef950c57aa33d8b446 Mon Sep 17 00:00:00 2001 From: tbaur Date: Wed, 8 May 2019 09:44:05 -0500 Subject: [PATCH 05/32] fix TestMail (remove __init_) --- tests/test_two_factor.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/test_two_factor.py b/tests/test_two_factor.py index 3fd1e4f2..86126fc7 100644 --- a/tests/test_two_factor.py +++ b/tests/test_two_factor.py @@ -35,11 +35,15 @@ def get_count(self): class TestMail(): - def __init__(self): - self.count = 0 - self.msg = "" + # def __init__(self): + # self.count = 0 + # self.msg = "" def send(self, msg): + if not self.msg: + self.msg="" + if not self.count: + self.count=0 self.msg = msg self.count += 1 @@ -148,6 +152,8 @@ def test_two_factor_flag(app, client): # change method (from sms to mail) setup_data = dict(setup='mail') testMail = TestMail() + testMail.msg="" + testMail.count=0 app.extensions['mail'] = testMail response = client.post('/two_factor_setup_function/', data=setup_data, follow_redirects=True) assert 'To complete logging in, please enter the code sent to your mail' in response.data From 4e1b9cb28e75fab06358f9317f3f2dce2b94596b Mon Sep 17 00:00:00 2001 From: tbaur Date: Wed, 8 May 2019 09:51:11 -0500 Subject: [PATCH 06/32] verify_and_update_password fix --- flask_security/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_security/core.py b/flask_security/core.py index da3a6a0f..647ee1b7 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -32,7 +32,7 @@ TwoFactorSetupForm, TwoFactorChangeMethodVerifyPasswordForm, TwoFactorRescueForm from .utils import config_value as cv from .utils import _, get_config, hash_data, localize_callback, string_types, \ - url_for_security, verify_hash, send_mail + url_for_security, verify_hash, send_mail, verify_and_update_password from .views import create_blueprint From cf617fd0b04c8b5a39d256de96dc1106a7a157cb Mon Sep 17 00:00:00 2001 From: tbaur Date: Wed, 8 May 2019 10:18:36 -0500 Subject: [PATCH 07/32] formatting --- flask_security/core.py | 13 ++++++++----- flask_security/forms.py | 21 +++++++++++++-------- flask_security/twofactor.py | 11 +++++++---- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 647ee1b7..2c6e427c 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -29,7 +29,8 @@ from .forms import LoginForm, ConfirmRegisterForm, RegisterForm, \ ForgotPasswordForm, ChangePasswordForm, ResetPasswordForm, \ SendConfirmationForm, PasswordlessLoginForm, TwoFactorVerifyCodeForm, \ - TwoFactorSetupForm, TwoFactorChangeMethodVerifyPasswordForm, TwoFactorRescueForm + TwoFactorSetupForm, TwoFactorChangeMethodVerifyPasswordForm,\ + TwoFactorRescueForm from .utils import config_value as cv from .utils import _, get_config, hash_data, localize_callback, string_types, \ url_for_security, verify_hash, send_mail, verify_and_update_password @@ -85,8 +86,10 @@ 'CHANGE_PASSWORD_TEMPLATE': 'security/change_password.html', 'SEND_CONFIRMATION_TEMPLATE': 'security/send_confirmation.html', 'SEND_LOGIN_TEMPLATE': 'security/send_login.html', - 'TWO_FACTOR_VERIFY_CODE_TEMPLATE': 'security/two_factor_verify_code.html', - 'TWO_FACTOR_CHOOSE_METHOD_TEMPLATE': 'security/two_factor_choose_method.html', + 'TWO_FACTOR_VERIFY_CODE_TEMPLATE': + 'security/two_factor_verify_code.html', + 'TWO_FACTOR_CHOOSE_METHOD_TEMPLATE': + 'security/two_factor_choose_method.html', 'TWO_FACTOR_CHANGE_METHOD_PASSWORD_CONFIRMATION_TEMPLATE': 'security/two_factor_change_method_password_confimration.html', 'CONFIRMABLE': False, @@ -596,8 +599,8 @@ def _register_i18n(): app.cli.add_command(roles, state.cli_roles_name) # configuration mismatch check - if cv('TWO_FACTOR', app=app) is True and len(cv('TWO_FACTOR_ENABLED_METHODS', app=app))\ - < 1: + if cv('TWO_FACTOR', app=app) is True and\ + len(cv('TWO_FACTOR_ENABLED_METHODS', app=app))< 1: raise ValueError() flag = False diff --git a/flask_security/forms.py b/flask_security/forms.py index a2e60a14..19f39f00 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -14,7 +14,7 @@ import os from flask import Markup, current_app, flash, request -from flask import request, current_app, flash, session +from flask import request, session, abort from flask_wtf import Form as BaseForm from wtforms import StringField, PasswordField, validators, \ SubmitField, HiddenField, BooleanField, ValidationError, Field, RadioField @@ -325,7 +325,7 @@ def __init__(self, *args, **kwargs): def validate(self): if 'setup' not in self.data or self.data['setup']\ not in config_value('TWO_FACTOR_ENABLED_METHODS'): - do_flash(*get_message('TWO_FACTOR_METHOD_NOT_AVAILABLE')) + flash(*get_message('TWO_FACTOR_METHOD_NOT_AVAILABLE')) return False return True @@ -346,7 +346,7 @@ def validate(self): elif 'password_confirmed' in session: self.user = current_user else: - os.abort() + abort(403) # codes sent by sms or mail will be valid for another window cycle if session['primary_method'] == 'google_authenticator': self.window = config_value('TWO_FACTOR_GOOGLE_AUTH_VALIDITY') @@ -360,7 +360,7 @@ def validate(self): # verify entered token with user's totp secret if not verify_totp(token=self.code.data, totp_secret=session['totp_secret'], window=self.window): - do_flash(*get_message('TWO_FACTOR_INVALID_TOKEN')) + flash(*get_message('TWO_FACTOR_INVALID_TOKEN')) return False return True @@ -373,10 +373,15 @@ class TwoFactorChangeMethodVerifyPasswordForm(Form, PasswordFormMixin): def validate(self): if not super(TwoFactorChangeMethodVerifyPasswordForm, self).validate(): - do_flash(*get_message('INVALID_PASSWORD')) + flash(*get_message('INVALID_PASSWORD')) return False - - if not verify_and_update_password(self.password.data, current_user): + if 'email' in session: + self.user = _datastore.find_user(email=session['email']) + elif 'password_confirmed' in session: + self.user = current_user + else: + abort(403) + if not self.user.verify_and_update_password(self.password.data, current_user): self.password.errors.append(get_message('INVALID_PASSWORD')[0]) return False @@ -399,7 +404,7 @@ def validate(self): self.user = _datastore.find_user(email=session['email']) if 'primary_method' not in session or 'totp_secret' not in session: - do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) + flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) return False return True diff --git a/flask_security/twofactor.py b/flask_security/twofactor.py index 241a8261..c20ef6d5 100644 --- a/flask_security/twofactor.py +++ b/flask_security/twofactor.py @@ -58,8 +58,9 @@ def get_totp_uri(username, totp_secret): :return: """ service_name = config_value('TWO_FACTOR_URI_SERVICE_NAME') - return 'otpauth://totp/{0}:{1}?secret={2}&issuer={0}'.format(service_name, username, - totp_secret) + + return 'otpauth://totp/{0}:{1}?secret={2}&issuer={0}'\ + .format(service_name, username, totp_secret) def verify_totp(token, totp_secret, window=0): @@ -86,9 +87,11 @@ def generate_totp(): def generate_qrcode(): """generate the qrcode for the two factor authentication process""" - if 'google_authenticator' not in config_value('TWO_FACTOR_ENABLED_METHODS'): + if 'google_authenticator' not in\ + config_value('TWO_FACTOR_ENABLED_METHODS'): return abort(404) - if 'primary_method' not in session or session['primary_method'] != 'google_authenticator' \ + if 'primary_method' not in session or\ + session['primary_method'] != 'google_authenticator' \ or 'totp_secret' not in session: return abort(404) From 0f23a5014e2fb21a1dc9897a569339a4cce8389c Mon Sep 17 00:00:00 2001 From: tbaur Date: Wed, 8 May 2019 10:55:49 -0500 Subject: [PATCH 08/32] more formatting --- flask_security/core.py | 26 ++++++++++++++++++-------- flask_security/twofactor.py | 37 +++++++++++++++++++++++-------------- 2 files changed, 41 insertions(+), 22 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 2c6e427c..c9c557c7 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -87,9 +87,9 @@ 'SEND_CONFIRMATION_TEMPLATE': 'security/send_confirmation.html', 'SEND_LOGIN_TEMPLATE': 'security/send_login.html', 'TWO_FACTOR_VERIFY_CODE_TEMPLATE': - 'security/two_factor_verify_code.html', + 'security/two_factor_verify_code.html', 'TWO_FACTOR_CHOOSE_METHOD_TEMPLATE': - 'security/two_factor_choose_method.html', + 'security/two_factor_choose_method.html', 'TWO_FACTOR_CHANGE_METHOD_PASSWORD_CONFIRMATION_TEMPLATE': 'security/two_factor_change_method_password_confimration.html', 'CONFIRMABLE': False, @@ -129,7 +129,7 @@ 'EMAIL_SUBJECT_PASSWORDLESS': _('Login instructions'), 'EMAIL_SUBJECT_PASSWORD_NOTICE': _('Your password has been reset'), 'EMAIL_SUBJECT_PASSWORD_CHANGE_NOTICE': _( - 'Your password has been changed'), + 'Your password has been changed'), 'EMAIL_SUBJECT_PASSWORD_RESET': _('Password reset instructions'), 'EMAIL_PLAINTEXT': True, 'EMAIL_HTML': True, @@ -267,7 +267,8 @@ 'passwordless_login_form': PasswordlessLoginForm, 'two_factor_verify_code_form': TwoFactorVerifyCodeForm, 'two_factor_setup_form': TwoFactorSetupForm, - 'two_factor_change_method_verify_password_form': TwoFactorChangeMethodVerifyPasswordForm, + 'two_factor_change_method_verify_password_form': + TwoFactorChangeMethodVerifyPasswordForm, 'two_factor_rescue_form': TwoFactorRescueForm } @@ -534,8 +535,12 @@ class Security(object): :param anonymous_user: class to use for anonymous user """ - def __init__(self, app=None, datastore=None, register_blueprint=True, + def __init__(self, + app=None, + datastore=None, + register_blueprint=True, **kwargs): + self.app = app self._datastore = datastore self._register_blueprint = register_blueprint @@ -549,7 +554,10 @@ def __init__(self, app=None, datastore=None, register_blueprint=True, register_blueprint=register_blueprint, **kwargs) - def init_app(self, app, datastore=None, register_blueprint=None, **kwargs): + def init_app(self, + app, datastore=None, + register_blueprint=None, + **kwargs): """Initializes the Flask-Security extension for the specified application and datastore implementation. @@ -600,7 +608,8 @@ def _register_i18n(): # configuration mismatch check if cv('TWO_FACTOR', app=app) is True and\ - len(cv('TWO_FACTOR_ENABLED_METHODS', app=app))< 1: + len(cv('TWO_FACTOR_ENABLED_METHODS', app=app)) < 1: + raise ValueError() flag = False @@ -610,7 +619,8 @@ def _register_i18n(): except: pass - if not flag and cv('TWO_FACTOR_SMS_SERVICE', app=app) == 'Twilio': + if not flag and cv('TWO_FACTOR_SMS_SERVICE', app=app)\ + == 'Twilio': raise ValueError() return state diff --git a/flask_security/twofactor.py b/flask_security/twofactor.py index c20ef6d5..523bb093 100644 --- a/flask_security/twofactor.py +++ b/flask_security/twofactor.py @@ -10,14 +10,15 @@ import os import base64 + import pyqrcode import onetimepass - from flask import current_app as app, session, abort from flask_login import current_user from werkzeug.local import LocalProxy -from .utils import send_mail, config_value, get_message, do_flash, SmsSenderFactory, login_user +from .utils import send_mail, config_value, get_message, do_flash,\ + SmsSenderFactory, login_user from .signals import user_two_factored, two_factor_method_changed # Convenient references @@ -34,17 +35,23 @@ def send_security_token(user, method, totp_secret): """ token_to_be_sent = get_totp_password(totp_secret) if method == 'mail': - send_mail(config_value('EMAIL_SUBJECT_TWO_FACTOR'), user.email, - 'two_factor_instructions', user=user, token=token_to_be_sent) + send_mail(config_value('EMAIL_SUBJECT_TWO_FACTOR'), + user.email, + 'two_factor_instructions', + user=user, + token=token_to_be_sent) elif method == 'sms': msg = "Use this code to log in: %s" % token_to_be_sent - from_number = config_value('TWO_FACTOR_SMS_SERVICE_CONFIG')['PHONE_NUMBER'] + from_number = config_value('TWO_FACTOR_SMS_SERVICE_CONFIG')[ + 'PHONE_NUMBER'] if 'phone_number' in session: to_number = session['phone_number'] else: to_number = user.phone_number - sms_sender = SmsSenderFactory.createSender(config_value('TWO_FACTOR_SMS_SERVICE')) - sms_sender.send_sms(from_number=from_number, to_number=to_number, msg=msg) + sms_sender = SmsSenderFactory.createSender( + config_value('TWO_FACTOR_SMS_SERVICE')) + sms_sender.send_sms(from_number=from_number, + to_number=to_number, msg=msg) elif method == 'google_authenticator': # password are generated automatically in the google authenticator app @@ -58,7 +65,7 @@ def get_totp_uri(username, totp_secret): :return: """ service_name = config_value('TWO_FACTOR_URI_SERVICE_NAME') - + return 'otpauth://totp/{0}:{1}?secret={2}&issuer={0}'\ .format(service_name, username, totp_secret) @@ -88,17 +95,17 @@ def generate_totp(): def generate_qrcode(): """generate the qrcode for the two factor authentication process""" if 'google_authenticator' not in\ - config_value('TWO_FACTOR_ENABLED_METHODS'): + config_value('TWO_FACTOR_ENABLED_METHODS'): return abort(404) if 'primary_method' not in session or\ - session['primary_method'] != 'google_authenticator' \ + session['primary_method'] != 'google_authenticator' \ or 'totp_secret' not in session: return abort(404) if 'email' in session: - email = session['email'] + email = session['email'] elif 'password_confirmed' in session: - email = current_user.email + email = current_user.email else: return abort(404) @@ -121,7 +128,8 @@ def complete_two_factor_process(user): :param user - user's to update in database and log in if necessary """ totp_secret_changed = user.totp_secret != session['totp_secret'] - if totp_secret_changed or user.two_factor_primary_method != session['primary_method']: + if totp_secret_changed or user.two_factor_primary_method\ + != session['primary_method']: user.totp_secret = session['totp_secret'] user.two_factor_primary_method = session['primary_method'] @@ -138,7 +146,8 @@ def complete_two_factor_process(user): if 'password_confirmed' in session: del session['password_confirmed'] do_flash(*get_message('TWO_FACTOR_CHANGE_METHOD_SUCCESSFUL')) - two_factor_method_changed.send(app._get_current_object(), user=user) + two_factor_method_changed.send(app._get_current_object(), + user=user) # if we are logging in for the first time else: From 09e9c697e8fc928f32e76585fa94b28a3d1d9fda Mon Sep 17 00:00:00 2001 From: tbaur Date: Wed, 8 May 2019 11:43:09 -0500 Subject: [PATCH 09/32] too many blank lines --- flask_security/datastore.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/flask_security/datastore.py b/flask_security/datastore.py index e2b932c9..9e93f9f9 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -228,6 +228,7 @@ class SQLAlchemyUserDatastore(SQLAlchemyDatastore, UserDatastore): """A SQLAlchemy datastore implementation for Flask-Security that assumes the use of the Flask-SQLAlchemy extension. """ + def __init__(self, db, user_model, role_model): SQLAlchemyDatastore.__init__(self, db) UserDatastore.__init__(self, user_model, role_model) @@ -267,17 +268,18 @@ def find_role(self, role): return self.role_model.query.filter_by(name=role).first() - class SQLAlchemySessionUserDatastore(SQLAlchemyUserDatastore, SQLAlchemyDatastore): """A SQLAlchemy datastore implementation for Flask-Security that assumes the use of the flask_sqlalchemy_session extension. """ + 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. """ + def __init__(self, session): self.session = session @@ -301,6 +303,7 @@ class MongoEngineUserDatastore(MongoEngineDatastore, UserDatastore): """A MongoEngine datastore implementation for Flask-Security that assumes the use of the Flask-MongoEngine extension. """ + def __init__(self, db, user_model, role_model): MongoEngineDatastore.__init__(self, db) UserDatastore.__init__(self, user_model, role_model) @@ -352,6 +355,7 @@ class PeeweeUserDatastore(PeeweeDatastore, UserDatastore): :param role_model: A role model class definition :param role_link: A model implementing the many-to-many user-role relation """ + def __init__(self, db, user_model, role_model, role_link): PeeweeDatastore.__init__(self, db) UserDatastore.__init__(self, user_model, role_model) From 9cfa47db41a43afa8eb23ebadb179b3c35d2cebe Mon Sep 17 00:00:00 2001 From: tbaur Date: Wed, 8 May 2019 15:00:53 -0500 Subject: [PATCH 10/32] double imports in forms.py --- flask_security/forms.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/flask_security/forms.py b/flask_security/forms.py index 19f39f00..aeb84518 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -11,18 +11,14 @@ """ import inspect -import os from flask import Markup, current_app, flash, request -from flask import request, session, abort -from flask_wtf import Form as BaseForm -from wtforms import StringField, PasswordField, validators, \ - SubmitField, HiddenField, BooleanField, ValidationError, Field, RadioField +from flask import session, abort +from wtforms import BooleanField, Field, HiddenField, PasswordField, \ + StringField, SubmitField, ValidationError, validators, RadioField from flask_login import current_user from flask_wtf import FlaskForm as BaseForm from speaklater import make_lazy_gettext -from wtforms import BooleanField, Field, HiddenField, PasswordField, \ - StringField, SubmitField, ValidationError, validators from .confirmable import requires_confirmation from .utils import _, _datastore, config_value, get_message, hash_password, \ From 6a932b0f0ec75bc12a409680fe0c50a5674f68e3 Mon Sep 17 00:00:00 2001 From: tbaur Date: Wed, 8 May 2019 15:05:26 -0500 Subject: [PATCH 11/32] formatting forms.py --- flask_security/forms.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/flask_security/forms.py b/flask_security/forms.py index aeb84518..2607c143 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -308,10 +308,11 @@ def validate(self): class TwoFactorSetupForm(Form, UserEmailFormMixin): """The Two Factor token validation form""" - setup = RadioField('Available Methods', choices=[('mail', 'Set Up Using Mail'), - ('google_authenticator', - 'Set Up Using Google Authenticator'), - ('sms', 'Set Up Using SMS')]) + setup = RadioField('Available Methods', + choices=[('mail', 'Set Up Using Mail'), + ('google_authenticator', + 'Set Up Using Google Authenticator'), + ('sms', 'Set Up Using SMS')]) phone = StringField(get_form_field_label('phone')) submit = SubmitField(get_form_field_label('sumbit')) @@ -354,7 +355,8 @@ def validate(self): return False # verify entered token with user's totp secret - if not verify_totp(token=self.code.data, totp_secret=session['totp_secret'], + if not verify_totp(token=self.code.data, + totp_secret=session['totp_secret'], window=self.window): flash(*get_message('TWO_FACTOR_INVALID_TOKEN')) return False @@ -368,7 +370,8 @@ class TwoFactorChangeMethodVerifyPasswordForm(Form, PasswordFormMixin): submit = SubmitField(get_form_field_label('verify_password')) def validate(self): - if not super(TwoFactorChangeMethodVerifyPasswordForm, self).validate(): + if not super(TwoFactorChangeMethodVerifyPasswordForm, + self).validate(): flash(*get_message('INVALID_PASSWORD')) return False if 'email' in session: @@ -377,7 +380,8 @@ def validate(self): self.user = current_user else: abort(403) - if not self.user.verify_and_update_password(self.password.data, current_user): + if not self.user.verify_and_update_password(self.password.data, + current_user): self.password.errors.append(get_message('INVALID_PASSWORD')[0]) return False @@ -388,8 +392,10 @@ class TwoFactorRescueForm(Form, UserEmailFormMixin): """The Two Factor Rescue validation form""" help_setup = RadioField('Trouble Accessing Your Account?', - choices=[('lost_device', 'Can not access mobile device?'), - ('no_mail_access', 'Can not access mail account?')]) + choices=[('lost_device', + 'Can not access mobile device?'), + ('no_mail_access', + 'Can not access mail account?')]) submit = SubmitField(get_form_field_label('submit')) def __init__(self, *args, **kwargs): From 9fa6005dc4694e3feb0cd2abd03fdace50948f1d Mon Sep 17 00:00:00 2001 From: tbaur Date: Wed, 8 May 2019 15:10:44 -0500 Subject: [PATCH 12/32] signals line length --- flask_security/signals.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flask_security/signals.py b/flask_security/signals.py index 25b23f29..27895ef2 100644 --- a/flask_security/signals.py +++ b/flask_security/signals.py @@ -29,4 +29,5 @@ two_factor_method_changed = signals.signal("two-factor-method-changed") -reset_password_instructions_sent = signals.signal("password-reset-instructions-sent") +reset_password_instructions_sent = signals.signal( + "password-reset-instructions-sent") From 117a0e10493ab89f2b79d1b465a2bb5f8d7298cc Mon Sep 17 00:00:00 2001 From: tbaur Date: Wed, 8 May 2019 15:55:49 -0500 Subject: [PATCH 13/32] twofactor formatting --- flask_security/twofactor.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/flask_security/twofactor.py b/flask_security/twofactor.py index 523bb093..207d8123 100644 --- a/flask_security/twofactor.py +++ b/flask_security/twofactor.py @@ -30,7 +30,8 @@ def send_security_token(user, method, totp_secret): """Sends the security token via email for the specified user. :param user: The user to send the code to - :param method: The method in which the code will be sent ('mail' or 'sms') at the moment + :param method: The method in which the code will be sent + ('mail' or 'sms') at the moment :param totp_secret: a unique shared secret of the user """ token_to_be_sent = get_totp_password(totp_secret) @@ -59,7 +60,8 @@ def send_security_token(user, method, totp_secret): def get_totp_uri(username, totp_secret): - """ Generate provisioning url for use with the qrcode scanner built into the app + """ Generate provisioning url for use with the qrcode + scanner built into the app :param username: username of the current user :param totp_secret: a unique shared secret of the user :return: @@ -74,8 +76,9 @@ def verify_totp(token, totp_secret, window=0): """ Verifies token for specific user_totp :param token - token to be check against user's secret :param totp_secret - a unique shared secret of the user - :param window - optional, compensate for clock skew, number of intervals to check on - each side of the current time. (default is 0 - only check the current clock time) + :param window - optional, compensate for clock skew, number of + intervals to check on each side of the current time. + (default is 0 - only check the current clock time) :return: """ return onetimepass.valid_totp(token, totp_secret, window=window) From 46c77af22bbfd6ceeee274591d664eec635db7c7 Mon Sep 17 00:00:00 2001 From: tbaur Date: Wed, 8 May 2019 15:59:37 -0500 Subject: [PATCH 14/32] utils formatting --- flask_security/utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/flask_security/utils.py b/flask_security/utils.py index 7f624acc..da478aa0 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -550,8 +550,10 @@ def createSender(cls, name, *args, **kwargs): class TwilioSmsSender(SmsSenderBaseClass): def __init__(self): - self.account_sid = config_value('TWO_FACTOR_SMS_SERVICE_CONFIG')['ACCOUNT_SID'] - self.auth_token = config_value('TWO_FACTOR_SMS_SERVICE_CONFIG')['AUTH_TOKEN'] + self.account_sid = config_value( + 'TWO_FACTOR_SMS_SERVICE_CONFIG')['ACCOUNT_SID'] + self.auth_token = config_value( + 'TWO_FACTOR_SMS_SERVICE_CONFIG')['AUTH_TOKEN'] def send_sms(self, from_number, to_number, msg): client = TwilioRestClient(self.account_sid, self.auth_token) From 09446724e24e54fb4a5e7a316ee20611f3ef80ea Mon Sep 17 00:00:00 2001 From: tbaur Date: Wed, 8 May 2019 16:06:29 -0500 Subject: [PATCH 15/32] update twilio client import --- flask_security/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flask_security/utils.py b/flask_security/utils.py index da478aa0..385ad252 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -546,7 +546,7 @@ def createSender(cls, name, *args, **kwargs): try: - from twilio.rest import TwilioRestClient + from twilio.rest import Client class TwilioSmsSender(SmsSenderBaseClass): def __init__(self): @@ -556,7 +556,7 @@ def __init__(self): 'TWO_FACTOR_SMS_SERVICE_CONFIG')['AUTH_TOKEN'] def send_sms(self, from_number, to_number, msg): - client = TwilioRestClient(self.account_sid, self.auth_token) + client = Client(self.account_sid, self.auth_token) client.messages.create( to=to_number, from_=from_number, From d1f854d9aa4430f5c444d7a49bb58bea2c575d9e Mon Sep 17 00:00:00 2001 From: tbaur Date: Wed, 8 May 2019 16:26:44 -0500 Subject: [PATCH 16/32] missing import --- flask_security/views.py | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/flask_security/views.py b/flask_security/views.py index dd93b4c1..cfec9fcc 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -25,7 +25,8 @@ from .registerable import register_user from .utils import config_value, do_flash, get_url, get_post_login_redirect, \ get_post_register_redirect, get_message, login_user, logout_user, \ - url_for_security as url_for, slash_url_suffix, send_mail + url_for_security as url_for, slash_url_suffix, send_mail,\ + get_post_logout_redirect from .twofactor import send_security_token, generate_totp, generate_qrcode, \ complete_two_factor_process @@ -200,7 +201,7 @@ def send_confirmation(): send_confirmation_instructions(form.user) if not request.is_json: do_flash(*get_message('CONFIRMATION_REQUEST', - email=form.user.email)) + email=form.user.email)) if request.is_json: return _render_json(form) @@ -261,7 +262,7 @@ def forgot_password(): send_reset_password_instructions(form.user) if not request.is_json: do_flash(*get_message('PASSWORD_RESET_REQUEST', - email=form.user.email)) + email=form.user.email)) if request.is_json: return _render_json(form, include_user=False) @@ -408,7 +409,8 @@ def two_factor_setup_function(): return _security.render_template(config_value('TWO_FACTOR_CHOOSE_METHOD_TEMPLATE'), two_factor_setup_form=form, two_factor_verify_code_form=code_form, - choices=config_value('TWO_FACTOR_ENABLED_METHODS'), + choices=config_value( + 'TWO_FACTOR_ENABLED_METHODS'), chosen_method=session['primary_method'], **_ctx('two_factor_setup_function')) @@ -419,7 +421,8 @@ def two_factor_setup_function(): return _security.render_template(config_value('TWO_FACTOR_CHOOSE_METHOD_TEMPLATE'), two_factor_setup_form=form, two_factor_verify_code_form=code_form, - choices=config_value('TWO_FACTOR_ENABLED_METHODS'), + choices=config_value( + 'TWO_FACTOR_ENABLED_METHODS'), **_ctx('two_factor_setup_function')) @@ -445,9 +448,9 @@ def two_factor_token_validation(): form = form_class() if form.validate_on_submit(): - complete_two_factor_process(form.user) - after_this_request(_commit) - return redirect(get_post_login_redirect()) + complete_two_factor_process(form.user) + after_this_request(_commit) + return redirect(get_post_login_redirect()) if request.json: return _render_json(form, include_user=False) @@ -458,7 +461,8 @@ def two_factor_token_validation(): return _security.render_template(config_value('TWO_FACTOR_CHOOSE_METHOD_TEMPLATE'), two_factor_setup_form=setup_form, two_factor_verify_code_form=form, - choices=config_value('TWO_FACTOR_ENABLED_METHODS'), + choices=config_value( + 'TWO_FACTOR_ENABLED_METHODS'), **_ctx('two_factor_setup_function')) # if we were trying to validate an existing method else: @@ -495,7 +499,8 @@ def two_factor_rescue_function(): problem = form.data['help_setup'] # if the problem is that user can't access his device, we send him code through mail if problem == 'lost_device': - send_security_token(user=form.user, method='mail', totp_secret=form.user.totp_secret) + send_security_token(user=form.user, method='mail', + totp_secret=form.user.totp_secret) # send app provider a mail message regarding trouble elif problem == 'no_mail_access': send_mail(config_value('EMAIL_SUBJECT_TWO_FACTOR_RESCUE'), @@ -510,7 +515,8 @@ def two_factor_rescue_function(): return _security.render_template(config_value('TWO_FACTOR_VERIFY_CODE_TEMPLATE'), two_factor_verify_code_form=code_form, two_factor_rescue_form=form, - rescue_mail=config_value('TWO_FACTOR_RESCUE_MAIL'), + rescue_mail=config_value( + 'TWO_FACTOR_RESCUE_MAIL'), problem=str(problem), **_ctx('two_factor_token_validation')) @@ -579,8 +585,8 @@ def create_blueprint(state, import_name): endpoint='two_factor_rescue_function')(two_factor_rescue_function) bp.route(state.change_url + slash_url_suffix( state.change_url, 'two_factor_password_confirmation'), - methods=['GET', 'POST'], - endpoint='two_factor_password_confirmation')(two_factor_password_confirmation) + methods=['GET', 'POST'], + endpoint='two_factor_password_confirmation')(two_factor_password_confirmation) else: bp.route(state.login_url, From f8c00ec618c54b35d1603cb333a9202bba52ce31 Mon Sep 17 00:00:00 2001 From: tbaur Date: Wed, 8 May 2019 16:58:03 -0500 Subject: [PATCH 17/32] blueprint line length (twofactor) --- flask_security/views.py | 130 +++++++++++++++++++++++----------------- 1 file changed, 76 insertions(+), 54 deletions(-) diff --git a/flask_security/views.py b/flask_security/views.py index cfec9fcc..72bdac50 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -376,13 +376,16 @@ def two_factor_login(): def two_factor_setup_function(): """View function for two factor setup during login process""" - # user's email&password not approved or we are logged in and didn't validate password + + # user's email&password not approved or we are + # logged in and didn't validate password if 'password_confirmed' not in session: if 'email' not in session or 'has_two_factor' not in session: do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) return redirect(get_post_login_redirect()) - # user's email&password approved and two factor properties were configured before + # user's email&password approved and + # two factor properties were configured before if session['has_two_factor'] is True: do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) return redirect(url_for('two_factor_token_validation')) @@ -398,7 +401,8 @@ def two_factor_setup_function(): form = form_class() if form.validate_on_submit(): - # totp and primarty_method are added to session to flag the user's temporary choice + # totp and primarty_method are added to + # session to flag the user's temporary choice session['totp_secret'] = generate_totp() session['primary_method'] = form['setup'].data if len(form.data['phone']) > 0: @@ -406,36 +410,40 @@ def two_factor_setup_function(): send_security_token(user=user, method=session['primary_method'], totp_secret=session['totp_secret']) code_form = _security.two_factor_verify_code_form() - return _security.render_template(config_value('TWO_FACTOR_CHOOSE_METHOD_TEMPLATE'), - two_factor_setup_form=form, - two_factor_verify_code_form=code_form, - choices=config_value( - 'TWO_FACTOR_ENABLED_METHODS'), - chosen_method=session['primary_method'], - **_ctx('two_factor_setup_function')) + return _security.render_template( + config_value('TWO_FACTOR_CHOOSE_METHOD_TEMPLATE'), + two_factor_setup_form=form, + two_factor_verify_code_form=code_form, + choices=config_value( + 'TWO_FACTOR_ENABLED_METHODS'), + chosen_method=session['primary_method'], + **_ctx('two_factor_setup_function')) if request.json: return _render_json(form, include_user=False) code_form = _security.two_factor_verify_code_form() - return _security.render_template(config_value('TWO_FACTOR_CHOOSE_METHOD_TEMPLATE'), - two_factor_setup_form=form, - two_factor_verify_code_form=code_form, - choices=config_value( - 'TWO_FACTOR_ENABLED_METHODS'), - **_ctx('two_factor_setup_function')) + return _security.render_template( + config_value('TWO_FACTOR_CHOOSE_METHOD_TEMPLATE'), + two_factor_setup_form=form, + two_factor_verify_code_form=code_form, + choices=config_value( + 'TWO_FACTOR_ENABLED_METHODS'), + **_ctx('two_factor_setup_function')) def two_factor_token_validation(): """View function for two factor token validation during login process""" # if we are in login process and not changing current two factor method if 'password_confirmed' not in session: - # user's email&password not approved or we are logged in and didn't validate password + # user's email&password not approved or we are logged in + # and didn't validate password if 'has_two_factor' not in session: do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) return redirect(get_post_login_redirect()) - # make sure user has or has chosen a two factor method before we try to validate + # make sure user has or has chosen a two factor + # method before we try to validate if 'totp_secret' not in session or 'primary_method' not in session: do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) return redirect(url_for('two_factor_setup_function')) @@ -458,31 +466,35 @@ def two_factor_token_validation(): # if we were trying to validate a new method if 'password_confirmed' in session or session['has_two_factor'] is False: setup_form = _security.two_factor_setup_form() - return _security.render_template(config_value('TWO_FACTOR_CHOOSE_METHOD_TEMPLATE'), - two_factor_setup_form=setup_form, - two_factor_verify_code_form=form, - choices=config_value( - 'TWO_FACTOR_ENABLED_METHODS'), - **_ctx('two_factor_setup_function')) + return _security.render_template( + config_value('TWO_FACTOR_CHOOSE_METHOD_TEMPLATE'), + two_factor_setup_form=setup_form, + two_factor_verify_code_form=form, + choices=config_value( + 'TWO_FACTOR_ENABLED_METHODS'), + **_ctx('two_factor_setup_function')) # if we were trying to validate an existing method else: rescue_form = _security.two_factor_rescue_form() - return _security.render_template(config_value('TWO_FACTOR_VERIFY_CODE_TEMPLATE'), - two_factor_rescue_form=rescue_form, - two_factor_verify_code_form=form, - problem=None, - **_ctx('two_factor_token_validaion')) + return _security.render_template( + config_value('TWO_FACTOR_VERIFY_CODE_TEMPLATE'), + two_factor_rescue_form=rescue_form, + two_factor_verify_code_form=form, + problem=None, + **_ctx('two_factor_token_validaion')) @anonymous_user_required def two_factor_rescue_function(): - """ Function that handles a situation where user can't enter his two factor validation code""" + """ Function that handles a situation where user can't + enter his two factor validation code""" # user's email&password yet to be approved if 'email' not in session: do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) return abort(404) - # user's email&password approved and two factor properties were not configured + # user's email&password approved and two factor properties + # were not configured if 'totp_secret' not in session or 'primary_method' not in session: do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) return abort(404) @@ -497,14 +509,17 @@ def two_factor_rescue_function(): problem = None if form.validate_on_submit(): problem = form.data['help_setup'] - # if the problem is that user can't access his device, we send him code through mail + # if the problem is that user can't access his device, w + # e send him code through mail if problem == 'lost_device': send_security_token(user=form.user, method='mail', totp_secret=form.user.totp_secret) # send app provider a mail message regarding trouble elif problem == 'no_mail_access': send_mail(config_value('EMAIL_SUBJECT_TWO_FACTOR_RESCUE'), - config_value('TWO_FACTOR_RESCUE_MAIL'), 'two_factor_rescue', user=form.user) + config_value('TWO_FACTOR_RESCUE_MAIL'), + 'two_factor_rescue', + user=form.user) else: return "", 404 @@ -512,13 +527,14 @@ def two_factor_rescue_function(): return _render_json(form, include_user=False) code_form = _security.two_factor_verify_code_form() - return _security.render_template(config_value('TWO_FACTOR_VERIFY_CODE_TEMPLATE'), - two_factor_verify_code_form=code_form, - two_factor_rescue_form=form, - rescue_mail=config_value( - 'TWO_FACTOR_RESCUE_MAIL'), - problem=str(problem), - **_ctx('two_factor_token_validation')) + return _security.render_template( + config_value('TWO_FACTOR_VERIFY_CODE_TEMPLATE'), + two_factor_verify_code_form=code_form, + two_factor_rescue_form=form, + rescue_mail=config_value( + 'TWO_FACTOR_RESCUE_MAIL'), + problem=str(problem), + **_ctx('two_factor_token_validation')) @login_required @@ -540,10 +556,11 @@ def two_factor_password_confirmation(): form.user = current_user return _render_json(form) - return _security.render_template(config_value - ('TWO_FACTOR_CHANGE_METHOD_PASSWORD_CONFIRMATION_TEMPLATE'), - two_factor_change_method_verify_password_form=form, - **_ctx('two_factor_change_method_password_confirmation')) + return _security.render_template( + config_value( + 'TWO_FACTOR_CHANGE_METHOD_PASSWORD_CONFIRMATION_TEMPLATE'), + two_factor_change_method_verify_password_form=form, + **_ctx('two_factor_change_method_password_confirmation')) def two_factor_qrcode(): @@ -569,24 +586,29 @@ def create_blueprint(state, import_name): endpoint='token_login')(token_login) elif state.two_factor: + tf_setup_function = 'two_factor_setup_function' + tf_token_validation = 'two_factor_token_validation' + tf_qrcode = 'two_factor_qrcode' + tf_rescue_function = 'two_factor_rescue_function' + tf_pass_validation = 'two_factor_password_confirmation' bp.route(state.login_url, methods=['GET', 'POST'], endpoint='login')(two_factor_login) - bp.route('/' + slash_url_suffix('/', 'two_factor_setup_function'), + bp.route('/' + slash_url_suffix('/', tf_setup_function), methods=['GET', 'POST'], - endpoint='two_factor_setup_function')(two_factor_setup_function) - bp.route('/' + slash_url_suffix('/', 'two_factor_token_validation'), + endpoint=tf_setup_function)(two_factor_setup_function) + bp.route('/' + slash_url_suffix('/', tf_token_validation), methods=['GET', 'POST'], - endpoint='two_factor_token_validation')(two_factor_token_validation) - bp.route('/' + slash_url_suffix('/', 'two_factor_qrcode'), - endpoint='two_factor_qrcode')(two_factor_qrcode) - bp.route('/' + slash_url_suffix('/', 'two_factor_rescue_function'), + endpoint=tf_token_validation)(two_factor_token_validation) + bp.route('/' + slash_url_suffix('/', tf_qrcode), + endpoint=tf_qrcode)(two_factor_qrcode) + bp.route('/' + slash_url_suffix('/', tf_rescue_function), methods=['GET', 'POST'], - endpoint='two_factor_rescue_function')(two_factor_rescue_function) + endpoint=tf_rescue_function)(two_factor_rescue_function) bp.route(state.change_url + slash_url_suffix( - state.change_url, 'two_factor_password_confirmation'), + state.change_url, tf_pass_validation), methods=['GET', 'POST'], - endpoint='two_factor_password_confirmation')(two_factor_password_confirmation) + endpoint=tf_pass_validation)(two_factor_password_confirmation) else: bp.route(state.login_url, From 1d1578c02e9b0af07bc369a6b8aeb24ba935c4d0 Mon Sep 17 00:00:00 2001 From: tbaur Date: Wed, 8 May 2019 17:00:52 -0500 Subject: [PATCH 18/32] trailing whitespace... --- flask_security/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_security/views.py b/flask_security/views.py index 72bdac50..5fcb9acd 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -486,7 +486,7 @@ def two_factor_token_validation(): @anonymous_user_required def two_factor_rescue_function(): - """ Function that handles a situation where user can't + """ Function that handles a situation where user can't enter his two factor validation code""" # user's email&password yet to be approved if 'email' not in session: From 47a5cd550ab22f7748cf00c17fb6ed54303fa7df Mon Sep 17 00:00:00 2001 From: tbaur Date: Thu, 9 May 2019 16:27:48 -0500 Subject: [PATCH 19/32] line too long/import/authors inadv. deleted --- AUTHORS | 2 ++ flask_security/forms.py | 14 +++++++------- flask_security/views.py | 3 ++- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/AUTHORS b/AUTHORS index 99396f74..635839db 100644 --- a/AUTHORS +++ b/AUTHORS @@ -39,4 +39,6 @@ Tristan Escalada Vadim Kotov Walt Askew John Paraskevopoulos +Gal Stainfeld +Ivan Piskunov Tyler Baur diff --git a/flask_security/forms.py b/flask_security/forms.py index 2607c143..10c8412b 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -12,7 +12,7 @@ import inspect -from flask import Markup, current_app, flash, request +from flask import Markup, current_app, request from flask import session, abort from wtforms import BooleanField, Field, HiddenField, PasswordField, \ StringField, SubmitField, ValidationError, validators, RadioField @@ -22,7 +22,7 @@ from .confirmable import requires_confirmation from .utils import _, _datastore, config_value, get_message, hash_password, \ - localize_callback, url_for_security, validate_redirect_url + localize_callback, url_for_security, validate_redirect_url, do_flash from .twofactor import verify_totp lazy_gettext = make_lazy_gettext(lambda: localize_callback) @@ -141,7 +141,7 @@ class NextFormMixin(): def validate_next(self, field): if field.data and not validate_redirect_url(field.data): field.data = '' - flash(*get_message('INVALID_REDIRECT')) + do_flash(*get_message('INVALID_REDIRECT')) raise ValidationError(get_message('INVALID_REDIRECT')[0]) @@ -322,7 +322,7 @@ def __init__(self, *args, **kwargs): def validate(self): if 'setup' not in self.data or self.data['setup']\ not in config_value('TWO_FACTOR_ENABLED_METHODS'): - flash(*get_message('TWO_FACTOR_METHOD_NOT_AVAILABLE')) + do_flash(*get_message('TWO_FACTOR_METHOD_NOT_AVAILABLE')) return False return True @@ -358,7 +358,7 @@ def validate(self): if not verify_totp(token=self.code.data, totp_secret=session['totp_secret'], window=self.window): - flash(*get_message('TWO_FACTOR_INVALID_TOKEN')) + do_flash(*get_message('TWO_FACTOR_INVALID_TOKEN')) return False return True @@ -372,7 +372,7 @@ class TwoFactorChangeMethodVerifyPasswordForm(Form, PasswordFormMixin): def validate(self): if not super(TwoFactorChangeMethodVerifyPasswordForm, self).validate(): - flash(*get_message('INVALID_PASSWORD')) + do_flash(*get_message('INVALID_PASSWORD')) return False if 'email' in session: self.user = _datastore.find_user(email=session['email']) @@ -406,7 +406,7 @@ def validate(self): self.user = _datastore.find_user(email=session['email']) if 'primary_method' not in session or 'totp_secret' not in session: - flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) + do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) return False return True diff --git a/flask_security/views.py b/flask_security/views.py index 5fcb9acd..8897e1df 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -361,7 +361,8 @@ def two_factor_login(): session['has_two_factor'] = True session['primary_method'] = user.two_factor_primary_method session['totp_secret'] = user.totp_secret - send_security_token(user=user, method=user.two_factor_primary_method, + send_security_token(user=user, + method=user.two_factor_primary_method, totp_secret=user.totp_secret) return redirect(url_for('two_factor_token_validation')) From 948d248971ec70f15eae16f2d3c45170c0b57f39 Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 10 May 2019 08:22:06 -0500 Subject: [PATCH 20/32] compat: release.py python3 --- scripts/release.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index 0aae4f0e..94c7e3ed 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -111,12 +111,23 @@ def build_and_upload(): def fail(message, *args): - print >> sys.stderr, 'Error:', message % args + import sys + version = sys.version_info[0] >= 3 + if not version: + print >> sys.stderr, 'Error:', message % args # pragma: no flakes + else: + print('Error' + message % args, file=sys.stderr) + sys.exit(1) def info(message, *args): - print >> sys.stderr, message % args + import sys + version = sys.version_info[0] >= 3 + if not version: + print >> sys.stderr, message % args # pragma: no flakes + else: + print('Error' + message % args, file=sys.stderr) def get_git_tags(): From a2c82c85bcc91409a08a1c09a87b06e5668c4285 Mon Sep 17 00:00:00 2001 From: tbaur Date: Fri, 10 May 2019 08:44:27 -0500 Subject: [PATCH 21/32] update install_requires in setup.py --- setup.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index b0a19bdb..695e7ce9 100755 --- a/setup.py +++ b/setup.py @@ -47,13 +47,13 @@ ] install_requires = [ - 'Flask>=0.11', - 'Flask-Login>=0.3.0', - 'Flask-Mail>=0.7.3', - 'Flask-Principal>=0.3.3', - 'Flask-WTF>=0.13.1', + 'Flask>=1.0.2', + 'Flask-Login>=0.4.1', + 'Flask-Mail>=0.9.1', + 'Flask-Principal>=0.4.0', + 'Flask-WTF>=0.14.2', 'Flask-BabelEx>=0.9.3', - 'itsdangerous>=0.21', + 'itsdangerous>=1.1.0', 'passlib>=1.7', 'pyqrcode>=1.2', 'onetimepass>=1.0.1', From d7d0cb4c45a3e8d3b488d1efa0d0be8f6cc2ebe9 Mon Sep 17 00:00:00 2001 From: tbaur Date: Fri, 10 May 2019 08:50:40 -0500 Subject: [PATCH 22/32] bump flask_sqlalchemy --- docs/two_factor_configurations.rst | 4 ++-- setup.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/two_factor_configurations.rst b/docs/two_factor_configurations.rst index 9207b52a..b19048f1 100644 --- a/docs/two_factor_configurations.rst +++ b/docs/two_factor_configurations.rst @@ -33,8 +33,8 @@ possible using SQLAlchemy: :: from flask import Flask, current_app, render_template - from flask.ext.sqlalchemy import SQLAlchemy - from flask.ext.security import Security, SQLAlchemyUserDatastore, \ + from flask_sqlalchemy import SQLAlchemy + from flask_security import Security, SQLAlchemyUserDatastore, \ UserMixin, RoleMixin, login_required diff --git a/setup.py b/setup.py index 695e7ce9..6d9fee1c 100755 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ 'Flask-CLI>=0.4.0', 'Flask-Mongoengine>=0.7.0', 'Flask-Peewee>=0.6.5', - 'Flask-SQLAlchemy>=1.0', + 'Flask-SQLAlchemy>=2.4', 'bcrypt>=1.0.2', 'check-manifest>=0.25', 'coverage>=4.0', From 5076a3cecef6a4ae154907e456b920f01796d479 Mon Sep 17 00:00:00 2001 From: tbaur Date: Fri, 10 May 2019 09:09:02 -0500 Subject: [PATCH 23/32] conftest.py allow nulls in sqlalchemy --- tests/conftest.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5c053c8a..c85b70b7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -169,7 +169,8 @@ class User(db.Document, UserMixin): password = db.StringField(required=False, max_length=255) last_login_at = db.DateTimeField() current_login_at = db.DateTimeField() - two_factor_primary_method = db.StringField(max_length=255) + two_factor_primary_method = db.StringField( + max_length=255) totp_secret = db.StringField(max_length=255) phone_number = db.StringField(max_length=255) last_login_ip = db.StringField(max_length=100) @@ -212,9 +213,9 @@ class User(db.Model, UserMixin): username = db.Column(db.String(255)) password = db.Column(db.String(255)) last_login_at = db.Column(db.DateTime()) - two_factor_primary_method = db.Column(db.String(255)) - totp_secret = db.Column(db.String(255)) - phone_number = db.Column(db.String(255)) + two_factor_primary_method = db.Column(db.String(255), nullable=True) + totp_secret = db.Column(db.String(255), nullable=True) + phone_number = db.Column(db.String(255), nullable=True) current_login_at = db.Column(db.DateTime()) last_login_ip = db.Column(db.String(100)) current_login_ip = db.Column(db.String(100)) From 176b8ca0c9d64da9f92fcf98d02e989e2c64bd68 Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 13 May 2019 16:22:46 -0500 Subject: [PATCH 24/32] Spelling, Update Functions and Tests Python 3.7 Support request.json->request.get_json update tests to use hash_password pep8 compliance Update dependencies for tests move from pyenchant to msgcheck --- .travis.yml | 10 +- docs/configuration.rst | 2 +- docs/customizing.rst | 2 +- flask_security/core.py | 21 ++- flask_security/forms.py | 14 +- ..._change_method_password_confirmation.html} | 6 +- flask_security/twofactor.py | 6 +- flask_security/views.py | 34 ++-- pytest.ini | 2 +- scripts/release.py | 13 +- setup.py | 13 +- tests/conftest.py | 25 ++- tests/test_hashing.py | 10 +- tests/test_misc.py | 4 +- tests/test_recoverable.py | 1 + tests/test_two_factor.py | 167 +++++++++++------- tests/utils.py | 26 ++- tox.ini | 2 +- 18 files changed, 209 insertions(+), 149 deletions(-) rename flask_security/templates/security/{two_factor_change_method_password_confimration.html => two_factor_change_method_password_confirmation.html} (85%) diff --git a/.travis.yml b/.travis.yml index ec5265c6..bf1d0817 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,15 +3,19 @@ notifications: language: python +dist: xenial + python: - "2.7" - "3.5" - "3.6" - - "pypy" + - "3.7" + - "pypy3.5-6.0" addons: apt: packages: + - gettext - libenchant-dev - aspell-en - aspell-da @@ -24,7 +28,7 @@ addons: matrix: allow_failures: - - python: pypy + - python: pypy3.5-6.0 sudo: false @@ -45,7 +49,7 @@ before_install: - "requirements-builder -e all --level=min setup.py > .travis-lowest-requirements.txt" - "requirements-builder -e all --level=pypi setup.py > .travis-release-requirements.txt" - | - if [ "$TRAVIS_PYTHON_VERSION" = "pypy" ]; then + if [ "$TRAVIS_PYTHON_VERSION" = "pypy3.5-6.0" ]; then export PYENV_ROOT="$HOME/.pyenv" if [ -f "$PYENV_ROOT/bin/pyenv" ]; then cd "$PYENV_ROOT" && git pull diff --git a/docs/configuration.rst b/docs/configuration.rst index 571475b4..f4d8f661 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -194,7 +194,7 @@ Template Paths the change method page for the two factor authentication process. Defaults to ``security/two_factor_change_method_ - password_confimration.html``. + password_confirmation.html``. ============================================== ======================================= diff --git a/docs/customizing.rst b/docs/customizing.rst index f3073dd0..38378444 100644 --- a/docs/customizing.rst +++ b/docs/customizing.rst @@ -21,7 +21,7 @@ following is a list of view templates: * `security/change_password.html` * `security/send_confirmation.html` * `security/send_login.html` -* `security/two_factor_change_method_password_confimration.html` +* `security/two_factor_change_method_password_confirmation.html` * `security/two_factor_choose_method.html` * `security/two_factor_verify_code.html` diff --git a/flask_security/core.py b/flask_security/core.py index c9c557c7..82beb3ff 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -91,7 +91,7 @@ 'TWO_FACTOR_CHOOSE_METHOD_TEMPLATE': 'security/two_factor_choose_method.html', 'TWO_FACTOR_CHANGE_METHOD_PASSWORD_CONFIRMATION_TEMPLATE': - 'security/two_factor_change_method_password_confimration.html', + 'security/two_factor_change_method_password_confirmation.html', 'CONFIRMABLE': False, 'REGISTERABLE': False, 'RECOVERABLE': False, @@ -239,21 +239,24 @@ 'LOGIN': ( _('Please log in to access this page.'), 'info'), 'REFRESH': ( - 'Please reauthenticate to access this page.', 'info'), + _('Please reauthenticate to access this page.'), 'info'), 'TWO_FACTOR_INVALID_TOKEN': ( - 'Invalid Token', 'error'), + _('Invalid Token'), 'error'), 'TWO_FACTOR_LOGIN_SUCCESSFUL': ( - 'Your token has been confirmed', 'success'), + _('Your token has been confirmed'), 'success'), 'TWO_FACTOR_CHANGE_METHOD_SUCCESSFUL': ( - 'You successfully changed your two factor method.', 'success'), + _('You successfully changed your two factor method.'), + 'success'), 'TWO_FACTOR_PASSWORD_CONFIRMATION_DONE': ( - 'You successfully confirmed password', 'success'), + _('You successfully confirmed password'), 'success'), 'TWO_FACTOR_PASSWORD_CONFIRMATION_NEEDED': ( - 'Password confirmation is needed in order to access page', 'error'), + _('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'), + _('You currently do not have permissions to access this page'), + 'error'), 'TWO_FACTOR_METHOD_NOT_AVAILABLE': ( - 'Marked method is not valid', 'error'), + _('Marked method is not valid'), 'error'), } _default_forms = { diff --git a/flask_security/forms.py b/flask_security/forms.py index 10c8412b..71430eb6 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -13,7 +13,7 @@ import inspect from flask import Markup, current_app, request -from flask import session, abort +from flask import session from wtforms import BooleanField, Field, HiddenField, PasswordField, \ StringField, SubmitField, ValidationError, validators, RadioField from flask_login import current_user @@ -343,7 +343,7 @@ def validate(self): elif 'password_confirmed' in session: self.user = current_user else: - abort(403) + return False # codes sent by sms or mail will be valid for another window cycle if session['primary_method'] == 'google_authenticator': self.window = config_value('TWO_FACTOR_GOOGLE_AUTH_VALIDITY') @@ -374,14 +374,8 @@ def validate(self): self).validate(): do_flash(*get_message('INVALID_PASSWORD')) return False - if 'email' in session: - self.user = _datastore.find_user(email=session['email']) - elif 'password_confirmed' in session: - self.user = current_user - else: - abort(403) - if not self.user.verify_and_update_password(self.password.data, - current_user): + 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 diff --git a/flask_security/templates/security/two_factor_change_method_password_confimration.html b/flask_security/templates/security/two_factor_change_method_password_confirmation.html similarity index 85% rename from flask_security/templates/security/two_factor_change_method_password_confimration.html rename to flask_security/templates/security/two_factor_change_method_password_confirmation.html index c34e72c7..5f7b92d2 100644 --- a/flask_security/templates/security/two_factor_change_method_password_confimration.html +++ b/flask_security/templates/security/two_factor_change_method_password_confirmation.html @@ -1,9 +1,9 @@ {% from "security/_macros.html" import render_field_with_errors, render_field %} {% include "security/_messages.html" %}

Please Enter Your Password

-
+ {{ two_factor_change_method_verify_password_form.hidden_tag() }} {{ render_field_with_errors(two_factor_change_method_verify_password_form.password, placeholder='enter password') }} {{ render_field(two_factor_change_method_verify_password_form.submit, value='verify password') }} -
- + \ No newline at end of file diff --git a/flask_security/twofactor.py b/flask_security/twofactor.py index 207d8123..6cda89e0 100644 --- a/flask_security/twofactor.py +++ b/flask_security/twofactor.py @@ -115,10 +115,10 @@ def generate_qrcode(): name = email.split('@')[0] totp = session['totp_secret'] url = pyqrcode.create(get_totp_uri(name, totp)) - from StringIO import StringIO - stream = StringIO() + from io import BytesIO + stream = BytesIO() url.svg(stream, scale=3) - return stream.getvalue().encode('utf-8'), 200, { + return stream.getvalue(), 200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', diff --git a/flask_security/views.py b/flask_security/views.py index 8897e1df..2ce601c5 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -343,8 +343,8 @@ def two_factor_login(): # if we already validated email&password, there is no need to do it again form_class = _security.login_form - if request.json: - form = form_class(MultiDict(request.json)) + if request.is_json: + form = form_class(MultiDict(request.get_json())) else: form = form_class() @@ -366,7 +366,7 @@ def two_factor_login(): totp_secret=user.totp_secret) return redirect(url_for('two_factor_token_validation')) - if request.json: + if request.is_json: form.user = current_user return _render_json(form) @@ -383,7 +383,7 @@ def two_factor_setup_function(): if 'password_confirmed' not in session: if 'email' not in session or 'has_two_factor' not in session: do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) - return redirect(get_post_login_redirect()) + return redirect(get_url(_security.login_url)) # user's email&password approved and # two factor properties were configured before @@ -396,8 +396,8 @@ def two_factor_setup_function(): user = current_user form_class = _security.two_factor_setup_form - if request.json: - form = form_class(MultiDict(request.json)) + if request.is_json: + form = form_class(MultiDict(request.get_json())) else: form = form_class() @@ -420,7 +420,7 @@ def two_factor_setup_function(): chosen_method=session['primary_method'], **_ctx('two_factor_setup_function')) - if request.json: + if request.is_json: return _render_json(form, include_user=False) code_form = _security.two_factor_verify_code_form() @@ -441,7 +441,7 @@ def two_factor_token_validation(): # and didn't validate password if 'has_two_factor' not in session: do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) - return redirect(get_post_login_redirect()) + return redirect(get_url(_security.login_url)) # make sure user has or has chosen a two factor # method before we try to validate @@ -451,8 +451,8 @@ def two_factor_token_validation(): form_class = _security.two_factor_verify_code_form - if request.json: - form = form_class(MultiDict(request.json)) + if request.is_json: + form = form_class(MultiDict(request.get_json())) else: form = form_class() @@ -461,7 +461,7 @@ def two_factor_token_validation(): after_this_request(_commit) return redirect(get_post_login_redirect()) - if request.json: + if request.is_json: return _render_json(form, include_user=False) # if we were trying to validate a new method @@ -502,8 +502,8 @@ def two_factor_rescue_function(): form_class = _security.two_factor_rescue_form - if request.json: - form = form_class(MultiDict(request.json)) + if request.is_json: + form = form_class(MultiDict(request.get_json())) else: form = form_class() @@ -524,7 +524,7 @@ def two_factor_rescue_function(): else: return "", 404 - if request.json: + if request.is_json: return _render_json(form, include_user=False) code_form = _security.two_factor_verify_code_form() @@ -543,8 +543,8 @@ def two_factor_password_confirmation(): """View function which handles a change two factor method request.""" form_class = _security.two_factor_change_method_verify_password_form - if request.json: - form = form_class(MultiDict(request.json)) + if request.is_json: + form = form_class(MultiDict(request.get_json())) else: form = form_class() @@ -553,7 +553,7 @@ def two_factor_password_confirmation(): do_flash(get_message('TWO_FACTOR_PASSWORD_CONFIRMATION_DONE')) return redirect(url_for('two_factor_setup_function')) - if request.json: + if request.is_json: form.user = current_user return _render_json(form) diff --git a/pytest.ini b/pytest.ini index ba53d747..97268d11 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,2 @@ [pytest] -addopts = -xrs --cov flask_security --cov-report term-missing --pep8 --flakes --translations --cache-clear +addopts = -xrs --cov flask_security --cov-report term-missing --pep8 --flakes --cache-clear diff --git a/scripts/release.py b/scripts/release.py index 94c7e3ed..be200d61 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -112,22 +112,13 @@ def build_and_upload(): def fail(message, *args): import sys - version = sys.version_info[0] >= 3 - if not version: - print >> sys.stderr, 'Error:', message % args # pragma: no flakes - else: - print('Error' + message % args, file=sys.stderr) - + sys.stderr.write('Error:' + message % args) sys.exit(1) def info(message, *args): import sys - version = sys.version_info[0] >= 3 - if not version: - print >> sys.stderr, message % args # pragma: no flakes - else: - print('Error' + message % args, file=sys.stderr) + sys.stderr.write('Error:' + message % args) def get_git_tags(): diff --git a/setup.py b/setup.py index 6d9fee1c..35fc3180 100755 --- a/setup.py +++ b/setup.py @@ -8,24 +8,24 @@ tests_require = [ 'Flask-CLI>=0.4.0', - 'Flask-Mongoengine>=0.7.0', + 'Flask-Mongoengine>=0.9.5', 'Flask-Peewee>=0.6.5', 'Flask-SQLAlchemy>=2.4', - 'bcrypt>=1.0.2', + 'bcrypt>=3.1', + 'msgcheck>=2.9', 'check-manifest>=0.25', 'coverage>=4.0', 'isort>=4.2.2', 'mock>=1.3.0', - 'mongoengine>=0.10.0', - 'pony>=0.7.1', + 'mongoengine>=0.12.0', + 'pony>=0.7.4', 'pydocstyle>=1.0.0', 'pytest-cache>=1.0', 'pytest-cov>=2.4.0', 'pytest-flakes>=1.0.1', 'pytest-pep8>=1.0.6', - 'pytest-translations>=2.0.0', 'pytest>=3.3.0', - 'sqlalchemy>=0.8.0', + 'sqlalchemy>=1.1.0', ] @@ -92,6 +92,7 @@ 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', 'Development Status :: 4 - Beta', diff --git a/tests/conftest.py b/tests/conftest.py index c85b70b7..270f7ab6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -55,13 +55,19 @@ def app(request): 'trackable', 'passwordless', 'confirmable', 'two_factor']: app.config['SECURITY_' + opt.upper()] = opt in request.keywords - if 'settings' in request.keywords: - for key, value in request.keywords['settings'].kwargs.items(): + pytest_major = int(pytest.__version__.split('.')[0]) + if pytest_major >= 4: + marker_getter = request.node.get_closest_marker + else: + marker_getter = request.keywords.get + settings = marker_getter('settings') + babel = marker_getter('babel') + if settings is not None: + for key, value in settings.kwargs.items(): app.config['SECURITY_' + key.upper()] = value mail = Mail(app) - if 'babel' not in request.keywords or \ - request.keywords['babel'].args[0]: + if babel is None or babel.args[0]: babel = Babel(app) app.babel = babel app.json_encoder = JSONEncoder @@ -278,6 +284,9 @@ class User(Base, UserMixin): password = Column(String(255)) last_login_at = Column(DateTime()) current_login_at = Column(DateTime()) + two_factor_primary_method = Column(String(255), nullable=True) + totp_secret = Column(String(255), nullable=True) + phone_number = Column(String(255), nullable=True) last_login_ip = Column(String(100)) current_login_ip = Column(String(100)) login_count = Column(Integer) @@ -336,13 +345,14 @@ class User(db.Model, UserMixin): class UserRoles(db.Model): """ Peewee does not have built-in many-to-many support, so we have to create this mapping class to link users to roles.""" - user = ForeignKeyField(User, related_name='roles') - role = ForeignKeyField(Role, related_name='users') + user = ForeignKeyField(User, backref='roles') + role = ForeignKeyField(Role, backref='users') name = property(lambda self: self.role.name) description = property(lambda self: self.role.description) with app.app_context(): for Model in (Role, User, UserRoles): + Model.drop_table() Model.create_table() def tear_down(): @@ -374,6 +384,9 @@ class User(db.Entity): password = Optional(str, nullable=True) last_login_at = Optional(datetime) current_login_at = Optional(datetime) + two_factor_primary_method = Optional(str, nullable=True) + totp_secret = Optional(str, nullable=True) + phone_number = Optional(str, nullable=True) last_login_ip = Optional(str) current_login_ip = Optional(str) login_count = Optional(int) diff --git a/tests/test_hashing.py b/tests/test_hashing.py index 46786cf6..8f0ed690 100644 --- a/tests/test_hashing.py +++ b/tests/test_hashing.py @@ -10,7 +10,7 @@ from utils import authenticate, init_app_with_options from passlib.hash import pbkdf2_sha256, django_pbkdf2_sha256, plaintext -from flask_security.utils import encrypt_password, verify_password, get_hmac +from flask_security.utils import hash_password, verify_password, get_hmac def test_verify_password_bcrypt_double_hash(app, sqlalchemy_datastore): @@ -20,7 +20,7 @@ def test_verify_password_bcrypt_double_hash(app, sqlalchemy_datastore): 'SECURITY_PASSWORD_SINGLE_HASH': False, }) with app.app_context(): - assert verify_password('pass', encrypt_password('pass')) + assert verify_password('pass', hash_password('pass')) def test_verify_password_bcrypt_single_hash(app, sqlalchemy_datastore): @@ -30,7 +30,7 @@ def test_verify_password_bcrypt_single_hash(app, sqlalchemy_datastore): 'SECURITY_PASSWORD_SINGLE_HASH': True, }) with app.app_context(): - assert verify_password('pass', encrypt_password('pass')) + assert verify_password('pass', hash_password('pass')) def test_verify_password_single_hash_list(app, sqlalchemy_datastore): @@ -44,7 +44,7 @@ def test_verify_password_single_hash_list(app, sqlalchemy_datastore): }) with app.app_context(): # double hash - assert verify_password('pass', encrypt_password('pass')) + assert verify_password('pass', hash_password('pass')) assert verify_password('pass', pbkdf2_sha256.hash(get_hmac('pass'))) # single hash assert verify_password('pass', django_pbkdf2_sha256.hash('pass')) @@ -59,7 +59,7 @@ def test_verify_password_backward_compatibility(app, sqlalchemy_datastore): }) with app.app_context(): # double hash - assert verify_password('pass', encrypt_password('pass')) + assert verify_password('pass', hash_password('pass')) # single hash assert verify_password('pass', plaintext.hash('pass')) diff --git a/tests/test_misc.py b/tests/test_misc.py index df7ae1f4..a50b7076 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -147,7 +147,9 @@ def test_addition_identity_attributes(app, sqlalchemy_datastore): assert b'Hello matt@lp.com' in response.data -def test_passwordless_and_two_factor_configuration_mismatch(app, sqlalchemy_datastore): +def test_passwordless_and_two_factor_configuration_mismatch( + app, + sqlalchemy_datastore): with pytest.raises(ValueError): init_app_with_options(app, sqlalchemy_datastore, **{ 'SECURITY_TWO_FACTOR': True, diff --git a/tests/test_recoverable.py b/tests/test_recoverable.py index b202ad33..167e130f 100644 --- a/tests/test_recoverable.py +++ b/tests/test_recoverable.py @@ -158,6 +158,7 @@ def test_expired_reset_token(client, get_message): def test_reset_token_deleted_user(app, client, get_message, sqlalchemy_datastore): with capture_reset_password_requests() as requests: + client.post( '/reset', data=dict( diff --git a/tests/test_two_factor.py b/tests/test_two_factor.py index 86126fc7..f2e064ed 100644 --- a/tests/test_two_factor.py +++ b/tests/test_two_factor.py @@ -20,7 +20,7 @@ class SmsTestSender(SmsSenderBaseClass): SmsSenderBaseClass.count = 0 def __init__(self): - super(SmsSenderBaseClass, self).__init__() + super(SmsTestSender, self).__init__() def send_sms(self, from_number, to_number, msg): SmsSenderBaseClass.messages.append(msg) @@ -30,6 +30,7 @@ def send_sms(self, from_number, to_number, msg): def get_count(self): return SmsSenderBaseClass.count + SmsSenderFactory.senders['test'] = SmsTestSender @@ -41,9 +42,9 @@ class TestMail(): def send(self, msg): if not self.msg: - self.msg="" + self.msg = "" if not self.count: - self.count=0 + self.count = 0 self.msg = msg self.count += 1 @@ -69,172 +70,214 @@ def test_two_factor_two_factor_setup_function_anonymous(app, client): def test_two_factor_flag(app, client): - # trying to verify code without going through two factor first login function - wrong_code = '000000' - response = client.post('/two_factor_token_validation/', data=dict(code=wrong_code), + # trying to verify code without going through two factor + # first login function + wrong_code = b'000000' + response = client.post('/two_factor_token_validation/', + data=dict(code=wrong_code), follow_redirects=True) - assert 'You currently do not have permissions to access this page' in response.data + + message = b'You currently do not have permissions to access this page' + assert message in response.data # Test login using invalid email data = dict(email="nobody@lp.com", password="password") response = client.post('/login', data=data, follow_redirects=True) - assert 'Specified user does not exist' in response.data + assert b'Specified user does not exist' in response.data json_data = '{"email": "nobody@lp.com", "password": "password"}' - response = client.post('/login', data=json_data, headers={'Content-Type': 'application/json'}, + response = client.post('/login', + data=json_data, + headers={'Content-Type': 'application/json'}, follow_redirects=True) - assert 'Specified user does not exist' in response.data + assert b'Specified user does not exist' in response.data # Test login using valid email and invalid password data = dict(email="gal@lp.com", password="wrong_pass") response = client.post('/login', data=data, follow_redirects=True) - assert 'Invalid password' in response.data + assert b'Invalid password' in response.data json_data = '{"email": "gal@lp.com", "password": "wrong_pass"}' - response = client.post('/login', data=json_data, headers={'Content-Type': 'application/json'}, + response = client.post('/login', data=json_data, + headers={'Content-Type': 'application/json'}, follow_redirects=True) - assert 'Invalid password' in response.data + assert b'Invalid password' in response.data # Test two factor authentication first login data = dict(email="matt@lp.com", password="password") response = client.post('/login', data=data, follow_redirects=True) - assert 'Two-factor authentication adds an extra layer of security' in response.data - response = client.post('/two_factor_setup_function/', data=dict(setup="not_a_method"), + message = b'Two-factor authentication adds an extra layer of security' + assert message in response.data + response = client.post('/two_factor_setup_function/', + data=dict(setup="not_a_method"), follow_redirects=True) - assert 'Marked method is not valid' in response.data + assert b'Marked method is not valid' in response.data # try non-existing setup on setup page (using json) json_data = '{"setup": "not_a_method"}' - response = client.post('/two_factor_setup_function/', data=json_data, - headers={'Content-Type': 'application/json'}, follow_redirects=True) - assert '"response": {}' in response.data + response = client.post('/two_factor_setup_function/', + data=json_data, + headers={'Content-Type': 'application/json'}, + follow_redirects=True) + assert b'"response": {}' in response.data json_data = '{"setup": "mail"}' - response = client.post('/two_factor_setup_function/', data=json_data, - headers={'Content-Type': 'application/json'}, follow_redirects=True) + response = client.post('/two_factor_setup_function/', + data=json_data, + headers={'Content-Type': 'application/json'}, + follow_redirects=True) # Test for sms in process of valid login sms_sender = SmsSenderFactory.createSender('test') json_data = '{"email": "gal@lp.com", "password": "password"}' - response = client.post('/login', data=json_data, headers={'Content-Type': 'application/json'}, + response = client.post('/login', data=json_data, + headers={'Content-Type': 'application/json'}, follow_redirects=True) - assert 'Please enter your authentication code' in response.data + assert b'Please enter your authentication code' in response.data assert sms_sender.get_count() == 1 code = sms_sender.messages[0].split()[-1] # submit bad token to two_factor_token_validation - response = client.post('/two_factor_token_validation/', data=dict(code=wrong_code)) - assert 'Invalid Token' in response.data + response = client.post('/two_factor_token_validation/', + data=dict(code=wrong_code)) + assert b'Invalid Token' in response.data # sumbit right token and show appropriate response - response = client.post('/two_factor_token_validation/', data=dict(code=code), + response = client.post('/two_factor_token_validation/', + data=dict(code=code), follow_redirects=True) - assert 'Your token has been confirmed' in response.data + assert b'Your token has been confirmed' in response.data # try confirming password with a wrong one - response = client.post('/change/two_factor_password_confirmation', data=dict(password=""), + response = client.post('/change/two_factor_password_confirmation', + data=dict(password=""), follow_redirects=True) - assert 'Invalid password' in response.data + assert b'Invalid password' in response.data # try confirming password with a wrong one + json json_data = '{"password": "wrong_password"}' response = client.post('/change/two_factor_password_confirmation', - data=json_data, headers={'Content-Type': 'application/json'}, + data=json_data, headers={ + 'Content-Type': 'application/json'}, follow_redirects=True) - assert 'Invalid password' in response.data + + assert response.jdata['meta']['code'] == 400 # Test change two_factor password confirmation view to mail password = 'password' response = client.post('/change/two_factor_password_confirmation', data=dict(password=password), follow_redirects=True) - assert 'You successfully confirmed password' in response.data - assert 'Two-factor authentication adds an extra layer of security' in response.data + + assert b'You successfully confirmed password' in response.data + message = b'Two-factor authentication adds an extra layer of security' + assert message in response.data # change method (from sms to mail) setup_data = dict(setup='mail') testMail = TestMail() - testMail.msg="" - testMail.count=0 + testMail.msg = "" + testMail.count = 0 app.extensions['mail'] = testMail - response = client.post('/two_factor_setup_function/', data=setup_data, follow_redirects=True) - assert 'To complete logging in, please enter the code sent to your mail' in response.data + response = client.post('/two_factor_setup_function/', + data=setup_data, follow_redirects=True) + msg = b'To complete logging in, please enter the code sent to your mail' + assert msg in response.data code = testMail.msg.body.split()[-1] # sumbit right token and show appropriate response - response = client.post('/two_factor_token_validation/', data=dict(code=code), + response = client.post('/two_factor_token_validation/', + data=dict(code=code), follow_redirects=True) - assert 'You successfully changed your two factor method' in response.data + assert b'You successfully changed your two factor method' in response.data # Test change two_factor password confirmation view to google authenticator password = 'password' response = client.post('/change/two_factor_password_confirmation', data=dict(password=password), follow_redirects=True) - assert 'You successfully confirmed password' in response.data - assert 'Two-factor authentication adds an extra layer of security' in response.data + assert b'You successfully confirmed password' in response.data + message = b'Two-factor authentication adds an extra layer of security' + assert message in response.data setup_data = dict(setup='google_authenticator') - response = client.post('/two_factor_setup_function/', data=setup_data, follow_redirects=True) - assert 'Open Google Authenticator on your device' in response.data + response = client.post('/two_factor_setup_function/', + data=setup_data, follow_redirects=True) + print(response.data) + assert b'Open Google Authenticator on your device' in response.data qrcode_page_response = client.get('/two_factor_qrcode/', data=setup_data, follow_redirects=True) - assert 'svg' in qrcode_page_response.data + print(qrcode_page_response) + assert b'svg' in qrcode_page_response.data logout(client) # Test for google_authenticator (test) json_data = '{"email": "gal2@lp.com", "password": "password"}' - response = client.post('/login', data=json_data, headers={'Content-Type': 'application/json'}, + response = client.post('/login', data=json_data, + headers={'Content-Type': 'application/json'}, follow_redirects=True) totp_secret = u'RCTE75AP2GWLZIFR' code = str(onetimepass.get_totp(totp_secret)) - response = client.post('/two_factor_token_validation/', data=dict(code=code), + response = client.post('/two_factor_token_validation/', + data=dict(code=code), follow_redirects=True) - assert 'Your token has been confirmed' in response.data + assert b'Your token has been confirmed' in response.data logout(client) # Test two factor authentication first login data = dict(email="matt@lp.com", password="password") response = client.post('/login', data=data, follow_redirects=True) - assert 'Two-factor authentication adds an extra layer of security' in response.data + message = b'Two-factor authentication adds an extra layer of security' + assert message in response.data # check availability of qrcode page when this option is not picked - qrcode_page_response = client.get('/two_factor_qrcode/', follow_redirects=False) + qrcode_page_response = client.get( + '/two_factor_qrcode/', follow_redirects=False) assert qrcode_page_response.status_code == 404 # check availability of qrcode page when this option is picked setup_data = dict(setup='google_authenticator') - response = client.post('/two_factor_setup_function/', data=setup_data, follow_redirects=True) - assert 'Open Google Authenticator on your device' in response.data + response = client.post('/two_factor_setup_function/', + data=setup_data, follow_redirects=True) + assert b'Open Google Authenticator on your device' in response.data + print(response.data) qrcode_page_response = client.get('/two_factor_qrcode/', data=setup_data, follow_redirects=True) - assert 'svg' in qrcode_page_response.data + assert b'svg' in qrcode_page_response.data # check appearence of setup page when sms picked and phone number entered sms_sender = SmsSenderFactory.createSender('test') data = dict(setup='sms', phone="+111111111111") - response = client.post('/two_factor_setup_function/', data=data, follow_redirects=True) - assert 'To Which Phone Number Should We Send Code To' in response.data + response = client.post('/two_factor_setup_function/', + data=data, follow_redirects=True) + assert b'To Which Phone Number Should We Send Code To' in response.data assert sms_sender.get_count() == 2 code = sms_sender.messages[1].split()[-1] - response = client.post('/two_factor_token_validation/', data=dict(code=code), + response = client.post('/two_factor_token_validation/', + data=dict(code=code), follow_redirects=True) - assert 'Your token has been confirmed' in response.data + assert b'Your token has been confirmed' in response.data logout(client) # check when two_factor_rescue function should not appear rescue_data_json = '{"help_setup": "lost_device"}' - response = client.post('/two_factor_rescue_function/', data=rescue_data_json, + response = client.post('/two_factor_rescue_function/', + data=rescue_data_json, headers={'Content-Type': 'application/json'}) assert response.status_code == 404 # check when two_factor_rescue function should appear data = dict(email="gal2@lp.com", password="password") response = client.post('/login', data=data, follow_redirects=True) - assert 'Please enter your authentication code' in response.data + assert b'Please enter your authentication code' in response.data rescue_data = dict(help_setup='lost_device') - response = client.post('/two_factor_rescue_function/', data=rescue_data, follow_redirects=True) - assert 'The code for authentication was sent to your email address' in response.data + response = client.post('/two_factor_rescue_function/', + data=rescue_data, follow_redirects=True) + message = b'The code for authentication was sent to your email address' + assert message in response.data rescue_data = dict(help_setup='no_mail_access') - response = client.post('/two_factor_rescue_function/', data=rescue_data, follow_redirects=True) - assert 'A mail was sent to us in order to reset your application account' in response.data + response = client.post('/two_factor_rescue_function/', + data=rescue_data, follow_redirects=True) + message = (b'A mail was sent to us in order' + + b' to reset your application account') + assert message in response.data diff --git a/tests/utils.py b/tests/utils.py index 7420b8c0..ce60e67d 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -10,7 +10,7 @@ from flask import json from flask_security import Security -from flask_security.utils import encrypt_password +from flask_security.utils import hash_password _missing = object @@ -50,24 +50,32 @@ def create_roles(ds): def create_users(ds, count=None): users = [('matt@lp.com', 'matt', 'password', ['admin'], True, None, None), ('joe@lp.com', 'joe', 'password', ['editor'], True, None, None), - ('dave@lp.com', 'dave', 'password', ['admin', 'editor'], True, None, None), + ('dave@lp.com', 'dave', 'password', [ + 'admin', 'editor'], True, None, None), ('jill@lp.com', 'jill', 'password', ['author'], True, None, None), ('tiya@lp.com', 'tiya', 'password', [], False, None, None), ('jess@lp.com', 'jess', None, [], True, None, None), - ('gal@lp.com', 'gal', 'password', ['admin'], True, 'sms', u'RCTE75AP2GWLZIFR'), - ('gal2@lp.com', 'gal2', 'password', ['admin'], True, 'google_authenticator', - u'RCTE75AP2GWLZIFR'), - ('gal3@lp.com', 'gal3', 'password', ['admin'], True, 'mail', u'RCTE75AP2GWLZIFR')] + ('gal@lp.com', 'gal', 'password', [ + 'admin'], True, 'sms', u'RCTE75AP2GWLZIFR'), + ('gal2@lp.com', 'gal2', 'password', ['admin'], True, + 'google_authenticator', u'RCTE75AP2GWLZIFR'), + ('gal3@lp.com', 'gal3', 'password', [ + 'admin'], True, 'mail', u'RCTE75AP2GWLZIFR'), + ('gene@lp.com', 'gene', 'password', [], True, None, None)] count = count or len(users) for u in users[:count]: pw = u[2] if pw is not None: - pw = encrypt_password(pw) + pw = hash_password(pw) roles = [ds.find_or_create_role(rn) for rn in u[3]] ds.commit() - user = ds.create_user(email=u[0], username=u[1], password=pw, active=u[4], - two_factor_primary_method=u[5], totp_secret=u[6]) + user = ds.create_user(email=u[0], + username=u[1], + password=pw, + active=u[4], + two_factor_primary_method=u[5], + totp_secret=u[6]) ds.commit() for role in roles: ds.add_role_to_user(user, role) diff --git a/tox.ini b/tox.ini index 64f71f5b..b1a659a1 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py34, py35, pypy +envlist = py27, py34, py35, py36, py37, pypy [testenv] commands = From a9feee750fee34850b2f9e9446039cf0e744f558 Mon Sep 17 00:00:00 2001 From: Tyler Baur <49296311+baurt@users.noreply.github.com> Date: Tue, 28 May 2019 08:25:27 -0500 Subject: [PATCH 25/32] Update docs/configuration.rst Co-Authored-By: malware-watch --- docs/configuration.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index f4d8f661..d3e5f83c 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -185,7 +185,7 @@ Template Paths ``security/two_factor_verify_code .html``. -``SECURITY_TWO_FACTOR_CHOOSE_METHOD_TEMPLT`` Specifies the path to the template for +``SECURITY_TWO_FACTOR_CHOOSE_METHOD_TEMPLATE`` Specifies the path to the template for the choose method page for the two factor authentication process. Defaults to ``security/two_factor_choose_method From 185fcbc901513bbcb99219e15bfcf25298a53423 Mon Sep 17 00:00:00 2001 From: Tyler Baur Date: Tue, 28 May 2019 08:36:18 -0500 Subject: [PATCH 26/32] consistent two-factor --- docs/api.rst | 4 ++-- docs/configuration.rst | 21 ++++++++---------- docs/customizing.rst | 8 +++---- docs/features.rst | 8 +++---- docs/index.rst | 2 +- docs/models.rst | 2 +- docs/two_factor_configurations.rst | 8 +++---- flask_security/core.py | 6 ++--- flask_security/forms.py | 6 ++--- .../security/two_factor_choose_method.html | 18 +++++++-------- .../security/two_factor_verify_code.html | 6 ++--- flask_security/twofactor.py | 6 ++--- flask_security/views.py | 22 +++++++++---------- tests/test_two_factor.py | 8 +++---- 14 files changed, 61 insertions(+), 64 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 7d7a5888..e44f9e1c 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -141,12 +141,12 @@ sends the following signals. .. data:: user_two_factored - Sent when a user performs two factor authentication login on the site. In + Sent when a user performs two-factor authentication login on the site. In addition to the app (which is the sender), it is passed `user` argument .. data:: two_factor_method_changed - Sent when two factor is used and user logs in. In addition to the app + Sent when two-factor is used and user logs in. In addition to the app (which is the sender), it is passed `user` argument. .. _Flask documentation on signals: http://flask.pocoo.org/docs/signals/ diff --git a/docs/configuration.rst b/docs/configuration.rst index d3e5f83c..1f7dad30 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -71,7 +71,7 @@ Core ``no-reply@localhost``. ``SECURITY_TWO_FACTOR_RESCUE_MAIL`` Specifies the email address users send mail to when they can't complete the - two factor authentication login. + two-factor authentication login. Defaults to ``no-reply@localhost``. ``SECURITY_TOKEN_AUTHENTICATION_KEY`` Specifies the query string parameter to read when using token authentication. @@ -180,21 +180,18 @@ Template Paths passwordless logins. Defaults to ``security/send_login.html``. ``SECURITY_TWO_FACTOR_VERIFY_CODE_TEMPLATE`` Specifies the path to the template for - the verify code page for the two factor + the verify code page for the two-factor authentication process. Defaults to - ``security/two_factor_verify_code - .html``. + ``security/two_factor_verify_code.html``. ``SECURITY_TWO_FACTOR_CHOOSE_METHOD_TEMPLATE`` Specifies the path to the template for the choose method page for the two factor authentication process. Defaults - to ``security/two_factor_choose_method - .html`` + to ``security/two_factor_choose_method.html`` ``SECURITY_TWO_FACTOR_CHANGE_METHOD_TEMPLATE`` Specifies the path to the template for the change method page for the two factor authentication process. Defaults - to ``security/two_factor_change_method_ - password_confirmation.html``. + to ``security/two_factor_change_method_password_confirmation.html``. ============================================== ======================================= @@ -236,7 +233,7 @@ Feature Flags specified by the ``SECURITY_CHANGE_URL`` configuration option. Defaults to ``False``. ``SECURITY_TWO_FACTOR`` Specifies if Flask-Security should enable the - two factor login feature. If set to ``True``, in + two-factor login feature. If set to ``True``, in addition to their passwords, users will be required to enter a code that is sent to them. The added feature includes the ability to send it either via email, sms @@ -281,10 +278,10 @@ Email to ``True``. ``SECURITY_EMAIL_SUBJECT_TWO_FACTOR`` Sets the subject for the two factor feature. Defaults to - ``Two Factor Login`` + ``Two-factor Login`` ``SECURITY_EMAIL_SUBJECT_TWO_FACTOR_RESCUE`` Sets the subject for the two factor help function. Defaults - to ``Two Factor Rescue`` + to ``Two-factor Rescue`` ================================================= ============================== Miscellaneous @@ -374,7 +371,7 @@ Miscellaneous me" value used when logging in a user. Defaults to ``False``. ``SECURITY_TWO_FACTOR_ENABLED_METHODS`` Specifies the default enabled - methods for two factor + methods for two-factor authentication. defaults to ``['mail', 'google_authenticator', 'sms']`` which are the only diff --git a/docs/customizing.rst b/docs/customizing.rst index 38378444..a7119f33 100644 --- a/docs/customizing.rst +++ b/docs/customizing.rst @@ -106,10 +106,10 @@ The following is a list of all the available form overrides: * ``change_password_form``: Change password form * ``send_confirmation_form``: Send confirmation form * ``passwordless_login_form``: Passwordless login form -* ``two_factor_verify_code_form``: Two factor code form -* ``two_factor_setup_form``: Two factor setup form -* ``two_factor_change_method_verify_password_form``: Two factor password form -* ``two_factor_rescue_form``: Two factor help user form +* ``two_factor_verify_code_form``: Two-factor code form +* ``two_factor_setup_form``: Two-factor setup form +* ``two_factor_change_method_verify_password_form``: Two-factor password form +* ``two_factor_rescue_form``: Two-factor help user form Emails ------ diff --git a/docs/features.rst b/docs/features.rst index c998f49c..5ea9a583 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -61,9 +61,9 @@ 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. -Two Factor Authentication +Two-factor Authentication ------------------------- -Two factor authentication is enabled by generating time-based one time passwords +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 method.(Doing this causes the previous totp secret to become invalid) The token @@ -129,8 +129,8 @@ JSON is supported for the following operations: * Confirmation requests * Forgot password requests * Passwordless login requests -* Two factor login requests -* Change two factor method requests +* Two-factor login requests +* Change two-factor method requests Command Line Interface diff --git a/docs/index.rst b/docs/index.rst index 0f5a4ce2..66bc374e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,7 +11,7 @@ Flask application. They include: 5. Token based authentication 6. Token based account activation (optional) 7. Token based password recovery / resetting (optional) -8. Two factor authentication (optional) +8. Two-factor authentication (optional) 9. User registration (optional) 10. Login tracking (optional) 11. JSON/Ajax Support diff --git a/docs/models.rst b/docs/models.rst index a848bd1c..a96909e7 100644 --- a/docs/models.rst +++ b/docs/models.rst @@ -79,7 +79,7 @@ serializable object: Two_Factor ^^^^^^^^^^ -If you enable two factor by setting your application's `TWO_FACTOR` +If you enable two-factor by setting your application's `TWO_FACTOR` configuration value to `True`, your `User` model will require the following additional fields: diff --git a/docs/two_factor_configurations.rst b/docs/two_factor_configurations.rst index b19048f1..0bbb2d08 100644 --- a/docs/two_factor_configurations.rst +++ b/docs/two_factor_configurations.rst @@ -1,14 +1,14 @@ -Two Factor Configurations +Two-factor Configurations ========================= -Two factor authentication provides a second layer of security to any type of +Two-factor authentication provides a second layer of security to any type of login, requiring extra information or a secondary device to log in, in addition to ones login credentials. The added feature includes the ability to add a secondary authentication method using either via email, sms message, or Google Authenticator. The following code sample illustrates how to get started as quickly as -possible using SQLAlchemy and two factor feature: +possible using SQLAlchemy and two-factor feature: - `Basic SQLAlchemy Application <#basic-sqlalchemy-application>`_ @@ -24,7 +24,7 @@ SQLAlchemy Install requirements $ pip install flask-security flask-sqlalchemy -Two Factor Application +Two-factor Application ~~~~~~~~~~~~~~~~~~~~~~ The following code sample illustrates how to get started as quickly as diff --git a/flask_security/core.py b/flask_security/core.py index 82beb3ff..65310cc5 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -133,8 +133,8 @@ 'EMAIL_SUBJECT_PASSWORD_RESET': _('Password reset instructions'), 'EMAIL_PLAINTEXT': True, 'EMAIL_HTML': True, - 'EMAIL_SUBJECT_TWO_FACTOR': 'Two Factor Login', - 'EMAIL_SUBJECT_TWO_FACTOR_RESCUE': 'Two Factor Rescue', + 'EMAIL_SUBJECT_TWO_FACTOR': 'Two-factor Login', + 'EMAIL_SUBJECT_TWO_FACTOR_RESCUE': 'Two-factor Rescue', 'USER_IDENTITY_ATTRIBUTES': ['email'], 'PASSWORD_SCHEMES': [ 'bcrypt', @@ -245,7 +245,7 @@ 'TWO_FACTOR_LOGIN_SUCCESSFUL': ( _('Your token has been confirmed'), 'success'), 'TWO_FACTOR_CHANGE_METHOD_SUCCESSFUL': ( - _('You successfully changed your two factor method.'), + _('You successfully changed your two-factor method.'), 'success'), 'TWO_FACTOR_PASSWORD_CONFIRMATION_DONE': ( _('You successfully confirmed password'), 'success'), diff --git a/flask_security/forms.py b/flask_security/forms.py index 71430eb6..cfab7a36 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -306,7 +306,7 @@ def validate(self): class TwoFactorSetupForm(Form, UserEmailFormMixin): - """The Two Factor token validation form""" + """The Two-factor token validation form""" setup = RadioField('Available Methods', choices=[('mail', 'Set Up Using Mail'), @@ -329,7 +329,7 @@ def validate(self): class TwoFactorVerifyCodeForm(Form, UserEmailFormMixin): - """The Two Factor token validation form""" + """The Two-factor token validation form""" code = StringField(get_form_field_label('code')) submit = SubmitField(get_form_field_label('submit code')) @@ -383,7 +383,7 @@ def validate(self): class TwoFactorRescueForm(Form, UserEmailFormMixin): - """The Two Factor Rescue validation form""" + """The Two-factor Rescue validation form""" help_setup = RadioField('Trouble Accessing Your Account?', choices=[('lost_device', diff --git a/flask_security/templates/security/two_factor_choose_method.html b/flask_security/templates/security/two_factor_choose_method.html index f0cab396..afd89d62 100644 --- a/flask_security/templates/security/two_factor_choose_method.html +++ b/flask_security/templates/security/two_factor_choose_method.html @@ -5,23 +5,23 @@

In addition to your username and password, you'll need to use a code that we
{{ two_factor_setup_form.hidden_tag() }} {% for subfield in two_factor_setup_form.setup %} - {% if subfield.data in choices %} - {{ render_field_with_errors(subfield) }} - {% endif %} + {% if subfield.data in choices %} + {{ render_field_with_errors(subfield) }} + {% endif %} {% endfor %} {{ render_field(two_factor_setup_form.submit, value='submit choice') }} {% if chosen_method=='mail' and chosen_method in choices %} -

To complete logging in, please enter the code sent to your mail

+

To complete logging in, please enter the code sent to your mail

{% endif %} {% if chosen_method=='google_authenticator' and chosen_method in choices %} -

Open Google Authenticator on your device and scan the following qrcode to start receiving codes:

-

+

Open Google Authenticator on your device and scan the following qrcode to start receiving codes:

+

{% endif %} {% if chosen_method=='sms' and chosen_method in choices %}

To Which Phone Number Should We Send Code To?

- {{ two_factor_setup_form.hidden_tag() }} - {{ render_field_with_errors(two_factor_setup_form.phone, placeholder="enter phone number") }} - {{ render_field(two_factor_setup_form.submit, value='submit phone') }} + {{ two_factor_setup_form.hidden_tag() }} + {{ render_field_with_errors(two_factor_setup_form.phone, placeholder="enter phone number") }} + {{ render_field(two_factor_setup_form.submit, value='submit phone') }} {% endif %}
diff --git a/flask_security/templates/security/two_factor_verify_code.html b/flask_security/templates/security/two_factor_verify_code.html index c544bbad..030116a7 100644 --- a/flask_security/templates/security/two_factor_verify_code.html +++ b/flask_security/templates/security/two_factor_verify_code.html @@ -1,6 +1,6 @@ {% from "security/_macros.html" import render_field_with_errors, render_field %} {% include "security/_messages.html" %} -

Two Factor Authentication

+

Two-factor Authentication

Please enter your authentication code

{{ two_factor_verify_code_form.hidden_tag() }} @@ -11,10 +11,10 @@

Please enter your authentication code

{{ two_factor_rescue_form.hidden_tag() }} {{ render_field_with_errors(two_factor_rescue_form.help_setup) }} {% if problem=='lost_device' %} -

The code for authentication was sent to your email address

+

The code for authentication was sent to your email address

{% endif %} {% if problem=='no_mail_access' %} -

A mail was sent to us in order to reset your application account

+

A mail was sent to us in order to reset your application account

{% endif %} {{ render_field(two_factor_rescue_form.submit, value='submit') }}
diff --git a/flask_security/twofactor.py b/flask_security/twofactor.py index 6cda89e0..f7b8bc96 100644 --- a/flask_security/twofactor.py +++ b/flask_security/twofactor.py @@ -96,7 +96,7 @@ def generate_totp(): def generate_qrcode(): - """generate the qrcode for the two factor authentication process""" + """generate the qrcode for the two-factor authentication process""" if 'google_authenticator' not in\ config_value('TWO_FACTOR_ENABLED_METHODS'): return abort(404) @@ -126,7 +126,7 @@ def generate_qrcode(): def complete_two_factor_process(user): - """clean session according to process (login or changing two factor method) + """clean session according to process (login or changing two-factor method) and perform action accordingly :param user - user's to update in database and log in if necessary """ @@ -145,7 +145,7 @@ def complete_two_factor_process(user): del session['primary_method'] del session['totp_secret'] - # if we are changing two factor method + # if we are changing two-factor method if 'password_confirmed' in session: del session['password_confirmed'] do_flash(*get_message('TWO_FACTOR_CHANGE_METHOD_SUCCESSFUL')) diff --git a/flask_security/views.py b/flask_security/views.py index 2ce601c5..1d36b9f0 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -339,7 +339,7 @@ def change_password(): @anonymous_user_required def two_factor_login(): - """View function for two factor authentication login""" + """View function for two-factor authentication login""" # if we already validated email&password, there is no need to do it again form_class = _security.login_form @@ -352,11 +352,11 @@ def two_factor_login(): if form.validate_on_submit(): user = form.user session['email'] = user.email - # if user's two factor properties are not configured + # if user's two-factor properties are not configured if user.two_factor_primary_method is None or user.totp_secret is None: session['has_two_factor'] = False return redirect(url_for('two_factor_setup_function')) - # if user's two factor properties are configured + # if user's two-factor properties are configured else: session['has_two_factor'] = True session['primary_method'] = user.two_factor_primary_method @@ -376,7 +376,7 @@ def two_factor_login(): def two_factor_setup_function(): - """View function for two factor setup during login process""" + """View function for two-factor setup during login process""" # user's email&password not approved or we are # logged in and didn't validate password @@ -386,7 +386,7 @@ def two_factor_setup_function(): return redirect(get_url(_security.login_url)) # user's email&password approved and - # two factor properties were configured before + # two-factor properties were configured before if session['has_two_factor'] is True: do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) return redirect(url_for('two_factor_token_validation')) @@ -434,8 +434,8 @@ def two_factor_setup_function(): def two_factor_token_validation(): - """View function for two factor token validation during login process""" - # if we are in login process and not changing current two factor method + """View function for two-factor token validation during login process""" + # if we are in login process and not changing current two-factor method if 'password_confirmed' not in session: # user's email&password not approved or we are logged in # and didn't validate password @@ -443,7 +443,7 @@ def two_factor_token_validation(): do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) return redirect(get_url(_security.login_url)) - # make sure user has or has chosen a two factor + # make sure user has or has chosen a two-factor # method before we try to validate if 'totp_secret' not in session or 'primary_method' not in session: do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) @@ -488,13 +488,13 @@ def two_factor_token_validation(): @anonymous_user_required def two_factor_rescue_function(): """ Function that handles a situation where user can't - enter his two factor validation code""" + enter his two-factor validation code""" # user's email&password yet to be approved if 'email' not in session: do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) return abort(404) - # user's email&password approved and two factor properties + # user's email&password approved and two-factor properties # were not configured if 'totp_secret' not in session or 'primary_method' not in session: do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) @@ -540,7 +540,7 @@ def two_factor_rescue_function(): @login_required def two_factor_password_confirmation(): - """View function which handles a change two factor method request.""" + """View function which handles a change two-factor method request.""" form_class = _security.two_factor_change_method_verify_password_form if request.is_json: diff --git a/tests/test_two_factor.py b/tests/test_two_factor.py index f2e064ed..bbce3113 100644 --- a/tests/test_two_factor.py +++ b/tests/test_two_factor.py @@ -70,7 +70,7 @@ def test_two_factor_two_factor_setup_function_anonymous(app, client): def test_two_factor_flag(app, client): - # trying to verify code without going through two factor + # trying to verify code without going through two-factor # first login function wrong_code = b'000000' response = client.post('/two_factor_token_validation/', @@ -101,7 +101,7 @@ def test_two_factor_flag(app, client): follow_redirects=True) assert b'Invalid password' in response.data - # Test two factor authentication first login + # Test two-factor authentication first login data = dict(email="matt@lp.com", password="password") response = client.post('/login', data=data, follow_redirects=True) message = b'Two-factor authentication adds an extra layer of security' @@ -187,7 +187,7 @@ def test_two_factor_flag(app, client): response = client.post('/two_factor_token_validation/', data=dict(code=code), follow_redirects=True) - assert b'You successfully changed your two factor method' in response.data + assert b'You successfully changed your two-factor method' in response.data # Test change two_factor password confirmation view to google authenticator password = 'password' @@ -222,7 +222,7 @@ def test_two_factor_flag(app, client): logout(client) - # Test two factor authentication first login + # Test two-factor authentication first login data = dict(email="matt@lp.com", password="password") response = client.post('/login', data=data, follow_redirects=True) message = b'Two-factor authentication adds an extra layer of security' From 148f8667311b1c78aa4e853ce87280b101149ae7 Mon Sep 17 00:00:00 2001 From: Tyler Baur Date: Tue, 28 May 2019 10:26:22 -0500 Subject: [PATCH 27/32] .gitignore .venv/ --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a34f1898..0f7e9830 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ pip-log.txt #Virtualenv env/ +.venv/ #Editor temporaries *~ From 3b90197261a79177b016e9f10207c371f126f5f3 Mon Sep 17 00:00:00 2001 From: Tyler Baur Date: Tue, 28 May 2019 14:06:05 -0500 Subject: [PATCH 28/32] fixes. passlib.totp, if not request.is_json --- flask_security/twofactor.py | 8 +-- flask_security/views.py | 124 ++++++++++++++++++------------------ requirements.txt | 10 +-- 3 files changed, 66 insertions(+), 76 deletions(-) diff --git a/flask_security/twofactor.py b/flask_security/twofactor.py index f7b8bc96..d52e06c7 100644 --- a/flask_security/twofactor.py +++ b/flask_security/twofactor.py @@ -10,6 +10,7 @@ import os import base64 +from passlib.totp import TOTP, generate_secret import pyqrcode import onetimepass @@ -66,10 +67,9 @@ def get_totp_uri(username, totp_secret): :param totp_secret: a unique shared secret of the user :return: """ + tp = TOTP(totp_secret) service_name = config_value('TWO_FACTOR_URI_SERVICE_NAME') - - return 'otpauth://totp/{0}:{1}?secret={2}&issuer={0}'\ - .format(service_name, username, totp_secret) + tp.to_uri(username + '@' + service_name, service_name) def verify_totp(token, totp_secret, window=0): @@ -92,7 +92,7 @@ def get_totp_password(totp_secret): def generate_totp(): - return base64.b32encode(os.urandom(10)).decode('utf-8') + return generate_secret() def generate_qrcode(): diff --git a/flask_security/views.py b/flask_security/views.py index 1d36b9f0..0785f323 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -353,18 +353,19 @@ def two_factor_login(): user = form.user session['email'] = user.email # if user's two-factor properties are not configured - if user.two_factor_primary_method is None or user.totp_secret is None: - session['has_two_factor'] = False - return redirect(url_for('two_factor_setup_function')) - # if user's two-factor properties are configured - else: - session['has_two_factor'] = True - session['primary_method'] = user.two_factor_primary_method - session['totp_secret'] = user.totp_secret - send_security_token(user=user, - method=user.two_factor_primary_method, - totp_secret=user.totp_secret) - return redirect(url_for('two_factor_token_validation')) + if not request.is_json: + if user.two_factor_primary_method is None or user.totp_secret is None: + session['has_two_factor'] = False + return redirect(url_for('two_factor_setup_function')) + # if user's two-factor properties are configured + else: + session['has_two_factor'] = True + session['primary_method'] = user.two_factor_primary_method + session['totp_secret'] = user.totp_secret + send_security_token(user=user, + method=user.two_factor_primary_method, + totp_secret=user.totp_secret) + return redirect(url_for('two_factor_token_validation')) if request.is_json: form.user = current_user @@ -380,20 +381,22 @@ def two_factor_setup_function(): # user's email&password not approved or we are # logged in and didn't validate password - if 'password_confirmed' not in session: - if 'email' not in session or 'has_two_factor' not in session: - do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) - return redirect(get_url(_security.login_url)) - - # user's email&password approved and - # two-factor properties were configured before - if session['has_two_factor'] is True: - do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) - return redirect(url_for('two_factor_token_validation')) + if not request.is_json: + if 'password_confirmed' not in session: + if 'email' not in session or 'has_two_factor' not in session: + do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) + return redirect(get_url(_security.login_url)) + + # user's email&password approved and + # two-factor properties were configured before + if session['has_two_factor'] is True: + do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) + return redirect(url_for('two_factor_token_validation')) + + user = _datastore.find_user(email=session['email']) + else: + user = current_user - user = _datastore.find_user(email=session['email']) - else: - user = current_user form_class = _security.two_factor_setup_form if request.is_json: @@ -411,43 +414,36 @@ def two_factor_setup_function(): send_security_token(user=user, method=session['primary_method'], totp_secret=session['totp_secret']) code_form = _security.two_factor_verify_code_form() - return _security.render_template( - config_value('TWO_FACTOR_CHOOSE_METHOD_TEMPLATE'), - two_factor_setup_form=form, - two_factor_verify_code_form=code_form, - choices=config_value( - 'TWO_FACTOR_ENABLED_METHODS'), - chosen_method=session['primary_method'], - **_ctx('two_factor_setup_function')) + if not request.is_json: + return _security.render_template( + config_value('TWO_FACTOR_CHOOSE_METHOD_TEMPLATE'), + two_factor_setup_form=form, + two_factor_verify_code_form=code_form, + choices=config_value( + 'TWO_FACTOR_ENABLED_METHODS'), + chosen_method=session['primary_method'], + **_ctx('two_factor_setup_function')) if request.is_json: return _render_json(form, include_user=False) - code_form = _security.two_factor_verify_code_form() - return _security.render_template( - config_value('TWO_FACTOR_CHOOSE_METHOD_TEMPLATE'), - two_factor_setup_form=form, - two_factor_verify_code_form=code_form, - choices=config_value( - 'TWO_FACTOR_ENABLED_METHODS'), - **_ctx('two_factor_setup_function')) - def two_factor_token_validation(): """View function for two-factor token validation during login process""" # if we are in login process and not changing current two-factor method - if 'password_confirmed' not in session: - # user's email&password not approved or we are logged in - # and didn't validate password - if 'has_two_factor' not in session: + if not request.is_json: + if 'password_confirmed' not in session: + # user's email&password not approved or we are logged in + # and didn't validate password + if 'has_two_factor' not in session: + do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) + return redirect(get_url(_security.login_url)) + + # make sure user has or has chosen a two-factor + # method before we try to validate + if 'totp_secret' not in session or 'primary_method' not in session: do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) - return redirect(get_url(_security.login_url)) - - # make sure user has or has chosen a two-factor - # method before we try to validate - if 'totp_secret' not in session or 'primary_method' not in session: - do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) - return redirect(url_for('two_factor_setup_function')) + return redirect(url_for('two_factor_setup_function')) form_class = _security.two_factor_verify_code_form @@ -490,15 +486,16 @@ def two_factor_rescue_function(): """ Function that handles a situation where user can't enter his two-factor validation code""" # user's email&password yet to be approved - if 'email' not in session: - do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) - return abort(404) + if not request.is_json: + if 'email' not in session: + do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) + return redirect(get_url(_security.login_url)) - # user's email&password approved and two-factor properties - # were not configured - if 'totp_secret' not in session or 'primary_method' not in session: - do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) - return abort(404) + # user's email&password approved and two-factor properties + # were not configured + if 'totp_secret' not in session or 'primary_method' not in session: + do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) + return redirect(get_url(_security.login_url)) form_class = _security.two_factor_rescue_form @@ -550,8 +547,9 @@ def two_factor_password_confirmation(): if form.validate_on_submit(): session['password_confirmed'] = True - do_flash(get_message('TWO_FACTOR_PASSWORD_CONFIRMATION_DONE')) - return redirect(url_for('two_factor_setup_function')) + if not request.is_json: + do_flash(get_message('TWO_FACTOR_PASSWORD_CONFIRMATION_DONE')) + return redirect(url_for('two_factor_setup_function')) if request.is_json: form.user = current_user diff --git a/requirements.txt b/requirements.txt index 3a666c6b..0e9ba454 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,3 @@ -Flask>=0.9 -Flask-Login>=0.3.0,<0.4 -Flask-Mail>=0.7.3 -Flask-Principal>=0.3.3 -Flask-WTF>=0.8 -itsdangerous>=0.17 -passlib>=1.6.4 -onetimepass==1.0.1 -PyQRCode==1.1.1 +# Trick for ReadTheDocs to install all requirements: -e .[all] From 0b0c366493327101ad3be6f7874f7ce47380a725 Mon Sep 17 00:00:00 2001 From: Tyler Baur Date: Tue, 28 May 2019 14:19:08 -0500 Subject: [PATCH 29/32] translation stubs for new messages --- .../translations/flask_security.pot | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/flask_security/translations/flask_security.pot b/flask_security/translations/flask_security.pot index a6df1467..4f294fe8 100644 --- a/flask_security/translations/flask_security.pot +++ b/flask_security/translations/flask_security.pot @@ -316,3 +316,30 @@ msgstr "" msgid "You can confirm your email through the link below:" msgstr "" +#: flask_security/core.py:244 +msgid "Invalid Token" +msgstr "" + +#: flask_security/core.py:246 +msgid "Your token has been confirmed" +msgstr "" + +#: flask_security/core.py:248 +msgid "You successfully changed your two-factor method." +msgstr "" + +#: flask_security/core.py:251 +msgid "You successfully confirmed password" +msgstr "" + +#: flask_security/core.py:253 +msgid "Password confirmation is needed in order to access page" +msgstr "" + +#: flask_security/core.py:256 +msgid "You currently do not have permissions to access this page" +msgstr "" + +#: flask_security/core.py:259 +msgid "Marked method is not valid" +msgstr "" From 4a766a1efee5f6b35b1782035cd8eae8b4c256d9 Mon Sep 17 00:00:00 2001 From: Tyler Baur Date: Tue, 28 May 2019 14:23:23 -0500 Subject: [PATCH 30/32] Make two-factor login more JSON friendly --- flask_security/twofactor.py | 40 +------- flask_security/views.py | 197 +++++++++++++++++++++++++----------- tests/test_two_factor.py | 9 +- 3 files changed, 149 insertions(+), 97 deletions(-) diff --git a/flask_security/twofactor.py b/flask_security/twofactor.py index d52e06c7..2fc50de5 100644 --- a/flask_security/twofactor.py +++ b/flask_security/twofactor.py @@ -10,12 +10,10 @@ import os import base64 -from passlib.totp import TOTP, generate_secret +from passlib.totp import TOTP -import pyqrcode import onetimepass -from flask import current_app as app, session, abort -from flask_login import current_user +from flask import current_app as app, session from werkzeug.local import LocalProxy from .utils import send_mail, config_value, get_message, do_flash,\ @@ -69,7 +67,7 @@ def get_totp_uri(username, totp_secret): """ tp = TOTP(totp_secret) service_name = config_value('TWO_FACTOR_URI_SERVICE_NAME') - tp.to_uri(username + '@' + service_name, service_name) + return tp.to_uri(username + '@' + service_name, service_name) def verify_totp(token, totp_secret, window=0): @@ -92,37 +90,7 @@ def get_totp_password(totp_secret): def generate_totp(): - return generate_secret() - - -def generate_qrcode(): - """generate the qrcode for the two-factor authentication process""" - if 'google_authenticator' not in\ - config_value('TWO_FACTOR_ENABLED_METHODS'): - return abort(404) - if 'primary_method' not in session or\ - session['primary_method'] != 'google_authenticator' \ - or 'totp_secret' not in session: - return abort(404) - - if 'email' in session: - email = session['email'] - elif 'password_confirmed' in session: - email = current_user.email - else: - return abort(404) - - name = email.split('@')[0] - totp = session['totp_secret'] - url = pyqrcode.create(get_totp_uri(name, totp)) - from io import BytesIO - stream = BytesIO() - url.svg(stream, scale=3) - return stream.getvalue(), 200, { - 'Content-Type': 'image/svg+xml', - 'Cache-Control': 'no-cache, no-store, must-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0'} + return base64.b32encode(os.urandom(10)).decode('utf-8') def complete_two_factor_process(user): diff --git a/flask_security/views.py b/flask_security/views.py index 0785f323..c2aca97c 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -14,6 +14,7 @@ from flask_login import current_user from werkzeug.datastructures import MultiDict from werkzeug.local import LocalProxy +import pyqrcode from .changeable import change_user_password from .confirmable import confirm_email_token_status, confirm_user, \ @@ -27,8 +28,8 @@ get_post_register_redirect, get_message, login_user, logout_user, \ url_for_security as url_for, slash_url_suffix, send_mail,\ get_post_logout_redirect -from .twofactor import send_security_token, generate_totp, generate_qrcode, \ - complete_two_factor_process +from .twofactor import send_security_token, generate_totp, \ + complete_two_factor_process, get_totp_uri # Convenient references _security = LocalProxy(lambda: current_app.extensions['security']) @@ -132,6 +133,7 @@ def register(): redirect_url = get_post_register_redirect() return redirect(redirect_url) + return _render_json(form, include_auth_token=True) if request.is_json: @@ -353,23 +355,26 @@ def two_factor_login(): user = form.user session['email'] = user.email # if user's two-factor properties are not configured - if not request.is_json: - if user.two_factor_primary_method is None or user.totp_secret is None: - session['has_two_factor'] = False + + if user.two_factor_primary_method is None or\ + user.totp_secret is None: + session['has_two_factor'] = False + if not request.is_json: return redirect(url_for('two_factor_setup_function')) - # if user's two-factor properties are configured - else: - session['has_two_factor'] = True - session['primary_method'] = user.two_factor_primary_method - session['totp_secret'] = user.totp_secret - send_security_token(user=user, - method=user.two_factor_primary_method, - totp_secret=user.totp_secret) + + # if user's two-factor properties are configured + else: + session['has_two_factor'] = True + session['primary_method'] = user.two_factor_primary_method + session['totp_secret'] = user.totp_secret + send_security_token(user=user, + method=user.two_factor_primary_method, + totp_secret=user.totp_secret) + if not request.is_json: return redirect(url_for('two_factor_token_validation')) if request.is_json: - form.user = current_user - return _render_json(form) + return _render_json(form, include_user=False) return _security.render_template(config_value('LOGIN_USER_TEMPLATE'), login_user_form=form, @@ -381,22 +386,6 @@ def two_factor_setup_function(): # user's email&password not approved or we are # logged in and didn't validate password - if not request.is_json: - if 'password_confirmed' not in session: - if 'email' not in session or 'has_two_factor' not in session: - do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) - return redirect(get_url(_security.login_url)) - - # user's email&password approved and - # two-factor properties were configured before - if session['has_two_factor'] is True: - do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) - return redirect(url_for('two_factor_token_validation')) - - user = _datastore.find_user(email=session['email']) - else: - user = current_user - form_class = _security.two_factor_setup_form if request.is_json: @@ -404,8 +393,34 @@ def two_factor_setup_function(): else: form = form_class() + if 'password_confirmed' not in session: + + if 'email' not in session or 'has_two_factor' not in session: + if not request.is_json: + do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) + return redirect(get_url(_security.login_url)) + else: + m, c = get_message('TWO_FACTOR_PERMISSION_DENIED') + form._errors = m + return _render_json(form) + + # user's email&password approved and + # two-factor properties were configured before + if session['has_two_factor'] is True: + if not request.is_json: + do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) + return redirect(url_for('two_factor_token_validation')) + else: + m, c = get_message('TWO_FACTOR_PERMISSION_DENIED') + form._errors = m + return _render_json(form) + + user = _datastore.find_user(email=session['email']) + else: + user = current_user + if form.validate_on_submit(): - # totp and primarty_method are added to + # totp and primary_method are added to # session to flag the user's temporary choice session['totp_secret'] = generate_totp() session['primary_method'] = form['setup'].data @@ -427,23 +442,19 @@ def two_factor_setup_function(): if request.is_json: return _render_json(form, include_user=False) + code_form = _security.two_factor_verify_code_form() + return _security.render_template( + config_value('TWO_FACTOR_CHOOSE_METHOD_TEMPLATE'), + two_factor_setup_form=form, + two_factor_verify_code_form=code_form, + choices=config_value( + 'TWO_FACTOR_ENABLED_METHODS'), + **_ctx('two_factor_setup_function')) + def two_factor_token_validation(): """View function for two-factor token validation during login process""" # if we are in login process and not changing current two-factor method - if not request.is_json: - if 'password_confirmed' not in session: - # user's email&password not approved or we are logged in - # and didn't validate password - if 'has_two_factor' not in session: - do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) - return redirect(get_url(_security.login_url)) - - # make sure user has or has chosen a two-factor - # method before we try to validate - if 'totp_secret' not in session or 'primary_method' not in session: - do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) - return redirect(url_for('two_factor_setup_function')) form_class = _security.two_factor_verify_code_form @@ -452,17 +463,42 @@ def two_factor_token_validation(): else: form = form_class() + if 'password_confirmed' not in session: + # user's email&password not approved or we are logged in + # and didn't validate password + if 'has_two_factor' not in session: + if not request.is_json: + do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) + return redirect(get_url(_security.login_url)) + else: + m, c = get_message('TWO_FACTOR_PERMISSION_DENIED') + form._errors = m + return _render_json(form) + # make sure user has or has chosen a two-factor + # method before we try to validate + if 'totp_secret' not in session or 'primary_method' not in session: + if not request.is_json: + do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) + return redirect(url_for('two_factor_setup_function')) + else: + m, c = get_message('TWO_FACTOR_PERMISSION_DENIED') + form._errors = m + return _render_json(form) + if form.validate_on_submit(): complete_two_factor_process(form.user) after_this_request(_commit) - return redirect(get_post_login_redirect()) + if not request.is_json: + return redirect(get_post_login_redirect()) if request.is_json: - return _render_json(form, include_user=False) + form.user = current_user + return _render_json(form) # if we were trying to validate a new method if 'password_confirmed' in session or session['has_two_factor'] is False: setup_form = _security.two_factor_setup_form() + return _security.render_template( config_value('TWO_FACTOR_CHOOSE_METHOD_TEMPLATE'), two_factor_setup_form=setup_form, @@ -470,9 +506,12 @@ def two_factor_token_validation(): choices=config_value( 'TWO_FACTOR_ENABLED_METHODS'), **_ctx('two_factor_setup_function')) + # if we were trying to validate an existing method else: + rescue_form = _security.two_factor_rescue_form() + return _security.render_template( config_value('TWO_FACTOR_VERIFY_CODE_TEMPLATE'), two_factor_rescue_form=rescue_form, @@ -486,16 +525,6 @@ def two_factor_rescue_function(): """ Function that handles a situation where user can't enter his two-factor validation code""" # user's email&password yet to be approved - if not request.is_json: - if 'email' not in session: - do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) - return redirect(get_url(_security.login_url)) - - # user's email&password approved and two-factor properties - # were not configured - if 'totp_secret' not in session or 'primary_method' not in session: - do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) - return redirect(get_url(_security.login_url)) form_class = _security.two_factor_rescue_form @@ -504,6 +533,26 @@ def two_factor_rescue_function(): else: form = form_class() + if 'email' not in session: + if not request.is_json: + do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) + return redirect(get_url(_security.login_url)) + else: + m, c = get_message('TWO_FACTOR_PERMISSION_DENIED') + form._errors = m + return _render_json(form, include_user=False) + # user's email&password approved and two-factor properties + # were not configured + if 'totp_secret' not in session or 'primary_method' not in session: + if not request.is_json: + do_flash(*get_message('TWO_FACTOR_PERMISSION_DENIED')) + return redirect(get_url(_security.login_url)) + + else: + m, c = get_message('TWO_FACTOR_PERMISSION_DENIED') + form._errors = m + return _render_json(form, include_user=False) + problem = None if form.validate_on_submit(): problem = form.data['help_setup'] @@ -551,6 +600,11 @@ def two_factor_password_confirmation(): do_flash(get_message('TWO_FACTOR_PASSWORD_CONFIRMATION_DONE')) return redirect(url_for('two_factor_setup_function')) + else: + m, c = get_message('TWO_FACTOR_PASSWORD_CONFIRMATION_DONE') + form._errors = m + return _render_json(form) + if request.is_json: form.user = current_user return _render_json(form) @@ -566,6 +620,35 @@ def two_factor_qrcode(): return generate_qrcode() +def generate_qrcode(): + if 'google_authenticator' not in\ + config_value('TWO_FACTOR_ENABLED_METHODS'): + return abort(404) + if 'primary_method' not in session or\ + session['primary_method'] != 'google_authenticator' \ + or 'totp_secret' not in session: + return abort(404) + + if 'email' in session: + email = session['email'] + elif 'password_confirmed' in session: + email = current_user.email + else: + return abort(404) + + name = email.split('@')[0] + totp = session['totp_secret'] + url = pyqrcode.create(get_totp_uri(name, totp)) + from io import BytesIO + stream = BytesIO() + url.svg(stream, scale=3) + return stream.getvalue(), 200, { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0'} + + def create_blueprint(state, import_name): """Creates the security extension blueprint""" diff --git a/tests/test_two_factor.py b/tests/test_two_factor.py index bbce3113..9c61c962 100644 --- a/tests/test_two_factor.py +++ b/tests/test_two_factor.py @@ -131,7 +131,7 @@ def test_two_factor_flag(app, client): response = client.post('/login', data=json_data, headers={'Content-Type': 'application/json'}, follow_redirects=True) - assert b'Please enter your authentication code' in response.data + assert b'"code": 200' in response.data assert sms_sender.get_count() == 1 code = sms_sender.messages[0].split()[-1] @@ -199,7 +199,7 @@ def test_two_factor_flag(app, client): setup_data = dict(setup='google_authenticator') response = client.post('/two_factor_setup_function/', data=setup_data, follow_redirects=True) - print(response.data) + assert b'Open Google Authenticator on your device' in response.data qrcode_page_response = client.get('/two_factor_qrcode/', data=setup_data, follow_redirects=True) @@ -238,9 +238,10 @@ def test_two_factor_flag(app, client): response = client.post('/two_factor_setup_function/', data=setup_data, follow_redirects=True) assert b'Open Google Authenticator on your device' in response.data - print(response.data) + qrcode_page_response = client.get('/two_factor_qrcode/', data=setup_data, follow_redirects=True) + print(qrcode_page_response) assert b'svg' in qrcode_page_response.data # check appearence of setup page when sms picked and phone number entered @@ -264,7 +265,7 @@ def test_two_factor_flag(app, client): response = client.post('/two_factor_rescue_function/', data=rescue_data_json, headers={'Content-Type': 'application/json'}) - assert response.status_code == 404 + assert b'"code": 400' in response.data # check when two_factor_rescue function should appear data = dict(email="gal2@lp.com", password="password") From 4f909ebf100b2055e740500c056f140aecffd6f1 Mon Sep 17 00:00:00 2001 From: Tyler Baur Date: Thu, 30 May 2019 08:56:14 -0500 Subject: [PATCH 31/32] make pyqrcode and onetimepass optional, check imps --- flask_security/core.py | 47 +++++++++++++++++++++++++++++++++--------- setup.py | 2 ++ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 65310cc5..24009cc9 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -611,21 +611,48 @@ def _register_i18n(): # configuration mismatch check if cv('TWO_FACTOR', app=app) is True and\ - len(cv('TWO_FACTOR_ENABLED_METHODS', app=app)) < 1: + len(cv('TWO_FACTOR_ENABLED_METHODS', + app=app)) < 1: # pragma: no cover raise ValueError() - flag = False - try: - import importlib.util as import_util - flag = import_util.find_spec('twilio') - except: - pass + config_value = cv('TWO_FACTOR', app=app) + if config_value: # pragma: no cover + self.check_two_factor_modules('onetimepass', + 'TWO_FACTOR', config_value) + self.check_two_factor_modules('pyqrcode', + 'TWO_FACTOR', config_value) - if not flag and cv('TWO_FACTOR_SMS_SERVICE', app=app)\ - == 'Twilio': - raise ValueError() + config_value = cv('TWO_FACTOR_SMS_SERVICE', app=app) + + if config_value == 'Twilio': # pragma: no cover + self.check_two_factor_modules('twilio', + 'TWO_FACTOR_SMS_SERVICE', + config_value) + + return state + + def check_two_factor_modules(self, module, + config_name, + config_value): # 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 + + if not module_exists: + raise ValueError('{} is required for {} = {}' + .format(module, config_name, config_value)) + + return module_exists return state def render_template(self, *args, **kwargs): diff --git a/setup.py b/setup.py index 35fc3180..7336f84c 100755 --- a/setup.py +++ b/setup.py @@ -18,6 +18,7 @@ 'isort>=4.2.2', 'mock>=1.3.0', 'mongoengine>=0.12.0', + 'onetimepass>=1.0.1', 'pony>=0.7.4', 'pydocstyle>=1.0.0', 'pytest-cache>=1.0', @@ -25,6 +26,7 @@ 'pytest-flakes>=1.0.1', 'pytest-pep8>=1.0.6', 'pytest>=3.3.0', + 'pyqrcode>=1.2', 'sqlalchemy>=1.1.0', ] From 7ef900396361cf7d02e55339297ea840bc0b202e Mon Sep 17 00:00:00 2001 From: Tyler Baur Date: Thu, 30 May 2019 09:01:40 -0500 Subject: [PATCH 32/32] fix function def and import --- flask_security/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_security/core.py b/flask_security/core.py index 24009cc9..2c19db2e 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -12,6 +12,7 @@ """ from datetime import datetime +import sys import pkg_resources from flask import current_app, render_template @@ -653,7 +654,6 @@ def check_two_factor_modules(self, module, .format(module, config_name, config_value)) return module_exists - return state def render_template(self, *args, **kwargs): return render_template(*args, **kwargs)