From c1f67004c3677499d4f43c806cdc61887f6a84c8 Mon Sep 17 00:00:00 2001 From: Jason Hines Date: Thu, 20 Dec 2018 12:12:44 -0500 Subject: [PATCH 01/11] updated for VS Code --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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 From 653bf5d6e364e12c556dba618d541a87230b69db Mon Sep 17 00:00:00 2001 From: Jason Hines Date: Wed, 9 Jan 2019 14:40:20 -0500 Subject: [PATCH 02/11] enable and disable TOTP working --- flask_user/__init__.py | 1 + flask_user/db_manager.py | 2 + flask_user/forms.py | 10 +++ .../templates/flask_user/disable_totp.html | 12 +++ .../flask_user/edit_user_profile.html | 7 ++ .../templates/flask_user/enable_totp.html | 13 ++++ .../flask_user/verify_totp_token.html | 13 ++++ flask_user/totp_manager.py | 39 ++++++++++ flask_user/user_manager.py | 23 ++++++ flask_user/user_manager__settings.py | 19 ++++- flask_user/user_manager__utils.py | 12 +++ flask_user/user_manager__views.py | 73 ++++++++++++++++++- requirements.txt | 4 + 13 files changed, 220 insertions(+), 8 deletions(-) create mode 100644 flask_user/templates/flask_user/disable_totp.html create mode 100644 flask_user/templates/flask_user/enable_totp.html create mode 100644 flask_user/templates/flask_user/verify_totp_token.html create mode 100644 flask_user/totp_manager.py 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..23a9ec92 100644 --- a/flask_user/forms.py +++ b/flask_user/forms.py @@ -352,6 +352,16 @@ class InviteUserForm(FlaskForm): submit = SubmitField(_('Invite!')) +class VerifyTOTPTokenForm(FlaskForm): + totp_token = StringField('TOTP Token', validators=[ + validators.DataRequired(), validators.Length(6, 6)]) + submit = SubmitField('Verify') + +class DisableTOTPTokenForm(FlaskForm): + 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..cb9981b7 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_secret %} +

{%trans%}Enabled TOTP{%endtrans%}

+ {% else%} +

{%trans%}Disabled 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..233242e5 --- /dev/null +++ b/flask_user/templates/flask_user/verify_totp_token.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%}Verify TOTP Token{%endtrans%}

+ +
+ {{ form.hidden_tag() }} + {{ render_field(form.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..ce9dc87f --- /dev/null +++ b/flask_user/totp_manager.py @@ -0,0 +1,39 @@ +"""This module implements the TOTPManager for Flask-User. +It uses onetimepass to generate URI and verify OTP 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""" + #TODO check if username is enabled, otherwise use email instead + #TODO compensate for spaces in APP_NAME + app_name = self.user_manager.USER_APP_NAME + return 'otpauth://totp/{0}:{1}?secret={2}&issuer={0}'.format(app_name, current_user.username, current_user.totp_secret) + + def verify_totp_token(self, totp_token): + return onetimepass.valid_totp(totp_token, current_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..f797f8ea 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 @@ -175,6 +176,8 @@ def call_or_get(function_or_property): self.RegisterFormClass = forms.RegisterForm self.ResendEmailConfirmationFormClass = forms.ResendEmailConfirmationForm self.ResetPasswordFormClass = forms.ResetPasswordForm + self.VerifyTOTPTokenFormClass = forms.VerifyTOTPTokenForm + self.DisableTOTPTokenFormClass = forms.DisableTOTPTokenForm # Set default managers # -------------------- @@ -193,6 +196,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) @@ -433,6 +439,17 @@ 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 unauthorized_stub(): return self.unauthorized_view() @@ -466,6 +483,12 @@ 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']) diff --git a/flask_user/user_manager__settings.py b/flask_user/user_manager__settings.py index 560126b7..f1ead2a7 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 @@ -185,7 +188,11 @@ class UserManager__Settings(object): 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 @@ -202,7 +209,10 @@ class UserManager__Settings(object): 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 +232,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 +244,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..cada390e 100644 --- a/flask_user/user_manager__utils.py +++ b/flask_user/user_manager__utils.py @@ -85,3 +85,15 @@ 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) + + def enable_totp(self): + """Convenience method that calls self.totp_manager.enable_totp(totp_token).""" + return self.totp_manager.enable_totp(totp_token) + + def verify_totp_token(self, totp_token): + """Convenience method that calls self.totp_manager.verify_totp_token(totp_token).""" + return self.totp_manager.verify_totp_token(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..bb2e26d0 100644 --- a/flask_user/user_manager__views.py +++ b/flask_user/user_manager__views.py @@ -16,6 +16,8 @@ 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,10 +392,22 @@ 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) + # Check if user has TOTP enabled + # if user.totp_secret: + # #TODO Send user to TOTP verify view + + + # else: + # 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) + + #TODO remove when TOTP verify is working 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) + # 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() @@ -603,6 +617,58 @@ def reset_password_view(self, token): self.prepare_domain_translations() return render_template(self.USER_RESET_PASSWORD_TEMPLATE, form=form) + @login_required + def enable_totp_view(self): + """ Display QR code and require validation.""" + + if current_user.totp_secret is None: + """ 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.VerifyTOTPTokenFormClass(request.form) + + # Process valid POST + if request.method == 'POST' and form.validate(): + + if not self.verify_totp_token(form.totp_token.data): + flash('Invalid token.') + #TODO add verified field to db + return render_template(self.USER_ENABLE_TOTP_TEMPLATE, form=form) + else: + flash('Token Valid.', 'success') + + # Redirect to 'next' URL + safe_next_url = self._get_safe_next_url('next', self.USER_TOTP_ENDPOINT) + return redirect(safe_next_url) + + # Render form + self.prepare_domain_translations() + return render_template(self.USER_ENABLE_TOTP_TEMPLATE, form=form) + + @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 + 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 unauthenticated_view(self): """ Prepare a Flash message and redirect to USER_UNAUTHENTICATED_ENDPOINT""" # Prepare Flash message @@ -710,4 +776,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 From 014bf3b13b37dfab8c7b085b230aeea0e6a3bb69 Mon Sep 17 00:00:00 2001 From: Jason Hines Date: Mon, 14 Jan 2019 16:12:36 -0500 Subject: [PATCH 03/11] TOTP verify working --- flask_user/decorators.py | 32 +++++++ flask_user/forms.py | 13 ++- .../flask_user/edit_user_profile.html | 6 +- .../flask_user/verify_totp_token.html | 7 +- flask_user/totp_manager.py | 15 +-- flask_user/user_manager.py | 8 +- flask_user/user_manager__utils.py | 10 +- flask_user/user_manager__views.py | 91 ++++++++++++++----- 8 files changed, 133 insertions(+), 49 deletions(-) diff --git a/flask_user/decorators.py b/flask_user/decorators.py index 8e837d23..64f2f38a 100644 --- a/flask_user/decorators.py +++ b/flask_user/decorators.py @@ -60,6 +60,38 @@ def decorator(*args, **kwargs): return decorator +def fresh_login_required(view_function): + """ This decorator ensures that the current user is logged in. + + Example:: + + @route('/member_page') + @login_required + def member_page(): # User must be logged in + ... + + If USER_ENABLE_EMAIL is True and USER_ENABLE_CONFIRM_EMAIL is True, + this view decorator also ensures that the user has a confirmed email address. + + | Calls unauthorized_view() when the user is not logged in + or when the user has not confirmed their email address. + | Calls the decorated view otherwise. + """ + @wraps(view_function) # Tells debuggers that is is a function wrapper + def decorator(*args, **kwargs): + user_manager = current_app.user_manager + + # User must be logged in with a confirmed email address + allowed = _is_logged_in_with_confirmed_email(user_manager) + if not allowed: + # Redirect to unauthenticated page + return user_manager.unauthenticated_view() + + # It's OK to call the view + return view_function(*args, **kwargs) + + return decorator + def roles_accepted(*role_names): """| This decorator ensures that the current user is logged in, | and has *at least one* of the specified roles (OR operation). diff --git a/flask_user/forms.py b/flask_user/forms.py index 23a9ec92..665d3a04 100644 --- a/flask_user/forms.py +++ b/flask_user/forms.py @@ -352,10 +352,17 @@ class InviteUserForm(FlaskForm): submit = SubmitField(_('Invite!')) -class VerifyTOTPTokenForm(FlaskForm): - totp_token = StringField('TOTP Token', validators=[ +class EnableTOTPForm(FlaskForm): + totp_token = StringField(_('TOTP Token'), validators=[ validators.DataRequired(), validators.Length(6, 6)]) - submit = SubmitField('Verify') + submit = SubmitField(_('Verify')) + + +class VerifyTOTPTokenForm(FlaskForm): + next = HiddenField() + remember_me = HiddenField() + totp_token = StringField(_('TOTP Token'), validators=[validators.DataRequired(), validators.Length(6, 6)]) + submit = SubmitField(_('Verify')) class DisableTOTPTokenForm(FlaskForm): disable = BooleanField(_('Disable')) diff --git a/flask_user/templates/flask_user/edit_user_profile.html b/flask_user/templates/flask_user/edit_user_profile.html index cb9981b7..aa592ae1 100644 --- a/flask_user/templates/flask_user/edit_user_profile.html +++ b/flask_user/templates/flask_user/edit_user_profile.html @@ -26,10 +26,10 @@

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

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

{% endif %} {% if user_manager.USER_ENABLE_TOTP%} - {% if not current_user.totp_secret %} -

{%trans%}Enabled TOTP{%endtrans%}

+ {% if not current_user.totp_verified %} +

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

{% else%} -

{%trans%}Disabled TOTP{%endtrans%}

+

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

{% endif %} {% endif %} {% endif %} diff --git a/flask_user/templates/flask_user/verify_totp_token.html b/flask_user/templates/flask_user/verify_totp_token.html index 233242e5..f6a05dc9 100644 --- a/flask_user/templates/flask_user/verify_totp_token.html +++ b/flask_user/templates/flask_user/verify_totp_token.html @@ -1,12 +1,11 @@ -{% extends 'flask_user/_authorized_base.html' %} +{% extends 'flask_user/_public_base.html' %} {% block content %} {% from "flask_user/_macros.html" import render_field, render_submit_field %} -

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

- +

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

{{ form.hidden_tag() }} - {{ render_field(form.token, tabindex=20) }} + {{ render_field(form.totp_token, tabindex=20) }} {{ render_submit_field(form.submit, tabindex=90) }}
diff --git a/flask_user/totp_manager.py b/flask_user/totp_manager.py index ce9dc87f..c034a82f 100644 --- a/flask_user/totp_manager.py +++ b/flask_user/totp_manager.py @@ -19,13 +19,16 @@ def __init__(self, app): def get_totp_uri(self): """Generate URI for User""" - #TODO check if username is enabled, otherwise use email instead - #TODO compensate for spaces in APP_NAME - app_name = self.user_manager.USER_APP_NAME - return 'otpauth://totp/{0}:{1}?secret={2}&issuer={0}'.format(app_name, current_user.username, current_user.totp_secret) + 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 - def verify_totp_token(self, totp_token): - return onetimepass.valid_totp(totp_token, current_user.totp_secret) + 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 """ diff --git a/flask_user/user_manager.py b/flask_user/user_manager.py index f797f8ea..c3a900bc 100644 --- a/flask_user/user_manager.py +++ b/flask_user/user_manager.py @@ -124,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.login' # Flask-Login calls this function to retrieve a User record by token. @self.login_manager.user_loader @@ -176,6 +177,7 @@ 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.DisableTOTPTokenForm @@ -450,6 +452,9 @@ def disable_totp_stub(): 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() @@ -489,6 +494,7 @@ def unauthorized_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__utils.py b/flask_user/user_manager__utils.py index cada390e..3aff46f9 100644 --- a/flask_user/user_manager__utils.py +++ b/flask_user/user_manager__utils.py @@ -84,15 +84,11 @@ 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 enable_totp(self): - """Convenience method that calls self.totp_manager.enable_totp(totp_token).""" - return self.totp_manager.enable_totp(totp_token) - - def verify_totp_token(self, totp_token): + 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(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().""" diff --git a/flask_user/user_manager__views.py b/flask_user/user_manager__views.py index bb2e26d0..576b252e 100644 --- a/flask_user/user_manager__views.py +++ b/flask_user/user_manager__views.py @@ -10,10 +10,10 @@ except ImportError: from urllib import quote, unquote # Python 2 -from flask import current_app, flash, redirect, render_template, request, url_for +from flask import current_app, flash, redirect, render_template, request, url_for, session from flask_login import current_user, login_user, logout_user -from .decorators import login_required +from .decorators import login_required, fresh_login_required from . import signals from .translation_utils import gettext as _ # map _() to gettext() import base64 @@ -392,23 +392,20 @@ 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) - # Check if user has TOTP enabled - # if user.totp_secret: - # #TODO Send user to TOTP verify view - - - # else: - # 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) - #TODO remove when TOTP verify is working 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)) + #return self.verify_totp_token_view(safe_next_url, 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 @@ -617,11 +614,14 @@ def reset_password_view(self, token): self.prepare_domain_translations() return render_template(self.USER_RESET_PASSWORD_TEMPLATE, form=form) - @login_required + + + @fresh_login_required def enable_totp_view(self): - """ Display QR code and require validation.""" + """ Display QR code and require validation.""" - if current_user.totp_secret is None: + # 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 @@ -629,27 +629,30 @@ def enable_totp_view(self): self.db_manager.commit() # Initialize form - form = self.VerifyTOTPTokenFormClass(request.form) + form = self.EnableTOTPFormClass(request.form) # Process valid POST if request.method == 'POST' and form.validate(): - if not self.verify_totp_token(form.totp_token.data): - flash('Invalid token.') - #TODO add verified field to db + 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 'next' URL - safe_next_url = self._get_safe_next_url('next', self.USER_TOTP_ENDPOINT) - return redirect(safe_next_url) + # 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) - @login_required + + + @fresh_login_required def disable_totp_view(self): """ Disable Time-based One Time Password for user """ @@ -658,6 +661,7 @@ def disable_totp_view(self): 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') @@ -669,6 +673,43 @@ def disable_totp_view(self): 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 From 3ff9ad9639277ed479af88479f3a0aa9fdef1464 Mon Sep 17 00:00:00 2001 From: Jason Hines Date: Mon, 14 Jan 2019 16:31:26 -0500 Subject: [PATCH 04/11] totp example app --- example_apps/basic_app_with_totp.py | 178 ++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 example_apps/basic_app_with_totp.py 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) From 5f97b54c3d6467dbc99e797481d86fcfde06b37a Mon Sep 17 00:00:00 2001 From: Jason Hines Date: Tue, 15 Jan 2019 13:09:36 -0500 Subject: [PATCH 05/11] refresh login working --- flask_user/decorators.py | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/flask_user/decorators.py b/flask_user/decorators.py index 64f2f38a..8e837d23 100644 --- a/flask_user/decorators.py +++ b/flask_user/decorators.py @@ -60,38 +60,6 @@ def decorator(*args, **kwargs): return decorator -def fresh_login_required(view_function): - """ This decorator ensures that the current user is logged in. - - Example:: - - @route('/member_page') - @login_required - def member_page(): # User must be logged in - ... - - If USER_ENABLE_EMAIL is True and USER_ENABLE_CONFIRM_EMAIL is True, - this view decorator also ensures that the user has a confirmed email address. - - | Calls unauthorized_view() when the user is not logged in - or when the user has not confirmed their email address. - | Calls the decorated view otherwise. - """ - @wraps(view_function) # Tells debuggers that is is a function wrapper - def decorator(*args, **kwargs): - user_manager = current_app.user_manager - - # User must be logged in with a confirmed email address - allowed = _is_logged_in_with_confirmed_email(user_manager) - if not allowed: - # Redirect to unauthenticated page - return user_manager.unauthenticated_view() - - # It's OK to call the view - return view_function(*args, **kwargs) - - return decorator - def roles_accepted(*role_names): """| This decorator ensures that the current user is logged in, | and has *at least one* of the specified roles (OR operation). From 5c06413d4fc113c0bf5cd098f9266041fbb7a8a7 Mon Sep 17 00:00:00 2001 From: Jason Hines Date: Tue, 15 Jan 2019 13:10:20 -0500 Subject: [PATCH 06/11] refresh login working --- flask_user/user_manager.py | 7 +++- flask_user/user_manager__settings.py | 6 ++- flask_user/user_manager__views.py | 58 +++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/flask_user/user_manager.py b/flask_user/user_manager.py index c3a900bc..10f19671 100644 --- a/flask_user/user_manager.py +++ b/flask_user/user_manager.py @@ -124,7 +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.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 @@ -423,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() @@ -477,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, diff --git a/flask_user/user_manager__settings.py b/flask_user/user_manager__settings.py index f1ead2a7..2242fd63 100644 --- a/flask_user/user_manager__settings.py +++ b/flask_user/user_manager__settings.py @@ -184,7 +184,8 @@ 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' #: @@ -205,7 +206,8 @@ 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' #: diff --git a/flask_user/user_manager__views.py b/flask_user/user_manager__views.py index 576b252e..92aba40d 100644 --- a/flask_user/user_manager__views.py +++ b/flask_user/user_manager__views.py @@ -11,9 +11,9 @@ from urllib import quote, unquote # Python 2 from flask import current_app, flash, redirect, render_template, request, url_for, session -from flask_login import current_user, login_user, logout_user +from flask_login import current_user, login_user, logout_user, login_fresh, fresh_login_required -from .decorators import login_required, fresh_login_required +from .decorators import login_required from . import signals from .translation_utils import gettext as _ # map _() to gettext() import base64 @@ -367,6 +367,7 @@ def login_view(self): # Immediately redirect already logged in users if self.call_or_get(current_user.is_authenticated) and self.USER_AUTO_LOGIN_AT_LOGIN: + print("redirect logged in user") return redirect(safe_next_url) # Initialize form @@ -414,6 +415,58 @@ 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)) + #return self.verify_totp_token_view(safe_next_url, 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.""" @@ -655,6 +708,7 @@ def enable_totp_view(self): @fresh_login_required def disable_totp_view(self): """ Disable Time-based One Time Password for user """ + print("Fresh Login: " + str(login_fresh())) form = self.DisableTOTPTokenFormClass(request.form) From 3a2706778c37770c05635a08fb2c7c16d7392332 Mon Sep 17 00:00:00 2001 From: Jason Hines Date: Tue, 15 Jan 2019 15:12:01 -0500 Subject: [PATCH 07/11] changed name for consistency --- flask_user/forms.py | 5 ++++- flask_user/user_manager.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/flask_user/forms.py b/flask_user/forms.py index 665d3a04..90ef13d2 100644 --- a/flask_user/forms.py +++ b/flask_user/forms.py @@ -353,18 +353,21 @@ class InviteUserForm(FlaskForm): 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 DisableTOTPTokenForm(FlaskForm): +class DisableTOTPForm(FlaskForm): + """Disable TOTP Token form.""" disable = BooleanField(_('Disable')) submit = SubmitField(_('Confirm')) diff --git a/flask_user/user_manager.py b/flask_user/user_manager.py index 10f19671..95dbf9dc 100644 --- a/flask_user/user_manager.py +++ b/flask_user/user_manager.py @@ -179,7 +179,7 @@ def call_or_get(function_or_property): self.ResetPasswordFormClass = forms.ResetPasswordForm self.EnableTOTPFormClass = forms.EnableTOTPForm self.VerifyTOTPTokenFormClass = forms.VerifyTOTPTokenForm - self.DisableTOTPTokenFormClass = forms.DisableTOTPTokenForm + self.DisableTOTPTokenFormClass = forms.DisableTOTPForm # Set default managers # -------------------- From 39bc0bf833439a55043d53109f2db35b95de32e3 Mon Sep 17 00:00:00 2001 From: Jason Hines Date: Tue, 15 Jan 2019 15:39:56 -0500 Subject: [PATCH 08/11] documentation and cleaned up comments --- docs/source/api.rst | 11 +++++++++++ docs/source/api_forms.rst | 33 +++++++++++++++++++++++++++++++ docs/source/base_templates.rst | 12 ++++++++--- flask_user/totp_manager.py | 2 +- flask_user/user_manager__views.py | 12 +++++------ 5 files changed, 59 insertions(+), 11 deletions(-) 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/flask_user/totp_manager.py b/flask_user/totp_manager.py index c034a82f..a11c2537 100644 --- a/flask_user/totp_manager.py +++ b/flask_user/totp_manager.py @@ -1,5 +1,5 @@ """This module implements the TOTPManager for Flask-User. -It uses onetimepass to generate URI and verify OTP tokens. +It generates the users URI and uses onetimepass to verify TOTP tokens. """ # Adapted from https://blog.miguelgrinberg.com/post/two-factor-authentication-with-flask diff --git a/flask_user/user_manager__views.py b/flask_user/user_manager__views.py index 92aba40d..7d136b24 100644 --- a/flask_user/user_manager__views.py +++ b/flask_user/user_manager__views.py @@ -401,7 +401,6 @@ def login_view(self): 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)) - #return self.verify_totp_token_view(safe_next_url, remember_me) else: # Log user in safe_next_url = self.make_safe_url(login_form.next.data) @@ -453,7 +452,6 @@ def refresh_login_view(self): 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)) - #return self.verify_totp_token_view(safe_next_url, remember_me) else: # Log user in safe_next_url = self.make_safe_url(login_form.next.data) @@ -668,10 +666,10 @@ def reset_password_view(self, token): 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.""" + """ 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: @@ -704,10 +702,10 @@ def enable_totp_view(self): 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 """ + """ Disable Time-based One Time Password for user.""" print("Fresh Login: " + str(login_fresh())) form = self.DisableTOTPTokenFormClass(request.form) @@ -729,7 +727,7 @@ def disable_totp_view(self): def verify_totp_token_view(self): - """ Verify TOTP Token """ + """ Verify TOTP Token.""" # Get the user_id from the session and get user from the db totp_user_id = session['totp_user_id'] From f3054c0053ab3fdb7d1fb1a39872db08844aef17 Mon Sep 17 00:00:00 2001 From: Jason Hines Date: Tue, 15 Jan 2019 15:42:30 -0500 Subject: [PATCH 09/11] removed print statements --- flask_user/user_manager__views.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/flask_user/user_manager__views.py b/flask_user/user_manager__views.py index 7d136b24..14e2468a 100644 --- a/flask_user/user_manager__views.py +++ b/flask_user/user_manager__views.py @@ -367,7 +367,6 @@ def login_view(self): # Immediately redirect already logged in users if self.call_or_get(current_user.is_authenticated) and self.USER_AUTO_LOGIN_AT_LOGIN: - print("redirect logged in user") return redirect(safe_next_url) # Initialize form @@ -706,7 +705,6 @@ def enable_totp_view(self): @fresh_login_required def disable_totp_view(self): """ Disable Time-based One Time Password for user.""" - print("Fresh Login: " + str(login_fresh())) form = self.DisableTOTPTokenFormClass(request.form) @@ -839,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 From 66f4329d3f1da72f6d64391f26755385fdae016e Mon Sep 17 00:00:00 2001 From: Jason Hines Date: Thu, 17 Jan 2019 15:29:52 -0500 Subject: [PATCH 10/11] fixed bug where qrcode didn't work if only email enabled for user --- flask_user/totp_manager.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flask_user/totp_manager.py b/flask_user/totp_manager.py index a11c2537..ddd28670 100644 --- a/flask_user/totp_manager.py +++ b/flask_user/totp_manager.py @@ -24,6 +24,9 @@ def get_totp_uri(self): 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) From a59e6909516512d3a654fbfbba6f8b7c25260e75 Mon Sep 17 00:00:00 2001 From: Jason Hines Date: Sun, 27 Jan 2019 12:40:54 -0500 Subject: [PATCH 11/11] added OTOP package requirements --- setup.py | 2 ++ 1 file changed, 2 insertions(+) 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'], )