573 lines
21 KiB
Python
573 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
User Self-Provisioning Service for org-stack
|
|
|
|
Provides a public registration form and admin approval dashboard.
|
|
Approved users are automatically created in lldap.
|
|
|
|
Workflow:
|
|
- Public registration form at /
|
|
- Admin dashboard at /admin (protected by Authelia forward-auth)
|
|
- Pending requests → Approve (creates in lldap) or Reject → Audit log
|
|
- lldap is the single source of truth for all active users
|
|
"""
|
|
|
|
import os
|
|
import sqlite3
|
|
import secrets
|
|
import string
|
|
import subprocess
|
|
from datetime import datetime
|
|
from contextlib import contextmanager
|
|
from typing import Optional
|
|
|
|
from fastapi import FastAPI, Request, Form, HTTPException
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
|
|
app = FastAPI(title="User Registration Service")
|
|
templates = Jinja2Templates(directory="templates")
|
|
|
|
# Configuration from environment
|
|
DATABASE = os.environ.get('DATABASE_PATH', '/data/registrations.db')
|
|
LLDAP_ADMIN_USER = os.environ.get('LLDAP_ADMIN_USER', 'admin')
|
|
LLDAP_BASE_DN = os.environ.get('LDAP_BASE_DN', 'dc=example,dc=com')
|
|
LDAP_HOST = 'ldap://lldap:3890'
|
|
ADMIN_EMAIL = os.environ.get('ADMIN_EMAIL', '')
|
|
SMTP_ENABLED = os.environ.get('SMTP_ENABLED', 'false').lower() == 'true'
|
|
SMTP_HOST = os.environ.get('SMTP_HOST', 'localhost')
|
|
SMTP_PORT = int(os.environ.get('SMTP_PORT', '587'))
|
|
SMTP_USER = os.environ.get('SMTP_USER', '')
|
|
SMTP_PASSWORD = os.environ.get('SMTP_PASSWORD', '')
|
|
SMTP_FROM = os.environ.get('SMTP_FROM', ADMIN_EMAIL)
|
|
SMTP_USE_TLS = os.environ.get('SMTP_USE_TLS', 'true').lower() == 'true'
|
|
EMAIL_LOG_FILE = '/data/emails.log'
|
|
|
|
# =============================================================================
|
|
# Database Functions
|
|
# =============================================================================
|
|
|
|
@contextmanager
|
|
def get_db():
|
|
"""Context manager for database connections"""
|
|
conn = sqlite3.connect(DATABASE)
|
|
conn.row_factory = sqlite3.Row
|
|
try:
|
|
init_db(conn)
|
|
yield conn
|
|
finally:
|
|
conn.close()
|
|
|
|
def init_db(db: sqlite3.Connection):
|
|
"""Initialize database tables"""
|
|
# Pending registration requests
|
|
db.execute('''
|
|
CREATE TABLE IF NOT EXISTS registration_requests (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT NOT NULL UNIQUE,
|
|
email TEXT NOT NULL,
|
|
first_name TEXT NOT NULL,
|
|
last_name TEXT NOT NULL,
|
|
reason TEXT,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
ip_address TEXT,
|
|
user_agent TEXT
|
|
)
|
|
''')
|
|
|
|
# Historical audit log of all approved/rejected requests
|
|
db.execute('''
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT NOT NULL,
|
|
email TEXT NOT NULL,
|
|
first_name TEXT NOT NULL,
|
|
last_name TEXT NOT NULL,
|
|
reason TEXT,
|
|
action TEXT NOT NULL,
|
|
performed_by TEXT,
|
|
rejection_reason TEXT,
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
created_at TIMESTAMP,
|
|
reviewed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
''')
|
|
|
|
db.commit()
|
|
|
|
# =============================================================================
|
|
# LDAP Security - Injection Prevention
|
|
# =============================================================================
|
|
|
|
def escape_ldap_dn(value: str) -> str:
|
|
"""Escape LDAP DN special characters per RFC 4514"""
|
|
value = value.replace('\\', '\\\\')
|
|
replacements = {',': '\\,', '#': '\\#', '+': '\\+', '<': '\\<',
|
|
'>': '\\>', ';': '\\;', '"': '\\"', '=': '\\='}
|
|
for char, escaped in replacements.items():
|
|
value = value.replace(char, escaped)
|
|
if value.startswith(' '):
|
|
value = '\\' + value
|
|
if value.endswith(' '):
|
|
value = value[:-1] + '\\ '
|
|
return value
|
|
|
|
def validate_username_strict(username: str) -> bool:
|
|
"""Validate username: 2-64 chars, lowercase alphanumeric + underscore, must start with letter"""
|
|
if not username or len(username) < 2 or len(username) > 64:
|
|
return False
|
|
if not username.replace('_', '').isalnum() or not username.islower():
|
|
return False
|
|
if not username[0].isalpha():
|
|
return False
|
|
return True
|
|
|
|
def validate_email_basic(email: str) -> bool:
|
|
"""Basic email validation"""
|
|
if not email or '@' not in email:
|
|
return False
|
|
if len(email) > 255:
|
|
return False
|
|
# Basic format check
|
|
parts = email.split('@')
|
|
if len(parts) != 2:
|
|
return False
|
|
local, domain = parts
|
|
if not local or not domain or '.' not in domain:
|
|
return False
|
|
return True
|
|
|
|
# =============================================================================
|
|
# lldap LDAP Integration
|
|
# =============================================================================
|
|
|
|
def get_lldap_admin_password() -> str:
|
|
"""Read lldap admin password from secret file"""
|
|
secret_file = '/secrets-lldap/LDAP_USER_PASS'
|
|
if os.path.exists(secret_file):
|
|
with open(secret_file, 'r') as f:
|
|
return f.read().strip()
|
|
return os.environ.get('LLDAP_ADMIN_PASSWORD', '')
|
|
|
|
async def create_lldap_user(username: str, email: str, first_name: str, last_name: str) -> tuple[bool, str, str]:
|
|
"""Create user in lldap via LDAP. Returns (success, password, error)"""
|
|
try:
|
|
# Validate inputs
|
|
if not validate_username_strict(username):
|
|
print(f'[SECURITY] Invalid username: {username}')
|
|
return False, '', 'Invalid username format'
|
|
|
|
if not validate_email_basic(email):
|
|
return False, '', 'Invalid email format'
|
|
|
|
if not first_name or len(first_name) > 100 or not last_name or len(last_name) > 100:
|
|
return False, '', 'Invalid name fields'
|
|
|
|
# Generate random password
|
|
password = ''.join(secrets.choice(
|
|
string.ascii_letters + string.digits + string.punctuation
|
|
) for _ in range(20))
|
|
|
|
admin_password = get_lldap_admin_password()
|
|
|
|
# Escape LDAP DN components
|
|
user_dn = f'uid={escape_ldap_dn(username)},ou=people,{LLDAP_BASE_DN}'
|
|
admin_dn = f'uid={escape_ldap_dn(LLDAP_ADMIN_USER)},ou=people,{LLDAP_BASE_DN}'
|
|
|
|
# Create LDIF for new user
|
|
ldif_content = f'''dn: {user_dn}
|
|
objectClass: person
|
|
objectClass: inetOrgPerson
|
|
uid: {escape_ldap_dn(username)}
|
|
cn: {escape_ldap_dn(first_name)} {escape_ldap_dn(last_name)}
|
|
sn: {escape_ldap_dn(last_name)}
|
|
givenName: {escape_ldap_dn(first_name)}
|
|
mail: {escape_ldap_dn(email)}
|
|
'''
|
|
|
|
# Step 1: Create user with ldapadd
|
|
try:
|
|
result = subprocess.run(
|
|
['ldapadd', '-H', LDAP_HOST, '-D', admin_dn, '-w', admin_password, '-x'],
|
|
input=ldif_content, capture_output=True, text=True, timeout=10
|
|
)
|
|
if result.returncode != 0:
|
|
print(f'[ERROR] ldapadd failed: {result.stderr}')
|
|
return False, '', f'Failed to create user: {result.stderr}'
|
|
|
|
print(f'[SUCCESS] User {username} created in lldap')
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return False, '', 'LDAP operation timed out'
|
|
except Exception as e:
|
|
return False, '', f'Failed to create user: {str(e)}'
|
|
|
|
# Step 2: Set password with ldappasswd
|
|
try:
|
|
result = subprocess.run(
|
|
['ldappasswd', '-H', LDAP_HOST, '-D', admin_dn, '-w', admin_password, '-s', password, user_dn],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
if result.returncode != 0:
|
|
print(f'[ERROR] ldappasswd failed: {result.stderr}')
|
|
# Cleanup: delete user entry
|
|
subprocess.run(['ldapdelete', '-H', LDAP_HOST, '-D', admin_dn, '-w', admin_password, user_dn],
|
|
capture_output=True, timeout=10)
|
|
return False, '', f'Failed to set password: {result.stderr}'
|
|
|
|
print(f'[SUCCESS] Password set for user {username}')
|
|
return True, password, ''
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return False, '', 'LDAP operation timed out'
|
|
except Exception as e:
|
|
return False, '', f'Failed to set password: {str(e)}'
|
|
|
|
except Exception as e:
|
|
return False, '', str(e)
|
|
|
|
# =============================================================================
|
|
# LDAP Validation Functions
|
|
# =============================================================================
|
|
|
|
def check_ldap_user_exists(username: str = None, email: str = None) -> tuple[bool, str]:
|
|
"""
|
|
Check if username or email already exists in lldap.
|
|
Returns (exists: bool, error_message: str)
|
|
"""
|
|
try:
|
|
admin_password = open('/secrets-lldap/LDAP_USER_PASS').read().strip()
|
|
admin_dn = f'uid={LLDAP_ADMIN_USER},ou=people,{LLDAP_BASE_DN}'
|
|
|
|
# Check username exists
|
|
if username:
|
|
result = subprocess.run(
|
|
['ldapsearch', '-x', '-LLL', '-H', LDAP_HOST, '-D', admin_dn, '-w', admin_password,
|
|
'-b', f'ou=people,{LLDAP_BASE_DN}', f'(uid={escape_ldap_dn(username)})', 'dn'],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
return True, f'Username "{username}" is already taken'
|
|
|
|
# Check email exists
|
|
if email:
|
|
result = subprocess.run(
|
|
['ldapsearch', '-x', '-LLL', '-H', LDAP_HOST, '-D', admin_dn, '-w', admin_password,
|
|
'-b', f'ou=people,{LLDAP_BASE_DN}', f'(mail={escape_ldap_dn(email)})', 'dn'],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
return True, f'Email "{email}" is already registered'
|
|
|
|
return False, ''
|
|
|
|
except subprocess.TimeoutExpired:
|
|
print('[ERROR] LDAP search timed out')
|
|
return False, '' # Fail open - allow registration if LDAP is slow
|
|
except Exception as e:
|
|
print(f'[ERROR] Failed to check LDAP: {str(e)}')
|
|
return False, '' # Fail open - allow registration if LDAP check fails
|
|
|
|
# =============================================================================
|
|
# Email Notifications
|
|
# =============================================================================
|
|
|
|
def log_email_to_file(to: str, subject: str, body: str):
|
|
"""Log email to file when SMTP is disabled"""
|
|
timestamp = datetime.now().isoformat()
|
|
with open(EMAIL_LOG_FILE, 'a') as f:
|
|
f.write(f'\n{"="*80}\n')
|
|
f.write(f'Timestamp: {timestamp}\n')
|
|
f.write(f'To: {to}\n')
|
|
f.write(f'Subject: {subject}\n')
|
|
f.write(f'Body:\n{body}\n')
|
|
f.write(f'{"="*80}\n')
|
|
|
|
def send_email(to: str, subject: str, body: str) -> bool:
|
|
"""Send email notification via SMTP or log to file"""
|
|
if not SMTP_ENABLED:
|
|
log_email_to_file(to, subject, body)
|
|
print(f'[EMAIL] Logged to {EMAIL_LOG_FILE}: {to} - {subject}')
|
|
return True
|
|
|
|
try:
|
|
import smtplib
|
|
from email.mime.text import MIMEText
|
|
from email.mime.multipart import MIMEMultipart
|
|
|
|
msg = MIMEMultipart()
|
|
msg['From'] = SMTP_FROM
|
|
msg['To'] = to
|
|
msg['Subject'] = subject
|
|
msg.attach(MIMEText(body, 'plain'))
|
|
|
|
server = smtplib.SMTP(SMTP_HOST, SMTP_PORT)
|
|
if SMTP_USE_TLS:
|
|
server.starttls()
|
|
|
|
if SMTP_USER and SMTP_PASSWORD:
|
|
server.login(SMTP_USER, SMTP_PASSWORD)
|
|
|
|
server.send_message(msg)
|
|
server.quit()
|
|
|
|
print(f'[EMAIL] Sent to {to}: {subject}')
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f'[ERROR] Failed to send email to {to}: {e}')
|
|
# Fallback: log to file
|
|
log_email_to_file(to, subject, body)
|
|
return False
|
|
|
|
def notify_admin_new_request(username: str, email: str, reason: str):
|
|
"""Send email to admin about new registration request"""
|
|
subject = f'New registration request: {username}'
|
|
body = f'''
|
|
A new user has requested an account:
|
|
|
|
Username: {username}
|
|
Email: {email}
|
|
Reason: {reason}
|
|
|
|
Please review and approve/reject at the admin dashboard.
|
|
'''
|
|
send_email(ADMIN_EMAIL, subject, body)
|
|
|
|
def notify_user_approved(email: str, username: str, password: str):
|
|
"""Send email to user with their credentials after approval"""
|
|
subject = 'Account approved'
|
|
body = f'''
|
|
Your account request has been approved!
|
|
|
|
Username: {username}
|
|
Temporary Password: {password}
|
|
|
|
Please login and change your password immediately after your first login.
|
|
For security, this password is randomly generated and should be changed.
|
|
'''
|
|
send_email(email, subject, body)
|
|
|
|
def notify_user_rejected(email: str, username: str, reason: str):
|
|
"""Send email to user about rejection"""
|
|
subject = 'Account request rejected'
|
|
body = f'''
|
|
Your account request for username '{username}' has been rejected.
|
|
|
|
Reason: {reason}
|
|
|
|
If you believe this was an error, please contact the administrator.
|
|
'''
|
|
send_email(email, subject, body)
|
|
|
|
# =============================================================================
|
|
# Routes
|
|
# =============================================================================
|
|
|
|
@app.get('/', response_class=HTMLResponse)
|
|
async def register_form(request: Request, success: Optional[str] = None, error: Optional[str] = None):
|
|
"""Public registration form"""
|
|
return templates.TemplateResponse(
|
|
'register.html',
|
|
{'request': request, 'success': success, 'error': error}
|
|
)
|
|
|
|
@app.post('/', response_class=HTMLResponse)
|
|
async def register_submit(
|
|
request: Request,
|
|
username: str = Form(...),
|
|
email: str = Form(...),
|
|
first_name: str = Form(...),
|
|
last_name: str = Form(...),
|
|
reason: str = Form(default='')
|
|
):
|
|
"""Handle registration form submission"""
|
|
username = username.strip().lower()
|
|
email = email.strip().lower()
|
|
first_name = first_name.strip()
|
|
last_name = last_name.strip()
|
|
reason = reason.strip()
|
|
|
|
if not all([username, email, first_name, last_name]):
|
|
return templates.TemplateResponse(
|
|
'register.html',
|
|
{'request': request, 'error': 'All fields except reason are required'}
|
|
)
|
|
|
|
if not validate_username_strict(username):
|
|
return templates.TemplateResponse(
|
|
'register.html',
|
|
{'request': request, 'error': 'Username must be 2-64 characters, start with a letter, and contain only lowercase letters, numbers, and underscores'}
|
|
)
|
|
|
|
if not validate_email_basic(email):
|
|
return templates.TemplateResponse(
|
|
'register.html',
|
|
{'request': request, 'error': 'Invalid email address'}
|
|
)
|
|
|
|
if len(first_name) > 100 or len(last_name) > 100:
|
|
return templates.TemplateResponse(
|
|
'register.html',
|
|
{'request': request, 'error': 'Names must be less than 100 characters'}
|
|
)
|
|
|
|
# Check if username or email already exists in lldap
|
|
exists, error_msg = check_ldap_user_exists(username=username, email=email)
|
|
if exists:
|
|
return templates.TemplateResponse(
|
|
'register.html',
|
|
{'request': request, 'error': error_msg}
|
|
)
|
|
|
|
# Check for pending registration and insert in single transaction
|
|
with get_db() as db:
|
|
# Check if username or email already has a pending request
|
|
existing = db.execute(
|
|
'SELECT username, email FROM registration_requests WHERE username = ? OR email = ?',
|
|
(username, email)
|
|
).fetchone()
|
|
|
|
if existing:
|
|
if existing[0] == username:
|
|
return templates.TemplateResponse(
|
|
'register.html',
|
|
{'request': request, 'error': f'Username "{username}" already has a pending registration request'}
|
|
)
|
|
else:
|
|
return templates.TemplateResponse(
|
|
'register.html',
|
|
{'request': request, 'error': f'Email "{email}" already has a pending registration request'}
|
|
)
|
|
|
|
# Insert new registration request
|
|
try:
|
|
db.execute(
|
|
'''INSERT INTO registration_requests
|
|
(username, email, first_name, last_name, reason, ip_address, user_agent)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)''',
|
|
(username, email, first_name, last_name, reason,
|
|
request.client.host, request.headers.get('User-Agent', ''))
|
|
)
|
|
db.commit()
|
|
|
|
# Notify admin
|
|
if ADMIN_EMAIL:
|
|
notify_admin_new_request(username, email, reason)
|
|
|
|
return RedirectResponse(
|
|
url='/?success=Registration request submitted! An administrator will review it shortly.',
|
|
status_code=303
|
|
)
|
|
|
|
except sqlite3.IntegrityError:
|
|
return templates.TemplateResponse(
|
|
'register.html',
|
|
{'request': request, 'error': 'Username already exists or is pending approval'}
|
|
)
|
|
|
|
@app.get('/admin', response_class=HTMLResponse)
|
|
async def admin_dashboard(request: Request):
|
|
"""Admin dashboard for reviewing registration requests"""
|
|
admin_user = request.headers.get('Remote-User', 'unknown')
|
|
|
|
with get_db() as db:
|
|
pending = db.execute(
|
|
'SELECT * FROM registration_requests ORDER BY created_at DESC'
|
|
).fetchall()
|
|
|
|
audit_log = db.execute(
|
|
'SELECT * FROM audit_log ORDER BY reviewed_at DESC LIMIT 50'
|
|
).fetchall()
|
|
|
|
return templates.TemplateResponse(
|
|
'admin.html',
|
|
{
|
|
'request': request,
|
|
'pending': pending,
|
|
'audit_log': audit_log,
|
|
'admin_user': admin_user
|
|
}
|
|
)
|
|
|
|
@app.post('/admin/approve/{request_id}')
|
|
async def approve_request(request_id: int, request: Request):
|
|
"""Approve request: create user in lldap, move to audit log"""
|
|
admin_user = request.headers.get('Remote-User', 'unknown')
|
|
|
|
with get_db() as db:
|
|
req = db.execute('SELECT * FROM registration_requests WHERE id = ?', (request_id,)).fetchone()
|
|
|
|
if not req:
|
|
raise HTTPException(status_code=404, detail='Request not found')
|
|
|
|
# Create user in lldap with generated password
|
|
success, password, error = await create_lldap_user(
|
|
req['username'],
|
|
req['email'],
|
|
req['first_name'],
|
|
req['last_name']
|
|
)
|
|
|
|
if success:
|
|
# Move to audit log
|
|
db.execute(
|
|
'''INSERT INTO audit_log
|
|
(username, email, first_name, last_name, reason, action, performed_by,
|
|
ip_address, user_agent, created_at)
|
|
VALUES (?, ?, ?, ?, ?, 'APPROVED', ?, ?, ?, ?)''',
|
|
(req['username'], req['email'], req['first_name'], req['last_name'],
|
|
req['reason'], admin_user, req['ip_address'], req['user_agent'], req['created_at'])
|
|
)
|
|
|
|
# Remove from pending queue
|
|
db.execute('DELETE FROM registration_requests WHERE id = ?', (request_id,))
|
|
db.commit()
|
|
|
|
print(f'[SUCCESS] User {req["username"]} approved and created in lldap by {admin_user}')
|
|
|
|
# Notify user
|
|
notify_user_approved(req['email'], req['username'], password)
|
|
else:
|
|
print(f'[ERROR] Failed to create user {req["username"]}: {error}')
|
|
|
|
return RedirectResponse(url='/admin', status_code=303)
|
|
|
|
@app.post('/admin/reject/{request_id}')
|
|
async def reject_request(request_id: int, request: Request, reason: str = Form(default='No reason provided')):
|
|
"""Reject request: move to audit log with reason"""
|
|
admin_user = request.headers.get('Remote-User', 'unknown')
|
|
|
|
with get_db() as db:
|
|
req = db.execute('SELECT * FROM registration_requests WHERE id = ?', (request_id,)).fetchone()
|
|
|
|
if not req:
|
|
raise HTTPException(status_code=404, detail='Request not found')
|
|
|
|
# Move to audit log
|
|
db.execute(
|
|
'''INSERT INTO audit_log
|
|
(username, email, first_name, last_name, reason, action, performed_by,
|
|
rejection_reason, ip_address, user_agent, created_at)
|
|
VALUES (?, ?, ?, ?, ?, 'REJECTED', ?, ?, ?, ?, ?)''',
|
|
(req['username'], req['email'], req['first_name'], req['last_name'],
|
|
req['reason'], admin_user, reason, req['ip_address'], req['user_agent'], req['created_at'])
|
|
)
|
|
|
|
# Remove from pending queue
|
|
db.execute('DELETE FROM registration_requests WHERE id = ?', (request_id,))
|
|
db.commit()
|
|
|
|
print(f'[INFO] User {req["username"]} rejected by {admin_user}: {reason}')
|
|
|
|
# Notify user
|
|
notify_user_rejected(req['email'], req['username'], reason)
|
|
|
|
return RedirectResponse(url='/admin', status_code=303)
|
|
|
|
@app.get('/health')
|
|
async def health():
|
|
"""Health check endpoint"""
|
|
return {'status': 'healthy'}
|