From d07f46ba9b819d343f418f3245a3e044a8d2b84f Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 00:23:56 +1000 Subject: [PATCH 01/28] refector app.py to use pure flask long-in and register blueprint cleanly --- app.py | 160 +++++++++++++++++++++++++-------------------------------- 1 file changed, 69 insertions(+), 91 deletions(-) diff --git a/app.py b/app.py index f9f3767..0e5900f 100644 --- a/app.py +++ b/app.py @@ -1,13 +1,17 @@ -from flask import Flask, jsonify, session, render_template, request, redirect +from flask import Flask, jsonify, session, render_template, redirect, url_for from flask_cors import CORS +from flask_migrate import Migrate +from flask_login import LoginManager, login_required +from dotenv import load_dotenv +import os + +# Local imports +from models import db, UserCredential from api.routes import api from api.goals import goals_bp from api.profile import api as profile_api from api.dashboard import dashboard_bp -from models import db -from dotenv import load_dotenv -import os -import pyrebase +from auth.routes import auth_bp # Import scripts here from scripts.add_default_user import add_default_user @@ -15,94 +19,68 @@ # Load environment variables from .env file load_dotenv() -app = Flask(__name__) -CORS(app, resources={r"/api/*": {"origins": "http://localhost:5173"}}) - -# Firebase configuration -config = { - 'apiKey': os.getenv('FIREBASE_API_KEY'), - 'authDomain': os.getenv('FIREBASE_AUTH_DOMAIN'), - 'projectId': os.getenv('FIREBASE_PROJECT_ID'), - 'storageBucket': os.getenv('FIREBASE_STORAGE_BUCKET'), - 'messagingSenderId': os.getenv('FIREBASE_MESSAGING_SENDER_ID'), - 'appId': os.getenv('FIREBASE_APP_ID'), - 'measurementId': os.getenv('FIREBASE_MEASUREMENT_ID'), - 'databaseURL': os.getenv('FIREBASE_DATABASE_URL') -} - -firebase = pyrebase.initialize_app(config) -auth = firebase.auth() - -# Flask config -app.secret_key = os.getenv("SECRET_KEY", "default_secret_key") -app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv("DATABASE_URL", "sqlite:///goals.db") -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - -# Initialize database -db.init_app(app) -with app.app_context(): - db.create_all() - - # Call function to create the default user - add_default_user() - -# Register Blueprints -app.register_blueprint(api, url_prefix='/api') -app.register_blueprint(goals_bp, url_prefix='/api/goals') -app.register_blueprint(dashboard_bp, url_prefix='/api/dashboard') -app.register_blueprint(profile_api, url_prefix='/api/profile') - -# Main index route (login + welcome) -@app.route('/', methods=['GET', 'POST']) -def index(): - error = None - if request.method == 'POST': - email = request.form.get('email') - password = request.form.get('password') - try: - user = auth.sign_in_with_email_and_password(email, password) - session['user'] = email - return redirect('/home') - except: - error = "Login failed. Please check your credentials." - - return render_template('index.html', user=session.get('user'), error=error) - -# Signup route -@app.route('/signup', methods=['GET', 'POST']) -def signup(): - error = None - if request.method == 'POST': - email = request.form.get('email') - password = request.form.get('password') - try: - auth.create_user_with_email_and_password(email, password) - session['user'] = email - return redirect('/home') - except Exception as e: - error = "Signup failed. " + str(e).split("]")[-1].strip().strip('"') - - return render_template('signup.html', error=error) - -# Logout route -@app.route('/logout') -def logout(): - session.pop('user', None) - return redirect('/') - -@app.route('/home') -def home(): - if 'user' in session: - return render_template('home.html', user=session['user']) - return redirect('/') - -# Example API route -@app.route('/api/hello', methods=['GET']) -def hello(): - return jsonify({'message': 'Hello from Flask!'}), 200 +login_manager = LoginManager() +login_manager.login_view = "auth.login" + +@login_manager.user_loader +def load_user(user_id: str): + try: + return db.session.get(UserCredential, int(user_id)) + except (TypeError, ValueError): + return None + +def create_app(): + app = Flask(__name__) + + # Flask config (Core) + app.secret_key = os.getenv("SECRET_KEY", "default_secret_key") + app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv("DATABASE_URL", "sqlite:///goals.db") + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + # Initialize database + db.init_app(app) + login_manager.init_app(app) + Migrate(app, db) + CORS( + app, + resources={r"/api/*": {"origins": os.getenv("CORS_ORIGINS", "http://localhost:5173")}}, + supports_credentials=True, + ) + + # Register Blueprints + app.register_blueprint(auth_bp, url_prefix='/auth') + app.register_blueprint(api, url_prefix='/api') + app.register_blueprint(goals_bp, url_prefix='/api/goals') + app.register_blueprint(dashboard_bp, url_prefix='/api/dashboard') + app.register_blueprint(profile_api, url_prefix='/api/profile') + + # Routes + @app.route('/') + def index(): + # send to login if not logged in + return redirect(url_for('auth.login')) + + @app.route('/home') + @login_required + def home(): + return render_template('home.html', user=session.get('user_id')) + + # Example API route + @app.route('/api/hello', methods=['GET']) + def hello(): + return jsonify({'message': 'Hello from Flask!'}), 200 + + # Create database tables if they don't exist + with app.app_context(): + db.create_all() + + return app + if __name__ == '__main__': + app = create_app() debug_mode = os.getenv("FLASK_DEBUG", "False").lower() == "true" - app.run(debug=debug_mode, port=int(os.getenv("PORT", 5000))) + port = int(os.getenv("PORT", 5000)) + app.run(debug=debug_mode, port=port) From 6f59f15bee02df98a0b54f5131320bce7314248e Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 00:26:44 +1000 Subject: [PATCH 02/28] implement signup, login and logout endpoints in auth/routes.py --- auth/routes.py | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 auth/routes.py diff --git a/auth/routes.py b/auth/routes.py new file mode 100644 index 0000000..e530a61 --- /dev/null +++ b/auth/routes.py @@ -0,0 +1,52 @@ +from flask import Blueprint, render_template, request, redirect, url_for +from flask_login import login_user, logout_user, login_required +from models import db, UserCredential + +auth_bp = Blueprint('auth', __name__) + +@auth_bp.route('/signup', methods=['GET', 'POST']) +def signup(): + error = None + if request.method == 'POST': + email = request.form.get('email').strip().lower() + password = request.form.get('password') + + try: + if not email or not password: + raise ValueError("Email and password are required.") + if UserCredential.query.filter_by(email=email).first(): + raise ValueError("Email already registered.") + + new_user = UserCredential(email=email) + new_user.set_password(password) + db.session.add(new_user) + db.session.commit() + + login_user(new_user) + return redirect(url_for("home")) + except Exception as e: + error = "Signup failed. "+ str(e) + + return render_template('signup.html', error=error) + +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + error = None + if request.method == 'POST': + email = request.form.get('email').strip().lower() + password = request.form.get('password') + user = UserCredential.query.filter_by(email=email).first() + if user and user.check_password(password): + login_user(user) + next_page = request.args.get('next') + return redirect(next_page or url_for("home")) + else: + error = "Invalid email or password." + + return render_template('login.html', error=error) + +@auth_bp.route('/logout', methods=['POST']) +@login_required +def logout(): + logout_user() + return redirect(url_for('auth.login')) \ No newline at end of file From 2a0fe6464ea3ae55865aa05d7739f929515beffb Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 00:29:40 +1000 Subject: [PATCH 03/28] Define UserCredential model --- models/user_credential.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 models/user_credential.py diff --git a/models/user_credential.py b/models/user_credential.py new file mode 100644 index 0000000..9acaedc --- /dev/null +++ b/models/user_credential.py @@ -0,0 +1,23 @@ +from . import db +from werkzeug.security import generate_password_hash, check_password_hash +from flask_login import UserMixin + +class UserCredential(db.Model, UserMixin): + __tablename__="user_credential" + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), index=True, nullable=False, unique=True) + password_hash = db.Column(db.String(255), nullable=False) + is_active = db.Column(db.Boolean, default=True, nullable=False) + created_at =db.Column(db.DateTime, server_default=db.func.now(), nullable=False) + + profile = db.relationship("UserProfile", back_populates="user", uselist=False) + + def set_password(self, raw_password: str): + self.password_hash = generate_password_hash(raw_password) + + def check_password(self, raw_password: str) -> bool: + return check_password_hash(self.password_hash, raw_password) + + def __repr__(self): + return f"" \ No newline at end of file From 62fb21cdd405e0e482192e3340a6f4ab14def346 Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 00:32:44 +1000 Subject: [PATCH 04/28] rename User to UserProfile and add one-to-one relationship to UserCredential --- models/user_profile.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 models/user_profile.py diff --git a/models/user_profile.py b/models/user_profile.py new file mode 100644 index 0000000..0c4d6b1 --- /dev/null +++ b/models/user_profile.py @@ -0,0 +1,26 @@ +from models import db + +class UserProfile(db.Model): + __tablename__ = 'user_profile' + + id = db.Column(db.Integer, primary_key=True) + # Link to UserCredential + user_id = db.Column(db.Integer, db.ForeignKey("user_credential.id"), nullable=False, unique=True) + user = db.relationship("UserCredential", back_populates="profile") + + name = db.Column(db.String(100), nullable=False) + account = db.Column(db.String(100), unique=True, nullable=False) + birthDate = db.Column(db.String(10), nullable=False) + gender = db.Column(db.String(10), nullable=False) + avatar = db.Column(db.String(200), nullable=True) + + + def as_dict(self): + return { + "id": self.id, + "name": self.name, + "account": self.account, + "birthDate": self.birthDate, + "gender": self.gender, + "avatar": self.avatar + } From 7af1be3a8d15f6c47f999eba80f2cae6a3d2ce3c Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 00:36:02 +1000 Subject: [PATCH 05/28] update requirement.txt and rearrange --- requirements.txt | Bin 782 -> 1268 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index 96b5b350b27c119c65ff3815218ac4edf51ad472..3396a74673c79bdb3054d15c7e1e2837a4c0b046 100644 GIT binary patch delta 556 zcmY*WK}y3=5Phj2D7X>27Yc49(j>(q;zCguQlX+ycbeF=hNg`)(UMgzAkvTFIBf?NgGI|LMrq#V= zmmh9%v1GAv^$!**YwM4-trdM@Ly`r=J!;%x+|k;uB23{fOQguOWQZGFqeBsW3W(Tc z$XjsF#N-8u61qdUu$6eCW5|`>CHG#+@k4}s0YfE2 f5ko3N5?FmIg9%uN2~dYA5F1Q3XEL1Z!FUS**CG`^ From 6b76dbd251f9d6a57c241387885bc380c8906c5b Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 00:40:35 +1000 Subject: [PATCH 06/28] Add base.html as shared layout --- templates/base.html | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 templates/base.html diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..560f11c --- /dev/null +++ b/templates/base.html @@ -0,0 +1,35 @@ + + + + + {% block title %}Flask Auth App{% endblock %} + + + + + +
+

Flask Auth Demo

+ {% if current_user.is_authenticated %} +

Logged in as {{ current_user.email }}

+ {% endif %} +
+ +
+ {% block content %}{% endblock %} +
+ + + From ea5c5acbb4ce500022469824a14149fd8075cb20 Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 00:42:10 +1000 Subject: [PATCH 07/28] refactor login page --- templates/login.html | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 templates/login.html diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..388ab71 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} + +{% block title %}Login{% endblock %} + +{% block content %} +
+

Login

+
+ + + +
+ + {% if error %} +

{{ error }}

+ {% endif %} + +

Don’t have an account? Sign up

+
+{% endblock %} From f01811c95e450d626d0b606c2da55d39c6efc699 Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 00:42:52 +1000 Subject: [PATCH 08/28] create signup page with user registration form --- templates/signup.html | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/templates/signup.html b/templates/signup.html index 4d42d6f..ab6a266 100644 --- a/templates/signup.html +++ b/templates/signup.html @@ -1,21 +1,21 @@ - - - - Signup - - -

Create an Account

+{% extends "base.html" %} - {% if error %} -

{{ error }}

- {% endif %} +{% block title %}Sign Up{% endblock %} -
- Email:

- Password:

- -
+{% block content %} +
+

Sign Up

+
+ + + +
+ + {% if error %} +

{{ error }}

+ {% endif %} + +

Already have an account? Log in

+
+{% endblock %} -

Already have an account? Log in here

- - From 3cd4944cdf8138fc79d30432cee30451861984e5 Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 00:43:30 +1000 Subject: [PATCH 09/28] create home page to display welcome message and logout option --- templates/home.html | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/templates/home.html b/templates/home.html index c7acb54..e49dd48 100644 --- a/templates/home.html +++ b/templates/home.html @@ -1,18 +1,17 @@ - - - - - Home - - - -

Welcome, {{ user }}!

-

You have successfully logged in.

- Logout - - \ No newline at end of file +{% extends "base.html" %} + +{% block title %}Home{% endblock %} + +{% block content %} +
+ {% if current_user.profile %} +

Welcome, {{ current_user.profile.name }}!

+ {% else %} +

Welcome, {{ current_user.email }}!

+ {% endif %} + +
+ +
+
+{% endblock %} From 3e946b1d47f82f6cc8e2cafe663b7ff9851349f5 Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 00:49:36 +1000 Subject: [PATCH 10/28] update gitignore to keep repository clean from unnecessary files --- .gitignore | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 487653c..4aa2284 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,23 @@ *.pyc __pycache__/ -env/ -.env .ipynb_checkpoints/ + + +# Secrets +.env +.flaskenv + +# Virutal Environments +/venv/ +env/ +.venv/ + +# Local Database +my_database.db + +# Logs +*.logs +logs/ + +migrations/__pycache__/ +migrations/versions/__pycache__/ \ No newline at end of file From 696dba4592c99d010241241903449ffde46821f2 Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 00:52:09 +1000 Subject: [PATCH 11/28] refactor API files to match model rename --- api/dashboard.py | 2 +- api/goals.py | 2 +- api/profile.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/dashboard.py b/api/dashboard.py index e6eda35..3fabd21 100644 --- a/api/dashboard.py +++ b/api/dashboard.py @@ -5,7 +5,7 @@ import sys from flask import Blueprint, jsonify -from models.user import db, UserProfile +from models import db, UserProfile from flask_cors import CORS from datetime import datetime, timezone diff --git a/api/goals.py b/api/goals.py index 0a85b04..b4db8f4 100644 --- a/api/goals.py +++ b/api/goals.py @@ -1,5 +1,5 @@ from flask import Blueprint, request, jsonify -from models.goal import db, Goal # Correctly import db and Goal +from models import db, Goal # Correctly import db and Goal from datetime import datetime # Create the Blueprint for goals diff --git a/api/profile.py b/api/profile.py index 0496bc3..c23a2fc 100644 --- a/api/profile.py +++ b/api/profile.py @@ -1,6 +1,6 @@ # /api/profile.py from flask import Blueprint, jsonify, request -from models.user import db, UserProfile +from models import db, UserProfile from flask_cors import CORS api = Blueprint('profile_api', __name__) From dcda154d6734e48bba1b6fed359a4c0178e1bfb1 Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 00:53:36 +1000 Subject: [PATCH 12/28] add CLI command to backfill UserProfile links to UserCredential --- scripts/cli_backfill.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 scripts/cli_backfill.py diff --git a/scripts/cli_backfill.py b/scripts/cli_backfill.py new file mode 100644 index 0000000..fd79b26 --- /dev/null +++ b/scripts/cli_backfill.py @@ -0,0 +1,21 @@ +import click +from flask.cli import with_appcontext +from models import db, UserCredential, UserProfile + +@click.command("backfill-user-links") +@with_appcontext +def backfill_user_links(): + count = 0 + for p in UserProfile.query.filter(UserProfile.user_id.is_(None)).all(): + email = (p.account or "").lower().strip() + if not email: + continue + u = UserCredential.query.filter_by(email=email).first() + if not u: + u = UserCredential(email=email, firebase_uid=f"pending:{email}") + db.session.add(u) + db.session.flush() + p.user_id = u.id + count += 1 + db.session.commit() + click.echo(f"Linked/created {count} profiles.") \ No newline at end of file From ccc8a08d1a37e7a17e3d0d19859f258e950da88b Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 00:54:43 +1000 Subject: [PATCH 13/28] update to match model rename --- scripts/add_default_user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/add_default_user.py b/scripts/add_default_user.py index 6fc9cc8..987df15 100644 --- a/scripts/add_default_user.py +++ b/scripts/add_default_user.py @@ -1,5 +1,5 @@ from models import db -from models.user import UserProfile +from models import UserProfile def add_default_user(): # Only add a default user if the user_profile table is completely empty From fba2bc5080a1dafe92adbbc6eee3505f3e4029b4 Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 00:56:07 +1000 Subject: [PATCH 14/28] add init.py to import UserCredential, UserProfile, and Goal --- models/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/models/__init__.py b/models/__init__.py index 589c64f..c9e7a18 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,2 +1,8 @@ from flask_sqlalchemy import SQLAlchemy -db = SQLAlchemy() \ No newline at end of file + +db = SQLAlchemy() + +from .user_credential import UserCredential +from .user_profile import UserProfile +from .goal import Goal + From 75327b177d1573f2f48aa13e0633ec8e5495a523 Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 00:57:38 +1000 Subject: [PATCH 15/28] update migration with latest schema changes --- migrations/README | 1 + migrations/alembic.ini | 50 ++++++++ migrations/env.py | 113 ++++++++++++++++++ migrations/script.py.mako | 24 ++++ ...0625c_make_user_credential_email_unique.py | 38 ++++++ ...c4_add_password_hash_to_user_credential.py | 56 +++++++++ ..._make_user_profile_user_id_non_nullable.py | 36 ++++++ .../versions/fb7796f17fd8_initial_schema.py | 36 ++++++ 8 files changed, 354 insertions(+) create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/13489110625c_make_user_credential_email_unique.py create mode 100644 migrations/versions/5468fe84a8c4_add_password_hash_to_user_credential.py create mode 100644 migrations/versions/f0718851c3d3_make_user_profile_user_id_non_nullable.py create mode 100644 migrations/versions/fb7796f17fd8_initial_schema.py diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/13489110625c_make_user_credential_email_unique.py b/migrations/versions/13489110625c_make_user_credential_email_unique.py new file mode 100644 index 0000000..36eb98e --- /dev/null +++ b/migrations/versions/13489110625c_make_user_credential_email_unique.py @@ -0,0 +1,38 @@ +"""Make user_credential.email unique + +Revision ID: 13489110625c +Revises: 5468fe84a8c4 +Create Date: 2025-08-27 00:10:30.225241 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '13489110625c' +down_revision = '5468fe84a8c4' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_credential', schema=None) as batch_op: + batch_op.drop_index('ix_user_credential_firebase_uid') + batch_op.drop_index('ix_user_credential_email') + batch_op.create_index(batch_op.f('ix_user_credential_email'), ['email'], unique=True) + batch_op.drop_column('firebase_uid') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_credential', schema=None) as batch_op: + batch_op.add_column(sa.Column('firebase_uid', sa.VARCHAR(length=128), autoincrement=False, nullable=False)) + batch_op.drop_index(batch_op.f('ix_user_credential_email')) + batch_op.create_index('ix_user_credential_email', ['email'], unique=False) + batch_op.create_index('ix_user_credential_firebase_uid', ['firebase_uid'], unique=True) + + # ### end Alembic commands ### diff --git a/migrations/versions/5468fe84a8c4_add_password_hash_to_user_credential.py b/migrations/versions/5468fe84a8c4_add_password_hash_to_user_credential.py new file mode 100644 index 0000000..de6beed --- /dev/null +++ b/migrations/versions/5468fe84a8c4_add_password_hash_to_user_credential.py @@ -0,0 +1,56 @@ +"""add password_hash to user_credential + +Revision ID: 5468fe84a8c4 +Revises: f0718851c3d3 +Create Date: 2025-08-21 22:51:49.978484 + +""" +from alembic import op +import sqlalchemy as sa + +from werkzeug.security import generate_password_hash + + +# revision identifiers, used by Alembic. +revision = '5468fe84a8c4' +down_revision = 'f0718851c3d3' +branch_labels = None +depends_on = None + + +def upgrade(): + # 1) Add column as NULLABLE first + op.add_column( + 'user_credential', + sa.Column('password_hash', sa.String(length=255), nullable=True) + ) + + # 2) Backfill existing rows with an unusable/placeholder hash + # (so users created before this migration will need a reset) + bind = op.get_bind() + + # Generate one consistent placeholder hash (or generate per row if you prefer) + placeholder_hash = generate_password_hash("reset-required") + + # Update all rows that have NULL password_hash + bind.execute( + sa.text(""" + UPDATE user_credential + SET password_hash = :ph + WHERE password_hash IS NULL + """), + {"ph": placeholder_hash} + ) + + # 3) Enforce NOT NULL now that every row has a value + op.alter_column('user_credential', 'password_hash', nullable=False) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_credential', schema=None) as batch_op: + batch_op.add_column(sa.Column('firebase_uid', sa.VARCHAR(length=128), autoincrement=False, nullable=False)) + batch_op.create_index('ix_user_credential_firebase_uid', ['firebase_uid'], unique=True) + batch_op.drop_column('password_hash') + + # ### end Alembic commands ### diff --git a/migrations/versions/f0718851c3d3_make_user_profile_user_id_non_nullable.py b/migrations/versions/f0718851c3d3_make_user_profile_user_id_non_nullable.py new file mode 100644 index 0000000..957b07e --- /dev/null +++ b/migrations/versions/f0718851c3d3_make_user_profile_user_id_non_nullable.py @@ -0,0 +1,36 @@ +"""make user_profile.user_id non-nullable + +Revision ID: f0718851c3d3 +Revises: fb7796f17fd8 +Create Date: 2025-08-20 11:53:29.684151 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f0718851c3d3' +down_revision = 'fb7796f17fd8' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_profile', schema=None) as batch_op: + batch_op.alter_column('user_id', + existing_type=sa.INTEGER(), + nullable=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_profile', schema=None) as batch_op: + batch_op.alter_column('user_id', + existing_type=sa.INTEGER(), + nullable=True) + + # ### end Alembic commands ### diff --git a/migrations/versions/fb7796f17fd8_initial_schema.py b/migrations/versions/fb7796f17fd8_initial_schema.py new file mode 100644 index 0000000..b4708d2 --- /dev/null +++ b/migrations/versions/fb7796f17fd8_initial_schema.py @@ -0,0 +1,36 @@ +"""initial schema + +Revision ID: fb7796f17fd8 +Revises: +Create Date: 2025-08-20 00:42:41.361974 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'fb7796f17fd8' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_profile', schema=None) as batch_op: + batch_op.add_column(sa.Column('user_id', sa.Integer(), nullable=True)) + batch_op.create_unique_constraint(None, ['user_id']) + batch_op.create_foreign_key(None, 'user_credential', ['user_id'], ['id']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_profile', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint(None, type_='unique') + batch_op.drop_column('user_id') + + # ### end Alembic commands ### From dd7969a90881a38e4a79b0b54d157eb175edc488 Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 10:47:25 +1000 Subject: [PATCH 16/28] add username to signup page --- auth/routes.py | 22 +++++++++++++++++++--- models/user.py | 21 --------------------- templates/signup.html | 1 + 3 files changed, 20 insertions(+), 24 deletions(-) delete mode 100644 models/user.py diff --git a/auth/routes.py b/auth/routes.py index e530a61..16d64ec 100644 --- a/auth/routes.py +++ b/auth/routes.py @@ -1,29 +1,45 @@ from flask import Blueprint, render_template, request, redirect, url_for from flask_login import login_user, logout_user, login_required -from models import db, UserCredential +from models import db, UserCredential, UserProfile auth_bp = Blueprint('auth', __name__) + @auth_bp.route('/signup', methods=['GET', 'POST']) def signup(): error = None if request.method == 'POST': + name = request.form.get('name').strip() email = request.form.get('email').strip().lower() password = request.form.get('password') try: - if not email or not password: - raise ValueError("Email and password are required.") + if not name or not email or not password: + raise ValueError("Name, email and password are required.") if UserCredential.query.filter_by(email=email).first(): raise ValueError("Email already registered.") + # Create user credentials new_user = UserCredential(email=email) new_user.set_password(password) db.session.add(new_user) + db.session.flush() # Ensure new_user.id is available + + + profile = UserProfile( + user_id=new_user.id, + name=name, + account=email, + birthDate="---", #placeholder + gender="---", #placeholder + ) + db.session.add(profile) db.session.commit() + login_user(new_user) return redirect(url_for("home")) + except Exception as e: error = "Signup failed. "+ str(e) diff --git a/models/user.py b/models/user.py deleted file mode 100644 index abb7d30..0000000 --- a/models/user.py +++ /dev/null @@ -1,21 +0,0 @@ -from models import db - -class UserProfile(db.Model): - __tablename__ = 'user_profile' - - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(100), nullable=False) - account = db.Column(db.String(100), unique=True, nullable=False) - birthDate = db.Column(db.String(10), nullable=False) - gender = db.Column(db.String(10), nullable=False) - avatar = db.Column(db.String(200), nullable=True) - - def as_dict(self): - return { - "id": self.id, - "name": self.name, - "account": self.account, - "birthDate": self.birthDate, - "gender": self.gender, - "avatar": self.avatar - } diff --git a/templates/signup.html b/templates/signup.html index ab6a266..b396755 100644 --- a/templates/signup.html +++ b/templates/signup.html @@ -6,6 +6,7 @@

Sign Up

+ From 4229b2314f6087c2d19620f43b8a6d1ed0aaad47 Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 11:10:28 +1000 Subject: [PATCH 17/28] add csrf protection and remember me sessions --- app.py | 9 ++++++++- auth/routes.py | 4 +++- templates/login.html | 1 + templates/signup.html | 1 + 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index 0e5900f..54a4a98 100644 --- a/app.py +++ b/app.py @@ -16,6 +16,10 @@ # Import scripts here from scripts.add_default_user import add_default_user +# CSRF protection +from flask_wtf.csrf import CSRFProtect +csrf = CSRFProtect() + # Load environment variables from .env file load_dotenv() @@ -33,9 +37,10 @@ def create_app(): app = Flask(__name__) # Flask config (Core) - app.secret_key = os.getenv("SECRET_KEY", "default_secret_key") + app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret") app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv("DATABASE_URL", "sqlite:///goals.db") app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + app.config["WTF_CSRF_ENABLED"] = True # Initialize database db.init_app(app) @@ -73,6 +78,8 @@ def hello(): # Create database tables if they don't exist with app.app_context(): db.create_all() + add_default_user(db) + csrf.init_app(app) return app diff --git a/auth/routes.py b/auth/routes.py index 16d64ec..de5e18a 100644 --- a/auth/routes.py +++ b/auth/routes.py @@ -52,8 +52,10 @@ def login(): email = request.form.get('email').strip().lower() password = request.form.get('password') user = UserCredential.query.filter_by(email=email).first() + remember = bool(request.form.get('remember')) + if user and user.check_password(password): - login_user(user) + login_user(user, remember=remember) next_page = request.args.get('next') return redirect(next_page or url_for("home")) else: diff --git a/templates/login.html b/templates/login.html index 388ab71..fcacdac 100644 --- a/templates/login.html +++ b/templates/login.html @@ -9,6 +9,7 @@

Login

+ {% if error %} diff --git a/templates/signup.html b/templates/signup.html index b396755..7cf6049 100644 --- a/templates/signup.html +++ b/templates/signup.html @@ -6,6 +6,7 @@

Sign Up

+ {{ csrf_token() }} From 0928f5cad9bd0afdb50c1c39eb8369e2bd7f2170 Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 11:25:00 +1000 Subject: [PATCH 18/28] add session cookie and limiter for security --- app.py | 15 +++++++++++++++ auth/routes.py | 2 ++ models/user_credential.py | 2 +- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app.py b/app.py index 54a4a98..25bd7be 100644 --- a/app.py +++ b/app.py @@ -2,6 +2,8 @@ from flask_cors import CORS from flask_migrate import Migrate from flask_login import LoginManager, login_required +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address from dotenv import load_dotenv import os @@ -23,8 +25,10 @@ # Load environment variables from .env file load_dotenv() +# Initialize Flask extensions login_manager = LoginManager() login_manager.login_view = "auth.login" +limiter = Limiter(key_func=get_remote_address, default_limits=["200 per hour"]) @login_manager.user_loader def load_user(user_id: str): @@ -42,9 +46,20 @@ def create_app(): app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config["WTF_CSRF_ENABLED"] = True + # Security settings for cookies + app.config.update( + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + SESSION_COOKIE_SECURE=True, + REMEMBER_COOKIE_HTTPONLY=True, + REMEMBER_COOKIE_SECURE=True, + ) + + # Initialize database db.init_app(app) login_manager.init_app(app) + limiter.init_app(app) Migrate(app, db) CORS( app, diff --git a/auth/routes.py b/auth/routes.py index de5e18a..626b085 100644 --- a/auth/routes.py +++ b/auth/routes.py @@ -1,6 +1,7 @@ from flask import Blueprint, render_template, request, redirect, url_for from flask_login import login_user, logout_user, login_required from models import db, UserCredential, UserProfile +from app import limiter auth_bp = Blueprint('auth', __name__) @@ -46,6 +47,7 @@ def signup(): return render_template('signup.html', error=error) @auth_bp.route('/login', methods=['GET', 'POST']) +@limiter.limit("5 per minute") # Rate limiting to prevent brute-force attacks def login(): error = None if request.method == 'POST': diff --git a/models/user_credential.py b/models/user_credential.py index 9acaedc..5e808f0 100644 --- a/models/user_credential.py +++ b/models/user_credential.py @@ -11,7 +11,7 @@ class UserCredential(db.Model, UserMixin): is_active = db.Column(db.Boolean, default=True, nullable=False) created_at =db.Column(db.DateTime, server_default=db.func.now(), nullable=False) - profile = db.relationship("UserProfile", back_populates="user", uselist=False) + profile = db.relationship("UserProfile", back_populates="user", uselist=False, cascade="all, delete-orphan") def set_password(self, raw_password: str): self.password_hash = generate_password_hash(raw_password) From cf5869ba3cc6c9834dd8a1b789ef85acd168b1fd Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 11:43:39 +1000 Subject: [PATCH 19/28] add password validation --- auth/routes.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/auth/routes.py b/auth/routes.py index 626b085..9603b26 100644 --- a/auth/routes.py +++ b/auth/routes.py @@ -5,6 +5,12 @@ auth_bp = Blueprint('auth', __name__) +def validate_password(pwd: str): + if len(pwd) < 8: + raise ValueError("Password must be at least 8 characters long.") + if pwd.isdigit() or pwd.isalpha(): + raise ValueError("Password must contain both letters and numbers.") + return True @auth_bp.route('/signup', methods=['GET', 'POST']) def signup(): @@ -15,11 +21,19 @@ def signup(): password = request.form.get('password') try: + # Validate inputs if not name or not email or not password: raise ValueError("Name, email and password are required.") + + # Check if email already exists if UserCredential.query.filter_by(email=email).first(): raise ValueError("Email already registered.") + # Validate password strength + error_msg = validate_password(password) + if error_msg: + raise ValueError(error_msg) + # Create user credentials new_user = UserCredential(email=email) new_user.set_password(password) @@ -37,7 +51,7 @@ def signup(): db.session.add(profile) db.session.commit() - + # Auto-login after signup login_user(new_user) return redirect(url_for("home")) @@ -69,4 +83,5 @@ def login(): @login_required def logout(): logout_user() - return redirect(url_for('auth.login')) \ No newline at end of file + return redirect(url_for('auth.login')) + From d44633079ed0c5dc2890b7dad70dcc886473911e Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 14:52:00 +1000 Subject: [PATCH 20/28] fix signup/login flow and validation to pass all auth tests --- __init__.py | 0 app.py | 33 +++++------ auth/routes.py | 39 ++++++++++--- extensions.py | 13 +++++ ...0625c_make_user_credential_email_unique.py | 38 ------------- ...c4_add_password_hash_to_user_credential.py | 56 ------------------ ..._make_user_profile_user_id_non_nullable.py | 36 ------------ .../versions/fb7796f17fd8_initial_schema.py | 36 ------------ models/__init__.py | 2 +- models/user_credential.py | 2 +- models/user_profile.py | 2 +- scripts/add_default_user.py | 57 +++++++++++++------ templates/login.html | 2 + tests/conftest.py | 34 +++++++++++ tests/test_auth_flow.py | 3 + tests/test_validation.py | 21 +++++++ 16 files changed, 160 insertions(+), 214 deletions(-) create mode 100644 __init__.py create mode 100644 extensions.py delete mode 100644 migrations/versions/13489110625c_make_user_credential_email_unique.py delete mode 100644 migrations/versions/5468fe84a8c4_add_password_hash_to_user_credential.py delete mode 100644 migrations/versions/f0718851c3d3_make_user_profile_user_id_non_nullable.py delete mode 100644 migrations/versions/fb7796f17fd8_initial_schema.py create mode 100644 tests/conftest.py create mode 100644 tests/test_auth_flow.py create mode 100644 tests/test_validation.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app.py b/app.py index 25bd7be..dffbb9f 100644 --- a/app.py +++ b/app.py @@ -1,14 +1,12 @@ +import os from flask import Flask, jsonify, session, render_template, redirect, url_for from flask_cors import CORS -from flask_migrate import Migrate -from flask_login import LoginManager, login_required -from flask_limiter import Limiter -from flask_limiter.util import get_remote_address +from flask_login import login_required, current_user from dotenv import load_dotenv -import os # Local imports -from models import db, UserCredential +from models import UserCredential +from extensions import db, login_manager, migrate, limiter, csrf from api.routes import api from api.goals import goals_bp from api.profile import api as profile_api @@ -18,17 +16,10 @@ # Import scripts here from scripts.add_default_user import add_default_user -# CSRF protection -from flask_wtf.csrf import CSRFProtect -csrf = CSRFProtect() # Load environment variables from .env file load_dotenv() -# Initialize Flask extensions -login_manager = LoginManager() -login_manager.login_view = "auth.login" -limiter = Limiter(key_func=get_remote_address, default_limits=["200 per hour"]) @login_manager.user_loader def load_user(user_id: str): @@ -50,23 +41,27 @@ def create_app(): app.config.update( SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", - SESSION_COOKIE_SECURE=True, + SESSION_COOKIE_SECURE=False, REMEMBER_COOKIE_HTTPONLY=True, - REMEMBER_COOKIE_SECURE=True, + REMEMBER_COOKIE_SECURE=False, ) # Initialize database db.init_app(app) + migrate.init_app(app, db) login_manager.init_app(app) - limiter.init_app(app) - Migrate(app, db) + if limiter: + limiter.init_app(app) + csrf.init_app(app) CORS( app, resources={r"/api/*": {"origins": os.getenv("CORS_ORIGINS", "http://localhost:5173")}}, supports_credentials=True, ) + app.cli.add_command(add_default_user) + # Register Blueprints app.register_blueprint(auth_bp, url_prefix='/auth') app.register_blueprint(api, url_prefix='/api') @@ -83,7 +78,7 @@ def index(): @app.route('/home') @login_required def home(): - return render_template('home.html', user=session.get('user_id')) + return render_template('home.html', user=current_user) # Example API route @app.route('/api/hello', methods=['GET']) @@ -93,8 +88,6 @@ def hello(): # Create database tables if they don't exist with app.app_context(): db.create_all() - add_default_user(db) - csrf.init_app(app) return app diff --git a/auth/routes.py b/auth/routes.py index 9603b26..e24cd96 100644 --- a/auth/routes.py +++ b/auth/routes.py @@ -1,7 +1,9 @@ from flask import Blueprint, render_template, request, redirect, url_for from flask_login import login_user, logout_user, login_required -from models import db, UserCredential, UserProfile -from app import limiter +from models import UserCredential, UserProfile +from urllib.parse import urlparse, urljoin +from extensions import db, limiter +from sqlalchemy.exc import IntegrityError auth_bp = Blueprint('auth', __name__) @@ -12,6 +14,13 @@ def validate_password(pwd: str): raise ValueError("Password must contain both letters and numbers.") return True +def is_safe_url(target: str) -> bool: + if not target: + return False + ref = urlparse(request.host_url) + test = urlparse(urljoin(request.host_url, target)) + return test.scheme in ('http', 'https') and ref.netloc == test.netloc + @auth_bp.route('/signup', methods=['GET', 'POST']) def signup(): error = None @@ -24,15 +33,13 @@ def signup(): # Validate inputs if not name or not email or not password: raise ValueError("Name, email and password are required.") + validate_password(password) # Check if email already exists if UserCredential.query.filter_by(email=email).first(): - raise ValueError("Email already registered.") + error = "signup failed. email already registered." + return render_template('signup.html', error=error), 400 - # Validate password strength - error_msg = validate_password(password) - if error_msg: - raise ValueError(error_msg) # Create user credentials new_user = UserCredential(email=email) @@ -55,8 +62,20 @@ def signup(): login_user(new_user) return redirect(url_for("home")) + except ValueError as e: + db.session.rollback() + error = "Signup failed. " + str(e) + return render_template('signup.html', error=error), 400 + + except IntegrityError: + db.session.rollback() + error = "signup failed. email already registered." + return render_template('signup.html', error=error), 400 + except Exception as e: + db.session.rollback() error = "Signup failed. "+ str(e) + return render_template('signup.html', error=error), 400 return render_template('signup.html', error=error) @@ -67,15 +86,17 @@ def login(): if request.method == 'POST': email = request.form.get('email').strip().lower() password = request.form.get('password') + next_page = request.args.get('next') or request.form.get('next') user = UserCredential.query.filter_by(email=email).first() remember = bool(request.form.get('remember')) if user and user.check_password(password): login_user(user, remember=remember) - next_page = request.args.get('next') - return redirect(next_page or url_for("home")) + dest = next_page if is_safe_url(next_page) else url_for("home") + return redirect(dest) else: error = "Invalid email or password." + return render_template('login.html', error=error), 400 return render_template('login.html', error=error) diff --git a/extensions.py b/extensions.py new file mode 100644 index 0000000..4f5082b --- /dev/null +++ b/extensions.py @@ -0,0 +1,13 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager +from flask_migrate import Migrate +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_wtf import CSRFProtect + + +db = SQLAlchemy() +login_manager = LoginManager() +migrate = Migrate() +csrf = CSRFProtect() +limiter = Limiter(key_func=get_remote_address, default_limits=["200 per hour"]) \ No newline at end of file diff --git a/migrations/versions/13489110625c_make_user_credential_email_unique.py b/migrations/versions/13489110625c_make_user_credential_email_unique.py deleted file mode 100644 index 36eb98e..0000000 --- a/migrations/versions/13489110625c_make_user_credential_email_unique.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Make user_credential.email unique - -Revision ID: 13489110625c -Revises: 5468fe84a8c4 -Create Date: 2025-08-27 00:10:30.225241 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '13489110625c' -down_revision = '5468fe84a8c4' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('user_credential', schema=None) as batch_op: - batch_op.drop_index('ix_user_credential_firebase_uid') - batch_op.drop_index('ix_user_credential_email') - batch_op.create_index(batch_op.f('ix_user_credential_email'), ['email'], unique=True) - batch_op.drop_column('firebase_uid') - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('user_credential', schema=None) as batch_op: - batch_op.add_column(sa.Column('firebase_uid', sa.VARCHAR(length=128), autoincrement=False, nullable=False)) - batch_op.drop_index(batch_op.f('ix_user_credential_email')) - batch_op.create_index('ix_user_credential_email', ['email'], unique=False) - batch_op.create_index('ix_user_credential_firebase_uid', ['firebase_uid'], unique=True) - - # ### end Alembic commands ### diff --git a/migrations/versions/5468fe84a8c4_add_password_hash_to_user_credential.py b/migrations/versions/5468fe84a8c4_add_password_hash_to_user_credential.py deleted file mode 100644 index de6beed..0000000 --- a/migrations/versions/5468fe84a8c4_add_password_hash_to_user_credential.py +++ /dev/null @@ -1,56 +0,0 @@ -"""add password_hash to user_credential - -Revision ID: 5468fe84a8c4 -Revises: f0718851c3d3 -Create Date: 2025-08-21 22:51:49.978484 - -""" -from alembic import op -import sqlalchemy as sa - -from werkzeug.security import generate_password_hash - - -# revision identifiers, used by Alembic. -revision = '5468fe84a8c4' -down_revision = 'f0718851c3d3' -branch_labels = None -depends_on = None - - -def upgrade(): - # 1) Add column as NULLABLE first - op.add_column( - 'user_credential', - sa.Column('password_hash', sa.String(length=255), nullable=True) - ) - - # 2) Backfill existing rows with an unusable/placeholder hash - # (so users created before this migration will need a reset) - bind = op.get_bind() - - # Generate one consistent placeholder hash (or generate per row if you prefer) - placeholder_hash = generate_password_hash("reset-required") - - # Update all rows that have NULL password_hash - bind.execute( - sa.text(""" - UPDATE user_credential - SET password_hash = :ph - WHERE password_hash IS NULL - """), - {"ph": placeholder_hash} - ) - - # 3) Enforce NOT NULL now that every row has a value - op.alter_column('user_credential', 'password_hash', nullable=False) - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('user_credential', schema=None) as batch_op: - batch_op.add_column(sa.Column('firebase_uid', sa.VARCHAR(length=128), autoincrement=False, nullable=False)) - batch_op.create_index('ix_user_credential_firebase_uid', ['firebase_uid'], unique=True) - batch_op.drop_column('password_hash') - - # ### end Alembic commands ### diff --git a/migrations/versions/f0718851c3d3_make_user_profile_user_id_non_nullable.py b/migrations/versions/f0718851c3d3_make_user_profile_user_id_non_nullable.py deleted file mode 100644 index 957b07e..0000000 --- a/migrations/versions/f0718851c3d3_make_user_profile_user_id_non_nullable.py +++ /dev/null @@ -1,36 +0,0 @@ -"""make user_profile.user_id non-nullable - -Revision ID: f0718851c3d3 -Revises: fb7796f17fd8 -Create Date: 2025-08-20 11:53:29.684151 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'f0718851c3d3' -down_revision = 'fb7796f17fd8' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('user_profile', schema=None) as batch_op: - batch_op.alter_column('user_id', - existing_type=sa.INTEGER(), - nullable=False) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('user_profile', schema=None) as batch_op: - batch_op.alter_column('user_id', - existing_type=sa.INTEGER(), - nullable=True) - - # ### end Alembic commands ### diff --git a/migrations/versions/fb7796f17fd8_initial_schema.py b/migrations/versions/fb7796f17fd8_initial_schema.py deleted file mode 100644 index b4708d2..0000000 --- a/migrations/versions/fb7796f17fd8_initial_schema.py +++ /dev/null @@ -1,36 +0,0 @@ -"""initial schema - -Revision ID: fb7796f17fd8 -Revises: -Create Date: 2025-08-20 00:42:41.361974 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'fb7796f17fd8' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('user_profile', schema=None) as batch_op: - batch_op.add_column(sa.Column('user_id', sa.Integer(), nullable=True)) - batch_op.create_unique_constraint(None, ['user_id']) - batch_op.create_foreign_key(None, 'user_credential', ['user_id'], ['id']) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('user_profile', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.drop_constraint(None, type_='unique') - batch_op.drop_column('user_id') - - # ### end Alembic commands ### diff --git a/models/__init__.py b/models/__init__.py index c9e7a18..2d472d7 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,6 +1,6 @@ from flask_sqlalchemy import SQLAlchemy +from extensions import db -db = SQLAlchemy() from .user_credential import UserCredential from .user_profile import UserProfile diff --git a/models/user_credential.py b/models/user_credential.py index 5e808f0..73374ff 100644 --- a/models/user_credential.py +++ b/models/user_credential.py @@ -1,4 +1,4 @@ -from . import db +from extensions import db from werkzeug.security import generate_password_hash, check_password_hash from flask_login import UserMixin diff --git a/models/user_profile.py b/models/user_profile.py index 0c4d6b1..6dbca67 100644 --- a/models/user_profile.py +++ b/models/user_profile.py @@ -1,4 +1,4 @@ -from models import db +from extensions import db class UserProfile(db.Model): __tablename__ = 'user_profile' diff --git a/scripts/add_default_user.py b/scripts/add_default_user.py index 987df15..ca345e0 100644 --- a/scripts/add_default_user.py +++ b/scripts/add_default_user.py @@ -1,18 +1,43 @@ -from models import db -from models import UserProfile +from models import db, UserCredential, UserProfile +from flask.cli import with_appcontext +import click -def add_default_user(): - # Only add a default user if the user_profile table is completely empty - if UserProfile.query.first() is None: - default_user = UserProfile( - name='Austin Blaze', - account='redback.operations@deakin.edu.au', - birthDate='2000-01-01', - gender='Male', - avatar='src/assets/ProfilePic.png' +def ensure_default_user(): + + email = 'redback.operations@deakin.edu.au' + name = 'Austin Blaze' + password = 'Redback2024' + birthDate = '2000-01-01' + gender = "Male" + avatar='src/assets/ProfilePic.png' + + # 1) Ensure credential exists + user = UserCredential.query.filter_by(email=email.strip().lower()).first() + if not user: + user = UserCredential(email=email.strip().lower()) + user.set_password(password) + db.session.add(user) + db.session.flush() + + # 2) Ensure profile exists and is linked + profile = UserProfile.query.filter_by(user_id=user.id).first() + if not profile: + profile = UserProfile( + user_id=user.id, + name=name, + account=email, + birthDate=birthDate, + gender=gender, + avatar=avatar, ) - db.session.add(default_user) - db.session.commit() - print("Default user added because user table was empty.") - else: - print("User table is not empty. Default user not added.") + db.session.add(profile) + + db.session.commit() + return email + + +@click.command('add-default-user') +@with_appcontext +def add_default_user(): + email = ensure_default_user() + click.echo(f"Default user ensured with email: {email}") diff --git a/templates/login.html b/templates/login.html index fcacdac..d08607d 100644 --- a/templates/login.html +++ b/templates/login.html @@ -6,6 +6,8 @@

Login

+ {{ csrf_token() }} + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..aead5a8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,34 @@ +import os, sys +import pytest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from app import create_app +from models import db + + +@pytest.fixture() +def app(): + os.environ["SECRET_KEY"] = "test" + app = create_app() + app.config.update( + TESTING = True, + SQLALCHEMY_DATABASE_URI = ":memory:", + SQLALCHEMY_TRACK_MODIFICATIONS = False, + WTF_CSRF_ENABLED = False, # Disable CSRF for testing + SERVER_NAME = "localhost.localdomain", # Needed for url_for() during tests + RATE_LIMIT_ENABLED = False, # Disable rate limiting for tests + RATELIMIT_STORAGE_URL = "memory://", # Use in-memory storage for rate limiting + SESSION_COOKIE_SECURE = None, # Disable secure cookies for testing + ) + with app.app_context(): + db.drop_all() + db.create_all() + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() \ No newline at end of file diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py new file mode 100644 index 0000000..4b2fd76 --- /dev/null +++ b/tests/test_auth_flow.py @@ -0,0 +1,3 @@ +def test_next_param_is_safe(client): + response = client.get("/auth/login?next=/home") + assert response.status_code == 200 \ No newline at end of file diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..5f09f15 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,21 @@ +def test_signup_rejects_short_password(client): + response = client.post('/auth/signup', data={ + 'name': 'Test User', + 'email': 'a@b.com', + 'password': 'short' + }, follow_redirects=True) + assert b'Signup failed. Password must be at least 8 characters long.' in response.data + +def test_duplicate_email(client): + client.post('/auth/signup', data={ + 'name': 'Test User', + 'email': 'b@c.com', + 'password': 'Password123' + }) + + response = client.post('/auth/signup', data={ + 'name': 'Another User', + 'email': 'b@c.com', + 'password': 'Password123' + }, follow_redirects=True) + assert b'signup failed. email already registered.' in response.data.lower() \ No newline at end of file From cebffa97b44b4b4fdea26475183f3a10c256e17b Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 16:38:51 +1000 Subject: [PATCH 21/28] add authentication documentation --- docs/api-auth.md | 120 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 docs/api-auth.md diff --git a/docs/api-auth.md b/docs/api-auth.md new file mode 100644 index 0000000..4bf9c29 --- /dev/null +++ b/docs/api-auth.md @@ -0,0 +1,120 @@ +# Authentication API +This document describes the **login** and **logout** endpoints implemented in the `auth` blueprint. + +Authentication uses **Flask-Login** session cookies. +- Login expects **form POST**. +- Successful login redirects (302). +- Failed login returns **400** with a plain-text error message. +- Logout requires an active session and always redirects back to the login page. + +Base path: `/auth` + +--- + +## POST `/auth/login` + +Log a user in. + +### Request +- **Body fields** + - `email` *(string, required)* – user email + - `password` *(string, required)* + - `remember` *(optional, any truthy value)* – set a long-lived session + - `next` *(optional)* – relative path to redirect after login (validated for safety) + +### Responses +- **302**: Redirect to: + - `next` (if provided & safe), otherwise `/home` +- **400**: Plain text error + +### Rate limiting +- Limited to **5 POST requests per minute** per IP (`flask-limiter`). + +### Examples + + **Success (redirect to /home)** + ```bash + curl -i -X POST http://localhost:5000/auth/login \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "email=test@example.com&password=Password123&remember=1" + + curl -i -X POST "http://localhost:5000/auth/login?next=/dashboard" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "email=test@example.com&password=Password123" + + + curl -i -X POST http://localhost:5000/auth/login \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "email=test@example.com&password=wrong" + # HTTP/1.1 400 BAD REQUEST + # body: Login failed. Incorrect email or password. + +## GET `/auth/logout` + +Logs out the current user (if logged in) and redirects to login + +### Responses +- **302**: Redirect to `/auth/login` +- If not logged in -> **302** to `/auth/login` (handled by `unauthorized_handler`) + +### Examples + curl -i -X GET http://localhost:5000/auth/logout + +## POST `/auth/logout` + +Same as GET, but uses POST (sometimes preferred fro CSRF-protected UIs) + +### Responses +- **302**: Redirect to `/auth/login` +- If not logged in -> **302** to `/auth/login` + +### Examples + curl -i -X POST http://localhost:5000/auth/logout + +## GET `/auth/signup` + +Render the signup page (HTML) + +### Responses +- **200** OK: return HTML signup form + +## POST `/auth/signup` +Create a new account (UserCredential + UserProfile) + +### Request +- Content-Type: application/x-www-form-urlencoded +- Body fields + - name (string, required) + - email (string, required, unique) + - password (string, required, validated for strength) + +### Responses +- **302**: Redirect to `/home` after successfull signup and auto-login +- **400**: Validation error (missing fields, weak password, etc.) +- **409**: Duplicate email + +### Examples + **Success (redirect to /home)** + ```bash + curl -i -X POST http://localhost:5000/auth/signup \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "name=Test User&email=new@example.com&password=Password123!" + + curl -i -X POST http://localhost:5000/auth/signup \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "name=Test User&email=test@example.com&password=Password123!" + # HTTP/1.1 409 CONFLICT + # body: Signup failed. Email already registered. + +## Error Reference +| Endpoint | Scenario | Status | Behavior / Message | +| -------------- | ------------------------------ | ------ | -------------------------------------------- | +| `/auth/login` | Missing or wrong password | 400 | `Login failed. Incorrect email or password.` | +| `/auth/login` | Success no `next` | 302 | Redirect to `/home` | +| `/auth/login` | Success with safe `next` | 302 | Redirect to that path | +| `/auth/logout` | Not logged in | 302 | Redirect to `/auth/login` | +| `/auth/logout` | Success | 302 | Redirect to `/auth/login` | +| `/auth/signup` | Success | 302 | Redirect to `/home` | +| `/auth/signup` | Duplicate email | 409 | `Signup failed. Email already registered.` | +| `/auth/signup` | Weak password / missing fields | 400 | Error message (e.g. "Password too short") | + From c1d56ecd60755a80003e24480c50e9d4ee060a46 Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 16:40:57 +1000 Subject: [PATCH 22/28] add more test for api-auth --- app.py | 4 +++ auth/routes.py | 14 ++++---- extensions.py | 7 +++- ...user_profile_make_gender_nullable_with_.py | 36 +++++++++++++++++++ models/user_profile.py | 2 +- tests/test_auth_flow.py | 19 ++++++++++ 6 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 migrations/versions/83ce6751bfcd_user_profile_make_gender_nullable_with_.py diff --git a/app.py b/app.py index dffbb9f..059c71a 100644 --- a/app.py +++ b/app.py @@ -20,6 +20,7 @@ # Load environment variables from .env file load_dotenv() +login_manager.login_view = "auth.login" @login_manager.user_loader def load_user(user_id: str): @@ -27,6 +28,9 @@ def load_user(user_id: str): return db.session.get(UserCredential, int(user_id)) except (TypeError, ValueError): return None + +def unauthorized(): + return redirect(url_for('auth.login')) def create_app(): app = Flask(__name__) diff --git a/auth/routes.py b/auth/routes.py index e24cd96..68d2836 100644 --- a/auth/routes.py +++ b/auth/routes.py @@ -5,7 +5,7 @@ from extensions import db, limiter from sqlalchemy.exc import IntegrityError -auth_bp = Blueprint('auth', __name__) +auth_bp = Blueprint('auth', __name__, template_folder='templates') def validate_password(pwd: str): if len(pwd) < 8: @@ -87,20 +87,22 @@ def login(): email = request.form.get('email').strip().lower() password = request.form.get('password') next_page = request.args.get('next') or request.form.get('next') - user = UserCredential.query.filter_by(email=email).first() remember = bool(request.form.get('remember')) + # Loopkup user + user = UserCredential.query.filter_by(email=email).first() + + #Auth check if user and user.check_password(password): login_user(user, remember=remember) dest = next_page if is_safe_url(next_page) else url_for("home") return redirect(dest) else: - error = "Invalid email or password." - return render_template('login.html', error=error), 400 + return "Login failed. Incorrect email or password.", 400 - return render_template('login.html', error=error) + return render_template('login.html', error=None) -@auth_bp.route('/logout', methods=['POST']) +@auth_bp.route('/logout', methods=['GET','POST']) @login_required def logout(): logout_user() diff --git a/extensions.py b/extensions.py index 4f5082b..ffa3992 100644 --- a/extensions.py +++ b/extensions.py @@ -4,10 +4,15 @@ from flask_limiter import Limiter from flask_limiter.util import get_remote_address from flask_wtf import CSRFProtect +import os db = SQLAlchemy() login_manager = LoginManager() migrate = Migrate() csrf = CSRFProtect() -limiter = Limiter(key_func=get_remote_address, default_limits=["200 per hour"]) \ No newline at end of file +limiter = Limiter( + key_func=get_remote_address, + default_limits=["200 per hour"], + storage_uri=os.getenv("RATELIMIT_STORAGE_URL", "memory://"), +) \ No newline at end of file diff --git a/migrations/versions/83ce6751bfcd_user_profile_make_gender_nullable_with_.py b/migrations/versions/83ce6751bfcd_user_profile_make_gender_nullable_with_.py new file mode 100644 index 0000000..b2ceddb --- /dev/null +++ b/migrations/versions/83ce6751bfcd_user_profile_make_gender_nullable_with_.py @@ -0,0 +1,36 @@ +"""user_profile make gender nullable with default + +Revision ID: 83ce6751bfcd +Revises: +Create Date: 2025-08-27 15:11:21.146287 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '83ce6751bfcd' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_profile', schema=None) as batch_op: + batch_op.alter_column('gender', + existing_type=sa.VARCHAR(length=10), + nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_profile', schema=None) as batch_op: + batch_op.alter_column('gender', + existing_type=sa.VARCHAR(length=10), + nullable=False) + + # ### end Alembic commands ### diff --git a/models/user_profile.py b/models/user_profile.py index 6dbca67..d55064e 100644 --- a/models/user_profile.py +++ b/models/user_profile.py @@ -11,7 +11,7 @@ class UserProfile(db.Model): name = db.Column(db.String(100), nullable=False) account = db.Column(db.String(100), unique=True, nullable=False) birthDate = db.Column(db.String(10), nullable=False) - gender = db.Column(db.String(10), nullable=False) + gender = db.Column(db.String(10), nullable=True, server_default="---") avatar = db.Column(db.String(200), nullable=True) diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py index 4b2fd76..4ff8a62 100644 --- a/tests/test_auth_flow.py +++ b/tests/test_auth_flow.py @@ -1,3 +1,22 @@ +from models import db, UserCredential, UserProfile + +def test_login_wrong_password(client, app): + with app.app_context(): + u = UserCredential(email="x@y.com") + u.set_password("Password123") + db.session.add(u) + db.session.flush() + db.session.add(UserProfile(user_id=u.id, name="Test User", account="x@y", birthDate="2000-01-01")) + db.session.commit() + + res = client.post('/auth/login', data={"email": "x@y.com", "password": "badpw"}, follow_redirects=False) + assert res.status_code == 400 + assert b'Login failed. Incorrect email or password.' in res.data + +def test_logout_requires_login(client): + res = client.get('/auth/logout') + assert res.status_code == 302 # Redirect to login + def test_next_param_is_safe(client): response = client.get("/auth/login?next=/home") assert response.status_code == 200 \ No newline at end of file From b3fa0baaabcda0f48b4415b6a0c02daa0bcf4b2e Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 17:41:15 +1000 Subject: [PATCH 23/28] update requirements --- requirements.txt | Bin 1268 -> 1400 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index 3396a74673c79bdb3054d15c7e1e2837a4c0b046..29746878f1bdb4b6aaa3d8638b03b359ae3b5b4a 100644 GIT binary patch delta 144 zcmeyu`Gadi5sSPVLk>eCLoq`(gDyijLkNQ#gDnsmGUzcF0kPraT$bh(WuSN}P)!L# tCPN-WIs-2Q7efI delta 16 Xcmeyt^@Vdo5zFLhEOjir3|tHVIEV!7 From bfc0f6c82334d0449719040886963ad8486f36ea Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 17:44:05 +1000 Subject: [PATCH 24/28] fix api-auth documentation remove POST logout section to only use GET --- docs/api-auth.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/api-auth.md b/docs/api-auth.md index 4bf9c29..48e822b 100644 --- a/docs/api-auth.md +++ b/docs/api-auth.md @@ -53,12 +53,15 @@ Log a user in. Logs out the current user (if logged in) and redirects to login +### Auth +- Requires an active session (`@login_required`) +- If not logged in, user is redirected to `\auth\login` + ### Responses - **302**: Redirect to `/auth/login` -- If not logged in -> **302** to `/auth/login` (handled by `unauthorized_handler`) ### Examples - curl -i -X GET http://localhost:5000/auth/logout + curl -i http://localhost:5000/auth/logout ## POST `/auth/logout` From 4aed42366b0a9a36318f64988d07fb291d0fc9ea Mon Sep 17 00:00:00 2001 From: Chansada Date: Wed, 27 Aug 2025 17:45:44 +1000 Subject: [PATCH 25/28] fix CSRF errors and make logout work with GET --- app.py | 12 +++++++++--- auth/routes.py | 4 ++-- templates/home.html | 4 +--- templates/login.html | 5 +++-- templates/signup.html | 5 +++-- 5 files changed, 18 insertions(+), 12 deletions(-) diff --git a/app.py b/app.py index 059c71a..be69c85 100644 --- a/app.py +++ b/app.py @@ -3,6 +3,7 @@ from flask_cors import CORS from flask_login import login_required, current_user from dotenv import load_dotenv +from flask_wtf.csrf import CSRFError # Local imports from models import UserCredential @@ -22,15 +23,16 @@ login_manager.login_view = "auth.login" +@login_manager.unauthorized_handler +def unauthorized(): + return redirect(url_for('auth.login')) + @login_manager.user_loader def load_user(user_id: str): try: return db.session.get(UserCredential, int(user_id)) except (TypeError, ValueError): return None - -def unauthorized(): - return redirect(url_for('auth.login')) def create_app(): app = Flask(__name__) @@ -73,6 +75,10 @@ def create_app(): app.register_blueprint(dashboard_bp, url_prefix='/api/dashboard') app.register_blueprint(profile_api, url_prefix='/api/profile') + @app.errorhandler(CSRFError) + def handle_csrf_error(e): + return f"CSRF failed: {e.description}", 400 + # Routes @app.route('/') def index(): diff --git a/auth/routes.py b/auth/routes.py index 68d2836..7dd3ae8 100644 --- a/auth/routes.py +++ b/auth/routes.py @@ -80,7 +80,7 @@ def signup(): return render_template('signup.html', error=error) @auth_bp.route('/login', methods=['GET', 'POST']) -@limiter.limit("5 per minute") # Rate limiting to prevent brute-force attacks +@limiter.limit("5 per minute", methods=['POST'], per_method=True) # Rate limiting to prevent brute-force attacks def login(): error = None if request.method == 'POST': @@ -102,7 +102,7 @@ def login(): return render_template('login.html', error=None) -@auth_bp.route('/logout', methods=['GET','POST']) +@auth_bp.route('/logout', methods=['GET']) @login_required def logout(): logout_user() diff --git a/templates/home.html b/templates/home.html index e49dd48..d565302 100644 --- a/templates/home.html +++ b/templates/home.html @@ -10,8 +10,6 @@

Welcome, {{ current_user.profile.name }}!

Welcome, {{ current_user.email }}!

{% endif %} - - - + Logout
{% endblock %} diff --git a/templates/login.html b/templates/login.html index d08607d..d77e16d 100644 --- a/templates/login.html +++ b/templates/login.html @@ -5,9 +5,10 @@ {% block content %}

Login

-
- {{ csrf_token() }} + + + diff --git a/templates/signup.html b/templates/signup.html index 7cf6049..5c8e62f 100644 --- a/templates/signup.html +++ b/templates/signup.html @@ -5,8 +5,9 @@ {% block content %}

Sign Up

- - {{ csrf_token() }} + + + From da1546157053f075f59019c5c88fd36162a3537b Mon Sep 17 00:00:00 2001 From: Chansada Date: Fri, 29 Aug 2025 23:11:01 +1000 Subject: [PATCH 26/28] fix debug --- app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index be69c85..0185c8f 100644 --- a/app.py +++ b/app.py @@ -104,8 +104,8 @@ def hello(): if __name__ == '__main__': app = create_app() - debug_mode = os.getenv("FLASK_DEBUG", "False").lower() == "true" + debug = os.environ.get("FLASK_DEBUG") == "1" port = int(os.getenv("PORT", 5000)) - app.run(debug=debug_mode, port=port) + app.run(debug=debug, port=port) From 1ef09304fd4f9a15ac16ff61ffc94d046fc91bec Mon Sep 17 00:00:00 2001 From: Chansada Date: Fri, 29 Aug 2025 23:21:53 +1000 Subject: [PATCH 27/28] fix security remove debug=True --- test_vul.py | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/test_vul.py b/test_vul.py index be74eab..305b0e8 100644 --- a/test_vul.py +++ b/test_vul.py @@ -1,31 +1,28 @@ import os -import flask -from flask import Flask, request +from flask import Flask, request, render_template_string +from werkzeug.security import check_password_hash import sqlite3 app = Flask(__name__) -# Hardcoded secret (should be flagged) -SECRET_KEY = "my_hardcoded_secret_key_12345" +# Load secret from environment +app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-only-not-for-prod") -# Vulnerable SQL query (should be flagged) @app.route('/login', methods=['POST']) def login(): username = request.form['username'] password = request.form['password'] - query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'" - conn = sqlite3.connect('users.db') - cursor = conn.cursor() - cursor.execute(query) - user = cursor.fetchone() - return "Logged in" if user else "Login failed" -# XSS-like template rendering with unescaped input (should be flagged) -@app.route('/welcome') + with sqlite3.connect("users.db") as conn: + cursor = conn.cursor() + cursor.execute("SELECT password_hash FROM users WHERE username = ?", (username,)) + row = cursor.fetchone() + + return "Logged in" if (row and check_password_hash(row[0], password)) else "Login failed" + def welcome(): - user_input = request.args.get('name') - return flask.render_template_string("

Welcome " + user_input + "

") + user_input = request.args.get('name', '') + return render_template_string("

Welcome " + user_input + "

") -# Debug mode enabled (should be flagged) if __name__ == "__main__": - app.run(debug=True) + app.run() From a197f68dea7db5ddb0515045d185a4df941b4f39 Mon Sep 17 00:00:00 2001 From: Chansada Date: Fri, 12 Sep 2025 15:27:53 +1000 Subject: [PATCH 28/28] add password policy to docs --- docs/api-auth.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/api-auth.md b/docs/api-auth.md index 48e822b..7a8b5d8 100644 --- a/docs/api-auth.md +++ b/docs/api-auth.md @@ -11,6 +11,15 @@ Base path: `/auth` --- +## Password Policy +To ensure account security, all user must meet the following requirements: +- Minimum length: 8 character +- Must include atleast: one letter (a-z) and one number (0-9) + +### Validation Error Messages +- Too short -> Password must be at least 8 characters +- Missing complexity -> Password must contain both letters and numbers. + ## POST `/auth/login` Log a user in. @@ -89,7 +98,7 @@ Create a new account (UserCredential + UserProfile) - Body fields - name (string, required) - email (string, required, unique) - - password (string, required, validated for strength) + - password (string, required, must meet Password Policy) ### Responses - **302**: Redirect to `/home` after successfull signup and auto-login