add a way for mods to create a password reset link for users

This commit is contained in:
2025-08-10 19:00:47 +03:00
parent cf2d605077
commit 4c2877403d
6 changed files with 114 additions and 3 deletions

View File

@ -281,3 +281,7 @@ class Reactions(Model):
""" """
return db.query(q, post_id, reaction_text) return db.query(q, post_id, reaction_text)
class PasswordResetLinks(Model):
table = "password_reset_links"

View File

@ -1,9 +1,12 @@
from flask import ( from flask import (
Blueprint, render_template, request, redirect, url_for Blueprint, render_template, request, redirect, url_for
) )
from .users import login_required, mod_only, get_active_user from .users import login_required, mod_only, get_active_user, admin_only
from ..models import Users from ..models import Users, PasswordResetLinks
from ..db import db, DB from ..db import db, DB
import secrets
import time
bp = Blueprint("mod", __name__, url_prefix = "/mod/") bp = Blueprint("mod", __name__, url_prefix = "/mod/")
@bp.get("/sort-topics") @bp.get("/sort-topics")
@ -31,3 +34,18 @@ def sort_topics_post():
def user_list(): def user_list():
users = Users.select() users = Users.select()
return render_template("mod/user-list.html", users = users) return render_template("mod/user-list.html", users = users)
@bp.post("/reset-pass/<user_id>")
@login_required
@mod_only("topics.all_topics")
def create_reset_pass(user_id):
now = int(time.time())
key = secrets.token_urlsafe(20)
reset_link = PasswordResetLinks.create({
'user_id': int(user_id),
'expires_at': now + 24 * 60 * 60,
'key': key,
})
return redirect(url_for('users.reset_link_login', key=key))

View File

@ -4,7 +4,7 @@ from flask import (
from functools import wraps from functools import wraps
from ..db import db from ..db import db
from ..lib.babycode import babycode_to_html from ..lib.babycode import babycode_to_html
from ..models import Users, Sessions, Subscriptions, Avatars from ..models import Users, Sessions, Subscriptions, Avatars, PasswordResetLinks
from ..constants import InfoboxKind, PermissionLevel from ..constants import InfoboxKind, PermissionLevel
from ..auth import digest, verify from ..auth import digest, verify
from wand.image import Image from wand.image import Image
@ -516,3 +516,60 @@ def inbox(username):
}) })
return render_template("users/inbox.html", new_posts = new_posts, total_unreads_count = total_unreads_count, all_subscriptions = all_subscriptions) return render_template("users/inbox.html", new_posts = new_posts, total_unreads_count = total_unreads_count, all_subscriptions = all_subscriptions)
@bp.get('/reset-link/<key>')
def reset_link_login(key):
reset_link = PasswordResetLinks.find({
'key': key
})
if not reset_link:
return redirect(url_for('topics.all_topics'))
if int(time.time()) > int(reset_link.expires_at):
reset_link.delete()
return redirect(url_for('topics.all_topics'))
target_user = Users.find({
'id': reset_link.user_id
})
return render_template('users/reset_link_login.html', username = target_user.username)
@bp.post('/reset-link/<key>')
def reset_link_login_form(key):
reset_link = PasswordResetLinks.find({
'key': key
})
if not reset_link:
return redirect('topics.all_topics')
if int(time.time()) > int(reset_link.expires_at):
reset_link.delete()
return redirect('topics.all_topics')
password = request.form.get('password')
password2 = request.form.get('password2')
if not validate_password(password):
flash("Invalid password.", InfoboxKind.ERROR)
return redirect(url_for('.reset_link_login', key=key))
if password != password2:
flash("Passwords do not match.", InfoboxKind.ERROR)
return redirect(url_for('.reset_link_login', key=key))
target_user = Users.find({
'id': reset_link.user_id
})
reset_link.delete()
hashed = digest(password)
target_user.update({'password_hash': hashed})
session_obj = create_session(target_user.id)
session['pyrom_session_key'] = session_obj.key
flash("Logged in!", InfoboxKind.INFO)
return redirect(url_for('.page', username=target_user.username))

View File

@ -83,6 +83,13 @@ SCHEMA = [
"reaction_text" TEXT NOT NULL DEFAULT '' "reaction_text" TEXT NOT NULL DEFAULT ''
)""", )""",
"""CREATE TABLE IF NOT EXISTS "password_reset_links" (
"id" INTEGER NOT NULL PRIMARY KEY,
"user_id" REFERENCES users(id) ON DELETE CASCADE,
"expires_at" INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP)),
"key" TEXT NOT NULL UNIQUE
)""",
# INDEXES # INDEXES
"CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)", "CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)",
"CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at, id)", "CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at, id)",

View File

@ -37,6 +37,9 @@
<th>Username</th> <th>Username</th>
<th class="small">Permission</th> <th class="small">Permission</th>
<th class="small">Signed up on</th> <th class="small">Signed up on</th>
{% if active_user.is_admin() %}
<th class="small">Create password reset link</th>
{% endif %}
</thead> </thead>
{% for user in not_guests %} {% for user in not_guests %}
<tr> <tr>
@ -50,6 +53,13 @@
<td> <td>
{{ timestamp(user.created_at) }} {{ timestamp(user.created_at) }}
</td> </td>
{% if active_user.is_admin() %}
<td>
<form method="post" action="{{url_for('mod.create_reset_pass', user_id=user.id)}}">
<input type="submit" class="warn" value="Create password reset link">
</form>
</td>
{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>

View File

@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% block title %}Reset password{% endblock %}
{% block content %}
<div class="darkbg login-container">
<h1>Reset password for {{username}}</h1>
<p>Send this link to {{username}} to allow them to reset their password.</p>
<form method="post">
<label for="password">New password</label><br>
<input type="password" id="password" name="password" autocomplete="new-password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required><br>
<label for="password2">Confirm password</label><br>
<input type="password" id="password2" name="password2" autocomplete="new-password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required><br>
<input type="submit" value="Reset password">
</form>
</div>
{% endblock %}