Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Time-based One Time Passwords #256

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,6 @@ example_apps/translations/
# Private files
local_settings.py
example_apps/local_settings.py

# VS Code
settings.json
11 changes: 11 additions & 0 deletions docs/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,14 @@ These tokens are used in the following places:
:no-undoc-members:

.. seealso:: :ref:`Customizing the TokenManager <CustomizingManagers>`.

--------

.. _TOTPManager:

TOTPManager class
-----------------

The TOTPManager generates and verifies Time-based One Time Passwords.

.. autoclass:: flask_user.totp_manager.TOTPManager
33 changes: 33 additions & 0 deletions docs/source/api_forms.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ ChangeUsernameForm

--------

.. _DisableTOTPForm:

DisableTOTPForm
---------------

.. autoclass:: flask_user.forms.DisableTOTPForm
:no-undoc-members:
:no-inherited-members:

--------

.. _EditUserProfileForm:

EditUserProfileForm
Expand All @@ -38,6 +49,17 @@ EditUserProfileForm

--------

.. _EnableTOTPForm:

EnableTOTPForm
--------------

.. autoclass:: flask_user.forms.EnableTOTPForm
:no-undoc-members:
:no-inherited-members:

--------

.. _ForgotPasswordForm:

ForgotPasswordForm
Expand Down Expand Up @@ -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:
12 changes: 9 additions & 3 deletions docs/source/base_templates.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand All @@ -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``,
Expand All @@ -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
Expand All @@ -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
178 changes: 178 additions & 0 deletions example_apps/basic_app_with_totp.py
Original file line number Diff line number Diff line change
@@ -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 protected]'
MAIL_PASSWORD = 'password'
MAIL_DEFAULT_SENDER = '"MyApp" <[email protected]>'

# 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 = "[email protected]"
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 '[email protected]' user with no roles
if not User.query.filter(User.email == '[email protected]').first():
user = User(
email='[email protected]',
email_confirmed_at=datetime.datetime.utcnow(),
password=user_manager.hash_password('Password1'),
)
db.session.add(user)
db.session.commit()

# Create '[email protected]' user with 'Admin' and 'Agent' roles
if not User.query.filter(User.email == '[email protected]').first():
user = User(
email='[email protected]',
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 %}
<h2>{%trans%}Home page{%endtrans%}</h2>
<p><a href={{ url_for('user.register') }}>{%trans%}Register{%endtrans%}</a></p>
<p><a href={{ url_for('user.login') }}>{%trans%}Sign in{%endtrans%}</a></p>
<p><a href={{ url_for('home_page') }}>{%trans%}Home Page{%endtrans%}</a> (accessible to anyone)</p>
<p><a href={{ url_for('member_page') }}>{%trans%}Member Page{%endtrans%}</a> (login_required: [email protected] / Password1)</p>
<p><a href={{ url_for('admin_page') }}>{%trans%}Admin Page{%endtrans%}</a> (role_required: [email protected] / Password1')</p>
<p><a href={{ url_for('user.logout') }}>{%trans%}Sign out{%endtrans%}</a></p>
{% 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 %}
<h2>{%trans%}Members page{%endtrans%}</h2>
<p><a href={{ url_for('user.register') }}>{%trans%}Register{%endtrans%}</a></p>
<p><a href={{ url_for('user.login') }}>{%trans%}Sign in{%endtrans%}</a></p>
<p><a href={{ url_for('home_page') }}>{%trans%}Home Page{%endtrans%}</a> (accessible to anyone)</p>
<p><a href={{ url_for('member_page') }}>{%trans%}Member Page{%endtrans%}</a> (login_required: [email protected] / Password1)</p>
<p><a href={{ url_for('admin_page') }}>{%trans%}Admin Page{%endtrans%}</a> (role_required: [email protected] / Password1')</p>
<p><a href={{ url_for('user.logout') }}>{%trans%}Sign out{%endtrans%}</a></p>
{% 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 %}
<h2>{%trans%}Admin Page{%endtrans%}</h2>
<p><a href={{ url_for('user.register') }}>{%trans%}Register{%endtrans%}</a></p>
<p><a href={{ url_for('user.login') }}>{%trans%}Sign in{%endtrans%}</a></p>
<p><a href={{ url_for('home_page') }}>{%trans%}Home Page{%endtrans%}</a> (accessible to anyone)</p>
<p><a href={{ url_for('member_page') }}>{%trans%}Member Page{%endtrans%}</a> (login_required: [email protected] / Password1)</p>
<p><a href={{ url_for('admin_page') }}>{%trans%}Admin Page{%endtrans%}</a> (role_required: [email protected] / Password1')</p>
<p><a href={{ url_for('user.logout') }}>{%trans%}Sign out{%endtrans%}</a></p>
{% endblock %}
""")

return app


# Start development web server
if __name__ == '__main__':
app = create_app()
app.run(host='0.0.0.0', port=5000, debug=True)
1 change: 1 addition & 0 deletions flask_user/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand Down
2 changes: 2 additions & 0 deletions flask_user/db_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
# Author: Ling Thio <[email protected]>
# 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."""
Expand Down
20 changes: 20 additions & 0 deletions flask_user/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
12 changes: 12 additions & 0 deletions flask_user/templates/flask_user/disable_totp.html
Original file line number Diff line number Diff line change
@@ -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 %}
<h1>{%trans%}Disable TOTP{%endtrans%}</h1>
<form action="" method="POST" class="form" role="form">
{{ form.hidden_tag() }}
{{ render_checkbox_field(form.disable, tabindex=130) }}
{{ render_submit_field(form.submit, tabindex=90) }}
</form>

{% endblock %}
7 changes: 7 additions & 0 deletions flask_user/templates/flask_user/edit_user_profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ <h1>{%trans%}User profile{%endtrans%}</h1>
{% if user_manager.USER_ENABLE_CHANGE_PASSWORD %}
<p><a href="{{ url_for('user.change_password') }}">{%trans%}Change password{%endtrans%}</a></p>
{% endif %}
{% if user_manager.USER_ENABLE_TOTP%}
{% if not current_user.totp_verified %}
<p><a href="{{ url_for('user.enable_totp') }}">{%trans%}Enable TOTP{%endtrans%}</a></p>
{% else%}
<p><a href="{{ url_for('user.disable_totp') }}">{%trans%}Disable TOTP{%endtrans%}</a></p>
{% endif %}
{% endif %}
{% endif %}

{% endblock %}
13 changes: 13 additions & 0 deletions flask_user/templates/flask_user/enable_totp.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{% extends 'flask_user/_authorized_base.html' %}

{% block content %}
{% from "flask_user/_macros.html" import render_field, render_submit_field %}
<h1>{%trans%}Enable TOTP{%endtrans%}</h1>
<p><img id="qrcode" src="{{ url_for('user.get_totp_qrcode') }}"></p>
<form action="" method="POST" class="form" role="form">
{{ form.hidden_tag() }}
{{ render_field(form.totp_token, tabindex=20) }}
{{ render_submit_field(form.submit, tabindex=90) }}
</form>

{% endblock %}
12 changes: 12 additions & 0 deletions flask_user/templates/flask_user/verify_totp_token.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{% extends 'flask_user/_public_base.html' %}

{% block content %}
{% from "flask_user/_macros.html" import render_field, render_submit_field %}
<h1>{%trans%}Verify TOTP{%endtrans%}</h1>
<form action="" method="POST" class="form" role="form">
{{ form.hidden_tag() }}
{{ render_field(form.totp_token, tabindex=20) }}
{{ render_submit_field(form.submit, tabindex=90) }}
</form>

{% endblock %}
Loading