diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..72e294c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +__pycache__ +*.pyc +*.pyo +*.pyd \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..43a1de8 --- /dev/null +++ b/.env @@ -0,0 +1,8 @@ +DEBUG=True +SECRET_KEY=S3cr3t_K#Key +DB_ENGINE=postgresql +DB_NAME=appseed-flask +DB_HOST=localhost +DB_PORT=5432 +DB_USERNAME=appseed +DB_PASS=pass diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9fc790f --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# tests and coverage +*.pytest_cache +.coverage + +# database & logs +*.db +*.sqlite3 +*.log + +# venv +env +venv + +# other +.DS_Store + +# sphinx docs +_build +_static +_templates + +# javascript +package-lock.json +.vscode/symbols.json + +apps/static/assets/node_modules +apps/static/assets/yarn.lock +apps/static/assets/.temp + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1f09130 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.9 + +COPY . . + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# install python dependencies +RUN pip install --upgrade pip +RUN pip install --no-cache-dir -r requirements.txt + +# gunicorn +CMD ["gunicorn", "--config", "gunicorn-cfg.py", "run:app"] diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..a443d0f --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn run:app --log-file=- diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..f23857d --- /dev/null +++ b/apps/__init__.py @@ -0,0 +1,44 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +from flask import Flask +from flask_login import LoginManager +from flask_sqlalchemy import SQLAlchemy +from importlib import import_module + + +db = SQLAlchemy() +login_manager = LoginManager() + + +def register_extensions(app): + db.init_app(app) + login_manager.init_app(app) + + +def register_blueprints(app): + for module_name in ('authentication', 'home'): + module = import_module('apps.{}.routes'.format(module_name)) + app.register_blueprint(module.blueprint) + + +def configure_database(app): + + @app.before_first_request + def initialize_database(): + db.create_all() + + @app.teardown_request + def shutdown_session(exception=None): + db.session.remove() + + +def create_app(config): + app = Flask(__name__) + app.config.from_object(config) + register_extensions(app) + register_blueprints(app) + configure_database(app) + return app diff --git a/apps/authentication/__init__.py b/apps/authentication/__init__.py new file mode 100644 index 0000000..25243ec --- /dev/null +++ b/apps/authentication/__init__.py @@ -0,0 +1,12 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +from flask import Blueprint + +blueprint = Blueprint( + 'authentication_blueprint', + __name__, + url_prefix='' +) diff --git a/apps/authentication/forms.py b/apps/authentication/forms.py new file mode 100644 index 0000000..16cac6b --- /dev/null +++ b/apps/authentication/forms.py @@ -0,0 +1,31 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +from flask_wtf import FlaskForm +from wtforms import TextField, PasswordField +from wtforms.validators import Email, DataRequired + +# login and registration + + +class LoginForm(FlaskForm): + username = TextField('Username', + id='username_login', + validators=[DataRequired()]) + password = PasswordField('Password', + id='pwd_login', + validators=[DataRequired()]) + + +class CreateAccountForm(FlaskForm): + username = TextField('Username', + id='username_create', + validators=[DataRequired()]) + email = TextField('Email', + id='email_create', + validators=[DataRequired(), Email()]) + password = PasswordField('Password', + id='pwd_create', + validators=[DataRequired()]) diff --git a/apps/authentication/models.py b/apps/authentication/models.py new file mode 100644 index 0000000..cf152ef --- /dev/null +++ b/apps/authentication/models.py @@ -0,0 +1,48 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +from flask_login import UserMixin + +from apps import db, login_manager + +from apps.authentication.util import hash_pass + +class Users(db.Model, UserMixin): + + __tablename__ = 'Users' + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(64), unique=True) + email = db.Column(db.String(64), unique=True) + password = db.Column(db.LargeBinary) + + def __init__(self, **kwargs): + for property, value in kwargs.items(): + # depending on whether value is an iterable or not, we must + # unpack it's value (when **kwargs is request.form, some values + # will be a 1-element list) + if hasattr(value, '__iter__') and not isinstance(value, str): + # the ,= unpack of a singleton fails PEP8 (travis flake8 test) + value = value[0] + + if property == 'password': + value = hash_pass(value) # we need bytes here (not plain str) + + setattr(self, property, value) + + def __repr__(self): + return str(self.username) + + +@login_manager.user_loader +def user_loader(id): + return Users.query.filter_by(id=id).first() + + +@login_manager.request_loader +def request_loader(request): + username = request.form.get('username') + user = Users.query.filter_by(username=username).first() + return user if user else None diff --git a/apps/authentication/routes.py b/apps/authentication/routes.py new file mode 100644 index 0000000..23daf31 --- /dev/null +++ b/apps/authentication/routes.py @@ -0,0 +1,127 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +from flask import render_template, redirect, request, url_for +from flask_login import ( + current_user, + login_user, + logout_user +) + +from apps import db, login_manager +from apps.authentication import blueprint +from apps.authentication.forms import LoginForm, CreateAccountForm +from apps.authentication.models import Users + +from apps.authentication.util import verify_pass + + +@blueprint.route('/') +def route_default(): + return redirect(url_for('authentication_blueprint.login')) + + +# Login & Registration + +@blueprint.route('/login', methods=['GET', 'POST']) +def login(): + login_form = LoginForm(request.form) + if 'login' in request.form: + + # read form data + username = request.form['username'] + password = request.form['password'] + + # Locate user + user = Users.query.filter_by(username=username).first() + + # Check the password + if user and verify_pass(password, user.password): + + login_user(user) + return redirect(url_for('authentication_blueprint.route_default')) + + # Something (user or pass) is not ok + return render_template('accounts/login.html', + segment = 'login', + msg='Wrong user or password', + form=login_form) + + if not current_user.is_authenticated: + return render_template('accounts/login.html', + segment = 'login', + form=login_form) + return redirect(url_for('home_blueprint.index')) + + +@blueprint.route('/register', methods=['GET', 'POST']) +def register(): + create_account_form = CreateAccountForm(request.form) + if 'register' in request.form: + + username = request.form['username'] + email = request.form['email'] + + # Check usename exists + user = Users.query.filter_by(username=username).first() + if user: + return render_template('accounts/register.html', + msg='Username already registered', + segment = 'register', + success=False, + form=create_account_form) + + # Check email exists + user = Users.query.filter_by(email=email).first() + if user: + return render_template('accounts/register.html', + msg='Email already registered', + segment = 'register', + success=False, + form=create_account_form) + + # else we can create the user + user = Users(**request.form) + db.session.add(user) + db.session.commit() + + return render_template('accounts/register.html', + msg='User created please login', + segment = 'register', + success=True, + form=create_account_form) + + else: + return render_template( 'accounts/register.html', + segment = 'register', + form=create_account_form) + + +@blueprint.route('/logout') +def logout(): + logout_user() + return redirect(url_for('authentication_blueprint.login')) + + +# Errors + +@login_manager.unauthorized_handler +def unauthorized_handler(): + return render_template('home/page-403.html'), 403 + + +@blueprint.errorhandler(403) +def access_forbidden(error): + return render_template('home/page-403.html'), 403 + + +@blueprint.errorhandler(404) +def not_found_error(error): + return render_template('home/page-404.html'), 404 + + +@blueprint.errorhandler(500) +def internal_error(error): + return render_template('home/page-500.html'), 500 diff --git a/apps/authentication/util.py b/apps/authentication/util.py new file mode 100644 index 0000000..9130da8 --- /dev/null +++ b/apps/authentication/util.py @@ -0,0 +1,34 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +import os +import hashlib +import binascii + +# Inspiration -> https://www.vitoshacademy.com/hashing-passwords-in-python/ + + +def hash_pass(password): + """Hash a password for storing.""" + + salt = hashlib.sha256(os.urandom(60)).hexdigest().encode('ascii') + pwdhash = hashlib.pbkdf2_hmac('sha512', password.encode('utf-8'), + salt, 100000) + pwdhash = binascii.hexlify(pwdhash) + return (salt + pwdhash) # return bytes + + +def verify_pass(provided_password, stored_password): + """Verify a stored password against one provided by user""" + + stored_password = stored_password.decode('ascii') + salt = stored_password[:64] + stored_password = stored_password[64:] + pwdhash = hashlib.pbkdf2_hmac('sha512', + provided_password.encode('utf-8'), + salt.encode('ascii'), + 100000) + pwdhash = binascii.hexlify(pwdhash).decode('ascii') + return pwdhash == stored_password diff --git a/apps/config.py b/apps/config.py new file mode 100644 index 0000000..2fe5430 --- /dev/null +++ b/apps/config.py @@ -0,0 +1,48 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +import os +from decouple import config + +class Config(object): + + basedir = os.path.abspath(os.path.dirname(__file__)) + + # Set up the App SECRET_KEY + SECRET_KEY = config('SECRET_KEY', default='S#perS3crEt_007') + + # This will create a file in FOLDER + SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'db.sqlite3') + SQLALCHEMY_TRACK_MODIFICATIONS = False + + +class ProductionConfig(Config): + DEBUG = False + + # Security + SESSION_COOKIE_HTTPONLY = True + REMEMBER_COOKIE_HTTPONLY = True + REMEMBER_COOKIE_DURATION = 3600 + + # PostgreSQL database + SQLALCHEMY_DATABASE_URI = '{}://{}:{}@{}:{}/{}'.format( + config('DB_ENGINE', default='postgresql'), + config('DB_USERNAME', default='appseed'), + config('DB_PASS', default='pass'), + config('DB_HOST', default='localhost'), + config('DB_PORT', default=5432), + config('DB_NAME', default='appseed-flask') + ) + + +class DebugConfig(Config): + DEBUG = True + + +# Load all possible configurations +config_dict = { + 'Production': ProductionConfig, + 'Debug': DebugConfig +} diff --git a/apps/home/__init__.py b/apps/home/__init__.py new file mode 100644 index 0000000..dec76db --- /dev/null +++ b/apps/home/__init__.py @@ -0,0 +1,12 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +from flask import Blueprint + +blueprint = Blueprint( + 'home_blueprint', + __name__, + url_prefix='' +) diff --git a/apps/home/routes.py b/apps/home/routes.py new file mode 100644 index 0000000..455c524 --- /dev/null +++ b/apps/home/routes.py @@ -0,0 +1,53 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +from apps.home import blueprint +from flask import render_template, request +from flask_login import login_required +from jinja2 import TemplateNotFound + + +@blueprint.route('/index') +@login_required +def index(): + + return render_template('home/index.html', segment='index') + +@blueprint.route('/