From 43b5973dbe489fb615869c3600b777ebab7e3b7e Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Tue, 23 Nov 2021 13:00:03 +0000 Subject: [PATCH] Finished most of admin console Basic CRUD operations for managing registered admin users Encrypted personal information Still missing sections on managing tests and results Also missing dashboards/index/category landing pages --- .gitignore | 9 + REFERENCES.md | 79 ++++ database/initdb.d/init-mongo.sh | 14 + ref-test/Dockerfile | 5 + ref-test/admin/__init__.py | 0 ref-test/admin/auth.py | 148 +++++++ ref-test/admin/forms.py | 44 ++ ref-test/admin/results.py | 15 + ref-test/admin/static/css/style.css | 173 ++++++++ ref-test/admin/static/js/jquery-3.6.0.min.js | 2 + ref-test/admin/static/js/script.js | 375 ++++++++++++++++++ .../admin/templates/admin/auth/account.html | 56 +++ .../admin/templates/admin/auth/login.html | 32 ++ .../admin/templates/admin/auth/register.html | 43 ++ .../admin/templates/admin/auth/reset.html | 27 ++ .../templates/admin/auth/update-password.html | 29 ++ .../templates/admin/components/base.html | 66 +++ .../admin/components/client-alerts.html | 1 + .../templates/admin/components/footer.html | 2 + .../admin/components/input-forms.html | 4 + .../templates/admin/components/navbar.html | 79 ++++ .../admin/components/server-alerts.html | 41 ++ ref-test/admin/templates/admin/index.html | 5 + ref-test/admin/templates/admin/results.html | 1 + .../templates/admin/settings/delete-user.html | 44 ++ .../admin/templates/admin/settings/index.html | 1 + .../templates/admin/settings/questions.html | 1 + .../templates/admin/settings/update-user.html | 57 +++ .../admin/settings/upload-questions.html | 1 + .../admin/templates/admin/settings/users.html | 157 ++++++++ ref-test/admin/templates/admin/tests.html | 1 + ref-test/admin/user/__init__.py | 0 ref-test/admin/user/models.py | 185 +++++++++ ref-test/admin/views.py | 266 +++++++++++++ ref-test/config.py | 51 +++ ref-test/main.py | 83 ++++ ref-test/quiz/__init__.py | 0 ref-test/quiz/auth.py | 8 + ref-test/quiz/forms.py | 9 + ref-test/quiz/models.py | 0 ref-test/quiz/static/css/style.css | 0 ref-test/quiz/static/js/script.js | 0 ref-test/quiz/templates/quiz/base.html | 43 ++ ref-test/quiz/views.py | 29 ++ ref-test/security/__init__.py | 38 ++ ref-test/security/database.py | 46 +++ 46 files changed, 2270 insertions(+) create mode 100644 REFERENCES.md create mode 100644 database/initdb.d/init-mongo.sh create mode 100644 ref-test/Dockerfile create mode 100644 ref-test/admin/__init__.py create mode 100644 ref-test/admin/auth.py create mode 100644 ref-test/admin/forms.py create mode 100644 ref-test/admin/results.py create mode 100644 ref-test/admin/static/css/style.css create mode 100644 ref-test/admin/static/js/jquery-3.6.0.min.js create mode 100644 ref-test/admin/static/js/script.js create mode 100644 ref-test/admin/templates/admin/auth/account.html create mode 100644 ref-test/admin/templates/admin/auth/login.html create mode 100644 ref-test/admin/templates/admin/auth/register.html create mode 100644 ref-test/admin/templates/admin/auth/reset.html create mode 100644 ref-test/admin/templates/admin/auth/update-password.html create mode 100644 ref-test/admin/templates/admin/components/base.html create mode 100644 ref-test/admin/templates/admin/components/client-alerts.html create mode 100644 ref-test/admin/templates/admin/components/footer.html create mode 100644 ref-test/admin/templates/admin/components/input-forms.html create mode 100644 ref-test/admin/templates/admin/components/navbar.html create mode 100644 ref-test/admin/templates/admin/components/server-alerts.html create mode 100644 ref-test/admin/templates/admin/index.html create mode 100644 ref-test/admin/templates/admin/results.html create mode 100644 ref-test/admin/templates/admin/settings/delete-user.html create mode 100644 ref-test/admin/templates/admin/settings/index.html create mode 100644 ref-test/admin/templates/admin/settings/questions.html create mode 100644 ref-test/admin/templates/admin/settings/update-user.html create mode 100644 ref-test/admin/templates/admin/settings/upload-questions.html create mode 100644 ref-test/admin/templates/admin/settings/users.html create mode 100644 ref-test/admin/templates/admin/tests.html create mode 100644 ref-test/admin/user/__init__.py create mode 100644 ref-test/admin/user/models.py create mode 100644 ref-test/admin/views.py create mode 100644 ref-test/config.py create mode 100644 ref-test/main.py create mode 100644 ref-test/quiz/__init__.py create mode 100644 ref-test/quiz/auth.py create mode 100644 ref-test/quiz/forms.py create mode 100644 ref-test/quiz/models.py create mode 100644 ref-test/quiz/static/css/style.css create mode 100644 ref-test/quiz/static/js/script.js create mode 100644 ref-test/quiz/templates/quiz/base.html create mode 100644 ref-test/quiz/views.py create mode 100644 ref-test/security/__init__.py create mode 100644 ref-test/security/database.py 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/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 `