diff --git a/.gitignore b/.gitignore index f8b73e7..5865410 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,12 @@ dmypy.json # Cython debug symbols cython_debug/ +# Ignore Dev Environment Files +db/data/ +dev/ +.vscode/ +out/ +ref-test/testing.py + +# Ignore Encryption Keyfile +.encryption.key \ No newline at end of file diff --git a/REFERENCES.md b/REFERENCES.md new file mode 100644 index 0000000..1f58c48 --- /dev/null +++ b/REFERENCES.md @@ -0,0 +1,79 @@ +# References for Learning Flask, etc + +## Documentation + +### Docker/Docker-Compose + +- [Docker Compose Reference](https://docs.docker.com/compose/compose-file/compose-file-v3/) + +### MongoDB/PyMongo + +- [MongoDB Shell Commands](https://docs.mongodb.com/manual/reference/) +- [PyMongo Driver](https://pymongo.readthedocs.io/en/stable/) + +## Source Code + +- [MongoDB Docker Image entrypoint shell script](https://github.com/docker-library/mongo/blob/master/5.0/docker-entrypoint.sh) (Context: Tried to replicate the command to create a new user in the original entrypoint script in the custom initialisation script in this app.) + +## Guides + +### Flask General + +- [How to structure Flask Projects](https://www.digitalocean.com/community/tutorials/how-to-structure-large-flask-applications) +- [Tables](https://www.blog.pythonlibrary.org/2017/12/14/flask-101-adding-editing-and-displaying-data/) +- [Tables, but interactive](https://blog.miguelgrinberg.com/post/beautiful-interactive-tables-for-your-flask-templates) + +## Stack Exchange/Overflow + +### MongoDB + +- [Creating MongoDB Database on Container Start](https://stackoverflow.com/questions/42912755/how-to-create-a-db-for-mongodb-container-on-start-up) +- [Passing Environment Variables to Docker Container Entrypoint](https://stackoverflow.com/questions/64606674/how-can-i-pass-environment-variables-to-mongo-docker-entrypoint-initdb-d) +- [Integrating Flask-Login with MongoDB](https://stackoverflow.com/questions/54992412/flask-login-usermixin-class-with-a-mongodb) (**This does not work with the app as is, and is possibly something that needs more research and development in the future**) +- [Setting up a Postfix email notification system](https://medium.com/@vietgoeswest/a-simple-outbound-email-service-for-your-app-in-15-minutes-cc4da70a2af7) + +## YouTube Tutorials + +### General Flask Introduction + +- [Part 1: Basics](https://www.youtube.com/watch?v=mqhxxeeTbu0) +- [Part 2: HTML Templates](https://www.youtube.com/watch?v=xIgPMguqyws) +- [Part 3: Bootstrap and Jinja](https://www.youtube.com/watch?v=4nzI4RKwb5I) +- [Part 4: HTTP Methods and Data Handling](https://www.youtube.com/watch?v=9MHYHgh4jYc) +- [Part 5: Sessions](https://www.youtube.com/watch?v=iIhAfX4iek0) +- [Part 6: Flashing Alerts](https://www.youtube.com/watch?v=qbnqNWXf_tU) +- [Part 9: Static Files](https://www.youtube.com/watch?v=tXpFERibRaU) +- [Part 10: Blueprints and Sub-Files](https://www.youtube.com/watch?v=WteIH6J9v64) +- [All in One with some more precise methods and techniques](https://www.youtube.com/watch?v=GW_2O9CrnSU) + +Note: These tutorials use an outdated version of Bootstrap where some of the elements no longer apply. + +### Build a User Log-In System using Python and MongoDB + +- [Part 1](https://www.youtube.com/watch?v=w1STSSumoVk) +- [Part 2](https://www.youtube.com/watch?v=mISFEwojJmE) +- [Part 3](https://www.youtube.com/watch?v=tIoiR3N34i8) +- [Part 4](https://www.youtube.com/watch?v=5oC4-j3WWIk) + +This method uses a basic HTML form and handles log-ins manually (rather than `flask-login` or `flask-auth`). +It uses `sessions` to handle user interactions (rather than cookies). +A better method would be to layer this with other Flask modules, such as `flask-login`, `flask-auth`, `flask-wtforms`, etc. +This tutorial is nevertheless really useful for integrating with **MongoDB**. + +### Creating a User Login Session using Python, Flask, and MongoDB + +- [Tutorial](https://www.youtube.com/watch?v=vVx1737auSE) + +A much simpler and more rudimentary introduction to Flask and MongoDB. + +### `flask-login` + +- [Intro to `flask-login`](https://www.youtube.com/watch?v=2dEM-s3mRLE) +- [Build a User Login System with `flask-login`, `flask-wtforms`, `flask-bootstrap`, and `flask-sqlalchemy`](https://www.youtube.com/watch?v=8aTnmsDMldY) + +A much more robust method that uses the various Flask modules to make a more powerful framework. +Uses SQL rather than MongoDB. + +### Flask techniques + +- [Create a `config.py` file](https://www.youtube.com/watch?v=GW_2O9CrnSU) diff --git a/database/initdb.d/init-mongo.sh b/database/initdb.d/init-mongo.sh new file mode 100644 index 0000000..69ee616 --- /dev/null +++ b/database/initdb.d/init-mongo.sh @@ -0,0 +1,14 @@ +set -e +mongo=( mongo --host 127.0.0.1 --port 27017 --quiet ) + +if [ "$MONGO_INITDB_ROOT_USERNAME" ] && [ "$MONGO_INITDB_ROOT_PASSWORD" ] && [ "$MONGO_INITDB_USERNAME" ] && [ "$MONGO_INITDB_PASSWORD" ]; then +rootAuthDatabase='admin' + +"${mongo[@]}" "$rootAuthDatabase" <<-EOJS + db.createUser({ + user: $(_js_escape "$MONGO_INITDB_USERNAME"), + pwd: $(_js_escape "$MONGO_INITDB_PASSWORD"), + roles: [ { role: 'readWrite', db: $(_js_escape "$MONGO_INITDB_DATABASE") } ] + }) +EOJS +fi \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c81efd0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +version: '3.9' + +services: + ref_test_db: + container_name: ref_test_db + image: mongo:5.0.4-focal + restart: unless-stopped + volumes: + # - ./database/data:/data # Uncomment later when persistence is required. + - ./database/initdb.d/:/docker-entrypoint-initdb.d/ + env_file: + - ./.env + ports: + - 27017:27017 + networks: + - backend + + ref_test_postfix: + container_name: ref_test_postfix + image: catatnight/postfix:latest + restart: unless-stopped + env_file: + - ./.env + ports: + - 127.0.0.1:25:25 + networks: + - backend + +networks: + frontend: + external: false + backend: + external: false \ No newline at end of file diff --git a/ref-test/Dockerfile b/ref-test/Dockerfile new file mode 100644 index 0000000..14dfbac --- /dev/null +++ b/ref-test/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3.10-alpine +WORKDIR /app +COPY . . +RUN pip install -r requirements.txt +CMD [ "gunicorn", "-b", "0.0.0.0:5000", "app:app" ] \ No newline at end of file diff --git a/ref-test/admin/__init__.py b/ref-test/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ref-test/admin/auth.py b/ref-test/admin/auth.py new file mode 100644 index 0000000..fd9e854 --- /dev/null +++ b/ref-test/admin/auth.py @@ -0,0 +1,148 @@ +from flask import Blueprint, render_template, request, session, redirect +from flask.helpers import flash, url_for +from flask.json import jsonify +from .user.models import User +from uuid import uuid4 +from security.database import decrypt_find_one, encrypted_update +from werkzeug.security import check_password_hash + +from main import db + +from .views import admin_account_required, disable_on_registration, login_required, disable_if_logged_in, get_id_from_cookie + +auth = Blueprint( + 'admin_auth', + __name__, + template_folder='templates', + static_folder='static' +) + +@auth.route('/account/', methods = ['GET', 'POST']) +@admin_account_required +@login_required +def account(): + from .forms import UpdateAccountForm + form = UpdateAccountForm() + _id = get_id_from_cookie() + user = decrypt_find_one(db.users, {'_id': _id}) + if request.method == 'GET': + return render_template('/admin/auth/account.html', form = form, user = user) + if request.method == 'POST': + if form.validate_on_submit(): + password_confirm = request.form.get('password_confirm') + if not check_password_hash(user['password'], password_confirm): + return jsonify({ 'error': 'The password you entered is incorrect.' }), 401 + entry = User( + _id = _id, + password = request.form.get('password'), + email = request.form.get('email') + ) + return entry.update() + else: + errors = [*form.password_confirm.errors, *form.password_reenter.errors, *form.password.errors, *form.email.errors] + return jsonify({ 'error': errors}), 400 + +@auth.route('/login/', methods=['GET','POST']) +@admin_account_required +@disable_if_logged_in +def login(): + from .forms import LoginForm + form = LoginForm() + if request.method == 'GET': + return render_template('/admin/auth/login.html', form=form) + if request.method == 'POST': + if form.validate_on_submit(): + entry = User( + username = request.form.get('username').lower(), + password = request.form.get('password'), + remember = request.form.get('remember') + ) + return entry.login() + else: + errors = [*form.username.errors, *form.password.errors] + return jsonify({ 'error': errors}), 400 + +@auth.route('/logout/') +@admin_account_required +@login_required +def logout(): + _id = get_id_from_cookie() + return User(_id=_id).logout() + +@auth.route('/register/', methods=['GET','POST']) +@disable_on_registration +def register(): + from .forms import RegistrationForm + form = RegistrationForm() + if request.method == 'GET': + return render_template('/admin/auth/register.html', form=form) + if request.method == 'POST': + if form.validate_on_submit(): + entry = User( + _id = uuid4().hex, + username = request.form.get('username').lower(), + email = request.form.get('email'), + password = request.form.get('password'), + ) + return entry.register() + else: + errors = [*form.username.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors] + return jsonify({ 'error': errors}), 400 + +@auth.route('/reset/', methods = ['GET', 'POST']) +@admin_account_required +@disable_if_logged_in +def reset(): + from .forms import ResetPasswordForm + form = ResetPasswordForm() + if request.method == 'GET': + return render_template('/admin/auth/reset.html', form=form) + if request.method == 'POST': + if form.validate_on_submit(): + entry = User( + username = request.form.get('username').lower(), + email = request.form.get('email'), + ) + return entry.reset_password() + else: + errors = [*form.username.errors, *form.email.errors] + return jsonify({ 'error': errors}), 400 + +@auth.route('/reset///', methods = ['GET']) +@admin_account_required +@disable_if_logged_in +def reset_gateway(token1,token2): + from main import db + user = decrypt_find_one( db.users, {'reset_token' : token1} ) + if not user: + return redirect(url_for('admin_auth.login')) + encrypted_update( db.users, {'reset_token': token1}, {'$unset': {'reset_token' : '', 'verification_token': ''}}) + if not user['verification_token'] == token2: + flash('The verification of your password reset request failed and the token has been invalidated. Please make a new reset password request.', 'error'), 401 + return redirect(url_for('admin_auth.reset')) + session['_id'] = user['_id'] + session['reset_validated'] = True + return redirect(url_for('admin_auth.update_password_')) + +@auth.route('/reset/update/', methods = ['GET','POST']) +@admin_account_required +@disable_if_logged_in +def update_password_(): + from .forms import UpdatePasswordForm + form = UpdatePasswordForm() + if request.method == 'GET': + if 'reset_validated' not in session: + return redirect(url_for('admin_auth.login')) + session.pop('reset_validated') + return render_template('/admin/auth/update-password.html', form=form) + if request.method == 'POST': + if form.validate_on_submit(): + entry = User( + _id = session['_id'], + password = request.form.get('password') + ) + session.pop('_id') + return entry.update() + else: + errors = [*form.password.errors, *form.password_reenter.errors] + return jsonify({ 'error': errors}), 400 \ No newline at end of file diff --git a/ref-test/admin/forms.py b/ref-test/admin/forms.py new file mode 100644 index 0000000..89e8d5c --- /dev/null +++ b/ref-test/admin/forms.py @@ -0,0 +1,44 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, BooleanField +from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional + +class LoginForm(FlaskForm): + username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)]) + password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) + remember = BooleanField('Remember Log In', render_kw={'checked': True}) + +class RegistrationForm(FlaskForm): + username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)]) + email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)]) + password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) + password_reenter = PasswordField('Re-Enter Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.'), EqualTo('password', message='Passwords do not match.')]) + +class ResetPasswordForm(FlaskForm): + username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)]) + email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)]) + +class UpdatePasswordForm(FlaskForm): + password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) + password_reenter = PasswordField('Re-Enter Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.'), EqualTo('password', message='Passwords do not match.')]) + +class CreateUserForm(FlaskForm): + username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)]) + email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)]) + password = PasswordField('Password (Optional)', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) + +class DeleteUserForm(FlaskForm): + password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) + notify = BooleanField('Notify deletion by email', render_kw={'checked': True}) + +class UpdateUserForm(FlaskForm): + user_password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) + email = StringField('Email Address', validators=[Optional(), Email(message='You must enter a valid email address.'), Length(max=50)]) + password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) + password_reenter = PasswordField('Re-Enter New Password', validators=[EqualTo('password', message='Passwords do not match.')]) + notify = BooleanField('Notify changes by email', render_kw={'checked': True}) + +class UpdateAccountForm(FlaskForm): + password_confirm = PasswordField('Current Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) + email = StringField('Email Address', validators=[Optional(), Email(message='You must enter a valid email address.'), Length(max=50)]) + password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) + password_reenter = PasswordField('Re-Enter New Password', validators=[EqualTo('password', message='Passwords do not match.')]) \ No newline at end of file diff --git a/ref-test/admin/results.py b/ref-test/admin/results.py new file mode 100644 index 0000000..aaf2274 --- /dev/null +++ b/ref-test/admin/results.py @@ -0,0 +1,15 @@ +from flask import Blueprint, render_template +from .views import login_required, admin_account_required + +results = Blueprint( + 'results', + __name__, + template_folder='templates', + static_folder='static' +) + +@results.route('/') +@admin_account_required +@login_required +def _results(): + return render_template('/admin/results.html') \ No newline at end of file diff --git a/ref-test/admin/static/css/style.css b/ref-test/admin/static/css/style.css new file mode 100644 index 0000000..c409e0c --- /dev/null +++ b/ref-test/admin/static/css/style.css @@ -0,0 +1,173 @@ +body { + padding: 80px 0; +} + +.site-footer { + background-color: lightgray; + font-size: small; +} + +.site-footer p { + margin: 0; +} + +.form-container { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + padding-top: 40px; + padding-bottom: 40px; +} + +.form-signin { + width: 100%; + max-width: 420px; + padding: 15px; + margin: auto; +} + +.form-signin-heading { + margin-bottom: 2rem; +} + +.form-label-group { + position: relative; + margin-bottom: 2rem; +} + +.form-label-group input, +.form-label-group label { + padding: var(--input-padding-y) var(--input-padding-x); + font-size: 16pt; +} + +.form-label-group label { + position: absolute; + top: 0; + left: 0; + display: block; + width: 100%; + margin-bottom: 0; /* Override default `