IP Whitelistening - global ip, fixing 30s timeout on wrong sender authentication using username and password to be instant.

This commit is contained in:
nahakubuilde
2025-06-10 03:19:25 +01:00
parent f07b9c2150
commit b05611dca8
8 changed files with 310 additions and 154 deletions
+24 -7
View File
@@ -29,10 +29,11 @@ class EnhancedAuthenticator:
- Comprehensive audit logging - Comprehensive audit logging
""" """
def __call__(self, server, session, envelope, mechanism, auth_data): async def __call__(self, server, session, envelope, mechanism, auth_data):
if not isinstance(auth_data, LoginPassword): if not isinstance(auth_data, LoginPassword):
logger.warning(f'Invalid auth data format: {type(auth_data)}') logger.warning(f'Invalid auth data format: {type(auth_data)}')
return AuthResult(success=False, handled=True, message='535 Authentication failed') await server.push('535 Authentication failed')
return AuthResult(success=False, handled=True)
# Decode bytes to string if necessary # Decode bytes to string if necessary
username = auth_data.login username = auth_data.login
@@ -73,7 +74,8 @@ class EnhancedAuthenticator:
message=f'Invalid credentials for {username}' message=f'Invalid credentials for {username}'
) )
logger.warning(f'Authentication failed for {username}: invalid credentials') logger.warning(f'Authentication failed for {username}: invalid credentials')
return AuthResult(success=False, handled=True, message='535 Authentication failed') await server.push('535 Authentication failed')
return AuthResult(success=False, handled=True)
except Exception as e: except Exception as e:
logger.error(f'Authentication error for {username}: {e}') logger.error(f'Authentication error for {username}: {e}')
@@ -84,7 +86,8 @@ class EnhancedAuthenticator:
success=False, success=False,
message=f'Authentication error: {str(e)}' message=f'Authentication error: {str(e)}'
) )
return AuthResult(success=False, handled=True, message='451 Internal server error') await server.push('535 Authentication failed')
return AuthResult(success=False, handled=True)
class EnhancedIPAuthenticator: class EnhancedIPAuthenticator:
""" """
@@ -98,7 +101,7 @@ class EnhancedIPAuthenticator:
def can_authenticate_for_domain(self, ip_address: str, domain_name: str) -> tuple[bool, str]: def can_authenticate_for_domain(self, ip_address: str, domain_name: str) -> tuple[bool, str]:
""" """
Check if IP can authenticate for a specific domain. Check if IP can authenticate for a specific domain or is globally whitelisted.
Args: Args:
ip_address: Client IP address ip_address: Client IP address
@@ -108,11 +111,25 @@ class EnhancedIPAuthenticator:
(success, message) tuple (success, message) tuple
""" """
try: try:
# First, check for domain-specific whitelist
whitelisted_ip = get_whitelisted_ip(ip_address, domain_name) whitelisted_ip = get_whitelisted_ip(ip_address, domain_name)
if whitelisted_ip: if whitelisted_ip:
return True, f"IP {ip_address} authorized for domain {domain_name}" return True, f"IP {ip_address} authorized for domain {domain_name}"
else: # Then, check for global whitelist
return False, f"IP {ip_address} not authorized for domain {domain_name}" from email_server.models import Session, WhitelistedIP, get_domain_by_name
session = Session()
try:
global_ip = session.query(WhitelistedIP).filter_by(ip_address=ip_address, global_ip=True, is_active=True).first()
if global_ip:
# Check if the domain exists and is active
domain = get_domain_by_name(domain_name)
if domain:
return True, f"IP {ip_address} is globally whitelisted for existing domain {domain_name}"
else:
return False, f"Domain {domain_name} does not exist or is not active on this server"
finally:
session.close()
return False, f"IP {ip_address} not authorized for domain {domain_name} or globally"
except Exception as e: except Exception as e:
logger.error(f"Error checking IP authorization: {e}") logger.error(f"Error checking IP authorization: {e}")
return False, f"Error checking IP authorization: {str(e)}" return False, f"Error checking IP authorization: {str(e)}"
+9 -7
View File
@@ -88,16 +88,18 @@ class Sender(Base):
class WhitelistedIP(Base): class WhitelistedIP(Base):
""" """
IP whitelist model with domain-specific authentication. IP whitelist model with domain-specific and global authentication.
Security feature: Security feature:
- IPs can only send emails for their specific authorized domain - IPs can be global (allowed for any domain) or domain-specific
- IPs can only send emails for their specific authorized domain unless global_ip is True
""" """
__tablename__ = 'esrv_whitelisted_ips' __tablename__ = 'esrv_whitelisted_ips'
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
ip_address = Column(String, nullable=False) ip_address = Column(String, nullable=False)
domain_id = Column(Integer, ForeignKey('esrv_domains.id'), nullable=False) domain_id = Column(Integer, ForeignKey('esrv_domains.id'), nullable=True)
global_ip = Column(Boolean, default=False, nullable=False)
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now()) created_at = Column(DateTime, default=func.now())
@@ -107,13 +109,13 @@ class WhitelistedIP(Base):
Args: Args:
domain_name: The domain name to check domain_name: The domain name to check
Returns: Returns:
True if IP is authorized for this domain True if IP is authorized for this domain or is global
""" """
if not self.is_active: if not self.is_active:
return False return False
if self.global_ip:
return True
# Need to check against the actual domain # Need to check against the actual domain
session = Session() session = Session()
try: try:
@@ -126,7 +128,7 @@ class WhitelistedIP(Base):
session.close() session.close()
def __repr__(self): def __repr__(self):
return f"<WhitelistedIP(id={self.id}, ip='{self.ip_address}', domain_id={self.domain_id})>" return f"<WhitelistedIP(id={self.id}, ip='{self.ip_address}', domain_id={self.domain_id}, global_ip={self.global_ip})>"
class EmailLog(Base): class EmailLog(Base):
"""Email log model for tracking sent emails.""" """Email log model for tracking sent emails."""
+59 -43
View File
@@ -22,52 +22,66 @@ def ips_list():
"""List all whitelisted IPs.""" """List all whitelisted IPs."""
session = Session() session = Session()
try: try:
ips = session.query(WhitelistedIP, Domain).join(Domain, WhitelistedIP.domain_id == Domain.id).order_by(WhitelistedIP.ip_address).all() all_ips = session.query(WhitelistedIP, Domain).join(Domain, WhitelistedIP.domain_id == Domain.id, isouter=True).order_by(WhitelistedIP.ip_address).all()
return render_template('ips.html', ips=ips) domain_ips = [item for item in all_ips if not item[0].global_ip]
global_ips = [item for item in all_ips if item[0].global_ip]
return render_template('ips.html', ips=domain_ips, global_ips=global_ips)
finally: finally:
session.close() session.close()
@email_bp.route('/ips/add', methods=['GET', 'POST']) @email_bp.route('/ips/add', methods=['GET', 'POST'])
def add_ip(): def add_ip():
"""Add new whitelisted IP.""" """Add new whitelisted IP(s)."""
session = Session() session = Session()
try: try:
domains = session.query(Domain).filter_by(is_active=True).order_by(Domain.domain_name).all() domains = session.query(Domain).filter_by(is_active=True).order_by(Domain.domain_name).all()
if request.method == 'POST': if request.method == 'POST':
ip_address = request.form.get('ip_address', '').strip() ip_addresses_raw = request.form.get('ip_addresses', '').strip()
domain_id = request.form.get('domain_id', type=int) domain_id = request.form.get('domain_id', type=int)
global_ip = request.form.get('global_ip') == 'on'
if not all([ip_address, domain_id]): # Split IPs by line or comma
flash('All fields are required', 'error') ip_list = []
for line in ip_addresses_raw.splitlines():
for ip in line.split(','):
ip = ip.strip()
if ip:
ip_list.append(ip)
if not ip_list:
flash('Please enter at least one IP address', 'error')
return redirect(url_for('email.add_ip')) return redirect(url_for('email.add_ip'))
if not global_ip and not domain_id:
# Basic IP validation flash('Please select a domain for non-global IPs', 'error')
try:
socket.inet_aton(ip_address)
except socket.error:
flash('Invalid IP address format', 'error')
return redirect(url_for('email.add_ip')) return redirect(url_for('email.add_ip'))
added = 0
# Check if IP already exists for this domain for ip_address in ip_list:
existing = session.query(WhitelistedIP).filter_by(ip_address=ip_address, domain_id=domain_id).first() # Basic IP validation
if existing: try:
flash(f'IP {ip_address} already whitelisted for this domain', 'error') socket.inet_aton(ip_address)
return redirect(url_for('email.ips_list')) except socket.error:
flash(f'Invalid IP address format: {ip_address}', 'error')
# Create whitelisted IP continue
whitelist = WhitelistedIP( # Check if IP already exists for this domain/global
ip_address=ip_address, existing = session.query(WhitelistedIP).filter_by(ip_address=ip_address, global_ip=global_ip)
domain_id=domain_id if not global_ip:
) existing = existing.filter_by(domain_id=domain_id)
session.add(whitelist) else:
existing = existing.filter_by(domain_id=None)
if existing.first():
flash(f'IP {ip_address} already whitelisted for this domain/global', 'error')
continue
# Create whitelisted IP
whitelist = WhitelistedIP(
ip_address=ip_address,
domain_id=None if global_ip else domain_id,
global_ip=global_ip
)
session.add(whitelist)
added += 1
session.commit() session.commit()
if added:
flash(f'IP {ip_address} added to whitelist', 'success') flash(f'{added} IP(s) added to whitelist', 'success')
return redirect(url_for('email.ips_list')) return redirect(url_for('email.ips_list'))
return render_template('add_ip.html', domains=domains) return render_template('add_ip.html', domains=domains)
except Exception as e: except Exception as e:
session.rollback() session.rollback()
logger.error(f"Error adding IP: {e}") logger.error(f"Error adding IP: {e}")
@@ -166,33 +180,35 @@ def edit_ip(ip_id: int):
if request.method == 'POST': if request.method == 'POST':
ip_address = request.form.get('ip_address', '').strip() ip_address = request.form.get('ip_address', '').strip()
domain_id = request.form.get('domain_id', type=int) domain_id = request.form.get('domain_id', type=int)
global_ip = request.form.get('global_ip') == 'on'
if not all([ip_address, domain_id]): if not ip_address:
flash('All fields are required', 'error') flash('IP address is required', 'error')
return redirect(url_for('email.edit_ip', ip_id=ip_id)) return redirect(url_for('email.edit_ip', ip_id=ip_id))
# Basic IP validation # Basic IP validation
try: try:
socket.inet_aton(ip_address) socket.inet_aton(ip_address)
except socket.error: except socket.error:
flash('Invalid IP address format', 'error') flash('Invalid IP address format', 'error')
return redirect(url_for('email.edit_ip', ip_id=ip_id)) return redirect(url_for('email.edit_ip', ip_id=ip_id))
# Check if IP already exists for this domain/global (excluding current record)
# Check if IP already exists for this domain (excluding current record)
existing = session.query(WhitelistedIP).filter( existing = session.query(WhitelistedIP).filter(
WhitelistedIP.ip_address == ip_address, WhitelistedIP.ip_address == ip_address,
WhitelistedIP.domain_id == domain_id, WhitelistedIP.global_ip == global_ip,
WhitelistedIP.id != ip_id WhitelistedIP.id != ip_id
).first() )
if existing: if not global_ip:
flash(f'IP {ip_address} already whitelisted for this domain', 'error') existing = existing.filter(WhitelistedIP.domain_id == domain_id)
else:
existing = existing.filter(WhitelistedIP.domain_id == None)
if existing.first():
flash(f'IP {ip_address} already whitelisted for this domain/global', 'error')
return redirect(url_for('email.edit_ip', ip_id=ip_id)) return redirect(url_for('email.edit_ip', ip_id=ip_id))
# Update IP record # Update IP record
ip_record.ip_address = ip_address ip_record.ip_address = ip_address
ip_record.domain_id = domain_id ip_record.global_ip = global_ip
ip_record.domain_id = None if global_ip else domain_id
session.commit() session.commit()
flash(f'IP whitelist record updated', 'success') flash(f'IP whitelist record updated', 'success')
return redirect(url_for('email.ips_list')) return redirect(url_for('email.ips_list'))
@@ -40,30 +40,30 @@
<div class="card-body"> <div class="card-body">
<form method="POST"> <form method="POST">
<div class="mb-3"> <div class="mb-3">
<label for="ip_address" class="form-label">IP Address</label> <label for="ip_addresses" class="form-label">IP Addresses</label>
<input type="text" <textarea class="form-control font-monospace" id="ip_addresses" name="ip_addresses" rows="3" required placeholder="One IP per line, or separate with commas">{{ request.args.get('ip', '') }}</textarea>
class="form-control font-monospace"
id="ip_address"
name="ip_address"
required
pattern="^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"
placeholder="192.168.1.100"
value="{{ request.args.get('ip', '') }}">
<div class="form-text"> <div class="form-text">
IPv4 address that will be allowed to send emails without authentication Enter one or more IPv4 addresses (one per line or comma-separated).<br>
Each IP will be added as a separate whitelist entry.
</div> </div>
</div> </div>
<div class="mb-3 form-check">
<input class="form-check-input" type="checkbox" id="global_ip" name="global_ip">
<label class="form-check-label" for="global_ip">
Global IP (allow for any domain)
</label>
</div>
<div class="mb-4"> <div class="mb-4">
<label for="domain_id" class="form-label">Authorized Domain</label> <label for="domain_id" class="form-label">Authorized Domain</label>
<select class="form-select" id="domain_id" name="domain_id" required> <select class="form-select" id="domain_id" name="domain_id">
<option value="">Select a domain...</option> <option value="">Select a domain...</option>
{% for domain in domains %} {% for domain in domains %}
<option value="{{ domain.id }}">{{ domain.domain_name }}</option> <option value="{{ domain.id }}">{{ domain.domain_name }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="form-text"> <div class="form-text">
This IP will only be able to send emails for the selected domain This IP will only be able to send emails for the selected domain (unless Global IP is checked)
</div> </div>
</div> </div>
@@ -109,25 +109,18 @@
const data = await response.json(); const data = await response.json();
document.getElementById('current-ip').innerHTML = document.getElementById('current-ip').innerHTML =
`<span class="text-primary">${data.ip_addr}</span>`; `<span class="text-primary">${data.ip_addr}</span>`;
} catch (er) { } catch (error) {
try { document.getElementById('current-ip').innerHTML =
const response = await fetch('https://httpbin.org/ip');
const data = await response.json();
document.getElementById('current-ip').innerHTML =
`<span class="text-primary">${data.origin}</span>`;
} catch (error) {
document.getElementById('current-ip').innerHTML =
'<span class="text-muted">Unable to detect</span>'; '<span class="text-muted">Unable to detect</span>';
} }
} }
}
function useCurrentIP() { function useCurrentIP() {
const currentIPElement = document.getElementById('current-ip'); const currentIPElement = document.getElementById('current-ip');
const ip = currentIPElement.textContent.trim(); const ip = currentIPElement.textContent.trim();
if (ip && ip !== 'Detecting...' && ip !== 'Unable to detect') { if (ip && ip !== 'Detecting...' && ip !== 'Unable to detect') {
document.getElementById('ip_address').value = ip; document.getElementById('ip_addresses').value = ip;
// Focus on domain selection // Focus on domain selection
document.getElementById('domain_id').focus(); document.getElementById('domain_id').focus();
} else { } else {
@@ -135,19 +128,22 @@
} }
} }
// IP address validation
document.getElementById('ip_address').addEventListener('input', function(e) {
const ip = e.target.value;
const ipPattern = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (ip && !ipPattern.test(ip)) {
e.target.setCustomValidity('Please enter a valid IPv4 address');
} else {
e.target.setCustomValidity('');
}
});
// Auto-detect IP on page load // Auto-detect IP on page load
detectCurrentIP(); detectCurrentIP();
// Enable/disable domain select based on global_ip checkbox
const globalIpCheckbox = document.getElementById('global_ip');
const domainSelect = document.getElementById('domain_id');
function toggleDomainSelect() {
if (globalIpCheckbox.checked) {
domainSelect.disabled = true;
domainSelect.required = false;
} else {
domainSelect.disabled = false;
domainSelect.required = true;
}
}
globalIpCheckbox.addEventListener('change', toggleDomainSelect);
toggleDomainSelect();
</script> </script>
{% endblock %} {% endblock %}
@@ -21,16 +21,21 @@
id="ip_address" id="ip_address"
name="ip_address" name="ip_address"
value="{{ ip_record.ip_address }}" value="{{ ip_record.ip_address }}"
placeholder="e.g., 192.168.1.1 or 192.168.1.0/24" placeholder="e.g., 192.168.1.1"
required> required>
<div class="form-text"> <div class="form-text">
Enter a single IP address or CIDR block Enter a single IP address
</div> </div>
</div> </div>
<div class="mb-3 form-check">
<input class="form-check-input" type="checkbox" id="global_ip" name="global_ip" {% if ip_record.global_ip %}checked{% endif %}>
<label class="form-check-label" for="global_ip">
Global IP (allow for any domain)
</label>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="domain_id" class="form-label">Domain</label> <label for="domain_id" class="form-label">Domain</label>
<select class="form-select" id="domain_id" name="domain_id" required> <select class="form-select" id="domain_id" name="domain_id" {% if ip_record.global_ip %}disabled{% endif %} required>
<option value="">Select a domain</option> <option value="">Select a domain</option>
{% for domain in domains %} {% for domain in domains %}
<option value="{{ domain.id }}" <option value="{{ domain.id }}"
@@ -40,7 +45,7 @@
{% endfor %} {% endfor %}
</select> </select>
<div class="form-text"> <div class="form-text">
This IP will be able to send emails for the selected domain This IP will be able to send emails for the selected domain (unless Global IP is checked)
</div> </div>
</div> </div>
@@ -158,6 +163,19 @@ document.addEventListener('DOMContentLoaded', function() {
const parts = ip.split('/')[0].split('.'); const parts = ip.split('/')[0].split('.');
return parts.every(part => parseInt(part) >= 0 && parseInt(part) <= 255); return parts.every(part => parseInt(part) >= 0 && parseInt(part) <= 255);
} }
// Enable/disable domain select based on global_ip checkbox
const globalIpCheckbox = document.getElementById('global_ip');
const domainSelect = document.getElementById('domain_id');
function toggleDomainSelect() {
if (globalIpCheckbox.checked) {
domainSelect.disabled = true;
} else {
domainSelect.disabled = false;
}
}
globalIpCheckbox.addEventListener('change', toggleDomainSelect);
toggleDomainSelect();
}); });
</script> </script>
{% endblock %} {% endblock %}
+103 -6
View File
@@ -21,7 +21,7 @@
<div class="card-header"> <div class="card-header">
<h5 class="mb-0"> <h5 class="mb-0">
<i class="bi bi-list me-2"></i> <i class="bi bi-list me-2"></i>
Whitelisted IP Addresses Per Domain Whitelisted IP Addresses
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -44,7 +44,11 @@
<div class="fw-bold font-monospace">{{ ip.ip_address }}</div> <div class="fw-bold font-monospace">{{ ip.ip_address }}</div>
</td> </td>
<td> <td>
<span class="badge bg-secondary">{{ domain.domain_name }}</span> {% if ip.global_ip %}
<span class="badge bg-info">Global</span>
{% else %}
<span class="badge bg-secondary">{{ domain.domain_name }}</span>
{% endif %}
</td> </td>
<td> <td>
{% if ip.is_active %} {% if ip.is_active %}
@@ -123,6 +127,97 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% if global_ips %}
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-list me-2"></i>
Global Whitelisted IP Addresses <span class="text-muted small">(can be used for all domains)</span>
</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>IP Address</th>
<th>Status</th>
<th>Added</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for ip, domain in global_ips %}
<tr>
<td>
<div class="fw-bold font-monospace">{{ ip.ip_address }}</div>
</td>
<td>
{% if ip.is_active %}
<span class="badge bg-success">
<i class="bi bi-check-circle me-1"></i>
Active
</span>
{% else %}
<span class="badge bg-danger">
<i class="bi bi-x-circle me-1"></i>
Inactive
</span>
{% endif %}
</td>
<td>
<small class="text-muted">
{{ ip.created_at.strftime('%Y-%m-%d %H:%M') }}
</small>
</td>
<td>
<div class="btn-group" role="group">
<!-- Edit Button -->
<a href="{{ url_for('email.edit_ip', ip_id=ip.id) }}"
class="btn btn-outline-primary btn-sm"
title="Edit IP">
<i class="bi bi-pencil"></i>
</a>
<!-- Enable/Disable Button -->
{% if ip.is_active %}
<form method="post" action="{{ url_for('email.disable_ip', ip_id=ip.id) }}" class="d-inline">
<button type="submit"
class="btn btn-outline-warning btn-sm"
title="Disable IP"
onclick="return confirm('Disable {{ ip.ip_address }}?')">
<i class="bi bi-pause-circle"></i>
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('email.enable_ip', ip_id=ip.id) }}" class="d-inline">
<button type="submit"
class="btn btn-outline-success btn-sm"
title="Enable IP"
onclick="return confirm('Enable {{ ip.ip_address }}?')">
<i class="bi bi-play-circle"></i>
</button>
</form>
{% endif %}
<!-- Permanent Remove Button -->
<form method="post" action="{{ url_for('email.remove_ip', ip_id=ip.id) }}" class="d-inline">
<button type="submit"
class="btn btn-outline-danger btn-sm"
title="Permanently Remove IP"
onclick="return confirm('Permanently remove {{ ip.ip_address }}? This cannot be undone!')">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div> </div>
<div class="col-lg-4"> <div class="col-lg-4">
@@ -139,7 +234,7 @@
<ul class="list-unstyled mb-3"> <ul class="list-unstyled mb-3">
<li class="mb-2"> <li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i> <i class="bi bi-check-circle text-success me-2"></i>
<strong>Active IPs:</strong> {{ ips|selectattr('0.is_active')|list|length }} <strong>Active IPs:</strong> {{ (ips|selectattr('0.is_active')|list|length) + (global_ips|selectattr('0.is_active')|list|length) }}
</li> </li>
<li class="mb-2"> <li class="mb-2">
<i class="bi bi-server text-info me-2"></i> <i class="bi bi-server text-info me-2"></i>
@@ -161,9 +256,10 @@
</h6> </h6>
<ul class="mb-0 small"> <ul class="mb-0 small">
<li>Whitelisted IPs can send emails without username/password authentication</li> <li>Whitelisted IPs can send emails without username/password authentication</li>
<li>Each IP is associated with a specific domain</li> <li>Per Domain IP associated with a specific domain</li>
<li>IP can only send emails for its authorized domain</li> <li>Global IP can be used for all domains</li>
<li>Useful for server-to-server email sending</li> <li>Global IP whitelisting only Domains added to server, if domain is not added, global IP will not work</li>
<li>If IP is whitelisted, invalid username/password will still pass authentication</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -193,6 +289,7 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
+12 -6
View File
@@ -49,16 +49,22 @@ class EnhancedCombinedAuthenticator:
self.user_auth = EnhancedAuthenticator() self.user_auth = EnhancedAuthenticator()
self.ip_auth = EnhancedIPAuthenticator() self.ip_auth = EnhancedIPAuthenticator()
def __call__(self, server, session, envelope, mechanism, auth_data): async def __call__(self, server, session, envelope, mechanism, auth_data):
from aiosmtpd.smtp import LoginPassword from aiosmtpd.smtp import LoginPassword
# If auth_data is provided (username/password), try user authentication first # If auth_data is provided (username/password), try user authentication first
if auth_data and isinstance(auth_data, LoginPassword): if auth_data and isinstance(auth_data, LoginPassword):
result = self.user_auth(server, session, envelope, mechanism, auth_data) try:
if result.success: result = self.user_auth(server, session, envelope, mechanism, auth_data)
return result if result.success:
# If user auth fails, don't try IP auth - return the failure return result
return result # If user auth fails, send immediate response
await server.push('535 Authentication failed')
return AuthResult(success=False, handled=True)
except Exception as e:
logger.error(f"Authentication error: {e}")
await server.push('535 Authentication failed')
return AuthResult(success=False, handled=True)
# If no auth_data provided, IP auth will be validated during MAIL FROM # If no auth_data provided, IP auth will be validated during MAIL FROM
# For now, allow the connection to proceed # For now, allow the connection to proceed
@@ -1,8 +1,8 @@
"""Initial migration """update ip whitelist
Revision ID: 3ce273a1be20 Revision ID: 8652d2ab8a26
Revises: Revises:
Create Date: 2025-06-07 15:25:35.603295 Create Date: 2025-06-10 02:17:23.718102
""" """
from alembic import op from alembic import op
@@ -10,7 +10,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '3ce273a1be20' revision = '8652d2ab8a26'
down_revision = None down_revision = None
branch_labels = None branch_labels = None
depends_on = None depends_on = None
@@ -18,12 +18,12 @@ depends_on = None
def upgrade(): def upgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_table('esrv_dkim_keys')
op.drop_table('esrv_auth_logs') op.drop_table('esrv_auth_logs')
op.drop_table('esrv_users')
op.drop_table('esrv_domains')
op.drop_table('esrv_whitelisted_ips')
op.drop_table('esrv_email_logs') op.drop_table('esrv_email_logs')
op.drop_table('esrv_senders')
op.drop_table('esrv_whitelisted_ips')
op.drop_table('esrv_dkim_keys')
op.drop_table('esrv_domains')
op.drop_table('esrv_custom_headers') op.drop_table('esrv_custom_headers')
# ### end Alembic commands ### # ### end Alembic commands ###
@@ -37,8 +37,50 @@ def downgrade():
sa.Column('header_value', sa.VARCHAR(), nullable=False), sa.Column('header_value', sa.VARCHAR(), nullable=False),
sa.Column('is_active', sa.BOOLEAN(), nullable=True), sa.Column('is_active', sa.BOOLEAN(), nullable=True),
sa.Column('created_at', sa.DATETIME(), nullable=True), sa.Column('created_at', sa.DATETIME(), nullable=True),
sa.ForeignKeyConstraint(['domain_id'], ['esrv_domains.id'], ),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_table('esrv_domains',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('domain_name', sa.VARCHAR(), nullable=False),
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
sa.Column('created_at', sa.DATETIME(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('domain_name')
)
op.create_table('esrv_dkim_keys',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('domain_id', sa.INTEGER(), nullable=False),
sa.Column('selector', sa.VARCHAR(), nullable=False),
sa.Column('private_key', sa.TEXT(), nullable=False),
sa.Column('public_key', sa.TEXT(), nullable=False),
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
sa.Column('created_at', sa.DATETIME(), nullable=True),
sa.Column('replaced_at', sa.DATETIME(), nullable=True),
sa.ForeignKeyConstraint(['domain_id'], ['esrv_domains.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('esrv_whitelisted_ips',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('ip_address', sa.VARCHAR(), nullable=False),
sa.Column('domain_id', sa.INTEGER(), nullable=False),
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
sa.Column('created_at', sa.DATETIME(), nullable=True),
sa.ForeignKeyConstraint(['domain_id'], ['esrv_domains.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('esrv_senders',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('email', sa.VARCHAR(), nullable=False),
sa.Column('password_hash', sa.VARCHAR(), nullable=False),
sa.Column('domain_id', sa.INTEGER(), nullable=False),
sa.Column('can_send_as_domain', sa.BOOLEAN(), nullable=True),
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
sa.Column('created_at', sa.DATETIME(), nullable=True),
sa.ForeignKeyConstraint(['domain_id'], ['esrv_domains.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
op.create_table('esrv_email_logs', op.create_table('esrv_email_logs',
sa.Column('id', sa.INTEGER(), nullable=False), sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('message_id', sa.VARCHAR(), nullable=False), sa.Column('message_id', sa.VARCHAR(), nullable=False),
@@ -57,33 +99,6 @@ def downgrade():
sa.PrimaryKeyConstraint('id'), sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('message_id') sa.UniqueConstraint('message_id')
) )
op.create_table('esrv_whitelisted_ips',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('ip_address', sa.VARCHAR(), nullable=False),
sa.Column('domain_id', sa.INTEGER(), nullable=False),
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
sa.Column('created_at', sa.DATETIME(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('esrv_domains',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('domain_name', sa.VARCHAR(), nullable=False),
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
sa.Column('created_at', sa.DATETIME(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('domain_name')
)
op.create_table('esrv_users',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('email', sa.VARCHAR(), nullable=False),
sa.Column('password_hash', sa.VARCHAR(), nullable=False),
sa.Column('domain_id', sa.INTEGER(), nullable=False),
sa.Column('can_send_as_domain', sa.BOOLEAN(), nullable=True),
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
sa.Column('created_at', sa.DATETIME(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
op.create_table('esrv_auth_logs', op.create_table('esrv_auth_logs',
sa.Column('id', sa.INTEGER(), nullable=False), sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('auth_type', sa.VARCHAR(), nullable=False), sa.Column('auth_type', sa.VARCHAR(), nullable=False),
@@ -94,15 +109,4 @@ def downgrade():
sa.Column('created_at', sa.DATETIME(), nullable=True), sa.Column('created_at', sa.DATETIME(), nullable=True),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_table('esrv_dkim_keys',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('domain_id', sa.INTEGER(), nullable=False),
sa.Column('selector', sa.VARCHAR(), nullable=False),
sa.Column('private_key', sa.TEXT(), nullable=False),
sa.Column('public_key', sa.TEXT(), nullable=False),
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
sa.Column('created_at', sa.DATETIME(), nullable=True),
sa.Column('replaced_at', sa.DATETIME(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ### # ### end Alembic commands ###