better password management
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
20
models.py
20
models.py
@@ -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}>'
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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')
|
||||||
@@ -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'))
|
||||||
61
templates/auth/setup_password.html
Normal file
61
templates/auth/setup_password.html
Normal 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 %}
|
||||||
@@ -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"
|
||||||
|
|||||||
Binary file not shown.
@@ -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>
|
||||||
'''
|
'''
|
||||||
|
|||||||
Reference in New Issue
Block a user