first commit
This commit is contained in:
21
registration/Dockerfile
Normal file
21
registration/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM python:3.11-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install LDAP tools and dependencies
|
||||
RUN apk add --no-cache openldap-clients
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY app.py .
|
||||
COPY templates/ templates/
|
||||
|
||||
# Create data directory for SQLite database
|
||||
RUN mkdir -p /data && chmod 777 /data
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000"]
|
||||
572
registration/app.py
Normal file
572
registration/app.py
Normal file
@@ -0,0 +1,572 @@
|
||||
#!/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'}
|
||||
4
registration/requirements.txt
Normal file
4
registration/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn==0.27.0
|
||||
python-multipart==0.0.6
|
||||
jinja2==3.1.3
|
||||
109
registration/templates/admin.html
Normal file
109
registration/templates/admin.html
Normal file
@@ -0,0 +1,109 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Dashboard - User Registration{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="header">
|
||||
<h1>Registration Requests</h1>
|
||||
<div class="user-info">Logged in as: <strong>{{ admin_user }}</strong></div>
|
||||
</div>
|
||||
|
||||
<p class="info-text">
|
||||
<strong>Note:</strong> lldap is the single source of truth for user management.
|
||||
Approve requests to create users in lldap. Manage active users directly in lldap.
|
||||
</p>
|
||||
|
||||
<h2>Pending Requests ({{ pending|length }})</h2>
|
||||
{% if pending %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Reason</th>
|
||||
<th>Submitted</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for req in pending %}
|
||||
<tr>
|
||||
<td><strong>{{ req.username }}</strong></td>
|
||||
<td>{{ req.first_name }} {{ req.last_name }}</td>
|
||||
<td>{{ req.email }}</td>
|
||||
<td>{{ req.reason or '-' }}</td>
|
||||
<td>{{ req.created_at[:19] }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/approve/{{ req.id }}" style="display: inline; margin-right: 0.5rem;">
|
||||
<button type="submit" class="btn-success"
|
||||
onclick="return confirm('Approve this user and create account in lldap?')">
|
||||
Approve
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/reject/{{ req.id }}" style="display: inline;">
|
||||
<input type="text" name="reason" placeholder="Rejection reason (optional)"
|
||||
style="width: 200px; margin-right: 0.5rem;">
|
||||
<button type="submit" class="btn-danger"
|
||||
onclick="return confirm('Reject this request?')">
|
||||
Reject
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">No pending requests</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>Audit Log ({{ audit_log|length }} recent)</h2>
|
||||
<p class="info-text" style="font-size: 0.9rem; color: #666;">
|
||||
Historical record of all approval and rejection decisions.
|
||||
To manage active users, use the lldap admin interface.
|
||||
</p>
|
||||
|
||||
{% if audit_log %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Action</th>
|
||||
<th>Username</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Reviewed By</th>
|
||||
<th>Date</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in audit_log %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if entry.action == 'APPROVED' %}
|
||||
<span class="badge badge-success">Approved</span>
|
||||
{% else %}
|
||||
<span class="badge badge-danger">Rejected</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><strong>{{ entry.username }}</strong></td>
|
||||
<td>{{ entry.first_name }} {{ entry.last_name }}</td>
|
||||
<td>{{ entry.email }}</td>
|
||||
<td>{{ entry.performed_by }}</td>
|
||||
<td>{{ entry.reviewed_at[:19] }}</td>
|
||||
<td>
|
||||
{% if entry.action == 'REJECTED' %}
|
||||
{{ entry.rejection_reason or '-' }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">No audit log entries yet</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
251
registration/templates/base.html
Normal file
251
registration/templates/base.html
Normal file
@@ -0,0 +1,251 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}User Registration{% endblock %}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--text-primary: #212529;
|
||||
--text-secondary: #6c757d;
|
||||
--border-color: #dee2e6;
|
||||
--primary-color: #0d6efd;
|
||||
--primary-hover: #0b5ed7;
|
||||
--success-color: #198754;
|
||||
--success-hover: #157347;
|
||||
--danger-color: #dc3545;
|
||||
--danger-hover: #bb2d3b;
|
||||
--info-bg: #cfe2ff;
|
||||
--success-bg: #d1e7dd;
|
||||
--danger-bg: #f8d7da;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
margin: 2rem 0 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
button, .btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: var(--success-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--danger-hover);
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: var(--success-bg);
|
||||
color: #0f5132;
|
||||
border: 1px solid #badbcc;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: var(--danger-bg);
|
||||
color: #842029;
|
||||
border: 1px solid #f5c2c7;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
background: #f8f9fa;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #d1e7dd;
|
||||
color: #0f5132;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: #f8d7da;
|
||||
color: #842029;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.info-text {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: #e7f3ff;
|
||||
border-left: 4px solid var(--primary-color);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% block extra_style %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
57
registration/templates/register.html
Normal file
57
registration/templates/register.html
Normal file
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Register - User Registration{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Request Account</h1>
|
||||
|
||||
{% if success %}
|
||||
<div class="alert alert-success">{{ success }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="alert alert-info" style="background: var(--info-bg); color: #084298; border: 1px solid #b6d4fe; margin-bottom: 1.5rem;">
|
||||
<strong>Note:</strong> Your password will be automatically generated and sent to you via email upon approval.
|
||||
You can change it after your first login.
|
||||
</div>
|
||||
|
||||
<div style="max-width: 600px;">
|
||||
<form method="post">
|
||||
<div class="form-group">
|
||||
<label for="username">Username <span class="required">*</span></label>
|
||||
<input type="text" id="username" name="username" required pattern="[a-z0-9_]+"
|
||||
title="Only lowercase letters, numbers, and underscores allowed"
|
||||
style="text-transform: lowercase;"
|
||||
oninput="this.value = this.value.toLowerCase()">
|
||||
<div class="help-text">Only lowercase letters, numbers, and underscores.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email <span class="required">*</span></label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
<div class="help-text">Your temporary password will be sent to this address upon approval.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="first_name">First Name <span class="required">*</span></label>
|
||||
<input type="text" id="first_name" name="first_name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="last_name">Last Name <span class="required">*</span></label>
|
||||
<input type="text" id="last_name" name="last_name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="reason">Reason for Access (Optional)</label>
|
||||
<textarea id="reason" name="reason" placeholder="Why do you need an account?"></textarea>
|
||||
<div class="help-text">Optional: Help administrators understand your request.</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">Submit Request</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user