From 7092167001edba641a1451ba78d352e56dd01fa8 Mon Sep 17 00:00:00 2001 From: Kobe Date: Thu, 19 Jun 2025 16:11:42 +0200 Subject: [PATCH] password reset --- __pycache__/app.cpython-313.pyc | Bin 11253 -> 12125 bytes __pycache__/models.cpython-313.pyc | Bin 37475 -> 39018 bytes __pycache__/tasks.cpython-313.pyc | Bin 2158 -> 4325 bytes app.py | 7 + .../add_password_reset_tokens_table.py | 34 ++++ models.py | 19 +++ routes/__pycache__/auth.cpython-313.pyc | Bin 14393 -> 20755 bytes routes/__pycache__/main.cpython-313.pyc | Bin 90929 -> 91440 bytes routes/auth.py | 159 +++++++++++++++++- routes/main.py | 53 +++--- static/js/mail-log.js | 47 ++++-- tasks.py | 41 ++++- templates/auth/forgot_password.html | 50 ++++++ templates/auth/login.html | 5 + templates/auth/reset_password.html | 157 +++++++++++++++++ test_password_reset.py | 111 ++++++++++++ .../email_templates.cpython-313.pyc | Bin 6243 -> 6622 bytes .../__pycache__/notification.cpython-313.pyc | Bin 14995 -> 16843 bytes utils/email_templates.py | 8 +- utils/notification.py | 61 ++++--- 20 files changed, 680 insertions(+), 72 deletions(-) create mode 100644 migrations/versions/add_password_reset_tokens_table.py create mode 100644 templates/auth/forgot_password.html create mode 100644 templates/auth/reset_password.html create mode 100644 test_password_reset.py diff --git a/__pycache__/app.cpython-313.pyc b/__pycache__/app.cpython-313.pyc index 6ca4e8a9c035defd40822643a9d1d02c813b7b9f..9f46eac61e2567bb9e5792c0f777fd2f9d0f7b7a 100644 GIT binary patch delta 3389 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 diff --git a/__pycache__/models.cpython-313.pyc b/__pycache__/models.cpython-313.pyc index 32cd4ed80b70598c2a7700638156820112752d2f..3638571fd4e9fcc4cd85ea6ae10d54edbe7a5db1 100644 GIT binary patch delta 674 zcmaF7gz41=Cce+Syj%=GpduBL!4x}@PlB;yqdF@qmjr_nL$IY3L$KB43C!{=n!(mm zlOtJmA>wvGaeI(Bt4=zHro-kg)~98RD<>D!L@<7ye6pqu$PTEr1G4*TlNnb{zF01< zShR$ZfuYC~Xj9Q*5Q_uEN&yk-AYv)wWcebQ$?kRcCLhTaWzu7syrDgHvVw}vX8tN3 z#>u8h5|ig?h)u35<(!<86l5yJP$U^FQY4iwsws9$C?K)8xIDioB`CExwIn1zJ2fx! z5YUcWq6JWi_#%+ZTdqu$IEC0vI}e`SVd7J$eX4h!U#y*;;_lhPbtkwwJW*YC{7?c=-t)v)&ttU@l zmY*EWs?Czlp=q~y3+vM|M$O5yY9bhmCx5PK1G1}X?SSl^waHA&87JHKh$$B>W@KO} z@&uY)vPCUZ`c<+=zI1|o)HrOgJ@{xdRt KQkm>HLlXc{$y}NM diff --git a/__pycache__/tasks.cpython-313.pyc b/__pycache__/tasks.cpython-313.pyc index a367d139ecb50ce02e3a10ff30a6d55fa90da5b1..1de5e932df325df5a8192ad91e6be60d0dcd0939 100644 GIT binary patch delta 1929 zcmb7FO>7%g5T5;c_IhJGZfv*y_r{4Ehra?z3W|UR(v=grQ} z%=gWkdG;T+U%Ra}i$w&odOg$gUy93C7jtO2uO~ExFq$%8g9I^%ajzP&F~)lU3o%5b zX^!hRET8s2$5+_&nTin2<+9_M1-a7oW>Qt(&Ea%ZR%LBE_qMFGY%y6|TCCf?I)9O8 z{w;>k-;BBFF~ph3*NcWvnSCirL|>E@N1j?``ST~O3M6>_OVqG@#~w84udTh!MJ(=N z?VT{p0t_(?kEo>>;X{N03@t>c+^I+GIkQ@DE`iN*uwxFAVI>n?XW9s z%jG_>e4Kv>SZ5RV|IC7fy-!K()9J)eqFdMFj9#P_Vum(5-)1)sApK2(Ij$QL4n5uo z$*0MAaBbO{I2&~plblm#6JFgf&gmJfFtD9lf*_d@g&XB2n4?B$>a)x&yvPM%mf3-H zUFDMIaYlCF2)oq5Zr4P*y<;4D=%K%*mp8{X%50U^Z_1w+qjEO)j;u=Z zdy5$?r=`W_C6F+^1yWK;OJrYKlr(xFRB0CHvXVA0OX;MRJd;%A;%EG1A^*e}#yKpd zeQA} z%|{xfQ@Lz5qt$6Q_@yWkfl19Ll{r}*mioIez0H^&FeXC+xRVMS5fzb~T!9>&I+N6} zothC9ekuY~1gVgSsIZwdcF{M13VH!5+=8s&5YhTk#hhBgSXQ(dEt8cij%G(@>W!Fb z)Kx+C9q*^H+a79xcBtv1Qh13j+(*6kHmjM@^EEQzXDBFn)Jx`FLc_ z-h1}7Ed%(T+VBpPy#pKG;j(vlQ}ApE;j$3^!dw;xYaHt{{AMt{pZY+qB0vRqO@OZb zAEGMDo1B|w>xHRvQyXS~+3f#B{?Xif2a&H^wnxhOFe$4xgaVs_Shygb6E_5RS#Yll z-c7;sQOBjjC24TYH&pT-EFC?AOhw54Qw{yY1XW8dz*B9yG7>%z;$Z;akIULh^H<_R8&u-BR3SeU&Y9%aY7;YLIp z3!xj&_Kx|{iXRbuB`l5y(8__{aW`6ZBciW%ixZD=tB(&>?nxMD%{KXcqjZ>JwX^ zIVZ`EBUI4#Uxpfy(3lh9g={Xpv>?BPhlq{L1gP|{p~^7~a~mSJVPXv?ege~n+a delta 167 zcmaE=_)dWDGcPX}0}w1);FRISI+0I;(P*N&vWNmhFhel2H)|1VFpCsJ5nDR5ChNo; zS$?KlOesmXID+!?bKNp?Qa2u$&cawVIfC^&BiG~rwopdZ&CA$kF*0egPtF#Qbrk{{ zT%-ylRx*4BSyH3|ByMro#I) diff --git a/app.py b/app.py index 3de6e49..dd6e765 100644 --- a/app.py +++ b/app.py @@ -112,6 +112,13 @@ def create_app(): cleanup_trash() click.echo("Trash cleanup completed.") + @app.cli.command("cleanup-tokens") + def cleanup_tokens_command(): + """Clean up expired password reset and setup tokens.""" + from tasks import cleanup_expired_tokens + cleanup_expired_tokens() + click.echo("Token cleanup completed.") + @app.cli.command("create-admin") def create_admin(): """Create the default administrator user.""" diff --git a/migrations/versions/add_password_reset_tokens_table.py b/migrations/versions/add_password_reset_tokens_table.py new file mode 100644 index 0000000..7b8617e --- /dev/null +++ b/migrations/versions/add_password_reset_tokens_table.py @@ -0,0 +1,34 @@ +"""Add password reset tokens table + +Revision ID: add_password_reset_tokens +Revises: be1f7bdd10e1 +Create Date: 2024-01-01 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'add_password_reset_tokens' +down_revision = 'be1f7bdd10e1' +branch_labels = None +depends_on = None + +def upgrade(): + # Create password_reset_tokens table + op.create_table('password_reset_tokens', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('token', sa.String(length=100), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('used', sa.Boolean(), nullable=True), + sa.Column('ip_address', sa.String(length=45), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token') + ) + +def downgrade(): + # Drop password_reset_tokens table + op.drop_table('password_reset_tokens') \ No newline at end of file diff --git a/models.py b/models.py index 4fd334f..453eba5 100644 --- a/models.py +++ b/models.py @@ -447,6 +447,25 @@ class PasswordSetupToken(db.Model): def __repr__(self): return f'' +class PasswordResetToken(db.Model): + __tablename__ = 'password_reset_tokens' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) + token = db.Column(db.String(100), unique=True, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + expires_at = db.Column(db.DateTime, nullable=False) + used = db.Column(db.Boolean, default=False) + ip_address = db.Column(db.String(45)) # Store IP address for security + + # Relationships + user = db.relationship('User', backref=db.backref('password_reset_tokens', cascade='all, delete-orphan')) + + def is_valid(self): + return not self.used and datetime.utcnow() < self.expires_at + + def __repr__(self): + return f'' + def user_has_permission(room, perm_name): """ Check if the current user has a specific permission in a room. diff --git a/routes/__pycache__/auth.cpython-313.pyc b/routes/__pycache__/auth.cpython-313.pyc index b6d64b3df06742a900f5c1d0312acecf4aa8e25d..33f4c5602aa1bba85f28c0db747cf09dd2715010 100644 GIT binary patch delta 6356 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 NhPAE*rp&D9{Qq8;++F|x diff --git a/routes/__pycache__/main.cpython-313.pyc b/routes/__pycache__/main.cpython-313.pyc index 094ed887c6b8e455db511d84931daac616d35990..fb4d4885fac50a5e662985e785ed217dd6afe4a7 100644 GIT binary patch delta 1825 zcmah}YfMvD96$f|-ac$gYg_ubLP1x=Td0Lq6d8i3fjA$i#RsBdfzA~ybf-l#<737| z=3FA|emEohfPPr^Wr>(2>xV6lWshWjveVhF?hzNi+lK9v$+Gir0WmSzP44fU^M9ZJ zy}$pRAH~^wV%faOw1V)*EA_m+-*czzCqZ|{K*$OCgirZXmd$URqVmXyr`wpEJpb3J%iVLOZ{@yB*{ zyADhSaWTL_Q0_moEA4kbQ1%Qvl$%d_xf$N^xb^{oqvg4cl0= z1a?azTO{5@v6J zF32dip4PC-4g59(bKcjS73{*)g{)MSk$h>%x3tL{iPgy@Cgt_4r4edcvTo0eG%aP^ za@s8y^kR8g!EAD^m?tKSAx}(tL*7N!tSz`-lF=!JR>j`?3MufU;Mm)|OJg(ub+L;0QQU$GYITD8I_&#bui? zK{LWe_TC=&2sSa>UWjQ*UhFF0YnwIvIzuWpw+(_Re>c1#Ks$?upc5{pW<&733U;vZ zL%iGDF-|9YatPjp8=uu5253Cz3WVd)Kr9?Rod~o>N0TkM<$qM$AaB6ZW8^y$qu+o=P|SLB?E zbBxc5QZ);l({avuFCF7DSyv=pPER9)(Rh>-BBxD^4-7{JlR05vZ0KG3Ew`c*9Jm7c z5>DoH$w(}II-HEi7HUHE6$Bi0bQQuTgntn_5cXo{x)JsvEC%dcAnSe<9zZA-q{?X* zx|kD+o#h%*T}@l~lCzwOB>Ri%p-2+jo~uz-f919=7tP`qP2&%G>8aUv>6JE;wJ36^8+V3^r_C{POYpHva>0{cobJpt3IW2^0it>f%X0G7UJj lsGmot9(F$r&Ctd41F!@7*xmuCgcv(L0AK3|B`_54_#c8_%B%nY delta 1711 zcmZuxZERCj7(Va4x4pN${TSYO`RtiWBOIa^tY_#*X z10j-WlmrCA;3giU2IYrwBnDXg(LW}rDDflF4i*?2evp``(P(jClwY2+FTik<`5D`p`G5Hvi1Wv4YF3qJGJA-4caZ< zu9K@EXwW)%NxR4!KJ@8}fH<`B=Lr7EA7H&=0N`C9@o;M1(vjJj1(1 zU3k$fg^<>9->LnW!)vz+EXU zwO8p*uJ#;~8fVT?B^l}MPk53C2Nc5EA7}SR`UeTE(q>JHYUDuIjQ_^|g!%#*jLfU` zw8$`p6BI5|$R_TS#=(T5c13zfQT21e9?A|Xv1o6s$D=BVfp{!I^2#~*XFn9QzDlEx zP}oj^x=$K=6TPuSG7{@n)Nd&JHDz`E@klf=Y26gu+}5CU$D>N4s>anvW#Aq!xK}ud z23a>L%-p)GH>zGDdS^1vCNj^f=!!;?kpykG$K%1B<*=UF12{{D#rSeLNJF;;%PH}g zn09za>?3?y3XBUC3!LwRs!O_*dDMKptTydimzL_rh57|+*+}EX*7L0+gkLi*)MWI? zmV^DL{YUxRqJ6mM?Zi#7cuXwLFr2w^Dl2!wmH&?KP2aoQuDkrhe8xn*y?Ck|a>a}h zWjnD8yD~g>ErXJwzj)4W$q1-u$%L!mrpq_x@;%%*^Y1!MPZ^Jz@8uzbbzZ%D#G00> z$Awk5t&ZWt*R3UKq2!-TDUxz-+DgZ4rPBz^l@qz%k5l{SJ9qiB>^`y`ZS|~*don##kJxnCwB&R9^an+BD8lZAxHBI-pgSv`x zPpZhzR(xU%KHC7>`4U>&HTMOP>ef`C5kA$)PYQW>=hd{}S_*4$ZZn*L8hoJ{_UUJ% zaA6DVgY~IXEs*4(0dpPD1SeC?9q<_g8*$ba(%cO+xEa5=1vcr=QVn--dk6Ukf1I@A zm+HWb9U&NfeFxNm57+O2_bS~qK&DVZf&LKHS_<_P8Y#3>XrUt*Cw)%%wDucL90-Gi zH-*72ZYAbIYWPE++8c&9boL_Ml4%y{B?=W8jvO-^AmxZaEwFd>d8xy@U_FTRi&6b_ ja>e-DZm5P9EJUFJw&K<(xM2_Oi^4~S=UgDFYl+u?8*8p= diff --git a/routes/auth.py b/routes/auth.py index e07a853..e7559b9 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -1,10 +1,12 @@ from flask import render_template, request, flash, redirect, url_for, Blueprint, jsonify from flask_login import login_user, logout_user, login_required, current_user -from models import db, User, Notif, PasswordSetupToken +from models import db, User, Notif, PasswordSetupToken, PasswordResetToken from functools import wraps -from datetime import datetime +from datetime import datetime, timedelta from utils import log_event, create_notification, get_unread_count +from utils.notification import generate_mail_from_notification import string +import secrets auth_bp = Blueprint('auth', __name__) @@ -306,4 +308,155 @@ def init_routes(auth_bp): flash('Password set up successfully! Welcome to DocuPulse.', 'success') return redirect(url_for('main.dashboard')) - return render_template('auth/setup_password.html') \ No newline at end of file + return render_template('auth/setup_password.html') + + @auth_bp.route('/forgot-password', methods=['GET', 'POST']) + def forgot_password(): + if current_user.is_authenticated: + return redirect(url_for('main.dashboard')) + + if request.method == 'POST': + email = request.form.get('email') + + if not email: + flash('Please enter your email address.', 'error') + return render_template('auth/forgot_password.html') + + # Check if user exists + user = User.query.filter_by(email=email).first() + + if user: + # Generate a secure token + token = secrets.token_urlsafe(32) + + # Create password reset token + reset_token = PasswordResetToken( + user_id=user.id, + token=token, + expires_at=datetime.utcnow() + timedelta(hours=1), # 1 hour expiration + ip_address=request.remote_addr + ) + db.session.add(reset_token) + + # Create notification for password reset + notif = create_notification( + notif_type='password_reset', + user_id=user.id, + details={ + 'message': 'You requested a password reset. Click the link below to reset your password.', + 'reset_link': url_for('auth.reset_password', token=token, _external=True), + 'expiry_time': (datetime.utcnow() + timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S UTC'), + 'ip_address': request.remote_addr, + 'timestamp': datetime.utcnow().isoformat() + }, + generate_mail=False # Don't auto-generate email, we'll do it manually + ) + + # Generate and send email manually + if notif: + generate_mail_from_notification(notif) + + # Log the password reset request + log_event( + event_type='user_update', + details={ + 'user_id': user.id, + 'user_name': f"{user.username} {user.last_name}", + 'email': user.email, + 'update_type': 'password_reset_request', + 'ip_address': request.remote_addr, + 'success': True + } + ) + + db.session.commit() + + # Always show success message to prevent email enumeration + flash('If an account with that email exists, a password reset link has been sent to your email address.', 'success') + return redirect(url_for('auth.login')) + + return render_template('auth/forgot_password.html') + + @auth_bp.route('/reset-password/', methods=['GET', 'POST']) + def reset_password(token): + if current_user.is_authenticated: + return redirect(url_for('main.dashboard')) + + # Find the token + reset_token = PasswordResetToken.query.filter_by(token=token).first() + + if not reset_token or not reset_token.is_valid(): + flash('Invalid or expired password reset link. Please request a new password reset.', 'error') + return redirect(url_for('auth.forgot_password')) + + if request.method == 'POST': + password = request.form.get('password') + confirm_password = request.form.get('confirm_password') + + if not password or not confirm_password: + flash('Please fill in all fields.', 'error') + return render_template('auth/reset_password.html', token=token) + + if password != confirm_password: + flash('Passwords do not match.', 'error') + return render_template('auth/reset_password.html', token=token) + + # Password requirements + if len(password) < 8: + flash('Password must be at least 8 characters long.', 'error') + return render_template('auth/reset_password.html', token=token) + + if not any(c.isupper() for c in password): + flash('Password must contain at least one uppercase letter.', 'error') + return render_template('auth/reset_password.html', token=token) + + if not any(c.islower() for c in password): + flash('Password must contain at least one lowercase letter.', 'error') + return render_template('auth/reset_password.html', token=token) + + if not any(c.isdigit() for c in password): + flash('Password must contain at least one number.', 'error') + return render_template('auth/reset_password.html', token=token) + + if not any(c in string.punctuation for c in password): + flash('Password must contain at least one special character.', 'error') + return render_template('auth/reset_password.html', token=token) + + # Update user's password + user = reset_token.user + user.set_password(password) + + # Mark token as used + reset_token.used = True + + # Create password change notification + create_notification( + notif_type='password_changed', + user_id=user.id, + details={ + 'message': 'Your password has been reset successfully.', + 'timestamp': datetime.utcnow().isoformat() + } + ) + + # Log password reset event + log_event( + event_type='user_update', + details={ + 'user_id': user.id, + 'user_name': f"{user.username} {user.last_name}", + 'email': user.email, + 'update_type': 'password_reset', + 'ip_address': request.remote_addr, + 'success': True + } + ) + + db.session.commit() + + # Log the user in and redirect to dashboard + login_user(user) + flash('Password reset successfully! Welcome back to DocuPulse.', 'success') + return redirect(url_for('main.dashboard')) + + return render_template('auth/reset_password.html', token=token) \ No newline at end of file diff --git a/routes/main.py b/routes/main.py index 89f7068..8eb790f 100644 --- a/routes/main.py +++ b/routes/main.py @@ -1420,36 +1420,21 @@ def init_routes(main_bp): return jsonify({'error': 'Unauthorized'}), 403 event = Event.query.get_or_404(event_id) - logger.info(f"Raw event object: {event}") - logger.info(f"Event details type: {type(event.details)}") - logger.info(f"Event details value: {event.details}") - # Convert details to dict if it's a string - details = event.details - if isinstance(details, str): - try: - import json - details = json.loads(details) - except json.JSONDecodeError: - details = {'raw_details': details} - - # Return the raw event data - response_data = { + return jsonify({ 'id': event.id, 'event_type': event.event_type, 'timestamp': event.timestamp.isoformat(), + 'details': event.details, + 'ip_address': event.ip_address, + 'user_agent': event.user_agent, 'user': { 'id': event.user.id, 'username': event.user.username, - 'last_name': event.user.last_name - } if event.user else None, - 'ip_address': event.ip_address, - 'user_agent': event.user_agent, - 'details': details - } - - logger.info(f"Sending response: {response_data}") - return jsonify(response_data) + 'last_name': event.user.last_name, + 'email': event.user.email + } if event.user else None + }) @main_bp.route('/settings/events/download') @login_required @@ -1722,4 +1707,26 @@ def init_routes(main_bp): return jsonify({ 'success': True, 'results': results + }) + + @main_bp.route('/api/mails/') + @login_required + def get_mail_details(mail_id): + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + mail = Mail.query.get_or_404(mail_id) + + return jsonify({ + 'id': mail.id, + 'recipient': mail.recipient, + 'subject': mail.subject, + 'body': mail.body, + 'status': mail.status, + 'created_at': mail.created_at.isoformat(), + 'sent_at': mail.sent_at.isoformat() if mail.sent_at else None, + 'template': { + 'id': mail.template.id, + 'name': mail.template.name + } if mail.template else None }) \ No newline at end of file diff --git a/static/js/mail-log.js b/static/js/mail-log.js index ccbb011..4ad84c6 100644 --- a/static/js/mail-log.js +++ b/static/js/mail-log.js @@ -78,27 +78,38 @@ function changePage(page) { function viewMailDetails(mailId) { const csrfToken = document.querySelector('meta[name="csrf-token"]').content; - fetch(`/settings?tab=mails&mail_id=${mailId}`, { + fetch(`/api/mails/${mailId}`, { headers: { - 'X-CSRF-Token': csrfToken + 'X-CSRF-Token': csrfToken, + 'Accept': 'application/json' } }) - .then(response => response.json()) - .then(mail => { - document.getElementById('modalSubject').textContent = mail.subject; - document.getElementById('modalRecipient').textContent = mail.recipient; - document.getElementById('modalStatus').innerHTML = ` - - ${mail.status} - - `; - document.getElementById('modalTemplate').textContent = mail.template ? mail.template.name : '-'; - document.getElementById('modalCreatedAt').textContent = new Date(mail.created_at).toLocaleString(); - document.getElementById('modalSentAt').textContent = mail.sent_at ? new Date(mail.sent_at).toLocaleString() : '-'; - document.getElementById('modalBody').innerHTML = mail.body; - - new bootstrap.Modal(document.getElementById('mailDetailsModal')).show(); - }); + .then(response => { + if (!response.ok) { + throw new Error('Failed to fetch mail details'); + } + return response.json(); + }) + .then(mail => { + document.getElementById('modalSubject').textContent = mail.subject; + document.getElementById('modalRecipient').textContent = mail.recipient; + document.getElementById('modalStatus').innerHTML = ` + + ${mail.status} + + `; + document.getElementById('modalTemplate').textContent = mail.template ? mail.template.name : '-'; + document.getElementById('modalCreatedAt').textContent = new Date(mail.created_at).toLocaleString(); + document.getElementById('modalSentAt').textContent = mail.sent_at ? new Date(mail.sent_at).toLocaleString() : '-'; + document.getElementById('modalBody').innerHTML = mail.body; + + new bootstrap.Modal(document.getElementById('mailDetailsModal')).show(); + }) + .catch(error => { + console.error('Error viewing mail details:', error); + // Show a user-friendly error message + alert('Error loading mail details. Please try again.'); + }); } function downloadMailLog() { diff --git a/tasks.py b/tasks.py index a29d5bf..6fdbdd5 100644 --- a/tasks.py +++ b/tasks.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta -from models import db, RoomFile +from models import db, RoomFile, PasswordResetToken, PasswordSetupToken import os def cleanup_trash(): @@ -36,4 +36,43 @@ def cleanup_trash(): db.session.commit() except Exception as e: print(f"Error committing changes: {str(e)}") + db.session.rollback() + +def cleanup_expired_tokens(): + """ + Removes expired password reset and setup tokens from the database. + This function should be called by a scheduler (e.g., cron job) daily. + """ + current_time = datetime.utcnow() + + # Clean up expired password reset tokens + expired_reset_tokens = PasswordResetToken.query.filter( + PasswordResetToken.expires_at < current_time + ).all() + + for token in expired_reset_tokens: + try: + db.session.delete(token) + except Exception as e: + print(f"Error deleting expired password reset token {token.id}: {str(e)}") + continue + + # Clean up expired password setup tokens + expired_setup_tokens = PasswordSetupToken.query.filter( + PasswordSetupToken.expires_at < current_time + ).all() + + for token in expired_setup_tokens: + try: + db.session.delete(token) + except Exception as e: + print(f"Error deleting expired password setup token {token.id}: {str(e)}") + continue + + # Commit all changes + try: + db.session.commit() + print(f"Cleaned up {len(expired_reset_tokens)} expired password reset tokens and {len(expired_setup_tokens)} expired password setup tokens") + except Exception as e: + print(f"Error committing token cleanup changes: {str(e)}") db.session.rollback() \ No newline at end of file diff --git a/templates/auth/forgot_password.html b/templates/auth/forgot_password.html new file mode 100644 index 0000000..f95af1e --- /dev/null +++ b/templates/auth/forgot_password.html @@ -0,0 +1,50 @@ + + + + + + Forgot Password - DocuPulse + + + + + + + + +
+
+
+
+

Forgot Password

+

Enter your email to reset your password

+
+
+
+ +
+ + +
We'll send you a link to reset your password.
+
+ +
+ +
+

Remember your password? Sign In

+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/templates/auth/login.html b/templates/auth/login.html index ab000dc..329aa5a 100644 --- a/templates/auth/login.html +++ b/templates/auth/login.html @@ -45,6 +45,11 @@ Sign In + +
+

Forgot your password?

+

Don't have an account? Sign Up

+
diff --git a/templates/auth/reset_password.html b/templates/auth/reset_password.html new file mode 100644 index 0000000..4d24f10 --- /dev/null +++ b/templates/auth/reset_password.html @@ -0,0 +1,157 @@ + + + + + + Reset Password - DocuPulse + + + + + + + + +
+
+
+
+

Reset Password

+

Enter your new password

+
+
+
+ +
+ + +
+
+ + +
+ +
+
Password Requirements:
+
    +
  • + At least 8 characters long +
  • +
  • + At least one uppercase letter +
  • +
  • + At least one lowercase letter +
  • +
  • + At least one number +
  • +
  • + At least one special character +
  • +
+
+ + +
+ +
+

Remember your password? Sign In

+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/test_password_reset.py b/test_password_reset.py new file mode 100644 index 0000000..fa2fff2 --- /dev/null +++ b/test_password_reset.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +""" +Test script for password reset functionality +Run this script to test the password reset feature +""" + +import os +import sys +from datetime import datetime, timedelta + +# Add the current directory to Python path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from app import create_app +from models import db, User, PasswordResetToken +from utils import create_notification +from utils.notification import generate_mail_from_notification + +def test_password_reset(): + """Test the password reset functionality""" + app = create_app() + + with app.app_context(): + print("Testing password reset functionality...") + + # Check if we have a test user + test_user = User.query.filter_by(email='test@example.com').first() + if not test_user: + print("Creating test user...") + test_user = User( + username='testuser', + email='test@example.com', + last_name='Test User', + is_active=True + ) + test_user.set_password('oldpassword123!') + db.session.add(test_user) + db.session.commit() + print(f"Created test user: {test_user.email}") + + # Test 1: Create a password reset token + print("\n1. Testing password reset token creation...") + from routes.auth import forgot_password + import secrets + + token = secrets.token_urlsafe(32) + reset_token = PasswordResetToken( + user_id=test_user.id, + token=token, + expires_at=datetime.utcnow() + timedelta(hours=1), + ip_address='127.0.0.1' + ) + db.session.add(reset_token) + db.session.commit() + print(f"Created password reset token: {token[:20]}...") + + # Test 2: Create notification + print("\n2. Testing notification creation...") + notif = create_notification( + notif_type='password_reset', + user_id=test_user.id, + details={ + 'message': 'You requested a password reset. Click the link below to reset your password.', + 'reset_link': f'http://localhost:5000/reset-password/{token}', + 'expiry_time': (datetime.utcnow() + timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S UTC'), + 'ip_address': '127.0.0.1', + 'timestamp': datetime.utcnow().isoformat() + } + ) + print(f"Created notification: {notif.id}") + + # Test 3: Generate email + print("\n3. Testing email generation...") + try: + mail = generate_mail_from_notification(notif) + if mail: + print(f"Generated email: {mail.subject}") + print(f"Email body preview: {mail.body[:100]}...") + else: + print("No email template found for password reset") + except Exception as e: + print(f"Error generating email: {e}") + + # Test 4: Validate token + print("\n4. Testing token validation...") + stored_token = PasswordResetToken.query.filter_by(token=token).first() + if stored_token and stored_token.is_valid(): + print("Token is valid") + else: + print("Token is invalid or expired") + + # Test 5: Simulate password reset + print("\n5. Testing password reset...") + if stored_token and stored_token.is_valid(): + test_user.set_password('newpassword123!') + stored_token.used = True + db.session.commit() + print("Password reset successful") + + # Verify password change + if test_user.check_password('newpassword123!'): + print("Password verification successful") + else: + print("Password verification failed") + else: + print("Cannot reset password - token invalid") + + print("\nPassword reset test completed!") + +if __name__ == '__main__': + test_password_reset() \ No newline at end of file diff --git a/utils/__pycache__/email_templates.cpython-313.pyc b/utils/__pycache__/email_templates.cpython-313.pyc index fa9c54835dbe41e3e236dcf67df6978a4d037640..8928336b422dc4465557c12836bafbc60d64e2a6 100644 GIT binary patch delta 457 zcmX|;OHKko5QfDE11L&j&_L8w6caZf_#&ARH@Y>^1Q+h?>1k&=4BbON0xXOhZ_p<( z9z;*zLNB0U%IQEzc|Y0b5ph|<#zN|dR(kI)6E%!7M9JW^RN!L zT)>cP0cRZR3mr=W2xG39s)5;=TQei4bq`VvW76965$=S-ia{0gm?H%(J4gl|C^AshfM70=qMW(FII~uti-3r#Kmpa#g~PEmpfZWk z&;`n3%E)Tfk2&sm&_yDocCt~o)VgPOGduNTsRU=?Njstvth)n!8pl8e2^GWwpaGG+ zuXemUF`CciqIsSxlzN&-1!_E&ASwHH4-iBWWqO(Nb`7q_sr)OyP(ixL5;emSVj&4k zYH7@J3`=J#m@`ZSlYo4#)g;uvC_xge1yw_nOIOV6{MFNUhURnGV&T)tXLBDLCRw?g cKb`5OO6ix`;^^k-YsF7x&!acMz8eo=`+W|~4} zeyKu6Vp%FkE;+R*uXwVefaqjDA=AwjT$#LF?4kl6*||BnK1fY|EndrA&y>w5$##K7 V^mEMSVu>V1PH!eg(;_9H^8nM`HOv42 diff --git a/utils/__pycache__/notification.cpython-313.pyc b/utils/__pycache__/notification.cpython-313.pyc index c280b52985b3e8543f66586e2df5dc422eb4ad47..c5c8633b6fca3d1a5033c24aa3d7b58267bc8535 100644 GIT binary patch delta 3808 zcmbuBUrbxq9mns*v5jqv2?fkQUu*(xjWISDLule8O$p&oNIL&%lRB+)v9H0wf8h8! zTb(*iRqbh$mhGrovOgM`){SV&M8!k9hjvg+0@QBUy3*2FYAf}v)1+m}p7ykJuDRU9 zRW+pz55IfP@Av(D&tL8}%zgY8UH`~v)KloMr#-mPGI+PXzGmCB-cUcUZdS2>rKo^8 zq&kMmtV?WDu%W3<)kRV8R*PBH8p8c1Yrw2V5^oLmp{TSf&_+OQqDY3?C8$jSbr4X8 z0_v2Y4h3W(Ad3QGB*>zGx(KLC0a+!eO99yk$fkh0CCH|Lju23f0_v5Z9tHF?0X?ID z4olE83g}q^vMZoI39>672LbgfAZHa)1^U%++1#SV7r>>^xFutk!g!D{9#TLa2^vyB z!vr*}fV>hktbj%cXhZ>xR-wkghypqaZ<=k!vG7p!#!{4j7%pp@;J<2X`~0I)Qho-W z^U~EGamkN~CdZ%5#3!1ZcrufdqRFYRGdcR$%XxlpBYo(cPggh6>_qLS0x!&TPFvC% zD;3goQ-Sg7rq%^c<4p~m2~3Qs;Fab!%h`~Y*quqjZtDNoO;_#e15|u7w0b*E$cG}eLaM-QsK$Q^t~r}x{!>y~eg@8uY0|pzX({6$ms;}Ee^zcw zt&mPSbt@H~lC#j==4rYRqUtG6jjRaidZ5l^4ZZj^1fY&2@BE{toBbexmbQEwb&&48huZ!MtSFFakBv&NL zxL@sx<=j$?gyk-(<{JQ)%3R~XKrYLHG@Ifo+PzG^1x-X*#fT}(rDG(aL5U*Dna-b3+DtU%9E8-D}syNXUm(8;C z+yFA?xinwV?j`cg2vrm@(72!5+i#YyNV$l#=`5dbCGo2Cl?clti%Q*|MfIo~WuchB zihAh);t4Jp%jTPq=3ok|^zv}9m$?anWq@9QcP(?8B)XO0D%3HZ@Hfnd^s_M9HAQFO z{jTF`a2eI$zq`B^V8W_XC{${*Jc#3)RZ+*2Y<3YOV8QCr0`wz-vK!zZtS1?e!0LG% z7I8R&gB69W0R`x>9jp0^1}@p&rX%Z`?pt)x)N%W|VCq|a8X^-??HCOpZ0yXD+;3f>FE3Z}Qu?9T4YmR^aL&&33W!wayk z%m^me+T@0M%eZM2hGz-=)V6omx8o~aV#_mg0uwD_pnGLeFb%Gyx7EAG9iuQ3B7m9g z(C(`{ua?4z^6XWCS;Tt#Dw@#IlJDhG_{xLuLOHx3_!bDpUkYA&5M;|iwlw!fIam;w z8^pY(_D*6LpF~kQ>ivn$m1m@rII@DS#!zW=W;?fA*eM93mx)2nZF9S+omA=aQu+L{ zzyLggWu-$f^{+7--YwszPZ)fW&`(rXS9&Q{p5z23j`dwD#bbhLpyYXBL%auqCrA*J z+r7Ka9cSs{LU}qNFjp~AJ#0cW{^y0Q%tskv;^hYu=$2k7vAi&m6PRm{r!Iao`L|U+ z)KGQi+YZ6dyJ}y{y;pd*AoPuYR@eJ?c)uqP|JM7#(XjKie(LkaHXQr)5u57s5nH5F z^+l%|wE>U*c`I7{Vvi(Z6Ib#R;j5Ql4!cJ--mqvj*%cE#ys_*2)HHf&c>&2v1!g8&T<=*KVx z+$h|u0n6}bZm0T}sPkX=iQ7ltf%?Hmt)do(pj}H&3pNi0G`~i96%ErvlSuvE&^_i? zD1qjqn7%ri$)$PlHoW1nY2HB+9Sz^{IO#jc0K%z(C>vb``)D z6+<+~XX5b){?3EDkQsHT@1uMRJU@CFE%5iF?S{87t-|5PdTCTmKZnl_;H?ID=K$Uj zfR_mHbOFZiQQ!`4+fb;~9#3Urxg>W2d;m>jg?65%n^wIKDg0c!_ Jv9Atc@PBYzAT0m@ delta 2520 zcmbuBe@t6d6vyxD4re06@#dI&?&- zMQ^y2L&ad%&j7?_08EUqdQRSm^ZfGwaB{DTF#bANy^G3vB1)etx5g=RUUpqWKhA#` z=PM=V@%~wKzqV8z0>HOD7f%YXaw-Pj`|5K}7cPN}P1yHGnnL(mvvp24S{m1G#)c&XttoM(EC00O))TUuW=j7GJNEHd&)oGTxR`c$?8- ztyY&yy;;gyvWSX`OVsPx4L5-b)Lo)?-=-E$kt5uUr8|ECpa7TwTgK5+C(X!FVk)r% zz!tOS4%6M{NP!t}1r>lT$Gzy$ObMf8D_D!0j;tXY87sLpt2nkQhrs|Vn$Q_p<4^E@}^|aTY6Rb}J|IcA-b=wGG~`TxY8@TkDob zG`{Yf$Lq~mtFxCQD7_|kW@g~)%dK)6^xgVe?KTc*K&@;wg40wYRiT-4-DUxl10Vjf zvO@vV$_oCk| zLlygQG9|jV36`5ez9~*-M#IF)U z<2+s)pAeNX0S3t}EF?v>B9qlAvlSuE+Okum$M{1cGwBbdZUp{e7BVZF))}O7)cI{Bt|Ea0|ydtGC6QC)%!roap>iO8#FMtU}*?YN#nK~3|O~uMFZ9u z=fWv{^+G)DY)Lv>mJ8F)?xeFjW3D=*KBZoWE*(mlch51I3VXW3m8@_rF-w6ZZn-K^ zx9d~OMgCHCs)9S(al;5q+hi+dbnuQNGb?hSuSn}`Nu4cG-L%Ai5PdhA_Vgw_y@|g4 zDbH9+7k;uJQ=p$~Ochu&hN`r|o;27Kj@FMied7GcnQCX#?R%5$dlUO6Qtby)2KZ#h ze~EVdw{Rr4e^B3}=~IKNwWV0o>im8goz|E4HPh*42HS8HKXXwLkMVFYG%TvJZ!0$8 zJ+@wx1-9VZrY$#9z+f&>@I_rT*Od2w9;0;jk?)3nUJBl*oSM zuB&EVz~(n-v@S^T=yKiHc^PK^}d+-Ojv-Z@xu&_07~A`rciN zeD&fwDV|7=O>t{!sMF2LPhtO00G)R4k}}Sgy@VaOCh0s@TVMjwctn8Dp?4a}<*(w9 zUjh1}!H#n-*Oj8m#vkNT>~XZA$w-|*?xrdR|AgZ|GJz%xUOd;-Ls1Ke@)#PWKIFkr zSm0syDV)Y}f9uCa!v*XW3j%Q=8Vn8&`*HL<>h)}9&SAd_J>l7Fox_%m?&|8X$*yn3 z_D&mhy^Q#r@F6@wnn-L>*}_l_!8tBwa(&1|(HN!aNHg0m5=DqOxf`Ivfx4 m9(V!W)jDk$p(yQwYXy+`eE-t$oA>=F*(?3%%hv08B=HYhre9_N diff --git a/utils/email_templates.py b/utils/email_templates.py index 6bfedbf..fc1e684 100644 --- a/utils/email_templates.py +++ b/utils/email_templates.py @@ -30,9 +30,11 @@ def create_default_templates(): 'body': '''

Password Reset Request

Dear {{ user.username }},

-

We received a request to reset your password. Click the link below to set a new password:

-

Reset Password

-

If you didn't request this, please ignore this email or contact support if you have concerns.

+

We received a request to reset your password for your DocuPulse account. Click the link below to set a new password:

+

Reset Password

+

This link will expire in 1 hour.

+

If you didn't request this password reset, please ignore this email. Your password will remain unchanged.

+

If you have any concerns about your account security, please contact your administrator immediately.

Best regards,
The DocuPulse Team

''' }, diff --git a/utils/notification.py b/utils/notification.py index fe4298f..285adab 100644 --- a/utils/notification.py +++ b/utils/notification.py @@ -119,22 +119,44 @@ def generate_mail_from_notification(notif: Notif) -> Optional[Mail]: filled_body = filled_body.replace('{{ site.company_name }}', site_settings.company_name or '') filled_body = filled_body.replace('{{ site.company_website }}', site_settings.company_website or '') - # Add notification details + # Add notification-specific variables if notif.details: - for key, value in notif.details.items(): - # Handle nested keys (e.g., room.name -> room_name) - if '.' in key: - parts = key.split('.') - if len(parts) == 2: - obj_name, attr = parts - if obj_name in notif.details and isinstance(notif.details[obj_name], dict): - if attr in notif.details[obj_name]: - filled_body = filled_body.replace(f'{{{{ {key} }}}}', str(notif.details[obj_name][attr])) - else: - # Special handling for setup_link to ensure it's a proper URL - if key == 'setup_link' and value.startswith('http://http//'): - value = value.replace('http://http//', 'http://') - filled_body = filled_body.replace(f'{{{{ {key} }}}}', str(value)) + if 'setup_link' in filled_body and 'setup_link' in notif.details: + filled_body = filled_body.replace('{{ setup_link }}', notif.details['setup_link']) + if 'reset_link' in filled_body and 'reset_link' in notif.details: + filled_body = filled_body.replace('{{ reset_link }}', notif.details['reset_link']) + if 'expiry_time' in filled_body and 'expiry_time' in notif.details: + filled_body = filled_body.replace('{{ expiry_time }}', notif.details['expiry_time']) + if 'created_by' in filled_body and 'created_by' in notif.details: + filled_body = filled_body.replace('{{ created_by }}', notif.details['created_by']) + if 'deleted_by' in filled_body and 'deleted_by' in notif.details: + filled_body = filled_body.replace('{{ deleted_by }}', notif.details['deleted_by']) + if 'updated_by' in filled_body and 'updated_by' in notif.details: + filled_body = filled_body.replace('{{ updated_by }}', notif.details['updated_by']) + if 'remover.username' in filled_body and 'remover' in notif.details: + filled_body = filled_body.replace('{{ remover.username }}', notif.details['remover']) + if 'sender.username' in filled_body and 'sender' in notif.details: + filled_body = filled_body.replace('{{ sender.username }}', notif.details['sender']) + if 'conversation.name' in filled_body and 'conversation' in notif.details: + filled_body = filled_body.replace('{{ conversation.name }}', notif.details['conversation']) + if 'conversation.description' in filled_body and 'conversation_description' in notif.details: + filled_body = filled_body.replace('{{ conversation.description }}', notif.details['conversation_description']) + if 'message.content' in filled_body and 'message' in notif.details: + filled_body = filled_body.replace('{{ message.content }}', notif.details['message']) + if 'message.created_at' in filled_body and 'message_created_at' in notif.details: + filled_body = filled_body.replace('{{ message.created_at }}', notif.details['message_created_at']) + if 'message_link' in filled_body and 'message_link' in notif.details: + filled_body = filled_body.replace('{{ message_link }}', notif.details['message_link']) + if 'updated_fields' in filled_body and 'updated_fields' in notif.details: + filled_body = filled_body.replace('{{ updated_fields }}', notif.details['updated_fields']) + if 'created_at' in filled_body: + filled_body = filled_body.replace('{{ created_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')) + if 'updated_at' in filled_body: + filled_body = filled_body.replace('{{ updated_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')) + if 'deleted_at' in filled_body: + filled_body = filled_body.replace('{{ deleted_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')) + if 'removed_at' in filled_body: + filled_body = filled_body.replace('{{ removed_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')) # Handle special URL variables if 'room_link' in filled_body and 'room_id' in notif.details: @@ -147,15 +169,6 @@ def generate_mail_from_notification(notif: Notif) -> Optional[Mail]: conversation_link = url_for('conversations.conversation', conversation_id=notif.details['conversation_id'], _external=True) filled_body = filled_body.replace('{{ conversation_link }}', conversation_link) - # Add timestamps - filled_body = filled_body.replace('{{ created_at }}', notif.timestamp.strftime('%Y-%m-%d %H:%M:%S')) - if 'updated_at' in filled_body: - filled_body = filled_body.replace('{{ updated_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')) - if 'deleted_at' in filled_body: - filled_body = filled_body.replace('{{ deleted_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')) - if 'removed_at' in filled_body: - filled_body = filled_body.replace('{{ removed_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')) - # Create a new Mail record mail = Mail( recipient=notif.user.email,