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):
id = db.Column(db.Integer, primary_key=True)
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)
password_hash = db.Column(db.String(256))
is_admin = db.Column(db.Boolean, default=False)
@@ -364,3 +364,21 @@ class Mail(db.Model):
def __repr__(self):
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_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 datetime import datetime
from utils import log_event, create_notification, get_unread_count
import string
auth_bp = Blueprint('auth', __name__)
@@ -216,3 +217,80 @@ def init_routes(auth_bp):
return redirect(url_for('main.dashboard'))
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_login import login_required, current_user
from models import db, User, Notif
from models import db, User, Notif, PasswordSetupToken
from forms import UserForm
from flask import abort
from sqlalchemy import or_
@@ -9,7 +9,9 @@ from utils import log_event, create_notification, get_unread_count
import json
import os
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')
@@ -113,6 +115,10 @@ def new_contact():
file.save(file_path)
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
user = User(
username=form.first_name.data,
@@ -126,10 +132,20 @@ def new_contact():
is_admin=form.is_admin.data,
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.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(
notif_type='account_created',
@@ -140,7 +156,8 @@ def new_contact():
'username': user.username,
'email': user.email,
'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()
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 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')
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>
Edit
</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 %}
<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"

View File

@@ -16,7 +16,10 @@ def create_default_templates():
'body': '''
<h2>Welcome to DocuPulse!</h2>
<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>Best regards,<br>The DocuPulse Team</p>
'''