Compare commits
5 Commits
master
...
prune-fake
| Author | SHA1 | Date | |
|---|---|---|---|
| 6afa4619ef | |||
| 57bb0f5b9e | |||
| 8de4378ad9 | |||
| c375b9ee3d | |||
| 983d10ea97 |
@@ -25,3 +25,9 @@ MAIL_USE_TLS=true
|
||||
MAIL_USERNAME=your-email@example.com
|
||||
MAIL_PASSWORD=your-password
|
||||
MAIL_DEFAULT_SENDER=TimeTrack <noreply@timetrack.com>
|
||||
|
||||
# reCAPTCHA Configuration (Google reCAPTCHA v2)
|
||||
# Get your keys at: https://www.google.com/recaptcha/admin
|
||||
RECAPTCHA_SITE_KEY=your-site-key-here
|
||||
RECAPTCHA_SECRET_KEY=your-secret-key-here
|
||||
RECAPTCHA_ENABLED=true
|
||||
@@ -39,8 +39,16 @@ docker-compose up
|
||||
|
||||
# Debug mode with hot-reload
|
||||
docker-compose -f docker-compose.debug.yml up
|
||||
|
||||
# Manual cleanup of unverified accounts
|
||||
docker exec timetrack-timetrack-1 python cleanup_unverified_accounts.py
|
||||
|
||||
# Dry run to see what would be deleted
|
||||
docker exec timetrack-timetrack-1 python cleanup_unverified_accounts.py --dry-run
|
||||
```
|
||||
|
||||
**Note:** Unverified accounts are automatically cleaned up every hour via cron job in the Docker container.
|
||||
|
||||
## Database Operations
|
||||
|
||||
### Flask-Migrate Commands
|
||||
@@ -187,6 +195,7 @@ When modifying billing/invoice features:
|
||||
- Two-factor authentication (2FA) using TOTP
|
||||
- Session-based authentication with "Remember Me" option
|
||||
- Email verification for new accounts (configurable)
|
||||
- Automatic cleanup of unverified accounts after 24 hours
|
||||
|
||||
### Mobile UI Features
|
||||
- Progressive Web App (PWA) manifest for installability
|
||||
|
||||
@@ -14,6 +14,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
python3-dev \
|
||||
postgresql-client \
|
||||
cron \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -35,6 +36,12 @@ RUN pip install gunicorn==21.2.0
|
||||
# Copy the rest of the application
|
||||
COPY . .
|
||||
|
||||
# Setup cron job for cleanup
|
||||
COPY docker-cron /etc/cron.d/cleanup-cron
|
||||
RUN chmod 0644 /etc/cron.d/cleanup-cron && \
|
||||
crontab /etc/cron.d/cleanup-cron && \
|
||||
touch /var/log/cron.log
|
||||
|
||||
# Create the SQLite database directory with proper permissions
|
||||
RUN mkdir -p /app/instance && chmod 777 /app/instance
|
||||
|
||||
|
||||
95
app.py
95
app.py
@@ -19,6 +19,7 @@ from flask_mail import Mail, Message
|
||||
from dotenv import load_dotenv
|
||||
from password_utils import PasswordValidator
|
||||
from werkzeug.security import check_password_hash
|
||||
from recaptcha_helper import recaptcha
|
||||
|
||||
# Import blueprints
|
||||
from routes.notes import notes_bp
|
||||
@@ -129,6 +130,9 @@ logger.info(f"Mail default sender: {app.config['MAIL_DEFAULT_SENDER']}")
|
||||
|
||||
mail = Mail(app)
|
||||
|
||||
# Initialize reCAPTCHA
|
||||
recaptcha.init_app(app)
|
||||
|
||||
# Initialize the database with the app
|
||||
db.init_app(app)
|
||||
|
||||
@@ -652,6 +656,13 @@ def register():
|
||||
if not is_valid:
|
||||
error = password_errors[0] # Show first error
|
||||
|
||||
# Verify reCAPTCHA
|
||||
if not error:
|
||||
recaptcha_response = request.form.get('g-recaptcha-response')
|
||||
is_valid, recaptcha_error = recaptcha.verify(recaptcha_response, request.remote_addr)
|
||||
if not is_valid:
|
||||
error = recaptcha_error
|
||||
|
||||
# Find company by code or create new one if no code provided
|
||||
company = None
|
||||
if company_code:
|
||||
@@ -729,31 +740,47 @@ def register():
|
||||
# Make first user in company an admin with full privileges
|
||||
if is_first_user_in_company:
|
||||
new_user.role = Role.ADMIN
|
||||
new_user.is_verified = True # Auto-verify first user in company
|
||||
# Removed auto-verification - all users must verify email
|
||||
elif not email_verification_required:
|
||||
# If email verification is disabled, auto-verify new users
|
||||
new_user.is_verified = True
|
||||
|
||||
# Generate verification token (even if not needed, for consistency)
|
||||
|
||||
# Generate verification token
|
||||
token = new_user.generate_verification_token()
|
||||
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
|
||||
if is_first_user_in_company:
|
||||
# First user in company gets admin privileges and is auto-verified
|
||||
logger.info(f"First user account created in company {company.name}: {username} with admin privileges")
|
||||
flash(f'Welcome! You are the first user in {company.name} and have been granted administrator privileges. You can now log in.', 'success')
|
||||
elif not email_verification_required:
|
||||
if not email_verification_required:
|
||||
# Email verification is disabled, user can log in immediately
|
||||
logger.info(f"User account created with auto-verification in company {company.name}: {username}")
|
||||
flash('Registration successful! You can now log in.', 'success')
|
||||
else:
|
||||
# Send verification email for regular users when verification is required
|
||||
# Send verification email for all users (including first user)
|
||||
verification_url = url_for('verify_email', token=token, _external=True)
|
||||
msg = Message(f'Verify your {g.branding.app_name} account', recipients=[email])
|
||||
msg.body = f'''Hello {username},
|
||||
|
||||
# Special message for first user in company
|
||||
if is_first_user_in_company:
|
||||
msg.body = f'''Hello {username},
|
||||
|
||||
Thank you for registering with {g.branding.app_name}. You are the first user in {company.name} and have been granted administrator privileges.
|
||||
|
||||
To complete your registration and access your account, please click on the link below:
|
||||
|
||||
{verification_url}
|
||||
|
||||
This link will expire in 24 hours.
|
||||
|
||||
If you did not register for {g.branding.app_name}, please ignore this email.
|
||||
|
||||
Best regards,
|
||||
The {g.branding.app_name} Team
|
||||
'''
|
||||
logger.info(f"First user account created in company {company.name}: {username} with admin privileges - verification email sent")
|
||||
flash(f'Welcome! You are the first user in {company.name} and have been granted administrator privileges. Please check your email to verify your account.', 'success')
|
||||
else:
|
||||
msg.body = f'''Hello {username},
|
||||
|
||||
Thank you for registering with {g.branding.app_name}. To complete your registration, please click on the link below:
|
||||
|
||||
@@ -766,9 +793,10 @@ If you did not register for {g.branding.app_name}, please ignore this email.
|
||||
Best regards,
|
||||
The {g.branding.app_name} Team
|
||||
'''
|
||||
logger.info(f"User account created in company {company.name}: {username} - verification email sent")
|
||||
flash('Registration initiated! Please check your email to verify your account.', 'success')
|
||||
|
||||
mail.send(msg)
|
||||
logger.info(f"Verification email sent to {email}")
|
||||
flash('Registration initiated! Please check your email to verify your account.', 'success')
|
||||
|
||||
return redirect(url_for('login'))
|
||||
except Exception as e:
|
||||
@@ -815,6 +843,13 @@ def register_freelancer():
|
||||
if not is_valid:
|
||||
error = password_errors[0] # Show first error
|
||||
|
||||
# Verify reCAPTCHA
|
||||
if not error:
|
||||
recaptcha_response = request.form.get('g-recaptcha-response')
|
||||
is_valid, recaptcha_error = recaptcha.verify(recaptcha_response, request.remote_addr)
|
||||
if not is_valid:
|
||||
error = recaptcha_error
|
||||
|
||||
# Check for existing users globally (freelancers get unique usernames/emails)
|
||||
if not error:
|
||||
if User.query.filter_by(username=username).first():
|
||||
@@ -851,6 +886,9 @@ def register_freelancer():
|
||||
db.session.add(personal_company)
|
||||
db.session.flush() # Get company ID
|
||||
|
||||
# Check if email verification is required
|
||||
email_verification_required = get_system_setting('email_verification_required', 'true') == 'true'
|
||||
|
||||
# Create freelancer user
|
||||
new_user = User(
|
||||
username=username,
|
||||
@@ -859,15 +897,42 @@ def register_freelancer():
|
||||
account_type=AccountType.FREELANCER,
|
||||
business_name=business_name if business_name else None,
|
||||
role=Role.ADMIN, # Freelancers are admins of their personal company
|
||||
is_verified=True # Auto-verify freelancers
|
||||
is_verified=not email_verification_required # Only auto-verify if email verification is disabled
|
||||
)
|
||||
new_user.set_password(password)
|
||||
|
||||
# Generate verification token
|
||||
token = new_user.generate_verification_token()
|
||||
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Freelancer account created: {username} with personal company: {company_name}")
|
||||
flash(f'Welcome {username}! Your freelancer account has been created successfully. You can now log in.', 'success')
|
||||
if not email_verification_required:
|
||||
# Email verification is disabled, user can log in immediately
|
||||
logger.info(f"Freelancer account created with auto-verification: {username} with personal company: {company_name}")
|
||||
flash(f'Welcome {username}! Your freelancer account has been created successfully. You can now log in.', 'success')
|
||||
else:
|
||||
# Send verification email
|
||||
verification_url = url_for('verify_email', token=token, _external=True)
|
||||
msg = Message(f'Verify your {g.branding.app_name} freelancer account', recipients=[email])
|
||||
msg.body = f'''Hello {username},
|
||||
|
||||
Thank you for registering as a freelancer with {g.branding.app_name}. Your personal workspace "{company_name}" has been created.
|
||||
|
||||
To complete your registration and access your account, please click on the link below:
|
||||
|
||||
{verification_url}
|
||||
|
||||
This link will expire in 24 hours.
|
||||
|
||||
If you did not register for {g.branding.app_name}, please ignore this email.
|
||||
|
||||
Best regards,
|
||||
The {g.branding.app_name} Team
|
||||
'''
|
||||
mail.send(msg)
|
||||
logger.info(f"Freelancer account created: {username} with personal company: {company_name} - verification email sent")
|
||||
flash(f'Welcome {username}! Your freelancer workspace has been created. Please check your email to verify your account.', 'success')
|
||||
|
||||
return redirect(url_for('login'))
|
||||
|
||||
|
||||
327
cleanup_inactive_accounts.py
Executable file
327
cleanup_inactive_accounts.py
Executable file
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Cleanup verified but inactive user accounts.
|
||||
Identifies and removes bot-created or unused accounts that:
|
||||
- Are verified (is_verified = True)
|
||||
- Have never logged in (no SystemEvent login records)
|
||||
- Have never created time entries
|
||||
- Are older than a specified number of days
|
||||
|
||||
This script can be run manually or scheduled via cron.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import and_, or_
|
||||
|
||||
# Add the application path to Python path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from app import app, db
|
||||
from models import User, Company, SystemEvent, TimeEntry
|
||||
from models.enums import Role
|
||||
|
||||
def find_inactive_verified_accounts(min_age_days=30, dry_run=False):
|
||||
"""
|
||||
Find verified user accounts that have never been used.
|
||||
|
||||
Args:
|
||||
min_age_days (int): Minimum age in days for accounts to be considered
|
||||
dry_run (bool): If True, only show what would be deleted without actually deleting.
|
||||
|
||||
Returns:
|
||||
tuple: (accounts_to_delete, details_list)
|
||||
"""
|
||||
with app.app_context():
|
||||
cutoff_time = datetime.utcnow() - timedelta(days=min_age_days)
|
||||
|
||||
# Find verified accounts older than cutoff
|
||||
verified_old_users = User.query.filter(
|
||||
and_(
|
||||
User.is_verified == True,
|
||||
User.created_at < cutoff_time
|
||||
)
|
||||
).all()
|
||||
|
||||
inactive_accounts = []
|
||||
|
||||
for user in verified_old_users:
|
||||
# Check if user has any time entries
|
||||
time_entry_count = TimeEntry.query.filter_by(user_id=user.id).count()
|
||||
|
||||
# Check if user has any login events
|
||||
login_event_count = SystemEvent.query.filter(
|
||||
and_(
|
||||
SystemEvent.user_id == user.id,
|
||||
SystemEvent.event_type == 'user_login'
|
||||
)
|
||||
).count()
|
||||
|
||||
# If no time entries and no logins, this is an inactive account
|
||||
if time_entry_count == 0 and login_event_count == 0:
|
||||
account_age_days = (datetime.utcnow() - user.created_at).days
|
||||
|
||||
# Get company info
|
||||
company = Company.query.get(user.company_id) if user.company_id else None
|
||||
company_name = company.name if company else "Unknown"
|
||||
|
||||
# Check if user is the only admin in their company
|
||||
is_only_admin = False
|
||||
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN]:
|
||||
other_admins = User.query.filter(
|
||||
and_(
|
||||
User.company_id == user.company_id,
|
||||
User.id != user.id,
|
||||
User.role.in_([Role.ADMIN, Role.SYSTEM_ADMIN])
|
||||
)
|
||||
).count()
|
||||
is_only_admin = (other_admins == 0)
|
||||
|
||||
inactive_accounts.append({
|
||||
'user': user,
|
||||
'company_name': company_name,
|
||||
'age_days': account_age_days,
|
||||
'is_only_admin': is_only_admin,
|
||||
'company': company
|
||||
})
|
||||
|
||||
return inactive_accounts
|
||||
|
||||
|
||||
def cleanup_inactive_accounts(min_age_days=30, dry_run=False, skip_admins=True):
|
||||
"""
|
||||
Delete inactive verified accounts.
|
||||
|
||||
Args:
|
||||
min_age_days (int): Minimum age in days for accounts to be considered
|
||||
dry_run (bool): If True, only show what would be deleted without actually deleting
|
||||
skip_admins (bool): If True, skip accounts that are the only admin in their company
|
||||
|
||||
Returns:
|
||||
int: Number of accounts deleted (or would be deleted in dry run mode)
|
||||
"""
|
||||
with app.app_context():
|
||||
inactive_accounts = find_inactive_verified_accounts(min_age_days, dry_run)
|
||||
|
||||
deleted_count = 0
|
||||
skipped_count = 0
|
||||
companies_deleted = 0
|
||||
|
||||
print(f"\nFound {len(inactive_accounts)} inactive verified account(s) older than {min_age_days} days")
|
||||
print("=" * 80)
|
||||
|
||||
for account_info in inactive_accounts:
|
||||
user = account_info['user']
|
||||
company_name = account_info['company_name']
|
||||
age_days = account_info['age_days']
|
||||
is_only_admin = account_info['is_only_admin']
|
||||
company = account_info['company']
|
||||
|
||||
# Skip if this is the only admin and skip_admins is True
|
||||
if is_only_admin and skip_admins:
|
||||
print(f"\nSKIPPED (only admin): {user.username}")
|
||||
print(f" Email: {user.email}")
|
||||
print(f" Company: {company_name} (ID: {user.company_id})")
|
||||
print(f" Role: {user.role.value}")
|
||||
print(f" Created: {user.created_at} ({age_days} days ago)")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
print(f"\nWOULD DELETE: {user.username}")
|
||||
print(f" Email: {user.email}")
|
||||
print(f" Company: {company_name} (ID: {user.company_id})")
|
||||
print(f" Role: {user.role.value}")
|
||||
print(f" Created: {user.created_at} ({age_days} days ago)")
|
||||
|
||||
# Check if company would be deleted too
|
||||
if company:
|
||||
other_users = User.query.filter(
|
||||
and_(
|
||||
User.company_id == user.company_id,
|
||||
User.id != user.id
|
||||
)
|
||||
).count()
|
||||
if other_users == 0:
|
||||
print(f" -> Would also delete empty company: {company_name}")
|
||||
|
||||
deleted_count += 1
|
||||
else:
|
||||
print(f"\nDELETING: {user.username}")
|
||||
print(f" Email: {user.email}")
|
||||
print(f" Company: {company_name} (ID: {user.company_id})")
|
||||
print(f" Role: {user.role.value}")
|
||||
print(f" Created: {user.created_at} ({age_days} days ago)")
|
||||
|
||||
# Log the deletion as a system event
|
||||
try:
|
||||
SystemEvent.log_event(
|
||||
event_type='user_deleted',
|
||||
event_category='system',
|
||||
description=f'Inactive verified user {user.username} deleted (no activity for {age_days} days)',
|
||||
user_id=None, # No user context for system cleanup
|
||||
company_id=user.company_id
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" Warning: Could not log deletion event: {e}")
|
||||
|
||||
# Check if company should be deleted
|
||||
if company:
|
||||
other_users = User.query.filter(
|
||||
and_(
|
||||
User.company_id == user.company_id,
|
||||
User.id != user.id
|
||||
)
|
||||
).count()
|
||||
|
||||
# Delete the user
|
||||
db.session.delete(user)
|
||||
deleted_count += 1
|
||||
|
||||
# If no other users, delete the company too
|
||||
if other_users == 0:
|
||||
print(f" -> Also deleting empty company: {company_name}")
|
||||
db.session.delete(company)
|
||||
companies_deleted += 1
|
||||
else:
|
||||
# Delete the user even if company doesn't exist
|
||||
db.session.delete(user)
|
||||
deleted_count += 1
|
||||
|
||||
if not dry_run and deleted_count > 0:
|
||||
db.session.commit()
|
||||
print("\n" + "=" * 80)
|
||||
print(f"Successfully deleted {deleted_count} inactive account(s)")
|
||||
if companies_deleted > 0:
|
||||
print(f"Successfully deleted {companies_deleted} empty company/companies")
|
||||
if skipped_count > 0:
|
||||
print(f"Skipped {skipped_count} account(s) (only admin in company)")
|
||||
elif dry_run:
|
||||
print("\n" + "=" * 80)
|
||||
print(f"DRY RUN: Would delete {deleted_count} inactive account(s)")
|
||||
if skipped_count > 0:
|
||||
print(f"Would skip {skipped_count} account(s) (only admin in company)")
|
||||
else:
|
||||
print("\n" + "=" * 80)
|
||||
print("No inactive accounts found to delete")
|
||||
if skipped_count > 0:
|
||||
print(f"Skipped {skipped_count} account(s) (only admin in company)")
|
||||
|
||||
return deleted_count
|
||||
|
||||
|
||||
def generate_report(min_age_days=30):
|
||||
"""
|
||||
Generate a detailed report of inactive accounts without deleting anything.
|
||||
|
||||
Args:
|
||||
min_age_days (int): Minimum age in days for accounts to be considered
|
||||
"""
|
||||
with app.app_context():
|
||||
inactive_accounts = find_inactive_verified_accounts(min_age_days, dry_run=True)
|
||||
|
||||
print(f"\n{'='*80}")
|
||||
print(f"INACTIVE VERIFIED ACCOUNTS REPORT")
|
||||
print(f"Accounts older than {min_age_days} days with no activity")
|
||||
print(f"Generated: {datetime.utcnow()}")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
if not inactive_accounts:
|
||||
print("No inactive verified accounts found.")
|
||||
return
|
||||
|
||||
# Group by company
|
||||
by_company = {}
|
||||
for account_info in inactive_accounts:
|
||||
company_name = account_info['company_name']
|
||||
if company_name not in by_company:
|
||||
by_company[company_name] = []
|
||||
by_company[company_name].append(account_info)
|
||||
|
||||
# Print summary
|
||||
print(f"Total inactive accounts: {len(inactive_accounts)}")
|
||||
print(f"Companies affected: {len(by_company)}\n")
|
||||
|
||||
# Print details by company
|
||||
for company_name, accounts in sorted(by_company.items()):
|
||||
print(f"\nCompany: {company_name}")
|
||||
print("-" * 80)
|
||||
for account_info in accounts:
|
||||
user = account_info['user']
|
||||
age_days = account_info['age_days']
|
||||
is_only_admin = account_info['is_only_admin']
|
||||
|
||||
admin_warning = " [ONLY ADMIN]" if is_only_admin else ""
|
||||
print(f" • {user.username}{admin_warning}")
|
||||
print(f" Email: {user.email or 'N/A'}")
|
||||
print(f" Role: {user.role.value}")
|
||||
print(f" Created: {user.created_at} ({age_days} days ago)")
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to handle command line arguments"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Cleanup verified but inactive user accounts (no logins, no time entries)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would be deleted without actually deleting'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--min-age',
|
||||
type=int,
|
||||
default=30,
|
||||
help='Minimum account age in days (default: 30)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--include-admins',
|
||||
action='store_true',
|
||||
help='Include accounts that are the only admin in their company (default: skip them)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--report-only',
|
||||
action='store_true',
|
||||
help='Generate a detailed report only, do not delete anything'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--quiet',
|
||||
action='store_true',
|
||||
help='Suppress output except for errors'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.quiet:
|
||||
print(f"Starting cleanup of inactive verified accounts at {datetime.utcnow()}")
|
||||
print("-" * 80)
|
||||
|
||||
try:
|
||||
if args.report_only:
|
||||
generate_report(min_age_days=args.min_age)
|
||||
else:
|
||||
deleted_count = cleanup_inactive_accounts(
|
||||
min_age_days=args.min_age,
|
||||
dry_run=args.dry_run,
|
||||
skip_admins=not args.include_admins
|
||||
)
|
||||
|
||||
if not args.quiet:
|
||||
print("-" * 80)
|
||||
print(f"Cleanup completed at {datetime.utcnow()}")
|
||||
|
||||
# Exit with 0 for success
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"Error during cleanup: {str(e)}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
152
cleanup_unverified_accounts.py
Executable file
152
cleanup_unverified_accounts.py
Executable file
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Cleanup unverified user accounts older than 24 hours.
|
||||
This script can be run manually or scheduled via cron.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import and_
|
||||
|
||||
# Add the application path to Python path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from app import app, db
|
||||
from models import User, Company, SystemEvent
|
||||
from models.enums import Role
|
||||
|
||||
def cleanup_unverified_accounts(dry_run=False):
|
||||
"""
|
||||
Delete unverified user accounts that are older than 24 hours.
|
||||
|
||||
Args:
|
||||
dry_run (bool): If True, only show what would be deleted without actually deleting.
|
||||
|
||||
Returns:
|
||||
int: Number of accounts deleted (or would be deleted in dry run mode)
|
||||
"""
|
||||
with app.app_context():
|
||||
# Find unverified accounts older than 24 hours
|
||||
cutoff_time = datetime.utcnow() - timedelta(hours=24)
|
||||
|
||||
unverified_users = User.query.filter(
|
||||
and_(
|
||||
User.is_verified == False,
|
||||
User.created_at < cutoff_time
|
||||
)
|
||||
).all()
|
||||
|
||||
deleted_count = 0
|
||||
|
||||
for user in unverified_users:
|
||||
# Check if this user is the only admin in their company
|
||||
# We shouldn't delete them if they are, as it would orphan the company
|
||||
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN]:
|
||||
other_admins = User.query.filter(
|
||||
and_(
|
||||
User.company_id == user.company_id,
|
||||
User.id != user.id,
|
||||
User.role.in_([Role.ADMIN, Role.SYSTEM_ADMIN])
|
||||
)
|
||||
).count()
|
||||
|
||||
if other_admins == 0:
|
||||
print(f"Skipping {user.username} (ID: {user.id}) - only admin in company {user.company_id}")
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
print(f"Would delete unverified user: {user.username} (ID: {user.id}, Email: {user.email}, Created: {user.created_at})")
|
||||
deleted_count += 1
|
||||
|
||||
# Check if company would be deleted too (for dry run)
|
||||
company = Company.query.get(user.company_id)
|
||||
if company:
|
||||
other_users = User.query.filter(
|
||||
and_(
|
||||
User.company_id == user.company_id,
|
||||
User.id != user.id
|
||||
)
|
||||
).count()
|
||||
if other_users == 0:
|
||||
print(f" Would also delete empty company: {company.name} (ID: {company.id})")
|
||||
else:
|
||||
print(f"Deleting unverified user: {user.username} (ID: {user.id}, Email: {user.email}, Created: {user.created_at})")
|
||||
|
||||
# Log the deletion as a system event if SystemEvent exists
|
||||
try:
|
||||
SystemEvent.log_event(
|
||||
event_type='user_deleted',
|
||||
event_category='system',
|
||||
description=f'Unverified user {user.username} deleted after 24 hours',
|
||||
user_id=None, # No user context for system cleanup
|
||||
company_id=user.company_id
|
||||
)
|
||||
except:
|
||||
# SystemEvent might not exist, continue without logging
|
||||
pass
|
||||
|
||||
# Check if the company should be deleted (if it was created with this user and has no other users)
|
||||
company = Company.query.get(user.company_id)
|
||||
if company:
|
||||
other_users = User.query.filter(
|
||||
and_(
|
||||
User.company_id == user.company_id,
|
||||
User.id != user.id
|
||||
)
|
||||
).count()
|
||||
|
||||
# Delete the user
|
||||
db.session.delete(user)
|
||||
deleted_count += 1
|
||||
|
||||
# If no other users, delete the company too
|
||||
if other_users == 0:
|
||||
print(f" Also deleting empty company: {company.name} (ID: {company.id})")
|
||||
db.session.delete(company)
|
||||
else:
|
||||
# Delete the user even if company doesn't exist
|
||||
db.session.delete(user)
|
||||
deleted_count += 1
|
||||
|
||||
if not dry_run and deleted_count > 0:
|
||||
db.session.commit()
|
||||
print(f"\nSuccessfully deleted {deleted_count} unverified account(s)")
|
||||
elif dry_run:
|
||||
print(f"\nDry run: Would delete {deleted_count} unverified account(s)")
|
||||
else:
|
||||
print("No unverified accounts older than 24 hours found")
|
||||
|
||||
return deleted_count
|
||||
|
||||
def main():
|
||||
"""Main function to handle command line arguments"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='Cleanup unverified user accounts older than 24 hours')
|
||||
parser.add_argument('--dry-run', action='store_true',
|
||||
help='Show what would be deleted without actually deleting')
|
||||
parser.add_argument('--quiet', action='store_true',
|
||||
help='Suppress output except for errors')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.quiet:
|
||||
print(f"Starting cleanup of unverified accounts at {datetime.utcnow()}")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
deleted_count = cleanup_unverified_accounts(dry_run=args.dry_run)
|
||||
|
||||
if not args.quiet:
|
||||
print("-" * 60)
|
||||
print(f"Cleanup completed at {datetime.utcnow()}")
|
||||
|
||||
# Exit with 0 for success
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"Error during cleanup: {str(e)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -43,6 +43,9 @@ services:
|
||||
- MAIL_USERNAME=${MAIL_USERNAME}
|
||||
- MAIL_PASSWORD=${MAIL_PASSWORD}
|
||||
- MAIL_DEFAULT_SENDER=${MAIL_DEFAULT_SENDER}
|
||||
- RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY}
|
||||
- RECAPTCHA_SECRET_KEY=${RECAPTCHA_SECRET_KEY}
|
||||
- RECAPTCHA_ENABLED=${RECAPTCHA_ENABLED}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
9
docker-cron
Normal file
9
docker-cron
Normal file
@@ -0,0 +1,9 @@
|
||||
# Cron job for cleaning up unverified accounts
|
||||
# Run every hour at minute 0
|
||||
0 * * * * cd /app && /usr/local/bin/python /app/cleanup_unverified_accounts.py --quiet >> /var/log/cron.log 2>&1
|
||||
|
||||
# Cron job for cleaning up inactive verified accounts (no logins, no time entries)
|
||||
# Run monthly on the 1st at 2:00 AM, cleaning accounts older than 90 days
|
||||
0 2 1 * * cd /app && /usr/local/bin/python /app/cleanup_inactive_accounts.py --min-age 90 --quiet >> /var/log/cron.log 2>&1
|
||||
|
||||
# Empty line required at the end
|
||||
110
recaptcha_helper.py
Normal file
110
recaptcha_helper.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
reCAPTCHA Helper Module
|
||||
Provides verification functionality for Google reCAPTCHA v2
|
||||
"""
|
||||
|
||||
import os
|
||||
import requests
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReCaptcha:
|
||||
"""Helper class for reCAPTCHA verification"""
|
||||
|
||||
def __init__(self, app=None):
|
||||
self.site_key = None
|
||||
self.secret_key = None
|
||||
self.enabled = False
|
||||
|
||||
if app is not None:
|
||||
self.init_app(app)
|
||||
|
||||
def init_app(self, app):
|
||||
"""Initialize reCAPTCHA with Flask app configuration"""
|
||||
self.site_key = os.getenv('RECAPTCHA_SITE_KEY', '')
|
||||
self.secret_key = os.getenv('RECAPTCHA_SECRET_KEY', '')
|
||||
self.enabled = os.getenv('RECAPTCHA_ENABLED', 'true').lower() == 'true'
|
||||
|
||||
# Store in app config for template access
|
||||
app.config['RECAPTCHA_SITE_KEY'] = self.site_key
|
||||
app.config['RECAPTCHA_ENABLED'] = self.enabled
|
||||
|
||||
if self.enabled and (not self.site_key or not self.secret_key):
|
||||
logger.warning("reCAPTCHA is enabled but keys are not configured properly")
|
||||
|
||||
def verify(self, response_token, remote_ip=None):
|
||||
"""
|
||||
Verify a reCAPTCHA response token
|
||||
|
||||
Args:
|
||||
response_token (str): The g-recaptcha-response from the form
|
||||
remote_ip (str, optional): The user's IP address
|
||||
|
||||
Returns:
|
||||
tuple: (success: bool, error_message: str or None)
|
||||
"""
|
||||
# If reCAPTCHA is disabled, always return success
|
||||
if not self.enabled:
|
||||
return True, None
|
||||
|
||||
# Check if we have the required configuration
|
||||
if not self.secret_key:
|
||||
logger.error("reCAPTCHA secret key is not configured")
|
||||
return False, "reCAPTCHA is not properly configured"
|
||||
|
||||
# Check if response token was provided
|
||||
if not response_token:
|
||||
return False, "Please complete the reCAPTCHA challenge"
|
||||
|
||||
# Prepare the verification request
|
||||
verify_url = 'https://www.google.com/recaptcha/api/siteverify'
|
||||
payload = {
|
||||
'secret': self.secret_key,
|
||||
'response': response_token
|
||||
}
|
||||
|
||||
if remote_ip:
|
||||
payload['remoteip'] = remote_ip
|
||||
|
||||
try:
|
||||
# Make the verification request
|
||||
response = requests.post(verify_url, data=payload, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
|
||||
if result.get('success'):
|
||||
logger.info("reCAPTCHA verification successful")
|
||||
return True, None
|
||||
else:
|
||||
error_codes = result.get('error-codes', [])
|
||||
logger.warning(f"reCAPTCHA verification failed: {error_codes}")
|
||||
|
||||
# Map error codes to user-friendly messages
|
||||
error_messages = {
|
||||
'missing-input-secret': 'reCAPTCHA configuration error',
|
||||
'invalid-input-secret': 'reCAPTCHA configuration error',
|
||||
'missing-input-response': 'Please complete the reCAPTCHA challenge',
|
||||
'invalid-input-response': 'reCAPTCHA verification failed. Please try again.',
|
||||
'bad-request': 'reCAPTCHA verification failed. Please try again.',
|
||||
'timeout-or-duplicate': 'reCAPTCHA expired. Please try again.'
|
||||
}
|
||||
|
||||
# Get the first error message or use a default
|
||||
error_code = error_codes[0] if error_codes else 'unknown'
|
||||
error_message = error_messages.get(error_code, 'reCAPTCHA verification failed. Please try again.')
|
||||
|
||||
return False, error_message
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"reCAPTCHA verification request failed: {str(e)}")
|
||||
return False, "Unable to verify reCAPTCHA. Please try again later."
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during reCAPTCHA verification: {str(e)}")
|
||||
return False, "An error occurred during verification. Please try again."
|
||||
|
||||
|
||||
# Create a singleton instance
|
||||
recaptcha = ReCaptcha()
|
||||
@@ -17,3 +17,4 @@ Flask-Migrate==3.1.0
|
||||
psycopg2-binary==2.9.9
|
||||
markdown==3.4.4
|
||||
PyYAML==6.0.1
|
||||
requests==2.31.0
|
||||
|
||||
@@ -73,6 +73,16 @@ if [ -f "migrations_old/run_postgres_migrations.py" ]; then
|
||||
echo "Found old migration system. Consider removing after confirming Flask-Migrate is working."
|
||||
fi
|
||||
|
||||
# Start cron service for scheduled tasks
|
||||
echo ""
|
||||
echo "=== Starting Cron Service ==="
|
||||
service cron start
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Cron service started for scheduled cleanup tasks"
|
||||
else
|
||||
echo "⚠️ Failed to start cron service, cleanup tasks won't run automatically"
|
||||
fi
|
||||
|
||||
# Start the Flask application with gunicorn
|
||||
echo ""
|
||||
echo "=== Starting Application ==="
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}">
|
||||
{% if config.RECAPTCHA_ENABLED %}
|
||||
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
|
||||
{% endif %}
|
||||
<style>
|
||||
.registration-type {
|
||||
display: flex;
|
||||
@@ -202,6 +205,12 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{% if config.RECAPTCHA_ENABLED %}
|
||||
<div class="form-group" style="margin-top: 1.5rem;">
|
||||
<div class="g-recaptcha" data-sitekey="{{ config.RECAPTCHA_SITE_KEY }}"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Create Account</button>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}">
|
||||
{% if config.RECAPTCHA_ENABLED %}
|
||||
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
|
||||
{% endif %}
|
||||
</head>
|
||||
<body class="auth-page">
|
||||
<div class="auth-container">
|
||||
@@ -79,6 +82,12 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{% if config.RECAPTCHA_ENABLED %}
|
||||
<div class="form-group" style="margin-top: 1.5rem;">
|
||||
<div class="g-recaptcha" data-sitekey="{{ config.RECAPTCHA_SITE_KEY }}"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Create My Workspace</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user