Add reCAPTCHA feature

This commit is contained in:
2025-11-22 11:53:28 +01:00
parent 8de4378ad9
commit 57bb0f5b9e
6 changed files with 218 additions and 18 deletions

View File

@@ -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

91
app.py
View File

@@ -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,30 +740,46 @@ 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])
# 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,10 +793,11 @@ 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"Verification email sent to {email}")
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)
return redirect(url_for('login'))
except Exception as e:
db.session.rollback()
@@ -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}")
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'))

110
recaptcha_helper.py Normal file
View 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()

View File

@@ -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

View File

@@ -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>

View File

@@ -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>