Add email-based password reset flow
Users who forget their password can now request a reset link via email. Tokens are SHA-256 hashed before storage, expire after 1 hour, and previous unused tokens are invalidated on new requests. The forgot-password endpoint always returns success to prevent email enumeration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import bcrypt
|
||||
from sqlalchemy import func, select, update
|
||||
@@ -6,6 +9,7 @@ from sqlalchemy import func, select, update
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.conversation import Conversation
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models.password_reset import PasswordResetToken
|
||||
from fabledassistant.models.setting import Setting
|
||||
from fabledassistant.models.user import User
|
||||
|
||||
@@ -150,3 +154,69 @@ async def delete_user(user_id: int) -> bool:
|
||||
async def set_registration_open(admin_user_id: int, open: bool) -> None:
|
||||
from fabledassistant.services.settings import set_setting
|
||||
await set_setting(admin_user_id, "registration_open", "true" if open else "false")
|
||||
|
||||
|
||||
async def get_user_by_email(email: str) -> User | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(User).where(func.lower(User.email) == email.lower())
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def create_password_reset_token(user_id: int) -> str:
|
||||
"""Generate a password reset token. Returns the raw token (for the email link)."""
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
|
||||
async with async_session() as session:
|
||||
# Invalidate any existing unused tokens for this user
|
||||
result = await session.execute(
|
||||
select(PasswordResetToken).where(
|
||||
PasswordResetToken.user_id == user_id,
|
||||
PasswordResetToken.used.is_(False),
|
||||
)
|
||||
)
|
||||
for old_token in result.scalars().all():
|
||||
old_token.used = True
|
||||
|
||||
token = PasswordResetToken(
|
||||
user_id=user_id,
|
||||
token_hash=token_hash,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
session.add(token)
|
||||
await session.commit()
|
||||
|
||||
logger.info("Password reset token created for user %d", user_id)
|
||||
return raw_token
|
||||
|
||||
|
||||
async def reset_password_with_token(raw_token: str, new_password: str) -> bool:
|
||||
"""Validate a reset token and update the user's password. Returns True on success."""
|
||||
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash)
|
||||
)
|
||||
reset_token = result.scalars().first()
|
||||
|
||||
if not reset_token:
|
||||
return False
|
||||
if reset_token.used:
|
||||
return False
|
||||
if reset_token.expires_at < datetime.now(timezone.utc):
|
||||
return False
|
||||
|
||||
user = await session.get(User, reset_token.user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
user.password_hash = hash_password(new_password)
|
||||
reset_token.used = True
|
||||
await session.commit()
|
||||
|
||||
logger.info("Password reset via token for user %d (%s)", user.id, user.username)
|
||||
return True
|
||||
|
||||
@@ -92,6 +92,52 @@ async def notify_security_event(
|
||||
logger.exception("Failed to send security notification for user %d", user_id)
|
||||
|
||||
|
||||
async def send_password_reset_email(email: str, reset_url: str) -> None:
|
||||
"""Send a password reset email with a link to reset the user's password."""
|
||||
html = f"""
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
|
||||
<div style="background: #6366f1; color: #fff; padding: 16px 24px; border-radius: 8px 8px 0 0; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 20px;">Password Reset</h1>
|
||||
</div>
|
||||
<div style="background: #f9fafb; padding: 24px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin: 0 0 16px; color: #374151; font-size: 15px;">
|
||||
We received a request to reset your password. Click the button below to choose a new password:
|
||||
</p>
|
||||
<div style="text-align: center; margin: 24px 0;">
|
||||
<a href="{reset_url}" style="display: inline-block; background: #6366f1; color: #fff; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 600; font-size: 15px;">
|
||||
Reset Password
|
||||
</a>
|
||||
</div>
|
||||
<p style="margin: 0 0 8px; color: #6b7280; font-size: 13px;">This link expires in 1 hour.</p>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 13px;">If you didn't request this, you can safely ignore this email.</p>
|
||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
|
||||
<p style="margin: 0; color: #9ca3af; font-size: 12px;">If the button doesn't work, copy and paste this link into your browser:</p>
|
||||
<p style="margin: 4px 0 0; color: #9ca3af; font-size: 12px; word-break: break-all;">{reset_url}</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
await send_email(email, "Fabled Assistant - Password Reset", html)
|
||||
|
||||
|
||||
async def send_password_reset_success_email(email: str) -> None:
|
||||
"""Send a notification that the password was successfully reset."""
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
html = f"""
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
|
||||
<div style="background: #6366f1; color: #fff; padding: 16px 24px; border-radius: 8px 8px 0 0; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 20px;">Password Changed</h1>
|
||||
</div>
|
||||
<div style="background: #f9fafb; padding: 24px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin: 0 0 16px; color: #374151; font-size: 15px;">
|
||||
Your password was successfully reset at {timestamp}.
|
||||
</p>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 13px;">If you didn't make this change, please contact your administrator immediately.</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
await send_email(email, "Fabled Assistant - Password Changed", html)
|
||||
|
||||
|
||||
async def check_due_tasks() -> None:
|
||||
"""Check for tasks due today and send reminder emails."""
|
||||
if not await is_smtp_configured():
|
||||
|
||||
Reference in New Issue
Block a user