Add password reset flow with email-based token verification

Implement secure password reset functionality:
- Add password_reset_tokens table for storing hashed reset tokens
- Create email utility module (dev mode logs to console)
- Add API endpoints: request, validate, and complete password reset
- Add web routes and templates for forgot-password and reset-password
- Security: 1-hour token expiry, single-use, rate limiting, session invalidation
- Prevent email enumeration by always returning success message

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
rob
2026-01-17 04:34:43 -04:00
co-authored by Claude Opus 4.5
parent 205cd8d9cf
commit 05a2fae94c
7 changed files with 577 additions and 7 deletions
@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% from "components/forms.html" import text_input, button_primary, form_errors %}
{% block title %}Reset Password - CmdForge{% endblock %}
{% block content %}
<div class="min-h-[70vh] flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<a href="{{ url_for('web.home') }}" class="inline-flex items-center justify-center">
<svg class="w-10 h-10 text-indigo-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</a>
<h1 class="mt-4 text-2xl font-bold text-gray-900">Reset your password</h1>
<p class="mt-2 text-gray-600">
Enter your email address and we'll send you a link to reset your password.
</p>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-8 shadow-sm">
{% if success_message %}
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<div class="flex">
<svg class="w-5 h-5 text-green-500 mr-3 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<div>
<p class="text-sm text-green-800">{{ success_message }}</p>
</div>
</div>
</div>
{% else %}
{{ form_errors(errors) }}
<form action="{{ url_for('web.forgot_password') }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{{ text_input(
name='email',
label='Email address',
type='email',
placeholder='[email protected]',
required=true,
value=email or ''
) }}
{{ button_primary('Send reset link', full_width=true) }}
</form>
{% endif %}
</div>
<p class="mt-6 text-center text-sm text-gray-600">
Remember your password?
<a href="{{ url_for('web.login') }}" class="text-indigo-600 hover:text-indigo-800 font-medium">
Sign in
</a>
</p>
</div>
</div>
{% endblock %}
@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% from "components/forms.html" import text_input, button_primary, form_errors %}
{% block title %}Set New Password - CmdForge{% endblock %}
{% block content %}
<div class="min-h-[70vh] flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<a href="{{ url_for('web.home') }}" class="inline-flex items-center justify-center">
<svg class="w-10 h-10 text-indigo-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</a>
<h1 class="mt-4 text-2xl font-bold text-gray-900">Set new password</h1>
{% if token_valid %}
<p class="mt-2 text-gray-600">
Enter your new password below.
</p>
{% endif %}
</div>
<div class="bg-white rounded-lg border border-gray-200 p-8 shadow-sm">
{% if success_message %}
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<div class="flex">
<svg class="w-5 h-5 text-green-500 mr-3 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<div>
<p class="text-sm text-green-800">{{ success_message }}</p>
<p class="mt-2 text-sm text-green-700">
<a href="{{ url_for('web.login') }}" class="font-medium underline hover:text-green-800">
Sign in with your new password
</a>
</p>
</div>
</div>
</div>
{% elif not token_valid %}
{{ form_errors(errors) }}
<div class="text-center">
<p class="text-gray-600 mb-4">Need to reset your password?</p>
<a href="{{ url_for('web.forgot_password') }}" class="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Request new reset link
</a>
</div>
{% else %}
{{ form_errors(errors) }}
<form action="{{ url_for('web.reset_password') }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="token" value="{{ token }}">
{{ text_input(
name='new_password',
label='New password',
type='password',
placeholder='At least 8 characters',
required=true,
help='Use a mix of letters, numbers, and symbols'
) }}
{{ text_input(
name='confirm_password',
label='Confirm new password',
type='password',
placeholder='Confirm your new password',
required=true
) }}
{{ button_primary('Set new password', full_width=true) }}
</form>
{% endif %}
</div>
<p class="mt-6 text-center text-sm text-gray-600">
Remember your password?
<a href="{{ url_for('web.login') }}" class="text-indigo-600 hover:text-indigo-800 font-medium">
Sign in
</a>
</p>
</div>
</div>
{% endblock %}