diff --git a/app.py b/app.py index 6812b46..181048e 100644 --- a/app.py +++ b/app.py @@ -630,6 +630,78 @@ def login(): return render_template('login.html') +@app.route('/password-reset-request', methods=['GET', 'POST']) +def password_reset_request(): + """Request a password reset""" + if current_user.is_authenticated: + return redirect(url_for('index')) + + if request.method == 'POST': + email = request.form.get('email', '').strip().lower() + + if not email: + flash('Please enter your email address', 'error') + return render_template('password_reset_request.html') + + # Find user by email + user = User.query.filter_by(email=email).first() + + # Always show success message for security (don't reveal if email exists) + flash('If an account exists with that email, a password reset link has been sent.', 'success') + + if user and user.password_hash: # Only send reset if user has a password (not OAuth only) + # Generate reset token + token = user.generate_reset_token() + + # Build reset URL + reset_url = url_for('password_reset', token=token, _external=True) + + # Log the reset URL (in production, this would be emailed) + logger.info(f"Password reset requested for {email}. Reset URL: {reset_url}") + + # For now, also flash it for development (remove in production) + flash(f'Reset link (development only): {reset_url}', 'info') + + return redirect(url_for('login')) + + return render_template('password_reset_request.html') + + +@app.route('/password-reset/', methods=['GET', 'POST']) +def password_reset(token): + """Reset password with token""" + if current_user.is_authenticated: + return redirect(url_for('index')) + + # Find user by token + user = User.query.filter_by(reset_token=token).first() + + if not user or not user.verify_reset_token(token): + flash('Invalid or expired reset token', 'error') + return redirect(url_for('login')) + + if request.method == 'POST': + password = request.form.get('password', '') + confirm_password = request.form.get('confirm_password', '') + + if not password or len(password) < 6: + flash('Password must be at least 6 characters', 'error') + return render_template('password_reset.html') + + if password != confirm_password: + flash('Passwords do not match', 'error') + return render_template('password_reset.html') + + # Set new password + user.set_password(password) + user.clear_reset_token() + + flash('Your password has been reset successfully. You can now log in.', 'success') + return redirect(url_for('login')) + + return render_template('password_reset.html') + + # Auth0 Routes @app.route('/auth0/login') def auth0_login(): diff --git a/migrate_password_reset.py b/migrate_password_reset.py new file mode 100644 index 0000000..799baaf --- /dev/null +++ b/migrate_password_reset.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +""" +Database migration to add password reset fields to users table. +Run this once to add the new columns for password reset functionality. +""" + +import sys +from app import app, db + +def migrate(): + """Add password reset columns to users table""" + with app.app_context(): + try: + # Check if columns already exist + from sqlalchemy import inspect + inspector = inspect(db.engine) + columns = [col['name'] for col in inspector.get_columns('users')] + + if 'reset_token' in columns and 'reset_token_expiry' in columns: + print("✓ Password reset columns already exist") + return True + + # Add the new columns using raw SQL + with db.engine.connect() as conn: + if 'reset_token' not in columns: + print("Adding reset_token column...") + conn.execute(db.text( + "ALTER TABLE users ADD COLUMN reset_token VARCHAR(100) UNIQUE" + )) + conn.execute(db.text( + "CREATE INDEX IF NOT EXISTS ix_users_reset_token ON users(reset_token)" + )) + conn.commit() + + if 'reset_token_expiry' not in columns: + print("Adding reset_token_expiry column...") + conn.execute(db.text( + "ALTER TABLE users ADD COLUMN reset_token_expiry TIMESTAMP" + )) + conn.commit() + + print("✓ Password reset columns added successfully") + return True + + except Exception as e: + print(f"✗ Migration failed: {e}") + import traceback + traceback.print_exc() + return False + +if __name__ == '__main__': + print("Running password reset migration...") + success = migrate() + sys.exit(0 if success else 1) diff --git a/models.py b/models.py index 5de19b2..48ad94f 100644 --- a/models.py +++ b/models.py @@ -41,6 +41,10 @@ class User(UserMixin, db.Model): # User settings (JSON stored as text) settings = db.Column(db.Text, default='{}') + # Password reset + reset_token = db.Column(db.String(100), nullable=True, unique=True, index=True) + reset_token_expiry = db.Column(db.DateTime, nullable=True) + def __init__(self, username, email, password=None, is_admin=False, auth0_id=None): """ Initialize a new user. @@ -102,6 +106,32 @@ class User(UserMixin, db.Model): self.last_login = datetime.utcnow() db.session.commit() + def generate_reset_token(self): + """Generate a password reset token that expires in 1 hour""" + import secrets + from datetime import timedelta + + self.reset_token = secrets.token_urlsafe(32) + self.reset_token_expiry = datetime.utcnow() + timedelta(hours=1) + db.session.commit() + return self.reset_token + + def verify_reset_token(self, token): + """Verify if the provided reset token is valid and not expired""" + if not self.reset_token or not self.reset_token_expiry: + return False + if self.reset_token != token: + return False + if datetime.utcnow() > self.reset_token_expiry: + return False + return True + + def clear_reset_token(self): + """Clear the reset token after use""" + self.reset_token = None + self.reset_token_expiry = None + db.session.commit() + def get_id(self): """Required by Flask-Login""" return self.id diff --git a/templates/login.html b/templates/login.html index df559b8..bc9132e 100644 --- a/templates/login.html +++ b/templates/login.html @@ -37,6 +37,10 @@ +
+ Forgot password? +
+ diff --git a/templates/password_reset.html b/templates/password_reset.html new file mode 100644 index 0000000..496d3c3 --- /dev/null +++ b/templates/password_reset.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block title %}Set New Password - BalanceBoard{% endblock %} + +{% block content %} +
+
+ + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+
+ + +
+ +
+ + +
+ + +
+ + +
+
+{% endblock %} diff --git a/templates/password_reset_request.html b/templates/password_reset_request.html new file mode 100644 index 0000000..409da9b --- /dev/null +++ b/templates/password_reset_request.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} + +{% block title %}Reset Password - BalanceBoard{% endblock %} + +{% block content %} +
+
+ + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+
+ + + + Enter the email address associated with your account and we'll send you a password reset link. + +
+ + +
+ + +
+
+{% endblock %}