diff --git a/.gitignore b/.gitignore index 79c51b8b..d33ba13e 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,6 @@ example_apps/translations/ # Private files local_settings.py example_apps/local_settings.py + +# VS Code +settings.json diff --git a/docs/source/api.rst b/docs/source/api.rst index 40e58724..1174ad92 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -71,3 +71,14 @@ These tokens are used in the following places: :no-undoc-members: .. seealso:: :ref:`Customizing the TokenManager `. + +-------- + +.. _TOTPManager: + +TOTPManager class +----------------- + +The TOTPManager generates and verifies Time-based One Time Passwords. + +.. autoclass:: flask_user.totp_manager.TOTPManager \ No newline at end of file diff --git a/docs/source/api_forms.rst b/docs/source/api_forms.rst index aa5cd926..0ea67f97 100644 --- a/docs/source/api_forms.rst +++ b/docs/source/api_forms.rst @@ -27,6 +27,17 @@ ChangeUsernameForm -------- +.. _DisableTOTPForm: + +DisableTOTPForm +--------------- + +.. autoclass:: flask_user.forms.DisableTOTPForm + :no-undoc-members: + :no-inherited-members: + +-------- + .. _EditUserProfileForm: EditUserProfileForm @@ -38,6 +49,17 @@ EditUserProfileForm -------- +.. _EnableTOTPForm: + +EnableTOTPForm +-------------- + +.. autoclass:: flask_user.forms.EnableTOTPForm + :no-undoc-members: + :no-inherited-members: + +-------- + .. _ForgotPasswordForm: ForgotPasswordForm @@ -101,3 +123,14 @@ ResetPasswordForm .. autoclass:: flask_user.forms.ResetPasswordForm :no-undoc-members: :no-inherited-members: + +-------- + +.. _VerifyTOTPTokenForm: + +VerifyTOTPTokenForm +------------------- + +.. autoclass:: flask_user.forms.VerifyTOTPTokenForm + :no-undoc-members: + :no-inherited-members: diff --git a/docs/source/base_templates.rst b/docs/source/base_templates.rst index d043c24d..f806ba08 100644 --- a/docs/source/base_templates.rst +++ b/docs/source/base_templates.rst @@ -19,8 +19,9 @@ Public forms are forms that do not require a logged-in user: * ``templates/flask_user/login.html``, * ``templates/flask_user/login_or_register.html``, * ``templates/flask_user/register.html``, -* ``templates/flask_user/request_email_confirmation.html``, and -* ``templates/flask_user/reset_password.html``. +* ``templates/flask_user/request_email_confirmation.html``, +* ``templates/flask_user/reset_password.html``, and +* ``tempates/flask_user/verify_totp_token.html``. Public forms extend the template file ``templates/flask_user/_public_base.html``, which by default extends the template file ``templates/base.html``. @@ -36,7 +37,9 @@ create the ``templates/flask_user/_public_base.html`` file in your application's Member forms are forms that require a logged-in user: * ``templates/flask_user/change_password.html``, -* ``templates/flask_user/change_username.html``, and +* ``templates/flask_user/change_username.html``, +* ``tempates/flask_user/disable_totp.html``, +* ``tempates/flask_user/enable_totp.html``, and * ``templates/flask_user/manage_emails.html``. Member forms extend the template file ``templates/flask_user/_authorized_base.html``, @@ -57,6 +60,8 @@ The following template files reside in the ``templates`` directory:: flask_user/_authorized_base.html # extends base.html flask_user/change_password.html # extends flask_user/_authorized_base.html flask_user/change_username.html # extends flask_user/_authorized_base.html + flask_user/disable_totp.html # extends flask_user/_authorized_base.html + flask_user/enable_totp.html # extends flask_user/_authorized_base.html flask_user/manage_emails.html # extends flask_user/_authorized_base.html flask_user/_public_base.html # extends base.html @@ -66,3 +71,4 @@ The following template files reside in the ``templates`` directory:: flask_user/register.html # extends flask_user/_public_base.html flask_user/request_email_confirmation.html # extends flask_user/_public_base.html flask_user/reset_password.html # extends flask_user/_public_base.html + flask_user/verify_totp_token.html # extends flask_user/_public_base.html \ No newline at end of file diff --git a/example_apps/basic_app_with_totp.py b/example_apps/basic_app_with_totp.py new file mode 100644 index 00000000..d5c0d84d --- /dev/null +++ b/example_apps/basic_app_with_totp.py @@ -0,0 +1,178 @@ +# This file contains an example Flask-User application. +# To keep the example simple, we are applying some unusual techniques: +# - Placing everything in one file +# - Using class-based configuration (instead of file-based configuration) +# - Using string-based templates (instead of file-based templates) + +import datetime +from flask import Flask, request, render_template_string +from flask_babelex import Babel +from flask_sqlalchemy import SQLAlchemy +from flask_user import current_user, login_required, roles_required, UserManager, UserMixin + + +# Class-based application configuration +class ConfigClass(object): + """ Flask application config """ + + # Flask settings + SECRET_KEY = 'This is an INSECURE secret!! DO NOT use this in production!!' + + # Flask-SQLAlchemy settings + SQLALCHEMY_DATABASE_URI = 'sqlite:///basic_app.sqlite' # File-based SQL database + SQLALCHEMY_TRACK_MODIFICATIONS = False # Avoids SQLAlchemy warning + + # Flask-Mail SMTP server settings + MAIL_SERVER = 'smtp.gmail.com' + MAIL_PORT = 465 + MAIL_USE_SSL = True + MAIL_USE_TLS = False + MAIL_USERNAME = 'email@example.com' + MAIL_PASSWORD = 'password' + MAIL_DEFAULT_SENDER = '"MyApp" ' + + # Flask-User settings + USER_APP_NAME = "Flask-User Basic App" # Shown in and email templates and page footers + USER_ENABLE_EMAIL = True # Enable email authentication + USER_ENABLE_USERNAME = False # Disable username authentication + USER_EMAIL_SENDER_NAME = USER_APP_NAME + USER_EMAIL_SENDER_EMAIL = "noreply@example.com" + USER_ENABLE_TOTP = True # Enable TOTP + + +def create_app(): + """ Flask application factory """ + + # Create Flask app load app.config + app = Flask(__name__) + app.config.from_object(__name__+'.ConfigClass') + + # Initialize Flask-BabelEx + babel = Babel(app) + + # Initialize Flask-SQLAlchemy + db = SQLAlchemy(app) + + # Define the User data-model. + # NB: Make sure to add flask_user UserMixin !!! + class User(db.Model, UserMixin): + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + active = db.Column('is_active', db.Boolean(), nullable=False, server_default='1') + + # User authentication information. The collation='NOCASE' is required + # to search case insensitively when USER_IFIND_MODE is 'nocase_collation'. + email = db.Column(db.String(255, collation='NOCASE'), nullable=False, unique=True) + email_confirmed_at = db.Column(db.DateTime()) + password = db.Column(db.String(255), nullable=False, server_default='') + + # User information + first_name = db.Column(db.String(100, collation='NOCASE'), nullable=False, server_default='') + last_name = db.Column(db.String(100, collation='NOCASE'), nullable=False, server_default='') + + # Define the relationship to Role via UserRoles + roles = db.relationship('Role', secondary='user_roles') + + # TOTP + totp_secret = db.Column(db.String(16), nullable=True, server_default=None) + totp_verified = db.Column(db.Boolean(), nullable=False, server_default='0') + + + # Define the Role data-model + class Role(db.Model): + __tablename__ = 'roles' + id = db.Column(db.Integer(), primary_key=True) + name = db.Column(db.String(50), unique=True) + + # Define the UserRoles association table + class UserRoles(db.Model): + __tablename__ = 'user_roles' + id = db.Column(db.Integer(), primary_key=True) + user_id = db.Column(db.Integer(), db.ForeignKey('users.id', ondelete='CASCADE')) + role_id = db.Column(db.Integer(), db.ForeignKey('roles.id', ondelete='CASCADE')) + + # Setup Flask-User and specify the User data-model + user_manager = UserManager(app, db, User) + + # Create all database tables + db.create_all() + + # Create 'member@example.com' user with no roles + if not User.query.filter(User.email == 'member@example.com').first(): + user = User( + email='member@example.com', + email_confirmed_at=datetime.datetime.utcnow(), + password=user_manager.hash_password('Password1'), + ) + db.session.add(user) + db.session.commit() + + # Create 'admin@example.com' user with 'Admin' and 'Agent' roles + if not User.query.filter(User.email == 'admin@example.com').first(): + user = User( + email='admin@example.com', + email_confirmed_at=datetime.datetime.utcnow(), + password=user_manager.hash_password('Password1'), + ) + user.roles.append(Role(name='Admin')) + user.roles.append(Role(name='Agent')) + db.session.add(user) + db.session.commit() + + # The Home page is accessible to anyone + @app.route('/') + def home_page(): + return render_template_string(""" + {% extends "flask_user_layout.html" %} + {% block content %} +

{%trans%}Home page{%endtrans%}

+

{%trans%}Register{%endtrans%}

+

{%trans%}Sign in{%endtrans%}

+

{%trans%}Home Page{%endtrans%} (accessible to anyone)

+

{%trans%}Member Page{%endtrans%} (login_required: member@example.com / Password1)

+

{%trans%}Admin Page{%endtrans%} (role_required: admin@example.com / Password1')

+

{%trans%}Sign out{%endtrans%}

+ {% endblock %} + """) + + # The Members page is only accessible to authenticated users + @app.route('/members') + @login_required # Use of @login_required decorator + def member_page(): + return render_template_string(""" + {% extends "flask_user_layout.html" %} + {% block content %} +

{%trans%}Members page{%endtrans%}

+

{%trans%}Register{%endtrans%}

+

{%trans%}Sign in{%endtrans%}

+

{%trans%}Home Page{%endtrans%} (accessible to anyone)

+

{%trans%}Member Page{%endtrans%} (login_required: member@example.com / Password1)

+

{%trans%}Admin Page{%endtrans%} (role_required: admin@example.com / Password1')

+

{%trans%}Sign out{%endtrans%}

+ {% endblock %} + """) + + # The Admin page requires an 'Admin' role. + @app.route('/admin') + @roles_required('Admin') # Use of @roles_required decorator + def admin_page(): + return render_template_string(""" + {% extends "flask_user_layout.html" %} + {% block content %} +

{%trans%}Admin Page{%endtrans%}

+

{%trans%}Register{%endtrans%}

+

{%trans%}Sign in{%endtrans%}

+

{%trans%}Home Page{%endtrans%} (accessible to anyone)

+

{%trans%}Member Page{%endtrans%} (login_required: member@example.com / Password1)

+

{%trans%}Admin Page{%endtrans%} (role_required: admin@example.com / Password1')

+

{%trans%}Sign out{%endtrans%}

+ {% endblock %} + """) + + return app + + +# Start development web server +if __name__ == '__main__': + app = create_app() + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/flask_user/__init__.py b/flask_user/__init__.py index 60de0f0f..8128fec0 100644 --- a/flask_user/__init__.py +++ b/flask_user/__init__.py @@ -28,6 +28,7 @@ class EmailError(Exception): from .email_manager import EmailManager from .password_manager import PasswordManager from .token_manager import TokenManager +from .totp_manager import TOTPManager # Export Flask-User decorators from .decorators import * diff --git a/flask_user/db_manager.py b/flask_user/db_manager.py index 38836fbf..8e72c32f 100644 --- a/flask_user/db_manager.py +++ b/flask_user/db_manager.py @@ -4,8 +4,10 @@ # Author: Ling Thio # Copyright (c) 2013 Ling Thio +import base64 from .db_adapters import PynamoDbAdapter, DynamoDbAdapter, MongoDbAdapter, SQLDbAdapter from flask_user import current_user, ConfigError +import os class DBManager(object): """Manage DB objects.""" diff --git a/flask_user/forms.py b/flask_user/forms.py index 19b5316e..90ef13d2 100644 --- a/flask_user/forms.py +++ b/flask_user/forms.py @@ -352,6 +352,26 @@ class InviteUserForm(FlaskForm): submit = SubmitField(_('Invite!')) +class EnableTOTPForm(FlaskForm): + """Enable TOTP form.""" + totp_token = StringField(_('TOTP Token'), validators=[ + validators.DataRequired(), validators.Length(6, 6)]) + submit = SubmitField(_('Verify')) + + +class VerifyTOTPTokenForm(FlaskForm): + """Verify TOTP token form.""" + next = HiddenField() + remember_me = HiddenField() + totp_token = StringField(_('TOTP Token'), validators=[validators.DataRequired(), validators.Length(6, 6)]) + submit = SubmitField(_('Verify')) + +class DisableTOTPForm(FlaskForm): + """Disable TOTP Token form.""" + disable = BooleanField(_('Disable')) + submit = SubmitField(_('Confirm')) + + # Manually Add translation strings from QuickStart apps that use string templates _sign_in = _('Sign in') _sign_out = _('Sign out') diff --git a/flask_user/templates/flask_user/disable_totp.html b/flask_user/templates/flask_user/disable_totp.html new file mode 100644 index 00000000..35e1055d --- /dev/null +++ b/flask_user/templates/flask_user/disable_totp.html @@ -0,0 +1,12 @@ +{% extends 'flask_user/_authorized_base.html' %} + +{% block content %} +{% from "flask_user/_macros.html" import render_field, render_checkbox_field,render_submit_field %} +

{%trans%}Disable TOTP{%endtrans%}

+
+ {{ form.hidden_tag() }} + {{ render_checkbox_field(form.disable, tabindex=130) }} + {{ render_submit_field(form.submit, tabindex=90) }} +
+ +{% endblock %} \ No newline at end of file diff --git a/flask_user/templates/flask_user/edit_user_profile.html b/flask_user/templates/flask_user/edit_user_profile.html index 1fa25751..aa592ae1 100644 --- a/flask_user/templates/flask_user/edit_user_profile.html +++ b/flask_user/templates/flask_user/edit_user_profile.html @@ -25,6 +25,13 @@

{%trans%}User profile{%endtrans%}

{% if user_manager.USER_ENABLE_CHANGE_PASSWORD %}

{%trans%}Change password{%endtrans%}

{% endif %} + {% if user_manager.USER_ENABLE_TOTP%} + {% if not current_user.totp_verified %} +

{%trans%}Enable TOTP{%endtrans%}

+ {% else%} +

{%trans%}Disable TOTP{%endtrans%}

+ {% endif %} + {% endif %} {% endif %} {% endblock %} \ No newline at end of file diff --git a/flask_user/templates/flask_user/enable_totp.html b/flask_user/templates/flask_user/enable_totp.html new file mode 100644 index 00000000..fead31d5 --- /dev/null +++ b/flask_user/templates/flask_user/enable_totp.html @@ -0,0 +1,13 @@ +{% extends 'flask_user/_authorized_base.html' %} + +{% block content %} +{% from "flask_user/_macros.html" import render_field, render_submit_field %} +

{%trans%}Enable TOTP{%endtrans%}

+

+
+ {{ form.hidden_tag() }} + {{ render_field(form.totp_token, tabindex=20) }} + {{ render_submit_field(form.submit, tabindex=90) }} +
+ +{% endblock %} \ No newline at end of file diff --git a/flask_user/templates/flask_user/verify_totp_token.html b/flask_user/templates/flask_user/verify_totp_token.html new file mode 100644 index 00000000..f6a05dc9 --- /dev/null +++ b/flask_user/templates/flask_user/verify_totp_token.html @@ -0,0 +1,12 @@ +{% extends 'flask_user/_public_base.html' %} + +{% block content %} +{% from "flask_user/_macros.html" import render_field, render_submit_field %} +

{%trans%}Verify TOTP{%endtrans%}

+
+ {{ form.hidden_tag() }} + {{ render_field(form.totp_token, tabindex=20) }} + {{ render_submit_field(form.submit, tabindex=90) }} +
+ +{% endblock %} \ No newline at end of file diff --git a/flask_user/totp_manager.py b/flask_user/totp_manager.py new file mode 100644 index 00000000..ddd28670 --- /dev/null +++ b/flask_user/totp_manager.py @@ -0,0 +1,45 @@ +"""This module implements the TOTPManager for Flask-User. +It generates the users URI and uses onetimepass to verify TOTP tokens. +""" + +# Adapted from https://blog.miguelgrinberg.com/post/two-factor-authentication-with-flask +# Author Jason Hines + +from io import BytesIO +import onetimepass +import pyqrcode +from flask_login import current_user + + +class TOTPManager(object): + """Time-based One Time Password URI generation and verify with onetimepass""" + def __init__(self, app): + self.app = app + self.user_manager = app.user_manager + + def get_totp_uri(self): + """Generate URI for User""" + app_name = ''.join(self.user_manager.USER_APP_NAME.split()) + if self.user_manager.USER_ENABLE_USERNAME: + user = current_user.username + if not user and self.user_manager.USER_ENABLE_EMAIL: + user = current_user.email + else: + # Find user by email (with form.email) + user = current_user.email + + return 'otpauth://totp/{0}:{1}?secret={2}&issuer={0}'.format(app_name, user, current_user.totp_secret) + + def verify_totp_token(self, user, totp_token): + return onetimepass.valid_totp(totp_token, user.totp_secret) + + def get_totp_qrcode(self): + """ render TOTP qrcode """ + url = pyqrcode.create(self.get_totp_uri()) + stream = BytesIO() + url.svg(stream, scale=5) + return stream.getvalue(), 200, { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0'} diff --git a/flask_user/user_manager.py b/flask_user/user_manager.py index 59cd3e7a..95dbf9dc 100644 --- a/flask_user/user_manager.py +++ b/flask_user/user_manager.py @@ -16,6 +16,7 @@ from .email_manager import EmailManager from .password_manager import PasswordManager from .token_manager import TokenManager +from .totp_manager import TOTPManager from .translation_utils import lazy_gettext as _ # map _() to lazy_gettext() from .user_manager__settings import UserManager__Settings from .user_manager__utils import UserManager__Utils @@ -123,6 +124,7 @@ def advance_session_timeout(): # Setup default LoginManager using Flask-Login self.login_manager = LoginManager(app) self.login_manager.login_view = 'user.login' + self.login_manager.refresh_view = 'user.refresh_login' # Flask-Login calls this function to retrieve a User record by token. @self.login_manager.user_loader @@ -175,6 +177,9 @@ def call_or_get(function_or_property): self.RegisterFormClass = forms.RegisterForm self.ResendEmailConfirmationFormClass = forms.ResendEmailConfirmationForm self.ResetPasswordFormClass = forms.ResetPasswordForm + self.EnableTOTPFormClass = forms.EnableTOTPForm + self.VerifyTOTPTokenFormClass = forms.VerifyTOTPTokenForm + self.DisableTOTPTokenFormClass = forms.DisableTOTPForm # Set default managers # -------------------- @@ -193,6 +198,9 @@ def call_or_get(function_or_property): if self.USER_ENABLE_EMAIL: self.email_manager = EmailManager(app) + # Setup TOTPManager + self.totp_manager = TOTPManager(app) + # Setup TokenManager self.token_manager = TokenManager(app) @@ -415,6 +423,9 @@ def invite_user_stub(): def login_stub(): return self.login_view() + def refresh_login_stub(): + return self.refresh_login_view() + def logout_stub(): return self.logout_view() @@ -433,6 +444,20 @@ def reset_password_stub(token): # def unconfirmed_email_stub(): # return self.unconfirmed_email_view() + def enable_totp_stub(): + if not self.USER_ENABLE_TOTP: + abort(404) + return self.enable_totp_view() + + def disable_totp_stub(): + return self.disable_totp_view() + + def get_totp_qrcode_stub(): + return self.get_totp_qrcode() + + def verify_totp_token_stub(): + return self.verify_totp_token_view() + def unauthorized_stub(): return self.unauthorized_view() @@ -455,6 +480,8 @@ def unauthorized_stub(): methods=['GET', 'POST']) app.add_url_rule(self.USER_LOGIN_URL, 'user.login', login_stub, methods=['GET', 'POST']) + app.add_url_rule(self.REFRESH_USER_LOGIN_URL, 'user.refresh_login', refresh_login_stub, + methods=['GET', 'POST']) app.add_url_rule(self.USER_LOGOUT_URL, 'user.logout', logout_stub, methods=['GET', 'POST']) app.add_url_rule(self.USER_MANAGE_EMAILS_URL, 'user.manage_emails', manage_emails_stub, @@ -466,6 +493,13 @@ def unauthorized_stub(): methods=['GET', 'POST']) app.add_url_rule(self.USER_RESET_PASSWORD_URL, 'user.reset_password', reset_password_stub, methods=['GET', 'POST']) - + app.add_url_rule(self.USER_ENABLE_TOTP_URL, 'user.enable_totp', enable_totp_stub, + methods=['GET', 'POST']) + app.add_url_rule(self.USER_DISABLE_TOTP_URL, 'user.disable_totp', disable_totp_stub, + methods=['GET', 'POST']) + app.add_url_rule(self.USER_TOTP_QRCODE_URL, 'user.get_totp_qrcode', get_totp_qrcode_stub, + methods=['GET']) + app.add_url_rule(self.USER_TOTP_VERIFICATION_URL, 'user.verify_totp_token', verify_totp_token_stub, + methods=['GET', 'POST']) diff --git a/flask_user/user_manager__settings.py b/flask_user/user_manager__settings.py index 560126b7..2242fd63 100644 --- a/flask_user/user_manager__settings.py +++ b/flask_user/user_manager__settings.py @@ -55,6 +55,9 @@ class UserManager__Settings(object): #: Generic settings and their defaults USER_ENABLE_REMEMBER_ME = True + #: | User Time-based One Time Passwords + USER_ENABLE_TOTP = False + USER_ENABLE_AUTH0 = False @@ -181,11 +184,16 @@ class UserManager__Settings(object): USER_EMAIL_ACTION_URL = '/user/email//' #: USER_FORGOT_PASSWORD_URL = '/user/forgot-password' #: USER_INVITE_USER_URL = '/user/invite' #: - USER_LOGIN_URL = '/user/sign-in' #: + USER_LOGIN_URL = '/user/sign-in' #: + REFRESH_USER_LOGIN_URL = '/user/refesh-login' #: USER_LOGOUT_URL = '/user/sign-out' #: USER_MANAGE_EMAILS_URL = '/user/manage-emails' #: USER_REGISTER_URL = '/user/register' #: - USER_RESEND_EMAIL_CONFIRMATION_URL = '/user/resend-email-confirmation' #: + USER_RESEND_EMAIL_CONFIRMATION_URL = '/user/resend-email-confirmation' #: + USER_ENABLE_TOTP_URL = '/user/enable-totp' + USER_DISABLE_TOTP_URL = '/user/disable-totp' + USER_TOTP_QRCODE_URL = '/user/totp-qrcode' + USER_TOTP_VERIFICATION_URL = '/user/verify-totp-token' #: .. This hack shows a header above the _next_ section #: .. code-block:: none @@ -198,11 +206,15 @@ class UserManager__Settings(object): USER_EDIT_USER_PROFILE_TEMPLATE = 'flask_user/edit_user_profile.html' #: USER_FORGOT_PASSWORD_TEMPLATE = 'flask_user/forgot_password.html' #: USER_INVITE_USER_TEMPLATE = 'flask_user/invite_user.html' #: - USER_LOGIN_TEMPLATE = 'flask_user/login.html' #: + USER_LOGIN_TEMPLATE = 'flask_user/login.html' #: + USER_REFRESH_LOGIN_TEMPLATE = 'flask_user/login.html' #: USER_LOGIN_AUTH0_TEMPLATE = 'flask_user/login_auth0.html' #: USER_MANAGE_EMAILS_TEMPLATE = 'flask_user/manage_emails.html' #: USER_REGISTER_TEMPLATE = 'flask_user/register.html' #: - USER_RESEND_CONFIRM_EMAIL_TEMPLATE = 'flask_user/resend_confirm_email.html' #: + USER_RESEND_CONFIRM_EMAIL_TEMPLATE = 'flask_user/resend_confirm_email.html' + USER_ENABLE_TOTP_TEMPLATE = 'flask_user/enable_totp.html' #: + USER_DISABLE_TOTP_TEMPLATE = 'flask_user/disable_totp.html' #: + USER_TOTP_VERIFICATION_TEMPLATE = 'flask_user/verify_totp_token.html' #: .. This hack shows a header above the _next_ section #: .. code-block:: none @@ -222,7 +234,7 @@ class UserManager__Settings(object): #: FLask endpoint settings USER_USERNAME_CHANGED_EMAIL_TEMPLATE = 'flask_user/emails/username_changed' - USER_AFTER_CHANGE_PASSWORD_ENDPOINT = '' #: + USER_AFTER_CHANGE_PASSWORD_ENDPOINT = '' #: USER_AFTER_CHANGE_USERNAME_ENDPOINT = '' #: USER_AFTER_CONFIRM_ENDPOINT = '' #: USER_AFTER_EDIT_USER_PROFILE_ENDPOINT = '' #: @@ -234,5 +246,6 @@ class UserManager__Settings(object): USER_AFTER_RESET_PASSWORD_ENDPOINT = '' #: USER_AFTER_INVITE_ENDPOINT = '' #: USER_UNAUTHENTICATED_ENDPOINT = 'user.login' #: - USER_UNAUTHORIZED_ENDPOINT = '' #: + USER_UNAUTHORIZED_ENDPOINT = '' #: + USER_TOTP_ENDPOINT = '' #: # USER_UNCONFIRMED_EMAIL_ENDPOINT = '' #: diff --git a/flask_user/user_manager__utils.py b/flask_user/user_manager__utils.py index 9cda8f8e..3aff46f9 100644 --- a/flask_user/user_manager__utils.py +++ b/flask_user/user_manager__utils.py @@ -84,4 +84,12 @@ def verify_password(self, password, password_hash): def verify_token(self, token, expiration_in_seconds=None): """Convenience method that calls self.token_manager.verify_token(token, expiration_in_seconds).""" - return self.token_manager.verify_token(token, expiration_in_seconds) + return self.token_manager.verify_token(token, expiration_in_seconds) + + def verify_totp_token(self, user, totp_token): + """Convenience method that calls self.totp_manager.verify_totp_token(totp_token).""" + return self.totp_manager.verify_totp_token(user, totp_token) + + def get_totp_qrcode(self): + """Convenience method that calls self.totp_manager.get_totp_qrcode().""" + return self.totp_manager.get_totp_qrcode() diff --git a/flask_user/user_manager__views.py b/flask_user/user_manager__views.py index 4b835c8c..14e2468a 100644 --- a/flask_user/user_manager__views.py +++ b/flask_user/user_manager__views.py @@ -10,12 +10,14 @@ except ImportError: from urllib import quote, unquote # Python 2 -from flask import current_app, flash, redirect, render_template, request, url_for -from flask_login import current_user, login_user, logout_user +from flask import current_app, flash, redirect, render_template, request, url_for, session +from flask_login import current_user, login_user, logout_user, login_fresh, fresh_login_required from .decorators import login_required from . import signals from .translation_utils import gettext as _ # map _() to gettext() +import base64 +import os # This class mixes into the UserManager class. @@ -390,11 +392,19 @@ def login_view(self): # Find user by email (with form.email) user, user_email = self.db_manager.get_user_and_user_email_by_email(login_form.email.data) + if user: - # Log user in - safe_next_url = self.make_safe_url(login_form.next.data) - return self._do_login_user(user, safe_next_url, login_form.remember_me.data) - + # Check if user has TOTP enabled for their account and verified it + if self.USER_ENABLE_TOTP and user.totp_secret and user.totp_verified: + session['totp_user_id'] = user.id + safe_next_url = self.make_safe_url(login_form.next.data) + remember_me = login_form.remember_me.data + return redirect(url_for('user.verify_totp_token') +'?next='+quote(safe_next_url) + '&remember_me=' + str(remember_me)) + else: + # Log user in + safe_next_url = self.make_safe_url(login_form.next.data) + return self._do_login_user(user, safe_next_url, login_form.remember_me.data) + # Render form self.prepare_domain_translations() template_filename = self.USER_LOGIN_AUTH0_TEMPLATE if self.USER_ENABLE_AUTH0 else self.USER_LOGIN_TEMPLATE @@ -403,6 +413,57 @@ def login_view(self): login_form=login_form, register_form=register_form) + + def refresh_login_view(self): + """Prepare and process the refresh login form.""" + + # Authenticate username/email and login authenticated users. + + safe_next_url = self._get_safe_next_url('next', self.USER_AFTER_LOGIN_ENDPOINT) + + + # Initialize form + login_form = self.LoginFormClass(request.form) # for login.html + if request.method != 'POST': + login_form.next.data = safe_next_url + + # Process valid POST + if request.method == 'POST' and login_form.validate(): + # Retrieve User + user = None + user_email = None + if self.USER_ENABLE_USERNAME: + # Find user record by username + user = self.db_manager.find_user_by_username(login_form.username.data) + + # Find user record by email (with form.username) + if not user and self.USER_ENABLE_EMAIL: + user, user_email = self.db_manager.get_user_and_user_email_by_email(login_form.username.data) + else: + # Find user by email (with form.email) + user, user_email = self.db_manager.get_user_and_user_email_by_email(login_form.email.data) + + + if user: + # Check if user has TOTP enabled for their account and verified it + if self.USER_ENABLE_TOTP and user.totp_secret and user.totp_verified: + session['totp_user_id'] = user.id + safe_next_url = self.make_safe_url(login_form.next.data) + remember_me = login_form.remember_me.data + return redirect(url_for('user.verify_totp_token') +'?next='+quote(safe_next_url) + '&remember_me=' + str(remember_me)) + else: + # Log user in + safe_next_url = self.make_safe_url(login_form.next.data) + return self._do_login_user(user, safe_next_url, login_form.remember_me.data) + + # Render form + self.prepare_domain_translations() + template_filename = self.USER_REFRESH_LOGIN_TEMPLATE + return render_template(template_filename, + form=login_form, + login_form=login_form) + + def logout_view(self): """Process the logout link.""" """ Sign the user out.""" @@ -603,6 +664,102 @@ def reset_password_view(self, token): self.prepare_domain_translations() return render_template(self.USER_RESET_PASSWORD_TEMPLATE, form=form) + + # Require a fresh login to ensure that it is the user + @fresh_login_required + def enable_totp_view(self): + """ Display QR code and require validation of TOTP token.""" + + # Generate a new secret if it was never verified + if current_user.totp_secret is None and not current_user.totp_verified: + """ Generate Time-based One Time Password for user """ + secret = base64.b32encode(os.urandom(10)).decode('utf-8') + current_user.totp_secret = secret + self.db_manager.save_object(current_user) + self.db_manager.commit() + + # Initialize form + form = self.EnableTOTPFormClass(request.form) + + # Process valid POST + if request.method == 'POST' and form.validate(): + + if not self.verify_totp_token(current_user, form.totp_token.data): + flash('Invalid token.', 'error') + return render_template(self.USER_ENABLE_TOTP_TEMPLATE, form=form) + else: + current_user.totp_verified = True + self.db_manager.save_object(current_user) + self.db_manager.commit() + flash('Token Valid.', 'success') + + # Redirect to profile + return redirect(url_for('user.edit_user_profile')) + + # Render form + self.prepare_domain_translations() + return render_template(self.USER_ENABLE_TOTP_TEMPLATE, form=form) + + + # Require a fresh login to ensure that it is the user + @fresh_login_required + def disable_totp_view(self): + """ Disable Time-based One Time Password for user.""" + + form = self.DisableTOTPTokenFormClass(request.form) + + if request.method == 'POST' and form.validate(): + if form.disable.data: + current_user.totp_secret = None + current_user.totp_verified = False + self.db_manager.save_object(current_user) + self.db_manager.commit() + flash('TOTP Disabled', 'success') + return redirect(url_for('user.edit_user_profile')) + else: + flash('TOTP was not disabled', 'error') + + # Render form + self.prepare_domain_translations() + return render_template(self.USER_DISABLE_TOTP_TEMPLATE, form=form) + + + def verify_totp_token_view(self): + """ Verify TOTP Token.""" + + # Get the user_id from the session and get user from the db + totp_user_id = session['totp_user_id'] + user = self.db_manager.get_user_by_id(totp_user_id) + + safe_next_url = self._get_safe_next_url('next', self.USER_AFTER_LOGIN_ENDPOINT) + remember_me = request.args.get('remember_me') + + + # Initialize form + form = self.VerifyTOTPTokenFormClass(request.form) + form.next.data = safe_next_url + form.remember_me.data = remember_me + + if request.method == 'POST' and form.validate(): + if not self.verify_totp_token(user, form.totp_token.data): + flash('Invalid token! You have been logged out.', 'error') + # Send user_logged_out signal + signals.user_logged_out.send( + current_app._get_current_object(), user=user) + # Use Flask-Login to sign out user + logout_user() + else: + safe_next_url = form.next.data + remember_me = form.remember_me.data + return self._do_login_user(user, safe_next_url, remember_me) + flash('Token Valid') + + # Render form + self.prepare_domain_translations() + return render_template(self.USER_TOTP_VERIFICATION_TEMPLATE, form=form) + + + def unauthenticated_view(self): """ Prepare a Flash message and redirect to USER_UNAUTHENTICATED_ENDPOINT""" # Prepare Flash message @@ -680,7 +837,6 @@ def _do_login_user(self, user, safe_next_url, remember_me=False): return redirect(url_for('user.login')) # Use Flask-Login to sign in user - # print('login_user: remember_me=', remember_me) login_user(user, remember=remember_me) # Send user_logged_in signal @@ -710,4 +866,3 @@ def _get_safe_next_url(self, param_name, default_endpoint): def _endpoint_url(self, endpoint): return url_for(endpoint) if endpoint else '/' - diff --git a/requirements.txt b/requirements.txt index f320958e..dff65233 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,6 +21,10 @@ Flask-MongoEngine==0.9.3 sendgrid==5.2.0 Flask-Sendmail==0.1 +#TOTP +onetimepass==1.0.1 +PyQRCode==1.2.1 + # Development packages pytest==3.0.5 pytest-cov==2.5.1 diff --git a/setup.py b/setup.py index 49f49ca8..6bef882b 100644 --- a/setup.py +++ b/setup.py @@ -83,6 +83,8 @@ def load_readme(): 'Flask-SQLAlchemy>=1.0', 'Flask-WTF>=0.9', 'passlib>=1.6', + 'onetimepass>=1.0.1', + 'PyQRCode>=1.2.1', ], tests_require=['pytest'], )