added password reset

This commit is contained in:
deole2 committed 2026-04-07 00:01:25 +01:00
1 parent ae22ab9317
commit 7a9b6acaa4
9 files changed
+254 -19

No files matched your search

+53 -1
View File
@@ -8,9 +8,10 @@ import random
def register_user(username, email, password, account_type="private_user"): def register_user(username, email, password, account_type="private_user"):
email = email.strip().lower()
with get_db() as conn: with get_db() as conn:
existing = conn.execute( existing = conn.execute(
"SELECT user_id FROM users WHERE username = ? OR email = ?", "SELECT user_id FROM users WHERE username = ? OR LOWER(email) = LOWER(?)",
(username, email) (username, email)
).fetchone() ).fetchone()
if existing: if existing:
@@ -61,6 +62,49 @@ def create_token_for_user(user_id):
return token return token
print("hello") print("hello")
def create_password_reset_request(email):
user = get_user_by_email(email)
if not user:
return None
reset_token = secrets.token_hex(64)
with get_db() as conn:
conn.execute("""
INSERT INTO password_resets (
token,
user_id,
created_at
)
VALUES (?, ?, ?)
""", (reset_token, user["user_id"], datetime.now()))
return reset_token
def get_password_reset_request(token):
with get_db() as conn:
row = conn.execute(
"SELECT * FROM password_resets WHERE token = ? LIMIT 1",
(token,)
).fetchone()
return dict(row) if row else None
def reset_password(token, password):
reset_request = get_password_reset_request(token)
if not reset_request:
return False
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt(14))
with get_db() as conn:
conn.execute(
"UPDATE users SET password_hash = ? WHERE user_id = ?",
(password_hash, reset_request["user_id"])
)
conn.execute(
"DELETE FROM password_resets WHERE token = ?",
(token,)
)
return True
def delete_token(token): def delete_token(token):
with get_db() as conn: with get_db() as conn:
conn.execute(""" conn.execute("""
@@ -68,6 +112,14 @@ def delete_token(token):
WHERE token = ? WHERE token = ?
""", (token,)) """, (token,))
def get_user_by_email(email):
with get_db() as conn:
row = conn.execute(
"SELECT * FROM users WHERE LOWER(email) = LOWER(?) LIMIT 1",
(email,)
).fetchone()
return dict(row) if row else None
def add_species(english, latin, body, category, extinction_risk, image_id): def add_species(english, latin, body, category, extinction_risk, image_id):
with get_db() as conn: with get_db() as conn:
try: try:
+63 -16
View File
@@ -1,11 +1,51 @@
import os
import sqlite3 import sqlite3
from contextlib import contextmanager from contextlib import contextmanager
DATABASE_PATH = "database/database.db" DATABASE_PATH = "database/database.db"
DATABASE_URL = os.getenv("DATABASE_URL")
USE_POSTGRES = DATABASE_URL is not None
db = sqlite3.connect(DATABASE_PATH, check_same_thread=False) if USE_POSTGRES:
db.row_factory = sqlite3.Row import psycopg2
db.execute(""" import psycopg2.extras
class DBCursor:
def __init__(self, cursor):
self._cursor = cursor
def execute(self, sql, params=None):
if params is None:
params = ()
sql = sql.replace("?", "%s")
return self._cursor.execute(sql, params)
def executemany(self, sql, params=None):
if params is None:
params = ()
sql = sql.replace("?", "%s")
return self._cursor.executemany(sql, params)
def __getattr__(self, name):
return getattr(self._cursor, name)
@contextmanager
def get_db():
conn = psycopg2.connect(DATABASE_URL, cursor_factory=psycopg2.extras.RealDictCursor)
cursor = conn.cursor()
try:
yield DBCursor(cursor)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
cursor.close()
conn.close()
else:
db = sqlite3.connect(DATABASE_PATH, check_same_thread=False)
db.row_factory = sqlite3.Row
db.execute("""
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
user_id INTEGER PRIMARY KEY, user_id INTEGER PRIMARY KEY,
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
@@ -15,22 +55,29 @@ CREATE TABLE IF NOT EXISTS users (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) )
""") """)
db.execute(""" db.execute("""
CREATE TABLE IF NOT EXISTS tokens ( CREATE TABLE IF NOT EXISTS tokens (
token TEXT PRIMARY KEY, token TEXT PRIMARY KEY,
user_id INTEGER user_id INTEGER
) )
""") """)
db.commit() db.execute("""
CREATE TABLE IF NOT EXISTS password_resets (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
db.commit()
@contextmanager @contextmanager
def get_db(): def get_db():
conn = db.cursor() conn = db.cursor()
try: try:
yield conn yield conn
db.commit() db.commit()
except Exception: except Exception:
db.rollback() db.rollback()
raise raise
finally: finally:
conn.close() conn.close()
+15 -1
View File
@@ -1,4 +1,4 @@
from database.db_connection import get_db, get_db from database.db_connection import get_db
from database.db_commands import register_user from database.db_commands import register_user
from datetime import datetime from datetime import datetime
@@ -127,6 +127,7 @@ def init_database():
photoid TEXT photoid TEXT
) )
""") """)
init_accounts_database()
print("Database initialised.") print("Database initialised.")
seed_species() seed_species()
@@ -210,6 +211,19 @@ def init_accounts_database():
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) )
""") """)
conn.execute("""
CREATE TABLE IF NOT EXISTS tokens (
token TEXT PRIMARY KEY,
user_id INTEGER
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS password_resets (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
print("Accounts database initialised.") print("Accounts database initialised.")
+1
View File
@@ -35,6 +35,7 @@ def import_pages(route, app):
app = Flask(__name__, static_folder="static", static_url_path="") app = Flask(__name__, static_folder="static", static_url_path="")
app.secret_key = "9279dc6c-d2bc-4f89-a1b3-fc1438a6910c" app.secret_key = "9279dc6c-d2bc-4f89-a1b3-fc1438a6910c"
init_database()
@app.before_request @app.before_request
def load_user(): def load_user():
+1
View File
@@ -6,3 +6,4 @@ itsdangerous==2.2.0
Jinja2==3.1.6 Jinja2==3.1.6
MarkupSafe==3.0.3 MarkupSafe==3.0.3
Werkzeug==3.1.5 Werkzeug==3.1.5
psycopg2-binary==2.9.10
+59 -1
View File
@@ -1,5 +1,11 @@
from flask import render_template, Blueprint, request, redirect, url_for, Response, g from flask import render_template, Blueprint, request, redirect, url_for, Response, g
from database.db_commands import register_user, login_user from database.db_commands import (
register_user,
login_user,
create_password_reset_request,
get_password_reset_request,
reset_password,
)
page = Blueprint("login", __name__, url_prefix="") page = Blueprint("login", __name__, url_prefix="")
@@ -24,6 +30,58 @@ def login():
return resp return resp
@page.route("/password-reset", methods=["GET", "POST"])
def password_reset_request():
if g.user:
return redirect(url_for("login.dashboard"))
message = None
reset_link = None
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
token = create_password_reset_request(email)
message = (
"If an account with that email exists, a password reset link has been created."
)
if token:
reset_link = url_for("login.password_reset", token=token, _external=True)
return render_template(
"accounts/password_reset_request.jinja",
message=message,
reset_link=reset_link,
)
@page.route("/password-reset/<token>", methods=["GET", "POST"])
def password_reset(token):
if g.user:
return redirect(url_for("login.dashboard"))
error = None
success = None
reset_request = get_password_reset_request(token)
if not reset_request:
error = "This reset link is invalid or has expired."
if request.method == "POST" and reset_request:
password = request.form.get("password", "")
password_confirm = request.form.get("password_confirm", "")
if password != password_confirm:
error = "Passwords do not match."
elif len(password) < 8:
error = "Password must be at least 8 characters."
else:
if reset_password(token, password):
success = "Your password has been reset. Please log in."
reset_request = None
else:
error = "Unable to reset your password. Please request a new link."
return render_template(
"accounts/password_reset.jinja",
error=error,
success=success,
token=token,
reset_request=reset_request,
)
@page.route("/register", methods=["GET", "POST"]) @page.route("/register", methods=["GET", "POST"])
def register(): def register():
if g.user: if g.user:
+1
View File
@@ -21,6 +21,7 @@
<button type="submit" class="submit-button"> <button type="submit" class="submit-button">
Login Login
</button> </button>
<p class="form-link"><a href="{{ url_for('login.password_reset_request') }}">Forgot your password?</a></p>
</form> </form>
</section> </section>
{% endblock %} {% endblock %}
+35
View File
@@ -0,0 +1,35 @@
{% extends "base.jinja" %}
{% block title %} Set New Password {% endblock %}
{% block head %}
{% endblock %}
{% block content %}
{% from 'button.jinja' import button %}
<section id="hero" class="form">
<div class="container">
{% if error %}
<p class="form-error">{{ error }}</p>
{% endif %}
{% if success %}
<p class="form-success">{{ success }}</p>
<p><a href="{{ url_for('login.login') }}">Return to login</a></p>
{% elif reset_request %}
<form action="{{ url_for('login.password_reset', token=token) }}" method="POST">
<div class="form-group">
<label for="password">New Password:</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label for="password_confirm">Confirm Password:</label>
<input type="password" id="password_confirm" name="password_confirm" required>
</div>
<button type="submit" class="submit-button">
Reset Password
</button>
</form>
{% else %}
<p>Please request a new password reset link.</p>
<p><a href="{{ url_for('login.password_reset_request') }}">Request another reset link</a></p>
{% endif %}
</div>
</section>
{% endblock %}
@@ -0,0 +1,26 @@
{% extends "base.jinja" %}
{% block title %} Password Reset {% endblock %}
{% block head %}
{% endblock %}
{% block content %}
{% from 'button.jinja' import button %}
<section id="hero" class="form">
<form action="{{ url_for('login.password_reset_request') }}" class="container" method="POST">
{% if message %}
<p class="form-success">{{ message }}</p>
{% endif %}
{% if reset_link %}
<p class="form-info">
Reset link: <a style="word-break: break-all;" href="{{ reset_link }}">{{ reset_link }}</a>
</p>
{% endif %}
<div class="form-group">
<label for="email">Email:</label>
<input type="email" id="email" name="email" required>
</div>
<button type="submit" class="submit-button">
Send Reset Link
</button>
</form>
</section>
{% endblock %}