forked from Ugric/Komodo-Hub
added password reset
This commit is contained in:
1 parent
ae22ab9317
commit
7a9b6acaa4
9 files changed
+254
-19
No files matched your search
+53
-1
@@ -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
@@ -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
@@ -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.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
Reference in new issue
Block a user