diff --git a/.vscode/launch.json b/.vscode/launch.json index 68bf174870fd950031c7088641521fd2edbab836..a2e884a29372cd113a7dfff98c62a007d4fc1bce 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -19,7 +19,8 @@ "--no-reload" ], "jinja": true, - "autoStartBrowser": false + "autoStartBrowser": false, + "preLaunchTask": "Install Requirements" } ] } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000000000000000000000000000000000000..7ba19183135e479bfc420a96ae151876f18105dc --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,11 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Install Requirements", + "type": "shell", + "command": "pip install -r requirements.txt", + "problemMatcher": [] + } + ] +} diff --git a/app/__init__.py b/app/__init__.py index 35b1be553b2454c1b9b997b2c47b3446cd68c444..db209774c40df748848c8536ae1db4a2ec276607 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -6,11 +6,13 @@ from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate from flask_login import LoginManager, current_user from dotenv import load_dotenv +from flask_wtf.csrf import CSRFProtect import os db = SQLAlchemy() migrate = Migrate() login_manager = LoginManager() +csrf = CSRFProtect() def create_app(config_class=Config): app = Flask(__name__) @@ -40,6 +42,9 @@ def create_app(config_class=Config): # Register blueprints and url prefixes register_blueprints(app) + + #Protect internal endpoints from external use + csrf.init_app(app) # Add any vars needed accessible through all templates @app.context_processor diff --git a/app/api/routes.py b/app/api/routes.py index 503853e56491a02df325c73afb31916631fe5157..1c40f57de07b90beaedd98c2b7f704215d5163bb 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -2,6 +2,7 @@ from flask import render_template, redirect, url_for, Flask, jsonify from app.api import bp from app.models import User, Listings from sqlalchemy import text +from app import csrf @bp.route('/user_id/<int:id>', methods=['GET']) def get_user_by_id(id): diff --git a/app/models/user.py b/app/models/user.py index dcc99b3c7ee8e9a4e1a30e9708f35af1ee4d6b66..fd813cc96e0cff9220df2ced7d4a74f8e9eb5446 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -31,4 +31,10 @@ class User(UserMixin, db.Model): def search_user_by_email(cls, user_email): user_exist = cls.query.filter_by(email=user_email).first() + return user_exist + + @classmethod + def search_user_by_username(cls, user_name): + user_exist = cls.query.filter_by(username=user_name).first() + return user_exist \ No newline at end of file diff --git a/app/profile/routes.py b/app/profile/routes.py index 63f67bb4963029ffbb8c60a8ca4a528de1c7d8bd..223415e105122a98c50398e40f94ea4469449dec 100644 --- a/app/profile/routes.py +++ b/app/profile/routes.py @@ -1,5 +1,5 @@ #https://www.digitalocean.com/community/tutorials/how-to-add-authentication-to-your-app-with-flask-login#step-1-installing-packages -from flask import Blueprint, render_template, redirect, url_for, request, flash +from flask import Blueprint, render_template, redirect, url_for, request, flash, jsonify from app.profile import bp from werkzeug.security import check_password_hash from app.models import User @@ -7,39 +7,39 @@ from app import db from flask_login import login_user, logout_user, login_required, current_user from app.logger import auth_logger -@bp.route('/signup') +@bp.route('/signup', methods=['GET', 'POST']) def signup(): - return render_template('profile/signup.html') + if request.method == 'POST': + form_data = { + 'email': request.form.get('email'), + 'username': request.form.get('username'), + 'password': request.form.get('password') + } -@bp.route('/signup', methods=['POST']) -def signup_post(): - form_data = { - 'email': request.form.get('email'), - 'username': request.form.get('username'), - 'password': request.form.get('password') - } + if not form_data['username'] or not form_data['email'] or not form_data['password']: + flash('Missing required fields: Email, Username, Password') + return redirect(url_for('profile.signup')) - user = User.query.filter_by(email=form_data['email']).first() + user = User.query.filter_by(email=form_data['email']).first() - if user: - return redirect(url_for('profile.signup')) + if user: + flash('Email address already exists') + return redirect(url_for('profile.signup')) - try: - if not form_data['username'] or not form_data['email'] or not form_data['password']: - raise ValueError("Missing required fields: Email, Username, Password") - new_user = User.create_user(username=form_data['username'], email=form_data['email'], password=form_data['password']) - except ValueError as e: - auth_logger.error("Unable to create user, full error was: " + str(e)) - flash('Missing required fields') - return redirect(url_for('profile.signup')) + try: + new_user = User.create_user(username=form_data['username'], email=form_data['email'], password=form_data['password']) + db.session.add(new_user) + db.session.commit() - db.session.add(new_user) - db.session.commit() + login_user(new_user) + return redirect(url_for('profile.index')) + except Exception as e: + auth_logger.error(f"Unable to create user: {e}") + flash('An error occurred. Please try again.') + return redirect(url_for('profile.signup')) - # Log the user in automatically - login_user(new_user) + return render_template('profile/signup.html') - return redirect(url_for('profile.index')) @bp.route('/login', methods=['POST']) def login_post(): @@ -56,6 +56,34 @@ def login_post(): login_user(user, remember=remember) return redirect(url_for('profile.index')) + +@bp.route('/check-username', methods=['POST']) +def check_username(): + data = request.get_json() + username = data.get('username', '').strip() + if username is None: + return jsonify({'error': 'Username is required'}), 400 + + #Search to see if username already exists + user_exist = User.search_user_by_username(username) + if user_exist is not None: + return jsonify({'error': 'Username already exists'}), 400 + + return jsonify({'available': True, 'success': username + 'is available.'}) + + +@bp.route('/check-email', methods=['POST']) +def check_email(): + data = request.get_json() + email = data.get('email', '').strip() + + if not email: + return jsonify({'error': 'Email is required'}), 400 + + is_available = email not in emails + return jsonify({'available': is_available}) + + @bp.route('/logout') @login_required def logout(): @@ -64,6 +92,8 @@ def logout(): @bp.route('/login') def login(): + if current_user.is_authenticated: + return redirect(url_for('profile.index')) return render_template('profile/login.html') @login_required diff --git a/app/static/base.css b/app/static/base.css index 008b2e0d62339674cb5aa2f197002e7fe1a499ab..b19ba9383e24e6bd0875b5971ab4384681074ce2 100644 --- a/app/static/base.css +++ b/app/static/base.css @@ -89,25 +89,23 @@ Form styling options .form_box_30 { position: relative; width: 100%; - max-width: 400px; /* Maximum width for larger screens */ + max-width: 400px; margin: auto; vertical-align: middle; - padding: 15px; /* Add some padding */ + padding: 15px; } @media (max-width: 600px) { .form_box_30 { - width: 90%; /* Adjust width for smaller screens */ + width: 90%; } } - -.background_1 { +.profile_form_background { border-radius: 25px; - background: #90AEAD; + background: #244855; padding: 50px; color: #FBE9D0; - background-image: linear-gradient(to bottom right, #4169e1, #89CFF0); } .form-check-input:checked { @@ -115,6 +113,16 @@ Form styling options background: #FBE9D0; } +.form_header { + text-align: center; + text-transform: uppercase; + margin-bottom: 20px; +} + +.username-feedback .email-feedback { + text-align: center; +} + /* HEX codes for colours used through out website don't delete */ /* Colour pallete taken from https://visme.co/blog/website-color-schemes/ diff --git a/app/static/navigation.js b/app/static/generic.js similarity index 100% rename from app/static/navigation.js rename to app/static/generic.js diff --git a/app/templates/base.html b/app/templates/base.html index 0864006d2b0324c008546a8f02c87d54c9061f21..f1586d9b504371930c5a7e783508c58aa2eb5756 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -2,7 +2,9 @@ <!DOCTYPE html> <html> <head> + <meta name="csrf-token" content="{{ csrf_token() }}"> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='base.css')}}"> + <link rel="stylesheet" href="{{ url_for('static', filename='generic.js')}}"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous"> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='bootstrap_overrides.css')}}"> diff --git a/app/templates/profile/login.html b/app/templates/profile/login.html index 130d7ef05531ea889a4da4f1c931e4406cfef4b3..0f6d9a0b9e706e896ba870ad359b8fe635ec8746 100644 --- a/app/templates/profile/login.html +++ b/app/templates/profile/login.html @@ -1,10 +1,11 @@ {% extends 'base.html' %} {% block content %} <div class="column is-4 is-offset-4"> - <div id="login-box" class="form_box_30"> - <h3 style="margin-top:30px; text-align:center">Login</h3> - <div class="background_1"> + <div id="login-box" class="form_box_30" style="margin-top: 30px;"> + <div class="profile_form_background"> + <h2 class="form_header">Login</h2> <form method="POST" action="{{ url_for('profile.login_post') }}"> + {{ csrf_token() }} <div class="mb-3"> <label class="form-label" autofocus="">Username</label> <input type="text" class="form-control" name="username"> @@ -26,4 +27,4 @@ </div> </div> </div> -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/templates/profile/signup.html b/app/templates/profile/signup.html index a7ef4e83f04fd94c80ebb7a1ea37f26cb82dfaaf..24849007116bf863f9c3d6018aa9a2a0a6947cfe 100644 --- a/app/templates/profile/signup.html +++ b/app/templates/profile/signup.html @@ -1,27 +1,82 @@ {% extends 'base.html' %} {% block content %} - <div class="form_box_30 background_1"> - <h3 style="margin-top:30px">Sign Up</h3> - <form method="POST" action="{{ url_for('profile.signup_post') }}"> +<div class="form_box_30 background_1"> + <div class="profile_form_background"> + <h3 class="form_header">Sign Up</h3> + <form method="POST" action="{{ url_for('profile.signup') }}"> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <div class="mb-3"> - <label class="form-label" autofocus="">Username</label> - <input type="username" class="form-control" name="username"> + <label class="form-label" for="username" autofocus="">Username</label> + <input type="text" id="username" class="form-control" name="username" required> + <small id="username-feedback" class="form-text"></small> </div> <div class="mb-3"> - <label class="form-label">Email address</label> - <input type="email" class="form-control" name="email"> + <label class="form-label" for="email">Email address</label> + <input type="email" id="email" class="form-control" name="email" required> + <small id="email-feedback" class="form-text"></small> </div> <div class="mb-3"> - <label class="form-label">Password</label> - <input type="password" class="form-control" name="password"> + <label class="form-label" for="password">Password</label> + <input type="password" id="password" class="form-control" name="password" required> </div> <div class="mb-3 form-check"> <input type="checkbox" class="form-check-input" id="remember" name="remember"> <label class="form-check-label" for="remember">Remember me</label> </div> - <button type="submit" class="btn btn-primary">Log In</button> - </form> + <button type="submit" class="btn btn-primary">Sign Up</button> + </form> </div> +</div> -{% endblock %} \ No newline at end of file +<script> + function makeAjaxRequest(url, data, field) { + const feedbackElement = document.getElementById(`${field}-feedback`); + const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); + + feedbackElement.textContent = ''; + feedbackElement.style.color = ''; + + if (data[field].length > 0) { + fetch(url, { + method: 'POST', + headers: { + 'X-CSRFToken': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }) + .then(response => response.json()) + .then(data => { + if (data.available) { + feedbackElement.textContent = `${field.charAt(0).toUpperCase() + field.slice(1)} is available.`; + feedbackElement.style.color = 'green'; + } else { + feedbackElement.textContent = `${field.charAt(0).toUpperCase() + field.slice(1)} is already taken.`; + feedbackElement.style.color = 'red'; + } + }) + .catch(error => { + feedbackElement.textContent = `Error checking ${field}.`; + feedbackElement.style.color = 'red'; + }); + } + } + + function checkUsernameAvailability(username) { + makeAjaxRequest("{{ url_for('profile.check_username') }}", { 'username': username }, 'username'); + } + + function checkEmailAvailability(email) { + makeAjaxRequest("{{ url_for('profile.check_email') }}", { 'email': email }, 'email'); + } + + document.getElementById('username').addEventListener('blur', function () { + checkUsernameAvailability(this.value); + }); + + document.getElementById('email').addEventListener('blur', function () { + checkEmailAvailability(this.value); + }); +</script> +{% endblock %} diff --git a/requirements.txt b/requirements.txt index 619304637acfffb56b3620f7b5474dc948294fc8..eebb30e827ff7a6a96ee58cf463372a60942ccb4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ python-dotenv jinja2 cryptography flask-login -debugpy \ No newline at end of file +debugpy +flask-wtf \ No newline at end of file