better password management

This commit is contained in:
2025-06-04 13:44:49 +02:00
parent 88c3bc1b5b
commit 41cdd5ec7f
24 changed files with 246 additions and 10 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -22,7 +22,7 @@ conversation_members = db.Table('conversation_members',
class User(UserMixin, db.Model): class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(150), unique=True, nullable=False) username = db.Column(db.String(150), unique=True, nullable=False)
last_name = db.Column(db.String(150), nullable=False, default='(You)') last_name = db.Column(db.String(150), nullable=False, default='--')
email = db.Column(db.String(150), unique=True, nullable=False) email = db.Column(db.String(150), unique=True, nullable=False)
password_hash = db.Column(db.String(256)) password_hash = db.Column(db.String(256))
is_admin = db.Column(db.Boolean, default=False) is_admin = db.Column(db.Boolean, default=False)
@@ -364,3 +364,21 @@ class Mail(db.Model):
def __repr__(self): def __repr__(self):
return f'<Mail to {self.recipient} status={self.status}>' return f'<Mail to {self.recipient} status={self.status}>'
class PasswordSetupToken(db.Model):
__tablename__ = 'password_setup_tokens'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), 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)
# Relationships
user = db.relationship('User', backref='password_setup_tokens')
def is_valid(self):
return not self.used and datetime.utcnow() < self.expires_at
def __repr__(self):
return f'<PasswordSetupToken {self.token}>'

View File

@@ -1,9 +1,10 @@
from flask import render_template, request, flash, redirect, url_for, Blueprint, jsonify 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 flask_login import login_user, logout_user, login_required, current_user
from models import db, User, Notif from models import db, User, Notif, PasswordSetupToken
from functools import wraps from functools import wraps
from datetime import datetime from datetime import datetime
from utils import log_event, create_notification, get_unread_count from utils import log_event, create_notification, get_unread_count
import string
auth_bp = Blueprint('auth', __name__) auth_bp = Blueprint('auth', __name__)
@@ -216,3 +217,80 @@ def init_routes(auth_bp):
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
return render_template('auth/change_password.html') return render_template('auth/change_password.html')
@auth_bp.route('/setup-password/<token>', methods=['GET', 'POST'])
def setup_password(token):
# Find the token
setup_token = PasswordSetupToken.query.filter_by(token=token).first()
if not setup_token or not setup_token.is_valid():
flash('Invalid or expired password setup link. Please contact your administrator for a new link.', 'error')
return redirect(url_for('auth.login'))
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/setup_password.html')
if password != confirm_password:
flash('Passwords do not match.', 'error')
return render_template('auth/setup_password.html')
# Password requirements
if len(password) < 8:
flash('Password must be at least 8 characters long.', 'error')
return render_template('auth/setup_password.html')
if not any(c.isupper() for c in password):
flash('Password must contain at least one uppercase letter.', 'error')
return render_template('auth/setup_password.html')
if not any(c.islower() for c in password):
flash('Password must contain at least one lowercase letter.', 'error')
return render_template('auth/setup_password.html')
if not any(c.isdigit() for c in password):
flash('Password must contain at least one number.', 'error')
return render_template('auth/setup_password.html')
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/setup_password.html')
# Update user's password
user = setup_token.user
user.set_password(password)
# Mark token as used
setup_token.used = True
# Create password change notification
create_notification(
notif_type='password_changed',
user_id=user.id,
details={
'message': 'Your password has been set up successfully.',
'timestamp': datetime.utcnow().isoformat()
}
)
# Log password setup event
log_event(
event_type='user_update',
details={
'user_id': user.id,
'user_name': f"{user.username} {user.last_name}",
'update_type': 'password_setup',
'success': True
}
)
db.session.commit()
flash('Password set up successfully! You can now log in.', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/setup_password.html')

View File

@@ -1,6 +1,6 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user from flask_login import login_required, current_user
from models import db, User, Notif from models import db, User, Notif, PasswordSetupToken
from forms import UserForm from forms import UserForm
from flask import abort from flask import abort
from sqlalchemy import or_ from sqlalchemy import or_
@@ -9,7 +9,9 @@ from utils import log_event, create_notification, get_unread_count
import json import json
import os import os
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from datetime import datetime from datetime import datetime, timedelta
import secrets
import string
contacts_bp = Blueprint('contacts', __name__, url_prefix='/contacts') contacts_bp = Blueprint('contacts', __name__, url_prefix='/contacts')
@@ -113,6 +115,10 @@ def new_contact():
file.save(file_path) file.save(file_path)
profile_picture = filename profile_picture = filename
# Generate a random password
alphabet = string.ascii_letters + string.digits + string.punctuation
random_password = ''.join(secrets.choice(alphabet) for _ in range(32))
# Create new user account # Create new user account
user = User( user = User(
username=form.first_name.data, username=form.first_name.data,
@@ -126,10 +132,20 @@ def new_contact():
is_admin=form.is_admin.data, is_admin=form.is_admin.data,
profile_picture=profile_picture profile_picture=profile_picture
) )
user.set_password('changeme') # Set default password user.set_password(random_password) # Set random password
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
# Create password setup token
token = secrets.token_urlsafe(32)
setup_token = PasswordSetupToken(
user_id=user.id,
token=token,
expires_at=datetime.utcnow() + timedelta(hours=24)
)
db.session.add(setup_token)
db.session.commit()
# Create notification for the new user # Create notification for the new user
create_notification( create_notification(
notif_type='account_created', notif_type='account_created',
@@ -140,7 +156,8 @@ def new_contact():
'username': user.username, 'username': user.username,
'email': user.email, 'email': user.email,
'created_by': f"{current_user.username} {current_user.last_name}", 'created_by': f"{current_user.username} {current_user.last_name}",
'timestamp': datetime.utcnow().isoformat() 'timestamp': datetime.utcnow().isoformat(),
'setup_link': url_for('auth.setup_password', token=token, _external=True)
} }
) )
@@ -159,7 +176,7 @@ def new_contact():
) )
db.session.commit() db.session.commit()
flash('User created successfully! They will need to change their password on first login.', 'success') flash('User created successfully! They will receive an email with a link to set up their password.', 'success')
return redirect(url_for('contacts.contacts_list')) return redirect(url_for('contacts.contacts_list'))
return render_template('contacts/form.html', form=form, title='New User', total_admins=total_admins) return render_template('contacts/form.html', form=form, title='New User', total_admins=total_admins)
@@ -447,3 +464,53 @@ def toggle_active(id):
flash(f'User marked as {"active" if user.is_active else "inactive"}!', 'success') flash(f'User marked as {"active" if user.is_active else "inactive"}!', 'success')
return redirect(url_for('contacts.contacts_list')) return redirect(url_for('contacts.contacts_list'))
@contacts_bp.route('/<int:id>/resend-setup', methods=['POST'])
@login_required
@require_password_change
def resend_setup_link(id):
result = admin_required()
if result: return result
user = User.query.get_or_404(id)
# Create new password setup token
token = secrets.token_urlsafe(32)
setup_token = PasswordSetupToken(
user_id=user.id,
token=token,
expires_at=datetime.utcnow() + timedelta(hours=24)
)
db.session.add(setup_token)
# Create notification for the user
create_notification(
notif_type='account_created',
user_id=user.id,
sender_id=current_user.id,
details={
'message': 'A new password setup link has been sent to you.',
'username': user.username,
'email': user.email,
'created_by': f"{current_user.username} {current_user.last_name}",
'timestamp': datetime.utcnow().isoformat(),
'setup_link': url_for('auth.setup_password', token=token, _external=True)
}
)
# Log the event
log_event(
event_type='user_update',
details={
'user_id': user.id,
'user_name': f"{user.username} {user.last_name}",
'updated_by': current_user.id,
'updated_by_name': f"{current_user.username} {current_user.last_name}",
'update_type': 'password_setup_link_resend'
}
)
db.session.commit()
flash('Password setup link has been resent to the user.', 'success')
return redirect(url_for('contacts.contacts_list'))

View File

@@ -0,0 +1,61 @@
{% extends "common/base.html" %}
{% block title %}Set Up Password{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="max-w-md mx-auto">
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-2xl font-bold mb-6 text-center" style="color: var(--primary-color);">Set Up Your Password</h2>
<div class="mb-6">
<div class="bg-blue-50 border-l-4 border-blue-400 p-4 mb-4">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">Password Requirements</h3>
<div class="mt-2 text-sm text-blue-700">
<ul class="list-disc pl-5 space-y-1">
<li>At least 8 characters long</li>
<li>At least one uppercase letter</li>
<li>At least one lowercase letter</li>
<li>At least one number</li>
<li>At least one special character</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<form method="POST" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">
<i class="fas fa-lock me-2" style="color: var(--primary-color);"></i>New Password
</label>
<input type="password" name="password" id="password" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2"
style="--tw-ring-color: var(--primary-color);">
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-gray-700 mb-1">
<i class="fas fa-lock me-2" style="color: var(--primary-color);"></i>Confirm Password
</label>
<input type="password" name="confirm_password" id="confirm_password" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2"
style="--tw-ring-color: var(--primary-color);">
</div>
<button type="submit" class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
Set Password
</button>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -113,6 +113,15 @@
<i class="fas fa-edit" style="font-size: 0.85em; opacity: 0.7;"></i> <i class="fas fa-edit" style="font-size: 0.85em; opacity: 0.7;"></i>
Edit Edit
</a> </a>
<form action="{{ url_for('contacts.resend_setup_link', id=user.id) }}" method="POST" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm no-underline transition-colors duration-200"
style="background-color: var(--primary-opacity-8); color: var(--primary-color);">
<i class="fas fa-paper-plane" style="font-size: 0.85em; opacity: 0.7;"></i>
Resend Setup Link
</button>
</form>
{% if user.email != current_user.email %} {% if user.email != current_user.email %}
<button type="button" <button type="button"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm no-underline transition-colors duration-200" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm no-underline transition-colors duration-200"

View File

@@ -16,7 +16,10 @@ def create_default_templates():
'body': ''' 'body': '''
<h2>Welcome to DocuPulse!</h2> <h2>Welcome to DocuPulse!</h2>
<p>Dear {{ user.username }},</p> <p>Dear {{ user.username }},</p>
<p>Your account has been successfully created. You can now log in and start using DocuPulse.</p> <p>Your account has been successfully created by {{ created_by }}. To get started, you need to set up your password.</p>
<p>Click the link below to set up your password (this link will expire in 24 hours):</p>
<p><a href="{{ setup_link }}">Set Up Password</a></p>
<p>If you didn't receive this email or if the link has expired, please contact your administrator to request a new password setup link.</p>
<p>If you have any questions, please don't hesitate to contact our support team.</p> <p>If you have any questions, please don't hesitate to contact our support team.</p>
<p>Best regards,<br>The DocuPulse Team</p> <p>Best regards,<br>The DocuPulse Team</p>
''' '''