From 5be7438afc4f2f3bf4e4ebf7af66caed2407eb31 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Sat, 1 Feb 2025 22:42:58 +1100 Subject: [PATCH] Add in auth however there are no restrictions currently --- app.py | 21 ++++++- db.py | 2 +- extensions.py | 4 ++ forms/login.py | 17 +++++ forms/signup.py | 34 ++++++++++ requirements.txt | 7 ++- routes/auth.py | 124 +++++++++++++++++++++++++++++++++++++ templates/auth/login.html | 32 ++++++++++ templates/auth/signup.html | 51 +++++++++++++++ templates/base.html | 49 ++++++++++++++- 10 files changed, 334 insertions(+), 7 deletions(-) create mode 100644 extensions.py create mode 100644 forms/login.py create mode 100644 forms/signup.py create mode 100644 routes/auth.py create mode 100644 templates/auth/login.html create mode 100644 templates/auth/signup.html diff --git a/app.py b/app.py index f0023ca..2b9dd9a 100644 --- a/app.py +++ b/app.py @@ -1,10 +1,12 @@ from datetime import date import os from flask import Flask, abort, render_template, redirect, request, url_for +from flask_login import LoginManager, current_user import jinja_partials from jinja2_fragments import render_block from decorators import validate_person, validate_topset, validate_workout -from db import DataBase +from routes.auth import auth, Person +from extensions import db from utils import convert_str_to_date, generate_plot from flask_htmx import HTMX import minify_html @@ -17,9 +19,23 @@ if os.environ.get('FLASK_ENV') != 'production': app = Flask(__name__) app.config.from_pyfile('config.py') +app.secret_key = os.environ.get('SECRET_KEY', '2a661781919643cb8a5a8bc57642d99f') jinja_partials.register_extensions(app) -db = DataBase(app) htmx = HTMX(app) +login_manager = LoginManager(app) + +login_manager.login_view = 'auth.login' +login_manager.login_message_category = 'info' + +@login_manager.user_loader +def load_user(person_id): + row = db.execute("""SELECT person_id, name, email, password_hash from Person WHERE person_id=%s""", [person_id], one=True) + if row: + return Person(row['person_id'], row['name'], row['email'], row['password_hash']) + return None + +app.register_blueprint(auth, url_prefix='/auth') + @app.after_request @@ -115,7 +131,6 @@ def person_overview(person_id): return render_template('person_overview.html', **render_args), 200, {"HX-Push-Url": url_for('person_overview', person_id=person_id, min_date=min_date, max_date=max_date, exercise_id=selected_exercise_ids), "HX-Trigger": "refreshStats"} @ app.route("/person//calendar") -@ validate_person def get_calendar(person_id): selected_date = convert_str_to_date(request.args.get( 'date'), '%Y-%m-%d') or date.today() diff --git a/db.py b/db.py index b179d1b..e3c3777 100644 --- a/db.py +++ b/db.py @@ -18,7 +18,7 @@ from utils import get_exercise_graph_model class DataBase(): - def __init__(self, app): + def __init__(self, app=None): self.calendar = Calendar(self.execute) self.stats = Stats(self.execute) self.workout = Workout(self.execute) diff --git a/extensions.py b/extensions.py new file mode 100644 index 0000000..ccdd018 --- /dev/null +++ b/extensions.py @@ -0,0 +1,4 @@ +from db import DataBase + + +db = DataBase() diff --git a/forms/login.py b/forms/login.py new file mode 100644 index 0000000..9fcbe24 --- /dev/null +++ b/forms/login.py @@ -0,0 +1,17 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, SubmitField +from wtforms.validators import DataRequired, Email + +class LoginForm(FlaskForm): + email = StringField( + 'Email', + validators=[ + DataRequired(message="Email is required."), + Email(message="Enter a valid email address.") + ] + ) + password = PasswordField( + 'Password', + validators=[DataRequired(message="Password is required.")] + ) + submit = SubmitField('Login') \ No newline at end of file diff --git a/forms/signup.py b/forms/signup.py new file mode 100644 index 0000000..9e160e2 --- /dev/null +++ b/forms/signup.py @@ -0,0 +1,34 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, SubmitField +from wtforms.validators import DataRequired, Length, EqualTo, Email + +class SignupForm(FlaskForm): + name = StringField( + 'Name', + validators=[ + DataRequired(), + Length(min=2, max=100, message="Name must be between 2 and 100 characters.") + ] + ) + email = StringField( + 'Email', + validators=[ + DataRequired(), + Email(message="Enter a valid email address.") + ] + ) + password = PasswordField( + 'Password', + validators=[ + DataRequired(), + Length(min=6, message="Password must be at least 6 characters long.") + ] + ) + confirm_password = PasswordField( + 'Confirm Password', + validators=[ + DataRequired(), + EqualTo('password', message="Passwords must match.") + ] + ) + submit = SubmitField('Sign Up') diff --git a/requirements.txt b/requirements.txt index bec58c3..e98d1e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,9 @@ Werkzeug==2.2.2 numpy==1.19.5 pandas==1.3.1 python-dotenv==1.0.1 -plotly==5.24.1 \ No newline at end of file +plotly==5.24.1 +wtforms==3.2.1 +flask-wtf==1.2.2 +Flask-Login==0.6.3 +Flask-Bcrypt==1.0.1 +email-validator==2.2.0 \ No newline at end of file diff --git a/routes/auth.py b/routes/auth.py new file mode 100644 index 0000000..d185a14 --- /dev/null +++ b/routes/auth.py @@ -0,0 +1,124 @@ +from flask import Blueprint, render_template, redirect, url_for, flash +from werkzeug.security import generate_password_hash, check_password_hash +from flask_login import login_user, login_required, logout_user +from forms.login import LoginForm +from forms.signup import SignupForm +from extensions import db + +auth = Blueprint('auth', __name__) + +class Person: + """ + Simple Person class compatible with Flask-Login. + """ + def __init__(self, person_id, name, email, password_hash): + self.id = person_id + self.name = name + self.email = email + self.password_hash = password_hash + + def get_id(self): + """Required by Flask-Login to get a unique user identifier.""" + return str(self.id) + + @property + def is_authenticated(self): + return True + + @property + def is_active(self): + return True + + @property + def is_anonymous(self): + return False + + +# ------------------------- +# Database helper functions +# ------------------------- + +def get_person_by_id(person_id): + """ + Fetch a person record by person_id and return a Person object. + """ + sql = """ + SELECT person_id, name, email, password_hash + FROM person + WHERE person_id = %s + LIMIT 1 + """ + row = db.execute(sql, [person_id], one=True) + if row: + return Person(row['person_id'], row['name'], row['email'], row['password_hash']) + return None + + +def get_person_by_email(email): + """ + Fetch a person record by email and return a Person object. + """ + sql = """ + SELECT person_id, name, email, password_hash + FROM person + WHERE email = %s + LIMIT 1 + """ + row = db.execute(sql, [email], one=True) + if row: + return Person(row['person_id'], row['name'], row['email'], row['password_hash']) + return None + + +def create_person(name, email, password_hash): + """ + Insert a new person into the database; return the new person's ID. + """ + sql = """ + INSERT INTO person (name, email, password_hash) + VALUES (%s, %s, %s) + RETURNING person_id AS person_id + """ + row = db.execute(sql, [name, email, password_hash], commit=True, one=True) + return row['person_id'] + + +# --------------------- +# Blueprint endpoints +# --------------------- + +@auth.route('/signup', methods=['GET', 'POST']) +def signup(): + form = SignupForm() + if form.validate_on_submit(): + hashed_password = generate_password_hash(form.password.data) + create_person( + name=form.name.data, + email=form.email.data, + password_hash=hashed_password + ) + flash("Account created successfully. Please log in.", "success") + return redirect(url_for('auth.login')) + return render_template('auth/signup.html', form=form) + + +@auth.route('/login', methods=['GET', 'POST']) +def login(): + form = LoginForm() + if form.validate_on_submit(): + person = get_person_by_email(form.email.data) + if person and check_password_hash(person.password_hash, form.password.data): + login_user(person) + flash("Logged in successfully.", "success") + return redirect(url_for('get_calendar', person_id=person.id)) + else: + flash("Invalid email or password.", "danger") + return render_template('auth/login.html', form=form) + + +@auth.route('/logout') +@login_required +def logout(): + logout_user() + flash('You have been logged out.', 'success') + return redirect(url_for('auth.login')) diff --git a/templates/auth/login.html b/templates/auth/login.html new file mode 100644 index 0000000..690a9bb --- /dev/null +++ b/templates/auth/login.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block content %} +
+

Login

+
+ {{ form.hidden_tag() }} + + +
+ {{ form.email.label(class="block text-sm font-medium text-gray-700") }} + {{ form.email(class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500") }} + {% for error in form.email.errors %} +

{{ error }}

+ {% endfor %} +
+ + +
+ {{ form.password.label(class="block text-sm font-medium text-gray-700") }} + {{ form.password(class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500") }} + {% for error in form.password.errors %} +

{{ error }}

+ {% endfor %} +
+ + +
+ {{ form.submit(class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700") }} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/auth/signup.html b/templates/auth/signup.html new file mode 100644 index 0000000..4a6c1db --- /dev/null +++ b/templates/auth/signup.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block content %} +
+

Sign Up

+
+ {{ form.hidden_tag() }} + + +
+ {{ form.name.label(class="block text-sm font-medium text-gray-700") }} + {{ form.name(class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500") }} + {% for error in form.name.errors %} +

{{ error }}

+ {% endfor %} +
+ + +
+ {{ form.email.label(class="block text-sm font-medium text-gray-700") }} + {{ form.email(class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500") }} + {% for error in form.email.errors %} +

{{ error }}

+ {% endfor %} +
+ + +
+ {{ form.password.label(class="block text-sm font-medium text-gray-700") }} + {{ form.password(class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500") }} + {% for error in form.password.errors %} +

{{ error }}

+ {% endfor %} +
+ + +
+ {{ form.confirm_password.label(class="block text-sm font-medium text-gray-700") }} + {{ form.confirm_password(class="w-full p-2 border rounded focus:outline-none focus:ring-2 + focus:ring-blue-500") }} + {% for error in form.confirm_password.errors %} +

{{ error }}

+ {% endfor %} +
+ + +
+ {{ form.submit(class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700") }} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 692eeb1..849b987 100644 --- a/templates/base.html +++ b/templates/base.html @@ -16,7 +16,7 @@ - + @@ -61,7 +61,52 @@ Workout Tracker -
+
+ {% if current_user.is_authenticated %} + + + {{ current_user.name }} + + + + + + + Logout + + {% else %} + + + + + + + Login + + + + + + + Sign Up + + {% endif %} Workout Tracker on GitHub