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 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 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__) diff --git a/app.py b/app.py index f9f3767..0185c8f 100644 --- a/app.py +++ b/app.py @@ -1,108 +1,111 @@ -from flask import Flask, jsonify, session, render_template, request, redirect +import os +from flask import Flask, jsonify, session, render_template, redirect, url_for 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 +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 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 + # 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.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 create_app(): + app = Flask(__name__) + + # Flask config (Core) + 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 + + # Security settings for cookies + app.config.update( + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + SESSION_COOKIE_SECURE=False, + REMEMBER_COOKIE_HTTPONLY=True, + REMEMBER_COOKIE_SECURE=False, + ) + + + # Initialize database + db.init_app(app) + migrate.init_app(app, db) + login_manager.init_app(app) + 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') + 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') + + @app.errorhandler(CSRFError) + def handle_csrf_error(e): + return f"CSRF failed: {e.description}", 400 + + # 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=current_user) + + # 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__': - debug_mode = os.getenv("FLASK_DEBUG", "False").lower() == "true" - app.run(debug=debug_mode, port=int(os.getenv("PORT", 5000))) + app = create_app() + debug = os.environ.get("FLASK_DEBUG") == "1" + port = int(os.getenv("PORT", 5000)) + app.run(debug=debug, port=port) diff --git a/auth/routes.py b/auth/routes.py new file mode 100644 index 0000000..7dd3ae8 --- /dev/null +++ b/auth/routes.py @@ -0,0 +1,110 @@ +from flask import Blueprint, render_template, request, redirect, url_for +from flask_login import login_user, logout_user, login_required +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__, template_folder='templates') + +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 + +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 + if request.method == 'POST': + name = request.form.get('name').strip() + email = request.form.get('email').strip().lower() + 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.") + validate_password(password) + + # Check if email already exists + if UserCredential.query.filter_by(email=email).first(): + error = "signup failed. email already registered." + return render_template('signup.html', error=error), 400 + + + # 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() + + # Auto-login after 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) + +@auth_bp.route('/login', methods=['GET', 'POST']) +@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': + email = request.form.get('email').strip().lower() + password = request.form.get('password') + next_page = request.args.get('next') or request.form.get('next') + 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: + return "Login failed. Incorrect email or password.", 400 + + return render_template('login.html', error=None) + +@auth_bp.route('/logout', methods=['GET']) +@login_required +def logout(): + logout_user() + return redirect(url_for('auth.login')) + diff --git a/docs/api-auth.md b/docs/api-auth.md new file mode 100644 index 0000000..7a8b5d8 --- /dev/null +++ b/docs/api-auth.md @@ -0,0 +1,132 @@ +# 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` + +--- + +## 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. + +### 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 + +### Auth +- Requires an active session (`@login_required`) +- If not logged in, user is redirected to `\auth\login` + +### Responses +- **302**: Redirect to `/auth/login` + +### Examples + curl -i 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, must meet Password Policy) + +### 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") | + diff --git a/extensions.py b/extensions.py new file mode 100644 index 0000000..ffa3992 --- /dev/null +++ b/extensions.py @@ -0,0 +1,18 @@ +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 +import os + + +db = SQLAlchemy() +login_manager = LoginManager() +migrate = Migrate() +csrf = CSRFProtect() +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/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/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/__init__.py b/models/__init__.py index 589c64f..2d472d7 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 +from extensions import db + + +from .user_credential import UserCredential +from .user_profile import UserProfile +from .goal import Goal + diff --git a/models/user_credential.py b/models/user_credential.py new file mode 100644 index 0000000..73374ff --- /dev/null +++ b/models/user_credential.py @@ -0,0 +1,23 @@ +from extensions 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, cascade="all, delete-orphan") + + 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 diff --git a/models/user.py b/models/user_profile.py similarity index 66% rename from models/user.py rename to models/user_profile.py index abb7d30..d55064e 100644 --- a/models/user.py +++ b/models/user_profile.py @@ -1,15 +1,20 @@ -from models import db +from extensions 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) + gender = db.Column(db.String(10), nullable=True, server_default="---") avatar = db.Column(db.String(200), nullable=True) + def as_dict(self): return { "id": self.id, diff --git a/requirements.txt b/requirements.txt index 96b5b35..2974687 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/scripts/add_default_user.py b/scripts/add_default_user.py index 6fc9cc8..ca345e0 100644 --- a/scripts/add_default_user.py +++ b/scripts/add_default_user.py @@ -1,18 +1,43 @@ -from models import db -from models.user 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/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 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 %} +
+ + + diff --git a/templates/home.html b/templates/home.html index c7acb54..d565302 100644 --- a/templates/home.html +++ b/templates/home.html @@ -1,18 +1,15 @@ - - - - - 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 %} + + Logout +
+{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..d77e16d --- /dev/null +++ b/templates/login.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block title %}Login{% endblock %} + +{% block content %} +
+

Login

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

{{ error }}

+ {% endif %} + +

Don’t have an account? Sign up

+
+{% endblock %} diff --git a/templates/signup.html b/templates/signup.html index 4d42d6f..5c8e62f 100644 --- a/templates/signup.html +++ b/templates/signup.html @@ -1,21 +1,24 @@ - - - - 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

- - 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() 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..4ff8a62 --- /dev/null +++ b/tests/test_auth_flow.py @@ -0,0 +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 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