├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── clean.sh ├── create.sh ├── manage.py ├── project ├── __init__.py ├── config.py ├── config │ ├── .gitignore │ ├── README.md │ └── production.cfg.sample ├── decorators.py ├── email.py ├── main │ ├── __init__.py │ └── views.py ├── models.py ├── static │ ├── main.css │ └── main.js ├── templates │ ├── _base.html │ ├── errors │ │ ├── 403.html │ │ ├── 404.html │ │ └── 500.html │ ├── main │ │ └── index.html │ ├── navigation.html │ └── user │ │ ├── activate.html │ │ ├── forgot.html │ │ ├── forgot_new.html │ │ ├── login.html │ │ ├── profile.html │ │ ├── register.html │ │ ├── reset.html │ │ └── unconfirmed.html ├── token.py ├── user │ ├── __init__.py │ ├── forms.py │ └── views.py └── util.py ├── readme.md ├── requirements.txt └── tests ├── __init__.py ├── test_config.py ├── test_main.py └── test_user.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.sqlite 3 | .DS_Store 4 | .coverage 5 | env 6 | venv 7 | migrations 8 | tmp 9 | env.sh -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.4" 5 | install: 6 | - pip install -r requirements.txt 7 | script: 8 | python manage.py test -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | How to contribute 2 | ----------------- 3 | 4 | ## env.sh 5 | 6 | You can create a file `env.sh` with the settings needed to setup the needed development (or testing) environment variables. 7 | 8 | ```sh 9 | #!/usr/bin/env bash 10 | 11 | function app_clean_env() { 12 | unset APP_MAIL_SERVER 13 | unset APP_MAIL_PORT 14 | unset APP_MAIL_USE_TLS 15 | unset APP_MAIL_USE_SSL 16 | unset APP_MAIL_USERNAME 17 | unset APP_MAIL_PASSWORD 18 | unset APP_MAIL_DEFAULT_SENDER 19 | unset APP_SQLALCHEMY_DATABASE_URI 20 | } 21 | 22 | app_clean_env 23 | 24 | if [[ "$APP_SETTINGS" == 'project.config.DevelopmentConfig' ]]; then 25 | echo "Apply DevelopmentConfig settings" 26 | 27 | # mail setting 28 | export APP_MAIL_SERVER='' 29 | export APP_MAIL_PORT='' 30 | export APP_MAIL_USE_TLS='' 31 | export APP_MAIL_USE_SSL='' 32 | 33 | # mail authentication 34 | export APP_MAIL_USERNAME='' 35 | export APP_MAIL_PASSWORD='' 36 | 37 | elif [[ "$APP_SETTINGS" == 'project.config.TestingConfig' ]]; then 38 | echo "Apply TestingConfig settings" 39 | # put your testing settings here 40 | 41 | elif [[ "$APP_SETTINGS" == 'project.config.ProductionConfig' ]]; then 42 | echo "Apply ProductionConfig settings" 43 | # put your production settings here 44 | 45 | else 46 | (>&2 echo "Unrecognized setting") 47 | fi 48 | ``` 49 | 50 | ## Sending and debugging emails 51 | 52 | To send email for development, testing and production you can use your own hosted SMTP server or other solutions. Here's two examples: 53 | 54 | * [Mailgun](https://mailgun.com) 55 | You can use Mailgun if you want to send emails in production. 56 | 57 | * [Debug Mail](https://mailgun.com) 58 | Debug Mail is a tool to debut email, you can use it for developing testing. 59 | 60 | Both services offer free plans that you can use for developing and testing `flask-registration`. 61 | 62 | ## Develop locally 63 | 64 | You can use the scripts `clean.sh` and `create.sh` to setup the environment. 65 | 66 | You need to set up the `APP_SETTINGS` variable: 67 | 68 | ```sh 69 | export APP_SETTINGS=project.config.DevelopmentConfig 70 | ``` 71 | 72 | To run the development server: 73 | ```bash 74 | ./clean.sh; source env.sh && ./create.sh && python manage.py runserver 75 | ``` 76 | 77 | To run the tests: 78 | ```bash 79 | ./clean.sh; source env.sh && python manage.py test 80 | ``` 81 | 82 | ## Use Travis to run tests 83 | 84 | 1. Fork the repository 85 | 2. if you don't have an account on [Travis](http://travis-ci.org/), create it (it's free) 86 | 3. Add your `flask-registration` repository on the Travis settings page. 87 | 4. In Travis, click of the cog icon next to the repository and add the following [environment variables](https://docs.travis-ci.com/user/environment-variables/#Defining-Variables-in-Repository-Settings): 88 | 89 | ``` 90 | APP_SETTINGS: project.config.TestingConfig 91 | APP_MAIL_SERVER: 92 | APP_MAIL_PORT: 93 | APP_MAIL_USERNAME: 94 | APP_MAIL_PASSWORD: 95 | ``` 96 | 97 | You can use any valid SMTP credentials, the defaults are the following: 98 | 99 | ``` 100 | MAIL_SERVER = 'smtp.googlemail.com' 101 | MAIL_PORT = 465 102 | MAIL_USE_TLS = False 103 | MAIL_USE_SSL = True 104 | ``` 105 | 106 | When you commit to your repository, travis will automatically build the application with the testing settings (see the file `.travis.yml`. 107 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Real Python 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /clean.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function clean_env() { 4 | unset APP_MAIL_SERVER 5 | unset APP_MAIL_PORT 6 | unset APP_MAIL_USE_TLS 7 | unset APP_MAIL_USE_SSL 8 | unset APP_MAIL_USERNAME 9 | unset APP_MAIL_PASSWORD 10 | unset APP_MAIL_DEFAULT_SENDER 11 | unset APP_SQLALCHEMY_DATABASE_URI 12 | } 13 | 14 | clean_env 15 | 16 | rm -rf migrations 17 | rm -rf tmp 18 | rm -f project/dev.sqlite 19 | -------------------------------------------------------------------------------- /create.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | python manage.py create_db 4 | python manage.py db init 5 | python manage.py db migrate 6 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | # manage.py 2 | 3 | 4 | import os 5 | import unittest 6 | import coverage 7 | import datetime 8 | 9 | from flask_script import Manager 10 | from flask_migrate import Migrate, MigrateCommand 11 | 12 | COV = coverage.coverage( 13 | branch=True, 14 | include='project/*', 15 | omit=['*/__init__.py', '*/config/*'] 16 | ) 17 | COV.start() 18 | 19 | from project import app, db 20 | from project.models import User 21 | 22 | app.config.from_object(os.environ['APP_SETTINGS']) 23 | 24 | migrate = Migrate(app, db) 25 | manager = Manager(app) 26 | 27 | # migrations 28 | manager.add_command('db', MigrateCommand) 29 | 30 | 31 | @manager.command 32 | def test(): 33 | """Runs the unit tests without coverage.""" 34 | tests = unittest.TestLoader().discover('tests') 35 | result = unittest.TextTestRunner(verbosity=2).run(tests) 36 | if result.wasSuccessful(): 37 | return 0 38 | else: 39 | return 1 40 | 41 | 42 | @manager.command 43 | def cov(): 44 | """Runs the unit tests with coverage.""" 45 | tests = unittest.TestLoader().discover('tests') 46 | unittest.TextTestRunner(verbosity=2).run(tests) 47 | COV.stop() 48 | COV.save() 49 | print('Coverage Summary:') 50 | COV.report() 51 | basedir = os.path.abspath(os.path.dirname(__file__)) 52 | covdir = os.path.join(basedir, 'tmp/coverage') 53 | COV.html_report(directory=covdir) 54 | print('HTML version: file://%s/index.html' % covdir) 55 | COV.erase() 56 | 57 | 58 | @manager.command 59 | def create_db(): 60 | """Creates the db tables.""" 61 | db.create_all() 62 | 63 | 64 | @manager.command 65 | def drop_db(): 66 | """Drops the db tables.""" 67 | db.drop_all() 68 | 69 | 70 | @manager.command 71 | def create_admin(): 72 | """Creates the admin user.""" 73 | db.session.add(User( 74 | email="ad@min.com", 75 | password="admin", 76 | admin=True, 77 | confirmed=True, 78 | confirmed_on=datetime.datetime.now()) 79 | ) 80 | db.session.commit() 81 | 82 | 83 | if __name__ == '__main__': 84 | manager.run() 85 | -------------------------------------------------------------------------------- /project/__init__.py: -------------------------------------------------------------------------------- 1 | # project/__init__.py 2 | 3 | 4 | ################# 5 | #### imports #### 6 | ################# 7 | 8 | import os 9 | 10 | from flask import Flask, render_template 11 | from flask.ext.login import LoginManager 12 | from flask.ext.bcrypt import Bcrypt 13 | from flask_mail import Mail 14 | from flask.ext.debugtoolbar import DebugToolbarExtension 15 | from flask.ext.sqlalchemy import SQLAlchemy 16 | 17 | 18 | ################ 19 | #### config #### 20 | ################ 21 | 22 | def _check_config_variables_are_set(config): 23 | assert config['MAIL_USERNAME'] is not None,\ 24 | 'MAIL_USERNAME is not set, set the env variable APP_MAIL_USERNAME '\ 25 | 'or MAIL_USERNAME in the production config file.' 26 | assert config['MAIL_PASSWORD'] is not None,\ 27 | 'MAIL_PASSWORD is not set, set the env variable APP_MAIL_PASSWORD '\ 28 | 'or MAIL_PASSWORD in the production config file.' 29 | 30 | assert config['SECRET_KEY'] is not None,\ 31 | 'SECRET_KEY is not set, set it in the production config file.' 32 | assert config['SECURITY_PASSWORD_SALT'] is not None,\ 33 | 'SECURITY_PASSWORD_SALT is not set, '\ 34 | 'set it in the production config file.' 35 | 36 | assert config['SQLALCHEMY_DATABASE_URI'] is not None,\ 37 | 'SQLALCHEMY_DATABASE_URI is not set, '\ 38 | 'set it in the production config file.' 39 | 40 | if os.environ['APP_SETTINGS'] == 'project.config.ProductionConfig': 41 | assert config['STRIPE_SECRET_KEY'] is not None,\ 42 | 'STRIPE_SECRET_KEY is not set, '\ 43 | 'set it in the production config file.' 44 | assert config['STRIPE_PUBLISHABLE_KEY'] is not None,\ 45 | 'STRIPE_PUBLISHABLE_KEY is not set, '\ 46 | 'set it in the production config file.' 47 | 48 | 49 | app = Flask(__name__) 50 | 51 | app.config.from_object(os.environ['APP_SETTINGS']) 52 | 53 | _check_config_variables_are_set(app.config) 54 | 55 | #################### 56 | #### extensions #### 57 | #################### 58 | 59 | login_manager = LoginManager() 60 | login_manager.init_app(app) 61 | bcrypt = Bcrypt(app) 62 | mail = Mail(app) 63 | toolbar = DebugToolbarExtension(app) 64 | db = SQLAlchemy(app) 65 | 66 | 67 | #################### 68 | #### blueprints #### 69 | #################### 70 | 71 | from project.main.views import main_blueprint 72 | from project.user.views import user_blueprint 73 | app.register_blueprint(main_blueprint) 74 | app.register_blueprint(user_blueprint) 75 | 76 | 77 | #################### 78 | #### flask-login #### 79 | #################### 80 | 81 | from project.models import User 82 | 83 | login_manager.login_view = "user.login" 84 | login_manager.login_message_category = "danger" 85 | 86 | 87 | @login_manager.user_loader 88 | def load_user(user_id): 89 | return User.query.filter(User.id == int(user_id)).first() 90 | 91 | 92 | ######################## 93 | #### error handlers #### 94 | ######################## 95 | 96 | @app.errorhandler(403) 97 | def forbidden_page(error): 98 | return render_template("errors/403.html"), 403 99 | 100 | 101 | @app.errorhandler(404) 102 | def page_not_found(error): 103 | return render_template("errors/404.html"), 404 104 | 105 | 106 | @app.errorhandler(500) 107 | def server_error_page(error): 108 | return render_template("errors/500.html"), 500 109 | -------------------------------------------------------------------------------- /project/config.py: -------------------------------------------------------------------------------- 1 | # project/config.py 2 | 3 | import os 4 | try: 5 | # Python 2.7 6 | import ConfigParser as configparser 7 | except ImportError: 8 | # Python 3 9 | import configparser 10 | 11 | basedir = os.path.abspath(os.path.dirname(__file__)) 12 | 13 | 14 | def _get_bool_env_var(varname, default=None): 15 | 16 | value = os.environ.get(varname, default) 17 | 18 | if value is None: 19 | return False 20 | elif isinstance(value, str) and value.lower() == 'false': 21 | return False 22 | elif bool(value) is False: 23 | return False 24 | else: 25 | return bool(value) 26 | 27 | 28 | class BaseConfig(object): 29 | """Base configuration.""" 30 | 31 | # main config 32 | SECRET_KEY = 'my_precious' 33 | SECURITY_PASSWORD_SALT = 'my_precious_two' 34 | DEBUG = False 35 | BCRYPT_LOG_ROUNDS = 13 36 | WTF_CSRF_ENABLED = True 37 | DEBUG_TB_ENABLED = False 38 | DEBUG_TB_INTERCEPT_REDIRECTS = False 39 | 40 | # mail settings 41 | # defaults are: 42 | # - MAIL_SERVER = 'smtp.googlemail.com' 43 | # - MAIL_PORT = 465 44 | # - MAIL_USE_TLS = False 45 | # - MAIL_USE_SSL = True 46 | MAIL_SERVER = os.environ.get('APP_MAIL_SERVER', 'smtp.googlemail.com') 47 | MAIL_PORT = int(os.environ.get('APP_MAIL_PORT', 465)) 48 | MAIL_USE_TLS = _get_bool_env_var('APP_MAIL_USE_TLS', False) 49 | MAIL_USE_SSL = _get_bool_env_var('APP_MAIL_USE_SSL', True) 50 | 51 | # mail authentication 52 | MAIL_USERNAME = os.environ.get('APP_MAIL_USERNAME', None) 53 | MAIL_PASSWORD = os.environ.get('APP_MAIL_PASSWORD', None) 54 | 55 | # mail accounts 56 | MAIL_DEFAULT_SENDER = 'from@example.com' 57 | 58 | 59 | class DevelopmentConfig(BaseConfig): 60 | """Development configuration.""" 61 | DEBUG = True 62 | WTF_CSRF_ENABLED = False 63 | SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'dev.sqlite') 64 | DEBUG_TB_ENABLED = True 65 | 66 | 67 | class TestingConfig(BaseConfig): 68 | """Testing configuration.""" 69 | LOGIN_DISABLED=False 70 | TESTING = True 71 | DEBUG = False 72 | BCRYPT_LOG_ROUNDS = 1 73 | WTF_CSRF_ENABLED = False 74 | DEBUG_TB_ENABLED = False 75 | SQLALCHEMY_DATABASE_URI = 'sqlite://' 76 | 77 | 78 | class ProductionConfig(BaseConfig): 79 | """Production configuration.""" 80 | DEBUG = False 81 | DEBUG_TB_ENABLED = False 82 | 83 | SECRET_KEY = None 84 | SECURITY_PASSWORD_SALT = None 85 | 86 | STRIPE_SECRET_KEY = None 87 | STRIPE_PUBLISHABLE_KEY = None 88 | 89 | SQLALCHEMY_DATABASE_URI = None 90 | 91 | # production config takes precedence over env variables 92 | 93 | # production config file at ./project/config/production.cfg 94 | config_path = os.path.join(basedir, 'config', 'production.cfg') 95 | 96 | # if config file exists, read it: 97 | if os.path.isfile(config_path): 98 | config = configparser.ConfigParser() 99 | 100 | with open(config_path) as configfile: 101 | config.readfp(configfile) 102 | 103 | SECRET_KEY = config.get('keys', 'SECRET_KEY') 104 | SECURITY_PASSWORD_SALT = config.get('keys', 'SECRET_KEY') 105 | 106 | # mail settings 107 | MAIL_SERVER = config.get('mail', 'MAIL_SERVER') 108 | MAIL_PORT = config.getint('mail', 'MAIL_PORT') 109 | MAIL_USE_TLS = config.getboolean('mail', 'MAIL_USE_TLS') 110 | MAIL_USE_SSL = config.getboolean('mail', 'MAIL_USE_SSL') 111 | 112 | # mail authentication and sender 113 | MAIL_USERNAME = config.get('mail', 'MAIL_USERNAME') 114 | MAIL_PASSWORD = config.get('mail', 'MAIL_PASSWORD') 115 | MAIL_DEFAULT_SENDER = config.get('mail', 'MAIL_DEFAULT_SENDER') 116 | 117 | # database URI 118 | SQLALCHEMY_DATABASE_URI = config.get('db', 'SQLALCHEMY_DATABASE_URI') 119 | 120 | # stripe keys 121 | STRIPE_SECRET_KEY = config.get('stripe', 'STRIPE_SECRET_KEY') 122 | STRIPE_PUBLISHABLE_KEY = config.get('stripe', 'STRIPE_PUBLISHABLE_KEY') 123 | -------------------------------------------------------------------------------- /project/config/.gitignore: -------------------------------------------------------------------------------- 1 | *.cfg 2 | -------------------------------------------------------------------------------- /project/config/README.md: -------------------------------------------------------------------------------- 1 | Config file directory 2 | --------------------- 3 | 4 | You can put here your `production.cfg` for your production settings. 5 | Start from the template in `production.cfg.sample`. 6 | 7 | All files ending with `.cfg` are ignored by Git. 8 | -------------------------------------------------------------------------------- /project/config/production.cfg.sample: -------------------------------------------------------------------------------- 1 | # This is a sample config file. 2 | # Create from this template a config named: 3 | # production.cfg 4 | # 5 | # for your production settings. 6 | # 7 | # You don't need quotes for strings. 8 | # Lines starting with # are comments and are ignored 9 | 10 | [keys] 11 | SECRET_KEY = my_precious 12 | SECURITY_PASSWORD_SALT = my_precious_two 13 | 14 | [mail] 15 | MAIL_SERVER = smtp.gmail.com 16 | MAIL_PORT = 465 17 | MAIL_USE_TLS = False 18 | MAIL_USE_SSL = True 19 | 20 | # mail authentication 21 | MAIL_USERNAME = youremail@gmail.com 22 | MAIL_PASSWORD = your-gmail-password 23 | 24 | # mail sender 25 | MAIL_DEFAULT_SENDER = dev@example.org 26 | 27 | [db] 28 | # the database URL is specified as follows: 29 | # dialect+driver://username:password@host:port/database 30 | SQLALCHEMY_DATABASE_URI = sqlite:// 31 | 32 | [stripe] 33 | STRIPE_SECRET_KEY = foo 34 | STRIPE_PUBLISHABLE_KEY = bar 35 | -------------------------------------------------------------------------------- /project/decorators.py: -------------------------------------------------------------------------------- 1 | # project/decorators.py 2 | 3 | 4 | from functools import wraps 5 | 6 | from flask import flash, redirect, url_for 7 | from flask.ext.login import current_user 8 | 9 | 10 | def check_confirmed(func): 11 | @wraps(func) 12 | def decorated_function(*args, **kwargs): 13 | if current_user.confirmed is False: 14 | flash('Please confirm your account!', 'warning') 15 | return redirect(url_for('user.unconfirmed')) 16 | return func(*args, **kwargs) 17 | 18 | return decorated_function 19 | -------------------------------------------------------------------------------- /project/email.py: -------------------------------------------------------------------------------- 1 | # project/email.py 2 | 3 | from flask.ext.mail import Message 4 | 5 | from project import app, mail 6 | 7 | 8 | def send_email(to, subject, template): 9 | msg = Message( 10 | subject, 11 | recipients=[to], 12 | html=template, 13 | sender=app.config['MAIL_DEFAULT_SENDER'] 14 | ) 15 | mail.send(msg) 16 | -------------------------------------------------------------------------------- /project/main/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/realpython/flask-registration/aeb66980a55465169c7484c1b04a3689db71d100/project/main/__init__.py -------------------------------------------------------------------------------- /project/main/views.py: -------------------------------------------------------------------------------- 1 | # project/main/views.py 2 | 3 | 4 | ################# 5 | #### imports #### 6 | ################# 7 | 8 | from flask import render_template 9 | from flask import Blueprint 10 | from flask_login import current_user 11 | from flask_login import login_required 12 | 13 | ################ 14 | #### config #### 15 | ################ 16 | 17 | main_blueprint = Blueprint('main', __name__,) 18 | 19 | 20 | ################ 21 | #### routes #### 22 | ################ 23 | 24 | @main_blueprint.route('/') 25 | def home(): 26 | return render_template('main/index.html', current_user=current_user) 27 | -------------------------------------------------------------------------------- /project/models.py: -------------------------------------------------------------------------------- 1 | # project/models.py 2 | 3 | 4 | import datetime 5 | 6 | from project import db, bcrypt 7 | 8 | 9 | class User(db.Model): 10 | 11 | __tablename__ = "users" 12 | 13 | id = db.Column(db.Integer, primary_key=True) 14 | email = db.Column(db.String, unique=True, nullable=False) 15 | password = db.Column(db.String, nullable=False) 16 | registered_on = db.Column(db.DateTime, nullable=False) 17 | admin = db.Column(db.Boolean, nullable=False, default=False) 18 | confirmed = db.Column(db.Boolean, nullable=False, default=False) 19 | confirmed_on = db.Column(db.DateTime, nullable=True) 20 | password_reset_token = db.Column(db.String, nullable=True) 21 | 22 | def __init__(self, email, password, confirmed, 23 | admin=False, confirmed_on=None, 24 | password_reset_token=None): 25 | self.email = email 26 | self.password = bcrypt.generate_password_hash(password) 27 | self.registered_on = datetime.datetime.now() 28 | self.admin = admin 29 | self.confirmed = confirmed 30 | self.confirmed_on = confirmed_on 31 | self.password_reset_token = password_reset_token 32 | 33 | def is_authenticated(self): 34 | return True 35 | 36 | def is_active(self): 37 | return True 38 | 39 | def is_anonymous(self): 40 | return False 41 | 42 | def get_id(self): 43 | return self.id 44 | 45 | def __repr__(self): 46 | return ' 2 | 3 | 4 | 5 | Flask User Management 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {% block css %}{% endblock %} 14 | 15 | 16 | 17 | {% include "navigation.html" %} 18 | 19 |
20 | 21 |
22 | 23 | 24 | {% with messages = get_flashed_messages(with_categories=true) %} 25 | {% if messages %} 26 |
27 |
28 | {% for category, message in messages %} 29 |
30 | × 31 | {{message}} 32 |
33 | {% endfor %} 34 |
35 |
36 | {% endif %} 37 | {% endwith %} 38 | 39 | 40 | {% block content %}{% endblock %} 41 | 42 |
43 | 44 | 45 | {% if error %} 46 |

Error: {{ error }}

47 | {% endif %} 48 | 49 |
50 | 51 | 52 | 53 | 54 | 55 | {% block js %}{% endblock %} 56 | 57 | 58 | -------------------------------------------------------------------------------- /project/templates/errors/403.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | {% block content %} 3 |

403

4 |

Run along!

5 |

Return Home?

6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /project/templates/errors/404.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | {% block content %} 3 |

404

4 |

There's nothing here!

5 |

Return Home?

6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /project/templates/errors/500.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | {% block content %} 3 |

500

4 |

Something's wrong! We are on the job.

5 |

Return Home?

6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /project/templates/main/index.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | {% block content %} 3 | 4 | {% if current_user.is_authenticated() %} 5 |

Welcome, {{ current_user.email }}

6 | {% else %} 7 |

Welcome!

8 | {% endif %} 9 |
10 | 11 | {% if current_user.is_authenticated() %} 12 |

You are authenticated

13 | 16 | {% else %} 17 |

You are not authenticated:

18 | 23 | {% endif %} 24 | 25 | {% endblock %} -------------------------------------------------------------------------------- /project/templates/navigation.html: -------------------------------------------------------------------------------- 1 | 2 | 36 | -------------------------------------------------------------------------------- /project/templates/user/activate.html: -------------------------------------------------------------------------------- 1 |

Welcome! Thanks for signing up. Please follow this link to activate your account:

2 |

{{ confirm_url }}

3 |
4 |

Cheers!

-------------------------------------------------------------------------------- /project/templates/user/forgot.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | 3 | {% block content %} 4 | 5 |

Forgot Your Password?

6 |
7 | 20 | 24 | {% endblock %} -------------------------------------------------------------------------------- /project/templates/user/forgot_new.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | 3 | {% block content %} 4 | 5 |

Forgot Your Password?

6 |
7 | 32 | 33 | {% endblock %} -------------------------------------------------------------------------------- /project/templates/user/login.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | 3 | {% block content %} 4 | 5 |

Please login

6 |
7 | 36 | 37 | {% endblock %} -------------------------------------------------------------------------------- /project/templates/user/profile.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | 3 | {% block content %} 4 | 5 |

Your Profile

6 |
7 | 8 | {% if current_user.is_authenticated() %} 9 |

Email: {{current_user.email}}

10 | {% endif %} 11 | 12 |

Change Password

13 |
14 |
15 | {{ form.csrf_token }} 16 |

17 | {{ form.password(placeholder="password") }} 18 | 19 | {% if form.password.errors %} 20 | {% for error in form.password.errors %} 21 | {{ error }} 22 | {% endfor %} 23 | {% endif %} 24 | 25 |

26 |

27 | {{ form.confirm(placeholder="confirm") }} 28 | 29 | {% if form.confirm.errors %} 30 | {% for error in form.confirm.errors %} 31 | {{ error }} 32 | {% endfor %} 33 | {% endif %} 34 | 35 |

36 | 37 |
38 | 39 | 40 | {% endblock %} -------------------------------------------------------------------------------- /project/templates/user/register.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | 3 | {% block content %} 4 | 5 |

Please Register

6 |
7 | 42 | 43 | {% endblock %} -------------------------------------------------------------------------------- /project/templates/user/reset.html: -------------------------------------------------------------------------------- 1 |

Hi! Somobody requested a password reset for the account {{ username }}.

2 |

Click on the following link to reset your password:

3 |

{{ reset_url }}

4 |

If you didn't request it, you can ignore this email.

5 |
6 |

Cheers!

-------------------------------------------------------------------------------- /project/templates/user/unconfirmed.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | 3 | {% block content %} 4 | 5 |

Welcome!

6 |
7 |

You have not confirmed your account. Please check your inbox (and your spam folder) - you should have received an email with a confirmation link.

8 |

Didn't get the email? Resend.

9 | 10 | {% endblock %} -------------------------------------------------------------------------------- /project/token.py: -------------------------------------------------------------------------------- 1 | # project/token.py 2 | 3 | from itsdangerous import URLSafeTimedSerializer 4 | 5 | from project import app 6 | 7 | 8 | def generate_confirmation_token(email): 9 | serializer = URLSafeTimedSerializer(app.config['SECRET_KEY']) 10 | return serializer.dumps(email, salt=app.config['SECURITY_PASSWORD_SALT']) 11 | 12 | 13 | def confirm_token(token, expiration=3600): 14 | serializer = URLSafeTimedSerializer(app.config['SECRET_KEY']) 15 | try: 16 | email = serializer.loads( 17 | token, 18 | salt=app.config['SECURITY_PASSWORD_SALT'], 19 | max_age=expiration 20 | ) 21 | except: 22 | return False 23 | return email 24 | -------------------------------------------------------------------------------- /project/user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/realpython/flask-registration/aeb66980a55465169c7484c1b04a3689db71d100/project/user/__init__.py -------------------------------------------------------------------------------- /project/user/forms.py: -------------------------------------------------------------------------------- 1 | # project/user/forms.py 2 | 3 | 4 | from flask_wtf import Form 5 | from wtforms import TextField, PasswordField 6 | from wtforms.validators import DataRequired, Email, Length, EqualTo 7 | 8 | from project.models import User 9 | 10 | 11 | class LoginForm(Form): 12 | email = TextField('email', validators=[DataRequired(), Email()]) 13 | password = PasswordField('password', validators=[DataRequired()]) 14 | 15 | 16 | class RegisterForm(Form): 17 | email = TextField( 18 | 'email', 19 | validators=[DataRequired(), Email(message=None), Length(min=6, max=255)]) 20 | password = PasswordField( 21 | 'password', 22 | validators=[DataRequired(), Length(min=6, max=255)] 23 | ) 24 | confirm = PasswordField( 25 | 'Repeat password', 26 | validators=[ 27 | DataRequired(), 28 | EqualTo('password', message='Passwords must match.') 29 | ] 30 | ) 31 | 32 | def validate(self): 33 | initial_validation = super(RegisterForm, self).validate() 34 | if not initial_validation: 35 | return False 36 | user = User.query.filter_by(email=self.email.data).first() 37 | if user: 38 | self.email.errors.append("Email already registered") 39 | return False 40 | return True 41 | 42 | 43 | class ForgotForm(Form): 44 | email = TextField( 45 | 'email', 46 | validators=[DataRequired(), Email(message=None), Length(min=6, max=255)]) 47 | 48 | def validate(self): 49 | initial_validation = super(ForgotForm, self).validate() 50 | if not initial_validation: 51 | return False 52 | user = User.query.filter_by(email=self.email.data).first() 53 | if not user: 54 | self.email.errors.append("This email is not registered") 55 | return False 56 | return True 57 | 58 | 59 | class ChangePasswordForm(Form): 60 | password = PasswordField( 61 | 'password', 62 | validators=[DataRequired(), Length(min=6, max=255)] 63 | ) 64 | confirm = PasswordField( 65 | 'Repeat password', 66 | validators=[ 67 | DataRequired(), 68 | EqualTo('password', message='Passwords must match.') 69 | ] 70 | ) 71 | -------------------------------------------------------------------------------- /project/user/views.py: -------------------------------------------------------------------------------- 1 | # project/user/views.py 2 | 3 | 4 | ################# 5 | #### imports #### 6 | ################# 7 | 8 | import datetime 9 | 10 | from flask import render_template, Blueprint, url_for, \ 11 | redirect, flash, request 12 | from flask_login import login_user, logout_user, \ 13 | login_required, current_user 14 | 15 | from project.models import User 16 | from project.email import send_email 17 | from project.token import generate_confirmation_token, confirm_token 18 | from project.decorators import check_confirmed 19 | from project import db, bcrypt 20 | from .forms import LoginForm, RegisterForm, ChangePasswordForm, ForgotForm 21 | 22 | 23 | ################ 24 | #### config #### 25 | ################ 26 | 27 | user_blueprint = Blueprint('user', __name__,) 28 | 29 | 30 | ################ 31 | #### routes #### 32 | ################ 33 | 34 | @user_blueprint.route('/register', methods=['GET', 'POST']) 35 | def register(): 36 | form = RegisterForm(request.form) 37 | if form.validate_on_submit(): 38 | user = User( 39 | email=form.email.data, 40 | password=form.password.data, 41 | confirmed=False 42 | ) 43 | db.session.add(user) 44 | db.session.commit() 45 | 46 | token = generate_confirmation_token(user.email) 47 | confirm_url = url_for('user.confirm_email', token=token, _external=True) 48 | html = render_template('user/activate.html', confirm_url=confirm_url) 49 | subject = "Please confirm your email" 50 | send_email(user.email, subject, html) 51 | 52 | login_user(user) 53 | 54 | flash('A confirmation email has been sent via email.', 'success') 55 | return redirect(url_for("user.unconfirmed")) 56 | 57 | return render_template('user/register.html', form=form) 58 | 59 | 60 | @user_blueprint.route('/login', methods=['GET', 'POST']) 61 | def login(): 62 | form = LoginForm(request.form) 63 | if form.validate_on_submit(): 64 | user = User.query.filter_by(email=form.email.data).first() 65 | if user and bcrypt.check_password_hash( 66 | user.password, request.form['password']): 67 | login_user(user) 68 | flash('Welcome.', 'success') 69 | return redirect(url_for('main.home')) 70 | else: 71 | flash('Invalid email and/or password.', 'danger') 72 | return render_template('user/login.html', form=form) 73 | return render_template('user/login.html', form=form) 74 | 75 | 76 | @user_blueprint.route('/logout') 77 | @login_required 78 | def logout(): 79 | logout_user() 80 | flash('You were logged out.', 'success') 81 | return redirect(url_for('user.login')) 82 | 83 | 84 | @user_blueprint.route('/profile', methods=['GET', 'POST']) 85 | @login_required 86 | @check_confirmed 87 | def profile(): 88 | form = ChangePasswordForm(request.form) 89 | if form.validate_on_submit(): 90 | user = User.query.filter_by(email=current_user.email).first() 91 | if user: 92 | user.password = bcrypt.generate_password_hash(form.password.data) 93 | db.session.commit() 94 | flash('Password successfully changed.', 'success') 95 | return redirect(url_for('user.profile')) 96 | else: 97 | flash('Password change was unsuccessful.', 'danger') 98 | return redirect(url_for('user.profile')) 99 | return render_template('user/profile.html', form=form) 100 | 101 | 102 | @user_blueprint.route('/confirm/') 103 | @login_required 104 | def confirm_email(token): 105 | if current_user.confirmed: 106 | flash('Account already confirmed. Please login.', 'success') 107 | return redirect(url_for('main.home')) 108 | email = confirm_token(token) 109 | user = User.query.filter_by(email=current_user.email).first_or_404() 110 | if user.email == email: 111 | user.confirmed = True 112 | user.confirmed_on = datetime.datetime.now() 113 | db.session.add(user) 114 | db.session.commit() 115 | flash('You have confirmed your account. Thanks!', 'success') 116 | else: 117 | flash('The confirmation link is invalid or has expired.', 'danger') 118 | return redirect(url_for('main.home')) 119 | 120 | 121 | @user_blueprint.route('/unconfirmed') 122 | @login_required 123 | def unconfirmed(): 124 | if current_user.confirmed: 125 | return redirect(url_for('main.home')) 126 | flash('Please confirm your account!', 'warning') 127 | return render_template('user/unconfirmed.html') 128 | 129 | 130 | @user_blueprint.route('/resend') 131 | @login_required 132 | def resend_confirmation(): 133 | token = generate_confirmation_token(current_user.email) 134 | confirm_url = url_for('user.confirm_email', token=token, _external=True) 135 | html = render_template('user/activate.html', confirm_url=confirm_url) 136 | subject = "Please confirm your email" 137 | send_email(current_user.email, subject, html) 138 | flash('A new confirmation email has been sent.', 'success') 139 | return redirect(url_for('user.unconfirmed')) 140 | 141 | @user_blueprint.route('/forgot', methods=['GET', 'POST']) 142 | def forgot(): 143 | form = ForgotForm(request.form) 144 | if form.validate_on_submit(): 145 | 146 | user = User.query.filter_by(email=form.email.data).first() 147 | token = generate_confirmation_token(user.email) 148 | 149 | user.password_reset_token = token 150 | db.session.commit() 151 | 152 | reset_url = url_for('user.forgot_new', token=token, _external=True) 153 | html = render_template('user/reset.html', 154 | username=user.email, 155 | reset_url=reset_url) 156 | subject = "Reset your password" 157 | send_email(user.email, subject, html) 158 | 159 | flash('A password reset email has been sent via email.', 'success') 160 | return redirect(url_for("main.home")) 161 | 162 | return render_template('user/forgot.html', form=form) 163 | 164 | 165 | @user_blueprint.route('/forgot/new/', methods=['GET', 'POST']) 166 | def forgot_new(token): 167 | 168 | email = confirm_token(token) 169 | user = User.query.filter_by(email=email).first_or_404() 170 | 171 | if user.password_reset_token is not None: 172 | form = ChangePasswordForm(request.form) 173 | if form.validate_on_submit(): 174 | user = User.query.filter_by(email=email).first() 175 | if user: 176 | user.password = bcrypt.generate_password_hash(form.password.data) 177 | user.password_reset_token = None 178 | db.session.commit() 179 | 180 | login_user(user) 181 | 182 | flash('Password successfully changed.', 'success') 183 | return redirect(url_for('user.profile')) 184 | 185 | else: 186 | flash('Password change was unsuccessful.', 'danger') 187 | return redirect(url_for('user.profile')) 188 | else: 189 | flash('You can now change your password.', 'success') 190 | return render_template('user/forgot_new.html', form=form) 191 | else: 192 | flash('Can not reset the password, try again.', 'danger') 193 | 194 | return redirect(url_for('main.home')) 195 | -------------------------------------------------------------------------------- /project/util.py: -------------------------------------------------------------------------------- 1 | # project/util.py 2 | 3 | 4 | from flask.ext.testing import TestCase 5 | 6 | from project import app, db 7 | from project.models import User 8 | 9 | 10 | class BaseTestCase(TestCase): 11 | 12 | def create_app(self): 13 | app.config.from_object('project.config.TestingConfig') 14 | return app 15 | 16 | @classmethod 17 | def setUpClass(self): 18 | db.create_all() 19 | user = User( 20 | email="test@user.com", 21 | password="just_a_test_user", 22 | confirmed=False 23 | ) 24 | db.session.add(user) 25 | db.session.commit() 26 | 27 | @classmethod 28 | def tearDownClass(self): 29 | db.session.remove() 30 | db.drop_all() 31 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Flask Registration 2 | 3 | [![Build Status](https://travis-ci.org/realpython/flask-registration.svg?branch=master)](https://travis-ci.org/realpython/flask-registration) 4 | 5 | Starter app for managing users - login/logout, registration, and email confirmation. 6 | 7 | **Blog posts:** 8 | 9 | - Part 1: [Handling Email Confirmation During Registration in Flask](https://realpython.com/blog/python/handling-email-confirmation-in-flask) 10 | - Part 2: [The Minimum Viable Test Suite](https://realpython.com/blog/python/the-minimum-viable-test-suite/) 11 | 12 | ## QuickStart 13 | 14 | ### Set Environment Variables 15 | 16 | Development Example (with [Debug Mail](https://debugmail.io)): 17 | 18 | ```sh 19 | $ export APP_SETTINGS="project.config.DevelopmentConfig" 20 | $ export APP_MAIL_SERVER=debugmail.io 21 | $ export APP_MAIL_PORT=25 22 | $ export APP_MAIL_USE_TLS=true 23 | $ export APP_MAIL_USE_SSL=false 24 | $ export APP_MAIL_USERNAME=ADDYOUROWN 25 | $ export APP_MAIL_PASSWORD=ADDYOUROWN 26 | ``` 27 | 28 | Production Example: 29 | 30 | ```sh 31 | $ export APP_SETTINGS="project.config.ProductionConfig" 32 | $ export APP_MAIL_SERVER=ADDYOUROWN 33 | $ export APP_MAIL_PORT=ADDYOUROWN 34 | $ export APP_MAIL_USE_TLS=ADDYOUROWN 35 | $ export APP_MAIL_USE_SSL=ADDYOUROWN 36 | $ export APP_MAIL_USERNAME=ADDYOUROWN 37 | $ export APP_MAIL_PASSWORD=ADDYOUROWN 38 | ``` 39 | 40 | ### Update Settings in Production 41 | 42 | 1. `SECRET_KEY` 43 | 1. `SQLALCHEMY_DATABASE_URI` 44 | 45 | ### Create DB 46 | 47 | Run: 48 | 49 | ```sh 50 | $ sh create.sh 51 | ``` 52 | 53 | Or: 54 | 55 | ```sh 56 | $ python manage.py create_db 57 | $ python manage.py db init 58 | $ python manage.py db migrate 59 | $ python manage.py create_admin 60 | ``` 61 | 62 | Want to clean the environment? Run: 63 | 64 | ```sh 65 | sh clean.sh 66 | ``` 67 | 68 | ### Run 69 | 70 | ```sh 71 | $ python manage.py runserver 72 | ``` 73 | 74 | ### Testing 75 | 76 | Without coverage: 77 | 78 | ```sh 79 | $ python manage.py test 80 | ``` 81 | 82 | With coverage: 83 | 84 | ```sh 85 | $ python manage.py cov 86 | ``` 87 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==0.10.1 2 | Flask-Bcrypt==0.6.0 3 | Flask-DebugToolbar==0.9.0 4 | Flask-Login==0.2.11 5 | Flask-Mail==0.9.1 6 | Flask-Migrate==1.2.0 7 | Flask-SQLAlchemy==2.0 8 | Flask-Script==2.0.5 9 | Flask-Testing==0.4.2 10 | Flask-WTF==0.10.2 11 | Jinja2==2.7.3 12 | Mako==1.0.0 13 | MarkupSafe==0.23 14 | SQLAlchemy==0.9.8 15 | WTForms==2.0.1 16 | Werkzeug==0.9.6 17 | alembic==0.6.7 18 | blinker==1.3 19 | coverage==4.0a1 20 | ecdsa==0.11 21 | httplib2==0.9 22 | itsdangerous==0.24 23 | paramiko==1.15.1 24 | psycopg2==2.5.4 25 | py-bcrypt==0.4 26 | pycrypto==2.6.1 27 | requests==2.6.2 28 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/realpython/flask-registration/aeb66980a55465169c7484c1b04a3689db71d100/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | # tests/test_config.py 2 | 3 | 4 | import unittest 5 | 6 | from flask import current_app 7 | from flask_testing import TestCase 8 | 9 | from project import app 10 | 11 | 12 | class TestDevelopmentConfig(TestCase): 13 | 14 | def create_app(self): 15 | app.config.from_object('project.config.DevelopmentConfig') 16 | return app 17 | 18 | def test_app_is_development(self): 19 | self.assertTrue(app.config['DEBUG'] is True) 20 | self.assertTrue(app.config['WTF_CSRF_ENABLED'] is False) 21 | self.assertTrue(app.config['DEBUG_TB_ENABLED'] is True) 22 | 23 | 24 | class TestTestingConfig(TestCase): 25 | 26 | def create_app(self): 27 | app.config.from_object('project.config.TestingConfig') 28 | return app 29 | 30 | def test_app_is_testing(self): 31 | self.assertTrue(current_app.config['TESTING']) 32 | self.assertTrue(app.config['DEBUG'] is False) 33 | self.assertTrue(app.config['BCRYPT_LOG_ROUNDS'] == 1) 34 | self.assertTrue(app.config['WTF_CSRF_ENABLED'] is False) 35 | 36 | 37 | class TestProductionConfig(TestCase): 38 | 39 | def create_app(self): 40 | app.config.from_object('project.config.ProductionConfig') 41 | return app 42 | 43 | def test_app_is_production(self): 44 | self.assertTrue(app.config['DEBUG'] is False) 45 | self.assertTrue(app.config['DEBUG_TB_ENABLED'] is False) 46 | self.assertTrue(app.config['WTF_CSRF_ENABLED'] is True) 47 | self.assertTrue(app.config['BCRYPT_LOG_ROUNDS'] == 13) 48 | 49 | 50 | if __name__ == '__main__': 51 | unittest.main() 52 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from project.util import BaseTestCase 4 | 5 | 6 | class TestMainViews(BaseTestCase): 7 | 8 | def test_main_route_does_not_require_login(self): 9 | # Ensure main route requres a logged in user. 10 | response = self.client.get('/', follow_redirects=True) 11 | self.assertTrue(response.status_code == 200) 12 | self.assertTemplateUsed('main/index.html') 13 | 14 | 15 | if __name__ == '__main__': 16 | unittest.main() 17 | -------------------------------------------------------------------------------- /tests/test_user.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | 4 | from flask_login import current_user 5 | 6 | from project import db 7 | from project.models import User 8 | from project.util import BaseTestCase 9 | from project.user.forms import RegisterForm, \ 10 | LoginForm, ChangePasswordForm, ForgotForm 11 | from project.token import generate_confirmation_token, confirm_token 12 | 13 | 14 | class TestUserForms(BaseTestCase): 15 | 16 | def test_validate_success_register_form(self): 17 | # Ensure correct data validates. 18 | form = RegisterForm( 19 | email='new@test.test', 20 | password='example', confirm='example') 21 | self.assertTrue(form.validate()) 22 | 23 | def test_validate_invalid_password_format(self): 24 | # Ensure incorrect data does not validate. 25 | form = RegisterForm( 26 | email='new@test.test', 27 | password='example', confirm='') 28 | self.assertFalse(form.validate()) 29 | 30 | def test_validate_email_already_registered(self): 31 | # Ensure user can't register when a duplicate email is used 32 | form = RegisterForm( 33 | email='test@user.com', 34 | password='just_a_test_user', 35 | confirm='just_a_test_user' 36 | ) 37 | self.assertFalse(form.validate()) 38 | 39 | def test_validate_success_login_form(self): 40 | # Ensure correct data validates. 41 | form = LoginForm(email='test@user.com', password='just_a_test_user') 42 | self.assertTrue(form.validate()) 43 | 44 | def test_validate_invalid_email_format(self): 45 | # Ensure invalid email format throws error. 46 | form = LoginForm(email='unknown', password='example') 47 | self.assertFalse(form.validate()) 48 | 49 | def test_validate_success_change_password_form(self): 50 | # Ensure correct data validates. 51 | form = ChangePasswordForm(password='update', confirm='update') 52 | self.assertTrue(form.validate()) 53 | 54 | def test_validate_invalid_change_password(self): 55 | # Ensure passwords must match. 56 | form = ChangePasswordForm(password='update', confirm='unknown') 57 | self.assertFalse(form.validate()) 58 | 59 | def test_validate_invalid_change_password_format(self): 60 | # Ensure invalid email format throws error. 61 | form = ChangePasswordForm(password='123', confirm='123') 62 | self.assertFalse(form.validate()) 63 | 64 | def test_validate_success_forgot_password(self): 65 | # Ensure invalid email format throws error. 66 | form = ForgotForm(email='test@user.com') 67 | self.assertTrue(form.validate()) 68 | 69 | def test_validate_invalid_forgot_password_format(self): 70 | # Ensure invalid email format throws error. 71 | form = ForgotForm(email='unknown') 72 | self.assertFalse(form.validate()) 73 | 74 | def test_validate_invalid_forgot_password_no_such_user(self): 75 | # Ensure invalid email format throws error. 76 | form = ForgotForm(email='not@correct.com') 77 | self.assertFalse(form.validate()) 78 | 79 | 80 | class TestUserViews(BaseTestCase): 81 | 82 | def test_correct_login(self): 83 | # Ensure login behaves correctly with correct credentials. 84 | with self.client: 85 | response = self.client.post( 86 | '/login', 87 | data=dict(email="test@user.com", password="just_a_test_user"), 88 | follow_redirects=True 89 | ) 90 | self.assertTrue(response.status_code == 200) 91 | self.assertTrue(current_user.email == "test@user.com") 92 | self.assertTrue(current_user.is_active()) 93 | self.assertTrue(current_user.is_authenticated()) 94 | self.assertTemplateUsed('main/index.html') 95 | 96 | def test_incorrect_login(self): 97 | # Ensure login behaves correctly with incorrect credentials. 98 | with self.client: 99 | response = self.client.post( 100 | '/login', 101 | data=dict(email="not@correct.com", password="incorrect"), 102 | follow_redirects=True 103 | ) 104 | self.assertTrue(response.status_code == 200) 105 | self.assertIn(b'Invalid email and/or password.', response.data) 106 | self.assertFalse(current_user.is_active()) 107 | self.assertFalse(current_user.is_authenticated()) 108 | self.assertTemplateUsed('user/login.html') 109 | 110 | def test_profile_route_requires_login(self): 111 | # Ensure profile route requires logged in user. 112 | self.client.get('/profile', follow_redirects=True) 113 | self.assertTemplateUsed('user/login.html') 114 | 115 | def test_confirm_token_route_requires_login(self): 116 | # Ensure confirm/ route requires logged in user. 117 | self.client.get('/confirm/blah', follow_redirects=True) 118 | self.assertTemplateUsed('user/login.html') 119 | 120 | def test_confirm_token_route_valid_token(self): 121 | # Ensure user can confirm account with valid token. 122 | with self.client: 123 | self.client.post('/login', data=dict( 124 | email='test@user.com', password='just_a_test_user' 125 | ), follow_redirects=True) 126 | token = generate_confirmation_token('test@user.com') 127 | response = self.client.get( 128 | '/confirm/'+token, follow_redirects=True) 129 | self.assertIn( 130 | b'You have confirmed your account. Thanks!', response.data) 131 | self.assertTemplateUsed('main/index.html') 132 | user = User.query.filter_by(email='test@user.com').first_or_404() 133 | self.assertIsInstance(user.confirmed_on, datetime.datetime) 134 | self.assertTrue(user.confirmed) 135 | 136 | def test_confirm_token_route_invalid_token(self): 137 | # Ensure user cannot confirm account with invalid token. 138 | token = generate_confirmation_token('test@test1.com') 139 | with self.client: 140 | self.client.post('/login', data=dict( 141 | email='test@user.com', password='just_a_test_user' 142 | ), follow_redirects=True) 143 | response = self.client.get('/confirm/'+token, 144 | follow_redirects=True) 145 | self.assertIn( 146 | b'The confirmation link is invalid or has expired.', 147 | response.data 148 | ) 149 | 150 | def test_confirm_token_route_expired_token(self): 151 | # Ensure user cannot confirm account with expired token. 152 | user = User(email='test@test1.com', password='test1', confirmed=False) 153 | db.session.add(user) 154 | db.session.commit() 155 | token = generate_confirmation_token('test@test1.com') 156 | self.assertFalse(confirm_token(token, -1)) 157 | 158 | def test_forgot_password_does_not_require_login(self): 159 | # Ensure user can request new password without login. 160 | self.client.get('/forgot', follow_redirects=True) 161 | self.assertTemplateUsed('user/forgot.html') 162 | 163 | def test_correct_forgot_password_request(self): 164 | # Ensure login behaves correctly with correct credentials. 165 | with self.client: 166 | response = self.client.post( 167 | '/forgot', 168 | data=dict(email="test@user.com"), 169 | follow_redirects=True 170 | ) 171 | self.assertTrue(response.status_code == 200) 172 | self.assertTemplateUsed('main/index.html') 173 | 174 | def test_reset_forgotten_password_valid_token(self): 175 | # Ensure user can confirm account with valid token. 176 | with self.client: 177 | self.client.post('/forgot', data=dict( 178 | email='test@user.com', 179 | ), follow_redirects=True) 180 | token = generate_confirmation_token('test@user.com') 181 | response = self.client.get('/forgot/new/'+token, follow_redirects=True) 182 | self.assertTemplateUsed('user/forgot_new.html') 183 | self.assertIn( 184 | b'You can now change your password.', 185 | response.data 186 | ) 187 | self.assertFalse(current_user.is_authenticated()) 188 | 189 | def test_reset_forgotten_password_valid_token_correct_login(self): 190 | # Ensure user can confirm account with valid token. 191 | with self.client: 192 | self.client.post('/forgot', data=dict( 193 | email='test@user.com', 194 | ), follow_redirects=True) 195 | token = generate_confirmation_token('test@user.com') 196 | response = self.client.get('/forgot/new/'+token, follow_redirects=True) 197 | self.assertTemplateUsed('user/forgot_new.html') 198 | self.assertIn( 199 | b'You can now change your password.', 200 | response.data 201 | ) 202 | response = self.client.post( 203 | '/forgot/new/'+token, 204 | data=dict(password="new-password", confirm="new-password"), 205 | follow_redirects=True 206 | ) 207 | self.assertIn( 208 | b'Password successfully changed.', 209 | response.data 210 | ) 211 | self.assertTemplateUsed('user/profile.html') 212 | self.assertTrue(current_user.is_authenticated()) 213 | self.client.get('/logout') 214 | self.assertFalse(current_user.is_authenticated()) 215 | 216 | response = self.client.post( 217 | '/login', 218 | data=dict(email="test@user.com", password="new-password"), 219 | follow_redirects=True 220 | ) 221 | self.assertTrue(response.status_code == 200) 222 | self.assertTrue(current_user.email == "test@user.com") 223 | self.assertTrue(current_user.is_active()) 224 | self.assertTrue(current_user.is_authenticated()) 225 | self.assertTemplateUsed('main/index.html') 226 | 227 | def test_reset_forgotten_password_valid_token_invalid_login(self): 228 | # Ensure user can confirm account with valid token. 229 | with self.client: 230 | self.client.post('/forgot', data=dict( 231 | email='test@user.com', 232 | ), follow_redirects=True) 233 | token = generate_confirmation_token('test@user.com') 234 | response = self.client.get('/forgot/new/'+token, follow_redirects=True) 235 | self.assertTemplateUsed('user/forgot_new.html') 236 | self.assertIn( 237 | b'You can now change your password.', 238 | response.data 239 | ) 240 | response = self.client.post( 241 | '/forgot/new/'+token, 242 | data=dict(password="new-password", confirm="new-password"), 243 | follow_redirects=True 244 | ) 245 | self.assertIn( 246 | b'Password successfully changed.', 247 | response.data 248 | ) 249 | self.assertTemplateUsed('user/profile.html') 250 | self.assertTrue(current_user.is_authenticated()) 251 | self.client.get('/logout') 252 | self.assertFalse(current_user.is_authenticated()) 253 | 254 | response = self.client.post( 255 | '/login', 256 | data=dict(email="test@user.com", password="just_a_test_user"), 257 | follow_redirects=True 258 | ) 259 | self.assertTrue(response.status_code == 200) 260 | self.assertFalse(current_user.is_authenticated()) 261 | self.assertIn( 262 | b'Invalid email and/or password.', 263 | response.data 264 | ) 265 | self.assertTemplateUsed('user/login.html') 266 | 267 | 268 | if __name__ == '__main__': 269 | unittest.main() 270 | --------------------------------------------------------------------------------