first commit

This commit is contained in:
Stefano Manfredi
2025-12-01 14:58:40 +00:00
commit 2866bff217
28 changed files with 5515 additions and 0 deletions

158
.env.example Normal file
View File

@@ -0,0 +1,158 @@
# =============================================================================
# Organization Stack - Configuration Template
# =============================================================================
# Self-hosted authentication and collaboration stack with SSO
# Components: lldap (LDAP), Authelia (SSO/2FA), Gitea (Git), JSPWiki (Wiki)
#
# This is the single source of truth for all configuration.
# Copy this file to .env and customize for your deployment.
#
# Quick start (ALL FROM YOUR LOCAL MACHINE):
# 1. Copy: cp .env.example .env
# 2. Edit .env: BASE_DOMAIN, REMOTE_USER, REMOTE_HOST, SMTP settings
# 3. Run: ./deploy.sh (syncs to remote and starts services)
#
# The .env file stays on your local machine. deploy.sh syncs it to remote.
# =============================================================================
#=============================================================================
# DOMAIN CONFIGURATION
#=============================================================================
# Base domain for all services - CHANGE THIS TO YOUR ACTUAL DOMAIN
# All services will be accessible as subdomains of this domain
BASE_DOMAIN=example.com
# Service subdomains (creates: git.example.com, wiki.example.com, etc.)
GITEA_SUBDOMAIN=git
WIKI_SUBDOMAIN=wiki
AUTHELIA_SUBDOMAIN=auth
LLDAP_SUBDOMAIN=ldap
REGISTRATION_SUBDOMAIN=register
# LDAP Base DN - automatically derived from BASE_DOMAIN
# Leave as AUTO to generate from BASE_DOMAIN (example.com → dc=example,dc=com)
# Or manually specify: LDAP_BASE_DN=dc=myorg,dc=local
LDAP_BASE_DN=AUTO
#=============================================================================
# REMOTE SERVER CONFIGURATION
#=============================================================================
# SSH connection details for deployment
# The deploy script uses rsync over SSH to deploy files to your remote server
REMOTE_USER=deploy
REMOTE_HOST=example.com
REMOTE_PORT=22
# Remote installation path
# Recommended for multi-admin production: /opt/org-stack (requires sudo setup)
# Alternative for single admin: org-stack (relative to home, /home/$REMOTE_USER/org-stack)
REMOTE_PATH=/opt/org-stack
# Unix group for multi-admin access (optional)
# If set, deploy.sh will configure group ownership and permissions
# All admins should be members of this group (e.g., sudo usermod -aG orgstack admin1)
# Leave empty for single-user deployments
ADMIN_GROUP=orgstack
#=============================================================================
# TLS/SSL CONFIGURATION
#=============================================================================
# Certificate mode:
# false = Let's Encrypt (production) - Trusted certificates, requires DNS
# true = Self-signed (testing) - Browser warnings, no DNS required
#
# Recommended workflow:
# 1. Test with USE_SELF_SIGNED_CERTS=true (avoids Let's Encrypt rate limits)
# 2. Switch to false for production once everything works
USE_SELF_SIGNED_CERTS=false
#=============================================================================
# AUTHENTICATION CONFIGURATION
#=============================================================================
# Two-Factor Authentication (TOTP) requirement:
# true = Require 2FA for all services (recommended for production)
# false = Username/password only (easier for testing)
REQUIRE_2FA=true
#=============================================================================
# USER REGISTRATION CONFIGURATION
#=============================================================================
# Self-service user registration with admin approval
# Public users can submit registration requests at register.example.com
# Admins approve/reject requests at register.example.com/admin (requires Authelia login)
# Admin email for registration notifications
REGISTRATION_ADMIN_EMAIL="admin@yourdomain.com"
# Email notifications via SMTP
# When SMTP_ENABLED=false, emails are logged to /data/emails.log instead
# IMPORTANT: Always quote string values - handles any special characters automatically
SMTP_ENABLED=false
SMTP_HOST="smtp.example.com"
SMTP_PORT=587
SMTP_USER="your-username"
SMTP_PASSWORD="your-password"
SMTP_FROM="noreply@yourdomain.com"
SMTP_USE_TLS=true
#=============================================================================
# NETWORK CONFIGURATION
#=============================================================================
# External ports exposed on the host
# Security: All web services accessible ONLY through Caddy reverse proxy
# All services require Authelia authentication
HTTP_PORT=80
HTTPS_PORT=443
GITEA_SSH_PORT=2222 # Git SSH operations (git clone/push/pull)
# Timezone for all containers
TZ=Europe/Rome
#=============================================================================
# USER CONFIGURATION
#=============================================================================
# UID/GID for file permissions inside containers
# Set to match your remote server user's UID/GID (usually 1000:1000)
USER_UID=1000
USER_GID=1000
#=============================================================================
# SECRETS (auto-generated by deploy.sh)
#=============================================================================
# Secrets are stored as files in secrets/ directory for security best practices.
# The deploy script automatically generates all required secrets if they don't exist.
#
# File-based secrets (auto-generated in secrets/ directory):
# secrets/lldap/JWT_SECRET - lldap JWT token signing key
# secrets/lldap/LDAP_USER_PASS - lldap admin password
# secrets/authelia/JWT_SECRET - Authelia JWT signing key
# secrets/authelia/SESSION_SECRET - Session encryption key
# secrets/authelia/STORAGE_ENCRYPTION_KEY - Database encryption key
# secrets/authelia/OIDC_HMAC_SECRET - OIDC token HMAC key
# secrets/authelia/OIDC_PRIVATE_KEY - RSA private key for OIDC tokens
# secrets/authelia/RESET_PASSWORD_JWT_SECRET - JWT secret for password reset tokens
#
# Gitea OIDC client secret (stored in .env, auto-generated by deploy.sh)
# This is kept in .env because it needs to be hashed before use
GITEA_OIDC_CLIENT_SECRET=
#=============================================================================
# ADVANCED CONFIGURATION
#=============================================================================
# These settings have sensible defaults. Only change if you know what you're doing.
# Authelia session lifespans
SESSION_EXPIRATION=1h # Total session lifetime
SESSION_INACTIVITY=5m # Inactivity timeout
SESSION_REMEMBER_ME=1M # Remember-me duration (M = months)
# Brute force protection
MAX_RETRIES=3 # Failed login attempts before ban
FIND_TIME=2m # Time window for counting failed attempts
BAN_TIME=5m # Ban duration after MAX_RETRIES failures
# OIDC token lifespans (for Gitea SSO)
ACCESS_TOKEN_LIFESPAN=1h
AUTHORIZE_CODE_LIFESPAN=1m
ID_TOKEN_LIFESPAN=1h
REFRESH_TOKEN_LIFESPAN=90m

32
.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# Environment files with secrets
.env
# Generated configuration files (auto-generated from templates by deploy.sh)
Caddyfile
authelia/configuration.yml
# Log files
*.log
# OS files
.DS_Store
Thumbs.db
# Editor files
.vscode/
.idea/
*.swp
*.swo
*~
# Backup files
*.bak
*.backup
secrets/
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python

1105
ARCHITECTURE.md Normal file

File diff suppressed because it is too large Load Diff

572
CLAUDE.md Normal file
View File

@@ -0,0 +1,572 @@
# CLAUDE.md
This file provides guidance to AI assistants (like Claude Code) when working with this repository.
## Project Overview
**Project Type**: Self-hosted authentication and collaboration platform
**Primary Language**: Shell (deployment), YAML (configuration), Java (JSPWiki customization)
**Architecture**: Docker Compose multi-container application
**Deployment Model**: Remote deployment via SSH/rsync from local machine
### Core Services
1. **lldap** - LDAP user directory (Rust application, pre-built image)
2. **Authelia** - SSO/2FA server (Go application, pre-built image)
3. **Gitea** - Git hosting (Go application, pre-built image)
4. **JSPWiki** - Wiki platform (Java/Tomcat, custom Docker image)
5. **Caddy** - Reverse proxy (Go application, pre-built image)
## Project Philosophy
### Single Source of Truth
- `.env` file is the **only** place users configure the system
- All service configs are generated from `.env` via templates
- **Never** require users to edit multiple config files
- **Never** hardcode values that should be configurable
### Zero Manual Steps
- `./deploy.sh` should handle everything from secrets to deployment
- User should only need to edit `.env` and run one command
- Secrets auto-generated if missing
- Configs auto-generated from templates
- Service dependencies handled automatically
### Security First
- File-based secrets (not environment variables)
- Read-only mounts where possible
- Secrets never committed to git
- Modern authentication protocols (LDAP, OIDC, Forward-Auth)
- Defense in depth architecture
### Educational Value
- Code should teach authentication patterns
- Comments explain "why" not just "what"
- Documentation references RFCs and specs
- Architecture demonstrates industry best practices
## File Structure and Responsibilities
```
org-stack/
├── .env.example # Configuration template (edit this to add new options)
├── .env # User configuration (generated, not in git)
├── .gitignore # Ensures secrets/ and .env not committed
├── deploy.sh # Main deployment script
│ ├── Checks/creates .env
│ ├── Generates secrets if missing
│ ├── Derives LDAP_BASE_DN from BASE_DOMAIN
│ ├── Hashes Gitea OIDC secret
│ ├── Generates configs from templates
│ └── Rsyncs to remote server and starts services
├── manage.sh # Management operations on remote server
│ ├── logs, restart, update
│ ├── backup, restore
│ └── status, reset
├── compose.yml # Docker Compose service definitions
│ ├── Service configs with educational comments
│ ├── Volume mounts (configs, secrets, data)
│ └── Network and dependency definitions
├── Caddyfile.production.template # Caddy config for Let's Encrypt
├── Caddyfile.test.template # Caddy config for self-signed certs
│ └── Generated to Caddyfile by deploy.sh based on USE_SELF_SIGNED_CERTS
├── authelia/
│ └── configuration.yml.template # Authelia config template
│ ├── Uses modern v4.38+ syntax (no deprecation warnings)
│ ├── References secrets via _FILE environment variables
│ └── Generated to configuration.yml by deploy.sh
├── jspwiki/
│ ├── Dockerfile # Custom JSPWiki image with SSO support
│ ├── RemoteUserFilter.java # Servlet filter for container auth
│ ├── ldap-sync.sh # Syncs lldap users to JSPWiki XML
│ ├── configure-web-xml.sh # Modifies web.xml for RemoteUserFilter
│ └── entrypoint.sh # Container startup: sync LDAP, start Tomcat
├── jspwiki-custom.properties # JSPWiki config (container auth enabled)
├── jspwiki.policy # JSPWiki security policy
├── secrets/ # Auto-generated secrets (NEVER commit)
│ ├── lldap/
│ │ ├── JWT_SECRET
│ │ └── LDAP_USER_PASS
│ └── authelia/
│ ├── JWT_SECRET
│ ├── SESSION_SECRET
│ ├── STORAGE_ENCRYPTION_KEY
│ ├── OIDC_HMAC_SECRET
│ └── OIDC_PRIVATE_KEY
└── Documentation
├── README.md # User-facing documentation and quick start
├── ARCHITECTURE.md # Technical deep-dive
└── CLAUDE.md # This file (AI assistant guidance)
```
## Security Considerations
### LDAP Injection Prevention
The registration service handles user input that is used in LDAP operations. Always follow these practices:
**Input Validation (Defense Layer 1):**
- Username: Strict validation - alphanumeric + underscore only, must start with letter, 2-64 chars
- Email: Basic format validation
- Names: Length limits (max 100 characters)
- Apply validation at form submission AND before LDAP operations (defense in depth)
**LDAP DN Escaping (Defense Layer 2):**
```python
def escape_ldap_dn(value: str) -> str:
"""Escape LDAP DN special characters per RFC 4514"""
# Escape: , \ # + < > ; " =
# Also escape leading/trailing spaces
```
**Always escape when constructing DNs:**
```python
# BAD - Vulnerable to injection
user_dn = f'uid={username},ou=people,dc=example,dc=com'
# GOOD - Escaped and validated
if not validate_username_strict(username):
raise ValueError("Invalid username")
escaped_username = escape_ldap_dn(username)
user_dn = f'uid={escaped_username},ou=people,dc=example,dc=com'
```
**LDAP Filter Escaping:**
If you add ldapsearch operations, escape filter values:
```python
def escape_ldap_filter(value: str) -> str:
"""Escape LDAP filter special characters"""
# Escape: * ( ) \ NULL
value = value.replace('\\', '\\5c')
value = value.replace('*', '\\2a')
value = value.replace('(', '\\28')
value = value.replace(')', '\\29')
value = value.replace('\x00', '\\00')
return value
```
### SQL Injection Prevention
Always use parameterized queries:
```python
# GOOD - Parameterized
db.execute('SELECT * FROM users WHERE username = ?', (username,))
# BAD - String interpolation
db.execute(f'SELECT * FROM users WHERE username = "{username}"')
```
### GraphQL Security
The lldap GraphQL API is safe from injection because it uses parameterized variables. Always pass user input as variables, never in the query string:
```python
# GOOD - Parameterized variables
variables = {'user': {'id': username, 'email': email}}
response = client.post(url, json={'query': mutation, 'variables': variables})
# BAD - String interpolation in query
query = f'mutation {{ createUser(id: "{username}") }}' # Vulnerable!
```
## Common Tasks
### Adding a New Configuration Option
1. **Add to `.env.example`** with clear comments and default value
2. **Update template file** (Caddyfile or authelia config) to use `${NEW_VAR}`
3. **Update `deploy.sh`** to export the variable for envsubst
4. **Update documentation** (README.md) if user-facing
5. **Test** by removing .env and running deploy.sh
Example:
```bash
# .env.example
NEW_FEATURE_ENABLED=true # Enable new feature
# authelia/configuration.yml.template
new_feature:
enabled: ${NEW_FEATURE_ENABLED}
# deploy.sh (in export section)
export NEW_FEATURE_ENABLED
```
### Adding a New Service
1. **Add service to `compose.yml`** with clear comments
2. **Add subdomain to `.env.example`** (e.g., `NEWSERVICE_SUBDOMAIN=new`)
3. **Add to Caddyfile templates** with appropriate auth config
4. **Update `deploy.sh`** summary section to show new URL
5. **Update `manage.sh`** if service needs special handling
6. **Document in README.md** and ARCHITECTURE.md
### Modifying Authelia Configuration
**Important**: Always use **modern v4.38+ syntax**. Check [Authelia docs](https://www.authelia.com/configuration/prologue/introduction/) for latest syntax.
Common deprecations to avoid:
-`username_attribute` → ✅ `attributes.username`
-`access_token_lifespan` → ✅ `lifespans.access_token`
-`issuer_private_key: |` → ✅ Use `*_FILE` env var
When adding new features:
1. Check if Authelia exposes secret via `*_FILE` env variable
2. If yes, use file-based secret (add to deploy.sh generation)
3. If no, use template variable from .env
### Working with Secrets
**Adding a new secret:**
```bash
# 1. Add generation to deploy.sh
generate_secret_file "secrets/authelia/NEW_SECRET" 32
# 2. Add environment variable to compose.yml
environment:
- AUTHELIA_NEW_SECRET_FILE=/secrets/NEW_SECRET
# 3. Add volume mount if in new directory
volumes:
- ./secrets/authelia:/secrets:ro
# 4. Reference in template with comment
# authelia/configuration.yml.template
# new_secret read from AUTHELIA_NEW_SECRET_FILE
```
**Never:**
- ❌ Commit secrets to git
- ❌ Put secrets in environment variables (use _FILE pattern)
- ❌ Echo secrets in logs
- ❌ Use predictable secret values
### Debugging Authentication Issues
**Check in this order:**
1. **Service Status**
```bash
ssh user@host 'cd ~/org-stack && docker compose ps'
```
All services should be "Up" and "healthy" (Authelia)
2. **Authelia Logs**
```bash
ssh user@host 'cd ~/org-stack && docker compose logs authelia | tail -50'
```
Look for:
- Configuration errors (missing secrets, bad syntax)
- LDAP connection errors
- Authentication failures
3. **Secret Files**
```bash
ssh user@host 'ls -la ~/org-stack/secrets/authelia/'
```
All files should exist with 600 permissions
4. **Network Connectivity**
```bash
ssh user@host 'cd ~/org-stack && docker compose exec gitea curl http://authelia:9091/.well-known/openid-configuration'
```
Should return JSON (OIDC discovery document)
5. **LDAP Connectivity**
```bash
ssh user@host 'cd ~/org-stack && docker compose exec authelia ldapsearch -H ldap://lldap:3890 -D "uid=admin,ou=people,dc=example,dc=com" -w "$(cat secrets/lldap/LDAP_USER_PASS)" -b "dc=example,dc=com" "(uid=admin)"'
```
Should return admin user entry
### Common Error Patterns
**"502 Bad Gateway" on all services except Gitea**
- **Cause**: Authelia container not running or crashing
- **Check**: `docker compose logs authelia`
- **Common root cause**: Missing or incorrect secret file path
**"No such host" errors in Caddy logs**
- **Cause**: Docker network issues or service not started
- **Fix**: `docker compose down && docker compose up -d`
**"Certificate signed by unknown authority" in Gitea**
- **Cause**: Gitea trying to use external HTTPS URL with self-signed cert
- **Fix**: Use internal URL in OIDC config: `http://authelia:9091/...`
**Authelia deprecation warnings in logs**
- **Cause**: Using old configuration syntax
- **Fix**: Update `authelia/configuration.yml.template` to modern syntax
- **Reference**: [Authelia Migration Guides](https://www.authelia.com/reference/guides/migration/)
## Code Style Guidelines
### Shell Scripts (deploy.sh, manage.sh)
```bash
# Good: Clear error messages with context
if [ ! -f .env ]; then
error ".env file not found. Copy .env.example to .env and configure."
fi
# Good: Functions with clear names
generate_secret_file() {
local file_path=$1
local length=${2:-32}
# ...
}
# Good: Comments explain "why"
# Derive LDAP_BASE_DN from BASE_DOMAIN because most users won't know LDAP DN format
if [ "$LDAP_BASE_DN" = "AUTO" ]; then
DERIVED_DN=$(echo "$BASE_DOMAIN" | sed 's/\./,dc=/g' | sed 's/^/dc=/')
# ...
fi
```
### YAML Configuration
```yaml
# Good: Comments explain purpose and link to docs
authentication_backend:
ldap:
# lldap connection settings
# See: https://github.com/lldap/lldap
address: 'ldap://lldap:3890'
# Good: Group related settings
lifespans:
access_token: ${ACCESS_TOKEN_LIFESPAN}
authorize_code: ${AUTHORIZE_CODE_LIFESPAN}
id_token: ${ID_TOKEN_LIFESPAN}
refresh_token: ${REFRESH_TOKEN_LIFESPAN}
```
### Docker Compose
```yaml
# Good: Comments explain auth method and purpose
gitea:
# Self-hosted Git service
# Uses OIDC to authenticate users through Authelia (SSO)
image: gitea/gitea:latest
environment:
- GITEA__server__DOMAIN=${GITEA_SUBDOMAIN}.${BASE_DOMAIN}
volumes:
- gitea_data:/data # Git repos, database, config
```
## Testing Approach
### Before Committing Changes
1. **Test fresh deployment**:
```bash
rm .env
rm -rf secrets/
./deploy.sh
```
2. **Verify services start**:
```bash
ssh user@host 'cd ~/org-stack && docker compose ps'
```
All should be "Up"
3. **Test authentication flow**:
- Create user in lldap
- Login to wiki (tests Forward-Auth + 2FA)
- Login to Gitea (tests OIDC + 2FA)
4. **Check for deprecation warnings**:
```bash
ssh user@host 'cd ~/org-stack && docker compose logs authelia' | grep -i deprecat
```
Should be zero warnings
5. **Verify secrets not in git**:
```bash
git status
```
secrets/ and .env should not appear
### Test Scenarios
**Scenario 1: First-time deployment**
- User has never deployed before
- Should complete with zero manual steps
- All secrets auto-generated
**Scenario 2: Configuration change**
- User changes BASE_DOMAIN in .env
- Runs deploy.sh
- All services update to new domain
**Scenario 3: Secret rotation**
- Delete a secret file
- Run deploy.sh
- Secret regenerated, services restart
**Scenario 4: Disaster recovery**
- Backup volumes with manage.sh backup
- Destroy everything with manage.sh reset
- Restore from backup
- Everything works
## Understanding the Authentication Flow
### When User Accesses Wiki
```
1. Browser → Caddy: GET https://wiki.example.com/
2. Caddy → Authelia: Forward-auth subrequest to /api/authz/forward-auth
3. Authelia checks session cookie:
a. If valid session: Return 200 + Remote-User header
b. If no session: Return 401 + redirect to login
4. If 401:
- Caddy → Browser: 302 to Authelia login
- User enters credentials
- Authelia → lldap: Validate password via LDAP BIND
- Authelia prompts for TOTP (if REQUIRE_2FA=true)
- Authelia creates session, sets cookie
- Authelia → Browser: 302 back to wiki
5. Caddy forwards request to JSPWiki with Remote-User header
6. JSPWiki trusts Remote-User (via RemoteUserFilter)
7. User sees wiki page as authenticated user
```
### When User Accesses Gitea
```
1. Browser → Gitea: Click "Sign in with OpenID Connect"
2. Gitea → Browser: 302 to Authelia /api/oidc/authorization
3. Authelia checks session:
a. If valid: Skip to step 5
b. If no session: Show login + 2FA
4. User authenticates (same as wiki flow)
5. Authelia → Browser: 302 to Gitea callback with code
6. Gitea → Authelia: POST /api/oidc/token with code
7. Authelia → Gitea: Access token + ID token (JWT)
8. Gitea validates JWT, extracts user info
9. Gitea creates local session
10. User logged into Gitea (SSO - no credential re-entry if already logged into wiki)
```
## When to Ask User for Clarification
**Always ask before:**
- Changing authentication behavior (2FA, SSO, etc.)
- Modifying security-related code
- Breaking changes to .env configuration
- Changing default behavior
- Adding new external dependencies
**No need to ask for:**
- Bug fixes (broken features)
- Documentation improvements
- Code cleanup/refactoring (no behavior change)
- Adding educational comments
- Fixing deprecation warnings
## Important Constraints
### Do Not
- ❌ Add configuration that requires editing multiple files
- ❌ Use deprecated Authelia configuration syntax
- ❌ Put secrets in environment variables (use _FILE pattern)
- ❌ Create services without proper health checks
- ❌ Bypass authentication (all services must auth via Authelia)
- ❌ Hardcode domains, IPs, or credentials
- ❌ Use `latest` tags for critical services (prefer versioned tags)
- ❌ Mix forward-auth and OIDC on same service
### Always
- ✅ Use file-based secrets
- ✅ Mount secrets read-only (`:ro`)
- ✅ Add educational comments to configs
- ✅ Test full deployment flow
- ✅ Keep `.env.example` in sync with actual usage
- ✅ Update documentation when changing behavior
- ✅ Use modern Authelia v4.38+ syntax
- ✅ Reference official docs/RFCs in comments
## Resources
### Official Documentation
- [lldap](https://github.com/lldap/lldap)
- [Authelia](https://www.authelia.com/)
- [Gitea](https://docs.gitea.io/)
- [JSPWiki](https://jspwiki-wiki.apache.org/)
- [Caddy](https://caddyserver.com/docs/)
### Protocol Specifications
- [LDAP - RFC 4511](https://tools.ietf.org/html/rfc4511)
- [OIDC Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
- [OAuth 2.0 - RFC 6749](https://tools.ietf.org/html/rfc6749)
- [TOTP - RFC 6238](https://tools.ietf.org/html/rfc6238)
### Helpful Articles
- [Understanding OIDC](https://connect2id.com/learn/openid-connect)
- [Forward-Auth Pattern](https://www.authelia.com/integration/proxies/fowarded-headers/)
- [Docker Secrets Best Practices](https://docs.docker.com/engine/swarm/secrets/)
## Project Goals
This project aims to:
1. **Demonstrate** modern authentication patterns (LDAP, OIDC, Forward-Auth)
2. **Educate** about SSO, 2FA, and centralized user management
3. **Provide** production-ready self-hosted alternative to cloud services
4. **Showcase** Docker Compose best practices
5. **Maintain** simplicity - one command deployment, zero manual config
When working on this project, prioritize these goals:
- Make it easier to deploy
- Make it more educational
- Make it more secure
- Make it well-documented
## Current State
**Production Ready**: Yes (with appropriate configuration)
**Deployment Model**: Remote server via SSH/rsync
**Supported Platforms**: Linux (Docker required)
**Active Development**: Yes
**Known Limitations**:
- SQLite databases (acceptable for <10k users)
- Filesystem notifier (should be SMTP for production)
- Single server deployment (no HA)
**Improvement Opportunities**:
- Add Redis for Authelia session storage
- Add PostgreSQL option for Gitea
- Add Prometheus monitoring
- Add automated backup scheduling
- Add user import scripts (CSV lldap)
## Final Notes
This is a **teaching project** as much as a production tool. Code should be:
- **Readable** - clear variable names, educational comments
- **Reliable** - error handling, validation, health checks
- **Reproducible** - anyone should be able to deploy successfully
- **Referenced** - link to RFCs, docs, and explanations
When in doubt, prioritize clarity over cleverness, and security over convenience.
If you're working on this project as an AI assistant, remember:
- The user may not be an expert in authentication protocols
- Comments should explain "why" not just "what"
- Changes should work end-to-end (don't break deployment)
- Documentation is as important as code
Good luck! 🚀

View File

@@ -0,0 +1,63 @@
# Production Caddyfile - Uses Let's Encrypt for automatic HTTPS
# Reusable forward authentication snippet
(auth) {
forward_auth authelia:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Uri {uri}
header_up X-Forwarded-For {remote_host}
}
}
# Authelia - NO forward auth (must be accessible for login)
${AUTHELIA_SUBDOMAIN}.${BASE_DOMAIN} {
reverse_proxy authelia:9091 {
# Pass through all headers properly
header_up Host {upstream_hostport}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
# Increase timeouts for slow connections
transport http {
read_timeout 60s
write_timeout 60s
}
}
}
# Gitea - Uses OIDC for authentication (no forward_auth)
${GITEA_SUBDOMAIN}.${BASE_DOMAIN} {
reverse_proxy gitea:3000
}
# JSPWiki - Protected by Authelia
${WIKI_SUBDOMAIN}.${BASE_DOMAIN} {
import auth
reverse_proxy jspwiki:8080
}
# lldap - Protected by Authelia (requires Authelia auth + lldap admin password)
${LLDAP_SUBDOMAIN}.${BASE_DOMAIN} {
import auth
reverse_proxy lldap:17170
}
# Registration - Public form, protected admin dashboard
${REGISTRATION_SUBDOMAIN}.${BASE_DOMAIN} {
# Admin dashboard requires authentication and admin group membership
@admin path /admin /admin/*
handle @admin {
import auth
reverse_proxy registration:5000
}
# Public registration form (no auth required - skip forward_auth)
handle {
reverse_proxy registration:5000
}
}

69
Caddyfile.test.template Normal file
View File

@@ -0,0 +1,69 @@
# Testing Caddyfile - Uses self-signed certificates (no Let's Encrypt rate limits)
# Reusable forward authentication snippet
(auth) {
forward_auth authelia:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Uri {uri}
header_up X-Forwarded-For {remote_host}
}
}
# Authelia - NO forward auth (must be accessible for login)
${AUTHELIA_SUBDOMAIN}.${BASE_DOMAIN} {
tls internal
reverse_proxy authelia:9091 {
# Pass through all headers properly
header_up Host {upstream_hostport}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
# Increase timeouts for slow connections
transport http {
read_timeout 60s
write_timeout 60s
}
}
}
# Gitea - Uses OIDC for authentication (no forward_auth)
${GITEA_SUBDOMAIN}.${BASE_DOMAIN} {
tls internal
reverse_proxy gitea:3000
}
# JSPWiki - Protected by Authelia
${WIKI_SUBDOMAIN}.${BASE_DOMAIN} {
tls internal
import auth
reverse_proxy jspwiki:8080
}
# lldap - Protected by Authelia (requires Authelia auth + lldap admin password)
${LLDAP_SUBDOMAIN}.${BASE_DOMAIN} {
tls internal
import auth
reverse_proxy lldap:17170
}
# Registration - Public form, protected admin dashboard
${REGISTRATION_SUBDOMAIN}.${BASE_DOMAIN} {
tls internal
# Admin dashboard requires authentication and admin group membership
@admin path /admin /admin/*
handle @admin {
import auth
reverse_proxy registration:5000
}
# Public registration form (no auth required - skip forward_auth)
handle {
reverse_proxy registration:5000
}
}

409
MULTI_ADMIN_SETUP.md Normal file
View File

@@ -0,0 +1,409 @@
# Multi-Admin Setup Guide
This guide explains how to configure org-stack for multiple administrators with proper permissions and shared access.
## Overview
By default, org-stack deploys to a user's home directory (`~/org-stack`). For production environments with multiple administrators, it's recommended to use a system-wide installation path with Unix group-based permissions.
## Architecture
**System-wide installation:**
```
/opt/org-stack/ # Installation root (deploy:orgstack, 750)
├── .env # Configuration (deploy:orgstack, 640)
├── compose.yml # Docker Compose definition
├── secrets/ # Auto-generated secrets (750)
│ ├── lldap/ # Individual secrets (600)
│ └── authelia/
├── data/ # Persistent Docker volumes
│ ├── gitea/
│ ├── wiki/
│ └── ...
└── backups/ # Backup storage (750)
```
**Permissions:**
- Directories: `750` (rwxr-x---) - Owner full access, group read+execute
- Config files: `640` (rw-r-----) - Owner read+write, group read
- Secrets: `600` (rw-------) - Owner only (Docker reads as owner)
- Scripts: `750` (rwxr-x---) - Owner+group can execute
## One-Time Server Setup
These steps are performed **once** on the remote server by a sysadmin with sudo access.
### 1. Create Deployment User and Admin Group
```bash
# Create the admin group
sudo groupadd orgstack
# Create dedicated deployment user
sudo useradd -r -m -d /opt/org-stack -s /bin/bash -g orgstack deploy
# Add deployment user to docker group (required for docker compose)
sudo usermod -aG docker deploy
# Set password for deploy user (for SSH access)
sudo passwd deploy
```
### 2. Add Administrators to Group
```bash
# Add each admin to the orgstack group
sudo usermod -aG orgstack admin1
sudo usermod -aG orgstack admin2
sudo usermod -aG orgstack admin3
# Verify membership
getent group orgstack
# Output: orgstack:x:1001:admin1,admin2,admin3
```
**Important**: Admins must log out and log back in for group membership to take effect.
### 3. Create Installation Directory
```bash
# Create directory structure
sudo mkdir -p /opt/org-stack
# Set ownership
sudo chown -R deploy:orgstack /opt/org-stack
# Set permissions
sudo chmod 750 /opt/org-stack
```
### 4. Configure SSH Access
Each admin needs SSH access to the `deploy` user:
**Option A: SSH Key Authentication (recommended)**
```bash
# On admin's local machine, copy SSH key
ssh-copy-id deploy@your-server.com
# Test access
ssh deploy@your-server.com
```
**Option B: Password Authentication**
```bash
# Ensure password authentication is enabled in /etc/ssh/sshd_config
# PasswordAuthentication yes
# Admins use the deploy user password
ssh deploy@your-server.com
```
### 5. Optional: Sudo Access for Admins
If admins need to perform system tasks (install packages, restart services):
```bash
# Create sudoers file for orgstack group
sudo visudo -f /etc/sudoers.d/orgstack
```
Add:
```
# Allow orgstack group members to run docker commands
%orgstack ALL=(ALL) NOPASSWD: /usr/bin/docker, /usr/bin/docker-compose
# Or give full sudo access
%orgstack ALL=(ALL) ALL
```
## Local Deployment Configuration
Each admin configures their **local** `.env` file on their workstation (not on the server).
### 1. Clone Repository Locally
```bash
git clone https://github.com/yourorg/org-stack.git
cd org-stack
```
### 2. Configure .env for Multi-Admin
```bash
cp .env.example .env
nano .env
```
**Required settings:**
```bash
# Domain
BASE_DOMAIN=yourdomain.com
# Remote server connection (all admins use the same deploy user)
REMOTE_USER=deploy
REMOTE_HOST=your-server.com
REMOTE_PORT=22
# Multi-admin configuration
REMOTE_PATH=/opt/org-stack # System-wide installation
ADMIN_GROUP=orgstack # Unix group for permissions
# SMTP, 2FA, etc. (same for all admins)
REQUIRE_2FA=true
# ... other settings ...
```
### 3. Deploy from Local Machine
```bash
./deploy.sh
```
The script will:
1. Sync files to `/opt/org-stack` on remote server
2. Set group ownership to `orgstack`
3. Set permissions (750 for dirs, 640 for files, 600 for secrets)
4. Generate secrets if missing
5. Start Docker services
## Admin Workflows
### Deploying Changes
Any admin can deploy changes:
```bash
# On local machine
cd ~/org-stack
git pull # Get latest changes
nano .env # Modify configuration if needed
./deploy.sh
```
### Managing Services Remotely
```bash
# From local machine using manage.sh
./manage.sh status
./manage.sh logs authelia
./manage.sh restart
# Or SSH directly to server
ssh deploy@your-server.com
cd /opt/org-stack
docker compose ps
docker compose logs -f authelia
docker compose restart gitea
```
### Viewing Configuration
All group members can read configs:
```bash
ssh deploy@your-server.com
cd /opt/org-stack
cat .env
cat secrets/lldap/LDAP_USER_PASS
docker compose config # Show resolved configuration
```
### Making Changes on Server
**Only the `deploy` user can modify files** (write permission). Admins must either:
**Option A: Deploy from local machine (recommended)**
```bash
# Edit .env locally, then deploy
./deploy.sh
```
**Option B: SSH as deploy user**
```bash
ssh deploy@your-server.com
cd /opt/org-stack
nano .env
docker compose up -d
```
**Option C: Use sudo (if configured)**
```bash
ssh admin1@your-server.com
cd /opt/org-stack
sudo -u deploy nano .env
sudo -u deploy docker compose up -d
```
## Security Considerations
### File Permissions
- **Secrets are owner-only (600)**: Only `deploy` user and Docker can read secrets
- **Configs are group-readable (640)**: Admins can audit configuration
- **No world access**: All files are 750/640/600 (no "others" permission)
### SSH Key Management
- Each admin should use their own SSH key to authenticate as `deploy` user
- Use `~deploy/.ssh/authorized_keys` to manage who has access
- Revoke access by removing admin's key from authorized_keys
### Audit Trail
Track who deployed what:
```bash
# On server, check file modification times
ls -la /opt/org-stack/.env
# Check who's currently logged in as deploy
who
# SSH logs show authentication
sudo journalctl -u ssh | grep deploy
```
### Backup Strategy
Only `deploy` user can create backups:
```bash
# From local machine
./manage.sh backup
# Or on server as deploy
ssh deploy@your-server.com
cd /opt/org-stack
docker compose exec registration python backup.py
```
Backups are stored in `/opt/org-stack/backups/` with 750 permissions (group-readable).
## Troubleshooting
### Permission Denied Errors
**Error:** `Permission denied: /opt/org-stack`
**Solution:** Ensure you're SSHing as `deploy` user:
```bash
ssh deploy@your-server.com # Not your personal user
```
**Error:** `Failed to set group ownership`
**Solution:** Verify `deploy` user is in `orgstack` group:
```bash
groups deploy
# Should show: deploy : orgstack docker
```
### Group Membership Not Working
Admins must **log out and back in** after being added to group:
```bash
# On server
exit
ssh deploy@your-server.com
groups # Should now show orgstack
```
### Docker Permission Denied
**Error:** `permission denied while trying to connect to Docker daemon`
**Solution:** Add `deploy` user to `docker` group:
```bash
sudo usermod -aG docker deploy
# Log out and back in
```
### Secrets Not Readable by Docker
**Error:** Container fails to start, can't read secret file
**Cause:** Secrets have 600 permissions, readable only by owner
**Solution:** This is correct! Docker runs containers as the file owner, so 600 is appropriate. Verify:
```bash
ls -l /opt/org-stack/secrets/lldap/LDAP_USER_PASS
# Should show: -rw------- 1 deploy orgstack
```
## Migration from Single-User Setup
If you're currently using `~/org-stack` and want to migrate to multi-admin:
### 1. Backup Current Deployment
```bash
ssh user@server
cd ~/org-stack
docker compose down
tar czf ~/org-stack-backup.tar.gz data/ secrets/ .env
```
### 2. Perform One-Time Server Setup
Follow steps in "One-Time Server Setup" section above.
### 3. Restore Data to New Location
```bash
# Extract backup
sudo tar xzf ~/org-stack-backup.tar.gz -C /opt/org-stack/
# Fix permissions
sudo chown -R deploy:orgstack /opt/org-stack
cd /opt/org-stack
sudo -u deploy bash
find . -type d -exec chmod 750 {} \;
find . -type f -exec chmod 640 {} \;
find secrets -type f -exec chmod 600 {} \;
```
### 4. Update Local .env
```bash
# On local machine
nano .env
# Change: REMOTE_DIR=org-stack
# To: REMOTE_PATH=/opt/org-stack
# ADMIN_GROUP=orgstack
```
### 5. Deploy
```bash
./deploy.sh
```
## Alternative: Single Admin with System Path
If you want system-wide path but don't need multi-admin:
```bash
# In .env
REMOTE_PATH=/opt/org-stack
ADMIN_GROUP= # Leave empty for single-user mode
```
Permissions will be standard (owner-only) without group access.
## Best Practices
1. **Use version control**: Keep `.env.example` in git, but never commit `.env`
2. **Document changes**: Use git commit messages to track infrastructure changes
3. **Test in staging**: Use `USE_SELF_SIGNED_CERTS=true` for testing deployments
4. **Regular backups**: Schedule automated backups with `./manage.sh backup`
5. **Audit access**: Regularly review `~deploy/.ssh/authorized_keys`
6. **Rotate secrets**: Periodically regenerate secrets (requires coordination)
7. **Communication**: Coordinate deployments among team (avoid conflicts)
## See Also
- [README.md](README.md) - Main documentation
- [ARCHITECTURE.md](ARCHITECTURE.md) - Technical architecture details
- [deploy.sh](deploy.sh) - Deployment script source
- [manage.sh](manage.sh) - Management operations

465
README.md Normal file
View File

@@ -0,0 +1,465 @@
# Organization Stack
A self-hosted collaboration and authentication platform featuring Single Sign-On (SSO), Two-Factor Authentication (2FA), centralized user management, Git hosting, and wiki collaboration.
## Overview
This project provides a complete, production-ready stack for small to medium organizations seeking self-hosted alternatives to cloud services. All components communicate through modern authentication standards (LDAP, OIDC, Forward-Auth) providing seamless single sign-on across all services.
### Components
- **[lldap](https://github.com/lldap/lldap)** - Lightweight LDAP directory for centralized user and group management
- **[Authelia](https://www.authelia.com/)** - SSO authentication server with 2FA/TOTP support, OIDC provider, and forward-auth endpoint
- **[Gitea](https://gitea.io/)** - Self-hosted Git service with web UI (authenticated via OIDC)
- **[JSPWiki](https://jspwiki.apache.org/)** - Collaborative wiki platform (authenticated via Forward-Auth)
- **Registration Service** - User self-provisioning with admin approval (FastAPI + SQLite)
- **[Caddy](https://caddyserver.com/)** - Reverse proxy with automatic HTTPS via Let's Encrypt
### Key Features
- **Single Sign-On (SSO)** - One set of credentials for all services
- **Two-Factor Authentication** - TOTP (Google Authenticator, Authy, etc.) support
- **Automatic HTTPS** - Let's Encrypt certificates managed by Caddy
- **File-Based Secrets** - Secure secret management following Docker best practices
- **One-Command Deployment** - Automated setup script handles everything
- **Zero Manual Configuration** - Template-based config generation from `.env`
## Quick Start
### Prerequisites
- **Remote Server**: Linux server with Docker and Docker Compose v2 installed
- **Local Machine**: SSH access to remote server, rsync installed
- **Domain**: Domain name with DNS pointing to your server (or `/etc/hosts` for testing)
### Installation
> **Note**: For production deployments with multiple administrators, see [MULTI_ADMIN_SETUP.md](MULTI_ADMIN_SETUP.md) for system-wide installation with proper permissions.
1. **Clone the repository**
```bash
git clone https://github.com/yourorg/org-stack.git
cd org-stack
```
2. **Configure environment**
```bash
cp .env.example .env
nano .env # Edit BASE_DOMAIN, REMOTE_USER, REMOTE_HOST
```
Required changes in `.env`:
- `BASE_DOMAIN=example.com` → your actual domain
- `REMOTE_USER=user` → your SSH username (or `deploy` for multi-admin)
- `REMOTE_HOST=example.com` → your server hostname/IP
- `REMOTE_PATH=/opt/org-stack` → optional, for system-wide installation
3. **Deploy**
```bash
./deploy.sh
```
The deploy script will:
- Generate all required secrets automatically
- Create configuration files from templates
- Sync files to your remote server via rsync
- Start all Docker containers
4. **Access services**
- **lldap**: https://ldap.yourdomain.com (create users here first)
- **Authelia**: https://auth.yourdomain.com
- **Gitea**: https://git.yourdomain.com
- **Wiki**: https://wiki.yourdomain.com
- **Registration**: https://register.yourdomain.com (public user registration)
### First-Time Setup
After deployment, complete these steps:
1. **Create users in lldap**
- Access https://ldap.yourdomain.com
- Login with admin credentials (shown by deploy.sh)
- Create user accounts and assign to `lldap_admin` group for admin privileges
2. **Setup Gitea**
- Access https://git.yourdomain.com
- Complete the installation wizard (use defaults)
- Go to Site Admin → Authentication Sources → Add Authentication Source
- Type: `OAuth2`
- Authentication Name: `authelia` ⚠️ **IMPORTANT: Must be lowercase!**
- Provider: `OpenID Connect`
- Client ID: `gitea`
- Client Secret: (shown by deploy.sh)
- OpenID Connect Auto Discovery URL: `http://authelia:9091/.well-known/openid-configuration`
- Logout and test "Sign in with OpenID Connect"
3. **Test the wiki**
- Access https://wiki.yourdomain.com
- You'll be redirected to Authelia for login
- After authentication, you'll have admin access if you're in the `lldap_admin` group
4. **Enable user self-registration (optional)**
- Users can submit registration requests at https://register.yourdomain.com
- Admins review requests at https://register.yourdomain.com/admin (requires Authelia login)
- Admin dashboard shows:
- **Pending requests**: Approve or reject with optional reason
- **Audit log**: Historical record of all approval/rejection decisions
- When approved:
- User is automatically created in lldap with random password
- User receives email with credentials (if SMTP configured in .env)
- Request is moved to audit log
- **User management**: After approval, manage users directly in lldap (single source of truth)
## Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ Internet / Users │
└──────────────────────────────┬──────────────────────────────────────┘
┌─────────▼──────────┐
│ Caddy (HTTPS) │ ← Automatic Let's Encrypt
│ Reverse Proxy │
└──────────┬─────────┘
┌───────────────────┼───────────────────┐
│ │ │
┌───────▼────────┐ ┌──────▼────────┐ ┌──────▼────────┐
│ Gitea (Git) │ │ JSPWiki (Wiki)│ │ lldap (Admin)│
│ via OIDC ────┼──┤ via Fwd-Auth ─┼──┤ via Fwd-Auth ─┼──┐
└────────────────┘ └───────────────┘ └───────────────┘ │
┌────────────────────────────────────┘
┌────────▼─────────┐
│ Authelia │ ← SSO/2FA/OIDC/Forward-Auth
│ (Auth Server) │
└────────┬─────────┘
┌────────▼─────────┐
│ lldap │ ← User Database (LDAP)
└──────────────────┘
```
### Authentication Flow
**OIDC Flow (Gitea)**:
1. User accesses Gitea → redirected to Authelia
2. Authelia validates credentials against lldap via LDAP
3. Authelia enforces 2FA (TOTP)
4. Authelia issues OIDC tokens
5. User redirected back to Gitea, logged in
**Forward-Auth Flow (Wiki, lldap)**:
1. User accesses Wiki → Caddy forwards auth request to Authelia
2. Authelia validates session (or prompts login + 2FA)
3. Authelia returns `Remote-User` header
4. Caddy forwards request to Wiki with authenticated user header
5. Wiki trusts the `Remote-User` header (container authentication)
For detailed architecture documentation, see [ARCHITECTURE.md](./ARCHITECTURE.md).
## Configuration
All configuration is managed through a single `.env` file. The deployment script generates service-specific configs from templates.
### Key Configuration Options
```bash
# Domain Configuration
BASE_DOMAIN=example.com # Your domain
GITEA_SUBDOMAIN=git # Creates git.example.com
WIKI_SUBDOMAIN=wiki # Creates wiki.example.com
AUTH_SUBDOMAIN=auth # Creates auth.example.com
LLDAP_SUBDOMAIN=ldap # Creates ldap.example.com
# TLS Mode
USE_SELF_SIGNED_CERTS=false # false=Let's Encrypt, true=self-signed
# Authentication
REQUIRE_2FA=true # Require TOTP for all services
# Advanced
SESSION_EXPIRATION=1h # How long sessions last
MAX_RETRIES=3 # Failed login attempts before ban
```
See `.env.example` for complete documentation of all options.
### SMTP Email Notifications
To enable email notifications for password resets, 2FA codes, and registration approvals:
1. Edit `.env` and configure SMTP settings:
```bash
SMTP_ENABLED=true
SMTP_HOST="smtp.gmail.com"
SMTP_PORT=587
SMTP_USER="your-email@gmail.com"
SMTP_PASSWORD='your-app-password' # Use quotes for special characters
SMTP_FROM="noreply@yourdomain.com"
```
2. Deploy changes:
```bash
./deploy.sh
```
See [SMTP_SETUP.md](SMTP_SETUP.md) for detailed configuration examples and troubleshooting.
## Management
The `manage.sh` script provides common operations:
```bash
# View logs
./manage.sh logs # All services
./manage.sh logs authelia # Specific service
# Restart services
./manage.sh restart
# Update to latest images
./manage.sh update
# Backup data volumes
./manage.sh backup
# Restore from backup
./manage.sh restore backup-YYYYMMDD-HHMMSS.tar.gz
# Complete reset (DESTRUCTIVE - deletes all data)
./manage.sh reset
# Check service status
./manage.sh status
```
## Security Features
### Secrets Management
All secrets are stored as individual files (not environment variables) following Docker security best practices:
- Mounted read-only to containers
- Not visible in `docker inspect` or process listings
- Individual file permissions (600)
- Never committed to git (secrets/ in .gitignore)
### Authentication Layers
1. **Centralized User Database**: Single source of truth in lldap
2. **SSO Authentication**: Authelia validates all login attempts
3. **Two-Factor Authentication**: TOTP enforcement for all services
4. **Session Management**: Configurable expiration and inactivity timeouts
5. **Rate Limiting**: Brute-force protection with automatic bans
### Network Security
**Hardened Configuration:**
- **Zero Direct Access**: No service ports exposed except through Caddy
- **Exposed Ports**: Only 80/443 (HTTP/HTTPS) and 2222 (Git SSH)
- **Internal Communication**: All services use Docker network hostnames
- **Authentication Required**: Every service requires Authelia login (except public registration form)
- **TLS Termination**: Caddy handles all HTTPS with automatic certificate management
- **Network Isolation**: Services isolated on internal Docker bridge network
**Port Summary:**
- `80/443` → Caddy (reverse proxy) → All web services
- `2222` → Gitea SSH (for Git operations only)
- All other services accessible ONLY via Caddy with Authelia authentication
## Protocols & Technologies
This project demonstrates integration of several authentication protocols:
### LDAP (Lightweight Directory Access Protocol)
- **Implementation**: lldap
- **RFC**: [RFC 4511](https://tools.ietf.org/html/rfc4511)
- **Used for**: Centralized user/group storage, credential verification
- **Resources**:
- [lldap GitHub](https://github.com/lldap/lldap)
- [LDAP Wikipedia](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol)
### OIDC (OpenID Connect)
- **Implementation**: Authelia (provider), Gitea (client)
- **Spec**: [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
- **Used for**: Gitea SSO authentication
- **Resources**:
- [OpenID Connect Explained](https://connect2id.com/learn/openid-connect)
- [Authelia OIDC Docs](https://www.authelia.com/integration/openid-connect/introduction/)
### Forward-Auth
- **Implementation**: Authelia (endpoint), Caddy (proxy)
- **Used for**: Wiki and lldap authentication via trusted headers
- **How it works**: Proxy delegates authentication to external service before forwarding requests
- **Resources**:
- [Authelia Forward-Auth Docs](https://www.authelia.com/integration/proxies/fowarded-headers/)
- [Caddy forward_auth Directive](https://caddyserver.com/docs/caddyfile/directives/forward_auth)
### TOTP (Time-Based One-Time Password)
- **Implementation**: Authelia, compatible with Google Authenticator/Authy
- **RFC**: [RFC 6238](https://tools.ietf.org/html/rfc6238)
- **Used for**: Two-factor authentication
- **Resources**:
- [TOTP Wikipedia](https://en.wikipedia.org/wiki/Time-based_One-Time_Password)
- [Authelia 2FA Docs](https://www.authelia.com/overview/authentication/one-time-password/)
## Troubleshooting
### Check notifications (2FA codes, password resets)
```bash
ssh user@server 'cd ~/org-stack && docker compose exec authelia cat /data/notification.txt'
```
Or use the manage script:
```bash
./manage.sh logs authelia | grep -A 20 "one-time code"
```
### Gitea OIDC not working
**Error: "invalid_request" or "redirect_uri does not match"**
- **Cause**: Authentication source name is case-sensitive
- **Solution**: Name must be exactly `authelia` (lowercase) in Gitea admin panel
- **Why**: Gitea uses the name in redirect URI: `/user/oauth2/{name}/callback`
**Other common issues:**
1. Verify OIDC configuration uses internal URL:
- Auto Discovery URL: `http://authelia:9091/.well-known/openid-configuration` (NOT https://)
2. Check Authelia logs: `./manage.sh logs authelia`
3. Verify client secret matches what deploy.sh showed
### Services showing 502 errors
1. Check if all containers are running: `./manage.sh status`
2. Check Authelia logs: `./manage.sh logs authelia`
3. Verify secrets are mounted: `ssh user@server 'ls -la ~/org-stack/secrets/authelia'`
### 2FA not working
1. Verify `REQUIRE_2FA=true` in `.env`
2. Redeploy: `./deploy.sh`
3. Check Authelia configuration: `cat authelia/configuration.yml | grep policy`
### Let's Encrypt certificate failures
1. Verify DNS points to your server: `dig yourdomain.com`
2. Verify ports 80 and 443 are accessible externally
3. Check Caddy logs: `./manage.sh logs caddy`
4. If testing, use self-signed: `USE_SELF_SIGNED_CERTS=true` in `.env`
## Backup & Recovery
### Backup
```bash
./manage.sh backup
```
Creates timestamped tarball of all Docker volumes in `backups/` directory.
### Restore
```bash
./manage.sh restore backups/backup-YYYYMMDD-HHMMSS.tar.gz
```
### What's Backed Up
- lldap database (all users and groups)
- Authelia data (sessions, 2FA registrations)
- Gitea data (all repositories)
- JSPWiki data (all wiki pages)
- Caddy data (TLS certificates)
**Note**: The `secrets/` directory is NOT backed up by design. Back it up separately and securely.
## Development
### Testing Changes Locally
```bash
# Use self-signed certs to avoid Let's Encrypt rate limits
echo "USE_SELF_SIGNED_CERTS=true" >> .env
# For local testing without DNS, add to /etc/hosts:
echo "127.0.0.1 git.example.com wiki.example.com auth.example.com ldap.example.com" | sudo tee -a /etc/hosts
# Deploy
./deploy.sh
```
### Project Structure
```
org-stack/
├── .env.example # Configuration template
├── deploy.sh # Automated deployment script
├── manage.sh # Management utilities
├── compose.yml # Docker Compose service definitions
├── Caddyfile.*.template # Caddy templates (prod/test)
├── authelia/
│ └── configuration.yml.template # Authelia config template
├── jspwiki/
│ ├── Dockerfile # Custom JSPWiki image
│ ├── RemoteUserFilter.java # Servlet filter for SSO
│ ├── ldap-sync.sh # LDAP user synchronization
│ ├── configure-web-xml.sh # web.xml modification for container auth
│ └── entrypoint.sh # Container startup script
├── jspwiki-custom.properties # JSPWiki config (container auth)
├── jspwiki.policy # JSPWiki security policy
└── secrets/ # Auto-generated secrets (gitignored)
├── lldap/
│ ├── JWT_SECRET
│ └── LDAP_USER_PASS
└── authelia/
├── JWT_SECRET
├── SESSION_SECRET
├── STORAGE_ENCRYPTION_KEY
├── OIDC_HMAC_SECRET
└── OIDC_PRIVATE_KEY
```
## Production Recommendations
### Network Security
1. **Firewall Rules**:
```bash
# Allow only necessary ports
ufw allow 80/tcp # HTTP (redirects to HTTPS)
ufw allow 443/tcp # HTTPS
ufw allow 2222/tcp # Git SSH (optional, only if using Git SSH)
ufw allow 22/tcp # SSH for server management
ufw enable
```
2. **Port Verification**: Only 80, 443, and 2222 should be accessible externally
3. **DNS Configuration**: Ensure A records point to your server for all subdomains
### Operational Security
4. **Backups**: Schedule regular backups with `./manage.sh backup`
5. **Monitoring**: Set up monitoring for container health and authentication failures
6. **Updates**: Regularly update with `./manage.sh update`
7. **Secrets Rotation**: Periodically regenerate secrets and redeploy
8. **SMTP**: Configure real email notifier in `.env` (replace file-based logging)
### Performance
9. **Database**: Consider PostgreSQL instead of SQLite for Gitea in high-traffic scenarios
10. **Rate Limiting**: Configure Caddy rate limiting for public endpoints
## Contributing
Contributions welcome! Please:
1. Fork the repository
2. Create a feature branch
3. Submit a pull request with clear description
## License
MIT License - see LICENSE file for details
## Acknowledgments
- **lldap**: Nitnelave and contributors
- **Authelia**: Authelia team
- **Gitea**: Gitea maintainers
- **JSPWiki**: Apache JSPWiki project
- **Caddy**: Caddy team
This project demonstrates integration of these excellent open-source tools into a cohesive authentication platform.
## Support
- **Issues**: [GitHub Issues](https://github.com/yourorg/org-stack/issues)
- **Discussions**: [GitHub Discussions](https://github.com/yourorg/org-stack/discussions)
- **Authelia Support**: [Authelia Discord](https://discord.authelia.com)
- **lldap Support**: [lldap Discussions](https://github.com/lldap/lldap/discussions)

157
SMTP_SETUP.md Normal file
View File

@@ -0,0 +1,157 @@
# SMTP Email Notification Setup
Configure SMTP email notifications for password resets, 2FA codes, and user registration approvals.
## Quick Setup
**All steps are done on your LOCAL machine** (the one with the org-stack git repo).
### 1. Edit Local `.env` File
On your local machine, edit `.env` and add your SMTP credentials:
```bash
# Enable SMTP
SMTP_ENABLED=true
# SMTP Server Configuration
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD='your-app-password' # Use single quotes for passwords with special chars
SMTP_FROM=noreply@yourdomain.com
SMTP_USE_TLS=true
# Admin email for registration notifications
REGISTRATION_ADMIN_EMAIL=admin@yourdomain.com
```
**Note**: If your password contains special characters like `( ) $ " '`, wrap it in single quotes.
### 2. Deploy from Local Machine
```bash
./deploy.sh
```
That's it! The deployment script:
- Syncs your `.env` to the remote server
- Automatically configures SMTP in all services
- Restarts containers
## SMTP Provider Examples
### Gmail
```bash
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-16-char-app-password # Create at https://myaccount.google.com/apppasswords
SMTP_USE_TLS=true
```
### SendGrid
```bash
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASSWORD=your-sendgrid-api-key
SMTP_USE_TLS=true
```
### Mailgun
```bash
SMTP_HOST=smtp.mailgun.org
SMTP_PORT=587
SMTP_USER=postmaster@your-domain.mailgun.org
SMTP_PASSWORD=your-mailgun-smtp-password
SMTP_USE_TLS=true
```
### Office 365
```bash
SMTP_HOST=smtp.office365.com
SMTP_PORT=587
SMTP_USER=your-email@yourdomain.com
SMTP_PASSWORD=your-password
SMTP_USE_TLS=true
```
## Testing
### Test Authelia (Password Reset)
1. Go to https://auth.yourdomain.com
2. Click "Forgot password?"
3. Enter your username
4. Check email for reset link
### Test Registration Service
1. Submit a registration at https://register.yourdomain.com
2. Admin receives notification email
3. Approve the request at https://register.yourdomain.com/admin
4. User receives credentials via email
## Troubleshooting
### Check Service Logs
**Authelia:**
```bash
ssh user@host 'cd ~/org-stack && docker compose logs authelia | grep -i smtp'
```
**Registration:**
```bash
ssh user@host 'cd ~/org-stack && docker compose logs registration | grep -i smtp'
```
### Common Issues
**Authentication Failed (535)**
- Gmail: Enable 2FA and create an [App Password](https://myaccount.google.com/apppasswords)
- Verify SMTP_USER and SMTP_PASSWORD are correct
**Connection Refused**
- Check SMTP_HOST and SMTP_PORT are correct
- Verify firewall allows outbound connections on port 587/465
**Certificate Errors**
- Ensure SMTP_USE_TLS=true for port 587
- Use SMTP_USE_TLS=false only for port 25 (not recommended)
### Disable SMTP
To switch back to filesystem logging:
```bash
# In .env
SMTP_ENABLED=false
# Deploy
./deploy.sh
```
## What Gets Sent
### Authelia Sends:
- 2FA setup verification codes
- Password reset links
- New device registration confirmations
### Registration Service Sends:
- Admin notification when user requests registration
- User approval with auto-generated credentials
- User rejection with reason
## Security Notes
- SMTP passwords are stored in `.env` (gitignored, not committed)
- Use app passwords for Gmail/Google Workspace
- Rotate passwords regularly by updating `.env` and redeploying
## See Also
- [Authelia SMTP Configuration](https://www.authelia.com/configuration/notifications/smtp/)
- [Gmail App Passwords](https://support.google.com/accounts/answer/185833)
- [SendGrid SMTP](https://docs.sendgrid.com/for-developers/sending-email/integrating-with-the-smtp-api)
- [Mailgun SMTP](https://documentation.mailgun.com/en/latest/user_manual.html#sending-via-smtp)

View File

@@ -0,0 +1,105 @@
---
theme: light
server:
address: 'tcp://0.0.0.0:9091'
endpoints:
authz:
forward-auth:
implementation: 'ForwardAuth'
log:
level: info
totp:
issuer: ${AUTHELIA_SUBDOMAIN}.${BASE_DOMAIN}
authentication_backend:
ldap:
address: 'ldap://lldap:3890'
implementation: lldap
timeout: 5s
start_tls: false
base_dn: ${LDAP_BASE_DN}
user: uid=admin,ou=people,${LDAP_BASE_DN}
# Password read from AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE
access_control:
default_policy: deny
rules:
- domain: ${WIKI_SUBDOMAIN}.${BASE_DOMAIN}
policy: ${AUTH_POLICY}
- domain: ${LLDAP_SUBDOMAIN}.${BASE_DOMAIN}
policy: ${AUTH_POLICY}
subject:
- ['group:lldap_admin']
- domain: ${REGISTRATION_SUBDOMAIN}.${BASE_DOMAIN}
resources:
- '^/admin(/.*)?$'
policy: ${AUTH_POLICY}
subject:
- ['group:lldap_admin']
- domain: ${AUTHELIA_SUBDOMAIN}.${BASE_DOMAIN}
policy: bypass
session:
# Secret read from AUTHELIA_SESSION_SECRET_FILE
expiration: ${SESSION_EXPIRATION}
inactivity: ${SESSION_INACTIVITY}
remember_me: ${SESSION_REMEMBER_ME}
cookies:
- domain: ${BASE_DOMAIN}
authelia_url: https://${AUTHELIA_SUBDOMAIN}.${BASE_DOMAIN}
default_redirection_url: https://${GITEA_SUBDOMAIN}.${BASE_DOMAIN}
regulation:
max_retries: ${MAX_RETRIES}
find_time: ${FIND_TIME}
ban_time: ${BAN_TIME}
storage:
# Encryption key read from AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE
local:
path: /data/db.sqlite3
notifier:
filesystem:
filename: /data/notification.txt
identity_validation:
reset_password:
# JWT secret read from AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE
identity_providers:
oidc:
# HMAC secret read from AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE
# Issuer private key read from AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE
enable_client_debug_messages: false
enforce_pkce: public_clients_only
lifespans:
access_token: ${ACCESS_TOKEN_LIFESPAN}
authorize_code: ${AUTHORIZE_CODE_LIFESPAN}
id_token: ${ID_TOKEN_LIFESPAN}
refresh_token: ${REFRESH_TOKEN_LIFESPAN}
cors:
endpoints:
- authorization
- token
- revocation
- introspection
allowed_origins_from_client_redirect_uris: true
clients:
- client_id: gitea
client_name: Gitea
client_secret: '${GITEA_OIDC_CLIENT_SECRET_HASH}'
public: false
authorization_policy: ${AUTH_POLICY}
redirect_uris:
- https://${GITEA_SUBDOMAIN}.${BASE_DOMAIN}/user/oauth2/authelia/callback
scopes:
- openid
- profile
- email
- groups
userinfo_signed_response_alg: none
token_endpoint_auth_method: client_secret_basic

View File

@@ -0,0 +1,114 @@
---
theme: light
server:
address: 'tcp://0.0.0.0:9091'
endpoints:
authz:
forward-auth:
implementation: 'ForwardAuth'
log:
level: info
totp:
issuer: ${AUTHELIA_SUBDOMAIN}.${BASE_DOMAIN}
authentication_backend:
ldap:
address: 'ldap://lldap:3890'
implementation: lldap
timeout: 5s
start_tls: false
base_dn: ${LDAP_BASE_DN}
user: uid=admin,ou=people,${LDAP_BASE_DN}
# Password read from AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE
access_control:
default_policy: deny
rules:
- domain: ${WIKI_SUBDOMAIN}.${BASE_DOMAIN}
policy: ${AUTH_POLICY}
- domain: ${LLDAP_SUBDOMAIN}.${BASE_DOMAIN}
policy: ${AUTH_POLICY}
subject:
- ['group:lldap_admin']
- domain: ${AUTHELIA_SUBDOMAIN}.${BASE_DOMAIN}
policy: bypass
- domain: ${REGISTRATION_SUBDOMAIN}.${BASE_DOMAIN}
resources:
- '^/admin(/.*)?$'
policy: ${AUTH_POLICY}
subject:
- ['group:lldap_admin']
session:
# Secret read from AUTHELIA_SESSION_SECRET_FILE
expiration: ${SESSION_EXPIRATION}
inactivity: ${SESSION_INACTIVITY}
remember_me: ${SESSION_REMEMBER_ME}
cookies:
- domain: ${BASE_DOMAIN}
authelia_url: https://${AUTHELIA_SUBDOMAIN}.${BASE_DOMAIN}
default_redirection_url: https://${GITEA_SUBDOMAIN}.${BASE_DOMAIN}
regulation:
max_retries: ${MAX_RETRIES}
find_time: ${FIND_TIME}
ban_time: ${BAN_TIME}
storage:
# Encryption key read from AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE
local:
path: /data/db.sqlite3
notifier:
smtp:
address: ${SMTP_HOST}:${SMTP_PORT}
timeout: 5s
username: ${SMTP_USER}
password: ${SMTP_PASSWORD}
sender: ${SMTP_FROM}
identifier: ${AUTH_SUBDOMAIN}.${BASE_DOMAIN}
subject: "[Authelia] {title}"
startup_check_address: ${REGISTRATION_ADMIN_EMAIL}
disable_require_tls: false
disable_html_emails: false
identity_validation:
reset_password:
# JWT secret read from AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE
identity_providers:
oidc:
# HMAC secret read from AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE
# Issuer private key read from AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE
enable_client_debug_messages: false
enforce_pkce: public_clients_only
lifespans:
access_token: ${ACCESS_TOKEN_LIFESPAN}
authorize_code: ${AUTHORIZE_CODE_LIFESPAN}
id_token: ${ID_TOKEN_LIFESPAN}
refresh_token: ${REFRESH_TOKEN_LIFESPAN}
cors:
endpoints:
- authorization
- token
- revocation
- introspection
allowed_origins_from_client_redirect_uris: true
clients:
- client_id: gitea
client_name: Gitea
client_secret: '${GITEA_OIDC_CLIENT_SECRET_HASH}'
public: false
authorization_policy: ${AUTH_POLICY}
redirect_uris:
- https://${GITEA_SUBDOMAIN}.${BASE_DOMAIN}/user/oauth2/authelia/callback
scopes:
- openid
- profile
- email
- groups
userinfo_signed_response_alg: none
token_endpoint_auth_method: client_secret_basic

View File

@@ -0,0 +1,114 @@
---
theme: light
server:
address: 'tcp://0.0.0.0:9091'
endpoints:
authz:
forward-auth:
implementation: 'ForwardAuth'
log:
level: info
totp:
issuer: ${AUTHELIA_SUBDOMAIN}.${BASE_DOMAIN}
authentication_backend:
ldap:
address: 'ldap://lldap:3890'
implementation: lldap
timeout: 5s
start_tls: false
base_dn: ${LDAP_BASE_DN}
user: uid=admin,ou=people,${LDAP_BASE_DN}
# Password read from AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE
access_control:
default_policy: deny
rules:
- domain: ${WIKI_SUBDOMAIN}.${BASE_DOMAIN}
policy: ${AUTH_POLICY}
- domain: ${LLDAP_SUBDOMAIN}.${BASE_DOMAIN}
policy: ${AUTH_POLICY}
- domain: ${REGISTRATION_SUBDOMAIN}.${BASE_DOMAIN}
policy: ${AUTH_POLICY}
- domain: ${AUTHELIA_SUBDOMAIN}.${BASE_DOMAIN}
policy: bypass
session:
# Secret read from AUTHELIA_SESSION_SECRET_FILE
expiration: ${SESSION_EXPIRATION}
inactivity: ${SESSION_INACTIVITY}
remember_me: ${SESSION_REMEMBER_ME}
cookies:
- domain: ${BASE_DOMAIN}
authelia_url: https://${AUTHELIA_SUBDOMAIN}.${BASE_DOMAIN}
default_redirection_url: https://${GITEA_SUBDOMAIN}.${BASE_DOMAIN}
regulation:
max_retries: ${MAX_RETRIES}
find_time: ${FIND_TIME}
ban_time: ${BAN_TIME}
storage:
# Encryption key read from AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE
local:
path: /data/db.sqlite3
notifier:
# Filesystem notifier (for testing/development - writes to /data/notification.txt)
# Uncomment below for email notifications via SMTP
# smtp:
# address: ${SMTP_HOST}:${SMTP_PORT}
# timeout: 5s
# username: ${SMTP_USER}
# password: ${SMTP_PASSWORD}
# sender: ${SMTP_FROM}
# identifier: ${AUTH_SUBDOMAIN}.${BASE_DOMAIN}
# subject: "[Authelia] {title}"
# startup_check_address: ${REGISTRATION_ADMIN_EMAIL}
# disable_require_tls: false
# disable_html_emails: false
# Using filesystem for now - switch to SMTP when configured (see SMTP_SETUP.md)
filesystem:
filename: /data/notification.txt
identity_validation:
reset_password:
# JWT secret read from AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE
identity_providers:
oidc:
# HMAC secret read from AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE
# Issuer private key read from AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE
enable_client_debug_messages: false
enforce_pkce: public_clients_only
lifespans:
access_token: ${ACCESS_TOKEN_LIFESPAN}
authorize_code: ${AUTHORIZE_CODE_LIFESPAN}
id_token: ${ID_TOKEN_LIFESPAN}
refresh_token: ${REFRESH_TOKEN_LIFESPAN}
cors:
endpoints:
- authorization
- token
- revocation
- introspection
allowed_origins_from_client_redirect_uris: true
clients:
- client_id: gitea
client_name: Gitea
client_secret: '${GITEA_OIDC_CLIENT_SECRET_HASH}'
public: false
authorization_policy: ${AUTH_POLICY}
redirect_uris:
- https://${GITEA_SUBDOMAIN}.${BASE_DOMAIN}/user/oauth2/authelia/callback
scopes:
- openid
- profile
- email
- groups
userinfo_signed_response_alg: none
token_endpoint_auth_method: client_secret_basic

216
compose.yml Normal file
View File

@@ -0,0 +1,216 @@
# =============================================================================
# Organization Stack - Docker Compose Configuration
# =============================================================================
# Defines six core services and their dependencies:
# 1. lldap - Lightweight LDAP directory for user management
# 2. Authelia - SSO authentication server with 2FA support
# 3. Gitea - Self-hosted Git service (uses OIDC for authentication)
# 4. JSPWiki - Wiki platform (uses forward-auth for authentication)
# 5. Registration - User self-provisioning service (forward-auth for admin)
# 6. Caddy - Reverse proxy with automatic HTTPS
# =============================================================================
services:
# ===========================================================================
# lldap - Lightweight LDAP Directory
# ===========================================================================
# Centralized user and group management
# All user credentials are stored here; other services authenticate against it
# Accessible only via Caddy (web UI) and internal Docker network (LDAP protocol)
lldap:
image: lldap/lldap:stable
container_name: lldap
environment:
- UID=${USER_UID:-1000}
- GID=${USER_GID:-1000}
- TZ=${TZ:-UTC}
- LLDAP_JWT_SECRET_FILE=/secrets/JWT_SECRET
- LLDAP_LDAP_USER_PASS_FILE=/secrets/LDAP_USER_PASS
- LLDAP_LDAP_BASE_DN=${LDAP_BASE_DN}
volumes:
- lldap_data:/data # Persistent LDAP database
- ./secrets/lldap:/secrets:ro # Read-only secrets mount
networks:
- org-network
restart: unless-stopped
# ===========================================================================
# Authelia - SSO Authentication & Authorization Server
# ===========================================================================
# Provides single sign-on, two-factor authentication, and access control
# Acts as both OIDC provider (for Gitea) and forward-auth endpoint (for Wiki/lldap)
# Accessible only via Caddy and internal Docker network
authelia:
image: authelia/authelia:latest
container_name: authelia
environment:
- TZ=${TZ:-UTC}
# All secrets loaded from files for security
- AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE=/secrets/RESET_PASSWORD_JWT_SECRET
- AUTHELIA_SESSION_SECRET_FILE=/secrets/SESSION_SECRET
- AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/secrets/STORAGE_ENCRYPTION_KEY
- AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE=/secrets/OIDC_HMAC_SECRET
- AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE=/secrets/OIDC_PRIVATE_KEY
- AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE=/secrets-lldap/LDAP_USER_PASS
# SMTP configuration (for email notifications - see SMTP_SETUP.md)
- SMTP_HOST=${SMTP_HOST:-localhost}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
- SMTP_FROM=${SMTP_FROM:-noreply@localhost}
- AUTH_SUBDOMAIN=${AUTH_SUBDOMAIN:-auth}
- REGISTRATION_ADMIN_EMAIL=${REGISTRATION_ADMIN_EMAIL:-}
volumes:
- ./authelia/configuration.yml:/config/configuration.yml:ro
- ./secrets/authelia:/secrets:ro # Authelia's own secrets
- ./secrets/lldap:/secrets-lldap:ro # lldap password for LDAP auth
- authelia_data:/data # Sessions, 2FA registrations
networks:
- org-network
restart: unless-stopped
depends_on:
- lldap
# ===========================================================================
# Gitea - Self-Hosted Git Service
# ===========================================================================
# Git repository hosting with web UI
# Uses OIDC to authenticate users through Authelia (SSO)
# Web interface accessible only via Caddy
# SSH exposed for Git operations (git clone, push, pull)
gitea:
image: gitea/gitea:latest
container_name: gitea
ports:
- "${GITEA_SSH_PORT:-2222}:22" # Git SSH access (required for git operations)
environment:
- USER_UID=${USER_UID:-1000}
- USER_GID=${USER_GID:-1000}
- GITEA__database__DB_TYPE=sqlite3
- GITEA__server__DOMAIN=${GITEA_SUBDOMAIN}.${BASE_DOMAIN}
- GITEA__server__ROOT_URL=https://${GITEA_SUBDOMAIN}.${BASE_DOMAIN}
- GITEA__server__SSH_DOMAIN=${GITEA_SUBDOMAIN}.${BASE_DOMAIN}
- GITEA__server__SSH_PORT=${GITEA_SSH_PORT:-2222}
- GITEA__oauth2_client__ACCOUNT_LINKING=auto
- GITEA__oauth2_client__ENABLE_AUTO_REGISTRATION=true
- GITEA__oauth2_client__USERNAME=preferred_username
- GITEA__oauth2_client__OPENID_CONNECT_SCOPES=openid email profile
- GITEA__openid__ENABLE_OPENID_SIGNIN=false
# Trust Caddy's self-signed CA when USE_SELF_SIGNED_CERTS=true
- SSL_CERT_FILE=/data/ca-bundle.crt
volumes:
- gitea_data:/data # Git repos, database, config
- caddy_data:/caddy_data:ro # Access Caddy's self-signed CA
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
- org-network
restart: unless-stopped
depends_on:
- authelia
# ===========================================================================
# JSPWiki - Wiki Platform
# ===========================================================================
# Collaborative wiki with LDAP user synchronization
# Uses forward-auth (trusts Remote-User header from Authelia via Caddy)
jspwiki:
build: ./jspwiki # Custom image with RemoteUserFilter
container_name: jspwiki
environment:
- LDAP_BASE_DN=${LDAP_BASE_DN}
volumes:
- jspwiki_data:/var/jspwiki # Wiki pages and config
- ./jspwiki-custom.properties:/usr/local/tomcat/lib/jspwiki-custom.properties:ro
- ./jspwiki.policy:/usr/local/tomcat/webapps/ROOT/WEB-INF/jspwiki.policy:ro
- ./secrets/lldap:/secrets-lldap:ro # lldap admin password for sync
networks:
- org-network
restart: unless-stopped
depends_on:
- lldap
# ===========================================================================
# Registration - User Self-Provisioning Service
# ===========================================================================
# Public registration form and admin approval dashboard
# Public route: / (registration form)
# Protected route: /admin (requires forward-auth via Authelia)
# Creates approved users in lldap via LDAP protocol (ldapadd + ldappasswd)
registration:
build: ./registration # FastAPI application
container_name: registration
user: "${USER_UID:-1000}:${USER_GID:-1000}"
environment:
- DATABASE_PATH=/data/registrations.db
- LLDAP_ADMIN_USER=admin
- LDAP_BASE_DN=${LDAP_BASE_DN}
- ADMIN_EMAIL=${REGISTRATION_ADMIN_EMAIL:-}
- SMTP_ENABLED=${SMTP_ENABLED:-false}
- SMTP_HOST=${SMTP_HOST:-localhost}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
- SMTP_FROM=${SMTP_FROM:-}
- SMTP_USE_TLS=${SMTP_USE_TLS:-true}
volumes:
- registration_data:/data # SQLite database and audit log
- ./secrets/lldap:/secrets-lldap:ro # lldap admin password for user creation
networks:
- org-network
restart: unless-stopped
depends_on:
- lldap
- authelia
# ===========================================================================
# Caddy - Reverse Proxy with Automatic HTTPS
# ===========================================================================
# Terminates TLS and routes traffic to backend services
# Automatically obtains Let's Encrypt certificates
# Enforces authentication via forward-auth for wiki and lldap
caddy:
image: caddy:latest
container_name: caddy
ports:
- "${HTTP_PORT:-80}:80" # HTTP (redirects to HTTPS)
- "${HTTPS_PORT:-443}:443" # HTTPS
- "${HTTPS_PORT:-443}:443/udp" # HTTP/3 (QUIC)
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro # Generated from template by deploy.sh
- caddy_data:/data # TLS certificates
- caddy_config:/config # Caddy runtime config
networks:
- org-network
restart: unless-stopped
depends_on:
- lldap
- authelia
- gitea
- jspwiki
- registration
# =============================================================================
# Networks
# =============================================================================
# All services communicate on an internal bridge network using Docker hostnames
# External access is ONLY through Caddy reverse proxy (ports 80/443)
# Exception: Gitea SSH port for Git operations (port 2222)
# No direct access to any service - all require Authelia authentication via Caddy
networks:
org-network:
driver: bridge
# =============================================================================
# Volumes
# =============================================================================
# Persistent storage for all services
# Back these up regularly with: ./manage.sh backup
volumes:
lldap_data: # LDAP database (users, groups)
authelia_data: # Authentication state (sessions, 2FA registrations)
gitea_data: # Git repositories and Gitea database
jspwiki_data: # Wiki pages and attachments
registration_data: # Registration requests and audit log
caddy_data: # TLS certificates from Let's Encrypt
caddy_config: # Caddy runtime configuration

346
deploy.sh Normal file
View File

@@ -0,0 +1,346 @@
#!/bin/bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
log() { echo -e "${BLUE}${NC} $1"; }
success() { echo -e "${GREEN}${NC} $1"; }
warn() { echo -e "${YELLOW}${NC} $1"; }
error() { echo -e "${RED}${NC} $1"; exit 1; }
echo "======================================="
echo " Organization Stack Deployment"
echo "======================================="
echo
#=============================================================================
# 1. Initialize .env if missing
#=============================================================================
if [ ! -f .env ]; then
log "Creating .env from template..."
cp .env.example .env
success ".env created"
warn "Please edit .env with your configuration, then run deploy.sh again"
exit 0
else
log ".env exists, checking configuration..."
fi
# Source the .env file
set -a
source .env
set +a
# Validate required variables
if [ -z "$BASE_DOMAIN" ] || [ -z "$REMOTE_USER" ] || [ -z "$REMOTE_HOST" ]; then
error "Required variables missing in .env: BASE_DOMAIN, REMOTE_USER, REMOTE_HOST"
fi
# Handle REMOTE_PATH (supports both absolute and relative paths)
if [ -z "$REMOTE_PATH" ]; then
# Backwards compatibility: use old REMOTE_DIR if REMOTE_PATH not set
REMOTE_PATH="${REMOTE_DIR:-org-stack}"
fi
# Determine if path is absolute or relative
if [[ "$REMOTE_PATH" = /* ]]; then
DEPLOY_PATH="$REMOTE_PATH"
log "Deploying to absolute path: $DEPLOY_PATH"
else
DEPLOY_PATH="~/$REMOTE_PATH"
log "Deploying to path relative to home: $DEPLOY_PATH"
fi
success "Configuration validated"
#=============================================================================
# 2. Sync files to remote server
#=============================================================================
log "Syncing files to remote server ${REMOTE_USER}@${REMOTE_HOST}..."
# Create remote directory with proper permissions
ssh -p ${REMOTE_PORT:-22} ${REMOTE_USER}@${REMOTE_HOST} "mkdir -p $DEPLOY_PATH" || \
error "Failed to create remote directory"
# Sync all necessary files (excluding secrets - they'll be generated remotely)
rsync -avz --delete \
--exclude='secrets/' \
--exclude='.git/' \
--exclude='*.tar.gz' \
--exclude='backups/' \
--exclude='data/' \
./ ${REMOTE_USER}@${REMOTE_HOST}:${DEPLOY_PATH}/ || \
error "Failed to sync files"
success "Files synced to remote server"
#=============================================================================
# 3. Run remote deployment script
#=============================================================================
log "Running deployment on remote server..."
# Export variables for remote script
ssh -p ${REMOTE_PORT:-22} ${REMOTE_USER}@${REMOTE_HOST} \
"export DEPLOY_PATH='${DEPLOY_PATH}' ADMIN_GROUP='${ADMIN_GROUP}'; bash -s" <<'REMOTE_SCRIPT'
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log() { echo -e "${BLUE}➜${NC} $1"; }
success() { echo -e "${GREEN}✓${NC} $1"; }
error() { echo -e "${RED}✗${NC} $1"; exit 1; }
# Change to deployment directory (expand tilde if present)
eval cd "$DEPLOY_PATH"
# Source environment
set -a
source .env
set +a
#=============================================================================
# Generate secrets
#=============================================================================
log "Checking secrets..."
mkdir -p secrets/lldap secrets/authelia
# Function to generate random secret
generate_secret_file() {
local file_path=$1
local length=${2:-32}
if [ ! -f "$file_path" ]; then
openssl rand -hex $length > "$file_path"
chmod 600 "$file_path"
log "Generated: $file_path"
fi
}
# Function to generate random password (alphanumeric + special chars)
generate_password_file() {
local file_path=$1
local length=${2:-32}
if [ ! -f "$file_path" ]; then
# Generate password with letters, numbers, and some special chars
< /dev/urandom tr -dc 'A-Za-z0-9!@#%^&*' | head -c${length} > "$file_path"
chmod 600 "$file_path"
log "Generated: $file_path"
fi
}
# Generate all secret files
generate_secret_file "secrets/lldap/JWT_SECRET" 32
generate_password_file "secrets/lldap/LDAP_USER_PASS" 32
generate_secret_file "secrets/authelia/JWT_SECRET" 32
generate_secret_file "secrets/authelia/SESSION_SECRET" 32
generate_secret_file "secrets/authelia/STORAGE_ENCRYPTION_KEY" 32
generate_secret_file "secrets/authelia/OIDC_HMAC_SECRET" 32
generate_secret_file "secrets/authelia/RESET_PASSWORD_JWT_SECRET" 32
# Generate Gitea OIDC secret in .env if missing
if ! grep -q "^GITEA_OIDC_CLIENT_SECRET=" .env || [ -z "$GITEA_OIDC_CLIENT_SECRET" ]; then
GITEA_OIDC_CLIENT_SECRET=$(openssl rand -hex 36)
echo "GITEA_OIDC_CLIENT_SECRET=${GITEA_OIDC_CLIENT_SECRET}" >> .env
log "Generated GITEA_OIDC_CLIENT_SECRET"
export GITEA_OIDC_CLIENT_SECRET
fi
# Generate RSA key using Docker
if [ ! -f "secrets/authelia/OIDC_PRIVATE_KEY" ]; then
log "Generating RSA private key using Docker..."
TEMP_DIR=$(mktemp -d)
docker run --rm --user "$(id -u):$(id -g)" -v "$TEMP_DIR:/keys" authelia/authelia:latest \
authelia crypto pair rsa generate --bits 2048 --directory /keys >/dev/null 2>&1 || \
error "Failed to generate RSA key"
if [ -f "$TEMP_DIR/private.pem" ]; then
mv "$TEMP_DIR/private.pem" "secrets/authelia/OIDC_PRIVATE_KEY"
chmod 600 "secrets/authelia/OIDC_PRIVATE_KEY"
rm -rf "$TEMP_DIR"
log "Generated: OIDC_PRIVATE_KEY"
else
rm -rf "$TEMP_DIR"
error "RSA key generation failed"
fi
fi
success "All secrets ready"
#=============================================================================
# Set up permissions for multi-admin access
#=============================================================================
if [ -n "$ADMIN_GROUP" ]; then
log "Configuring multi-admin permissions for group: $ADMIN_GROUP"
# Verify group exists
if ! getent group "$ADMIN_GROUP" >/dev/null 2>&1; then
error "Group '$ADMIN_GROUP' does not exist. Create it with: sudo groupadd $ADMIN_GROUP"
fi
# Set group ownership on all files (preserve user owner)
chgrp -R "$ADMIN_GROUP" . 2>/dev/null || \
error "Failed to set group ownership. Ensure user is in group: sudo usermod -aG $ADMIN_GROUP $USER"
# Set permissions:
# - Directories: 750 (rwxr-x---) - owner full, group read+execute, others none
# - Regular files: 640 (rw-r-----) - owner read+write, group read, others none
# - Secrets: 600 (rw-------) - owner only (Docker will read as owner)
# - Executables: 750 (rwxr-x---) - owner execute, group execute
find . -type d -exec chmod 750 {} \;
find . -type f -exec chmod 640 {} \;
find . -type f -name '*.sh' -exec chmod 750 {} \;
chmod 750 manage.sh deploy.sh 2>/dev/null || true
# Secrets should be more restrictive (owner only)
if [ -d "secrets" ]; then
chmod 750 secrets
find secrets -type d -exec chmod 750 {} \;
find secrets -type f -exec chmod 600 {} \;
fi
success "Permissions configured for multi-admin access"
log "Group members can read configs and manage with: cd $DEPLOY_PATH && ./manage.sh"
else
log "Single-user mode (ADMIN_GROUP not set)"
fi
#=============================================================================
# Derive LDAP_BASE_DN
#=============================================================================
if [ "$LDAP_BASE_DN" = "AUTO" ]; then
log "Deriving LDAP_BASE_DN from BASE_DOMAIN..."
DERIVED_DN=$(echo "$BASE_DOMAIN" | sed 's/\./,dc=/g' | sed 's/^/dc=/')
sed -i "s|^LDAP_BASE_DN=.*|LDAP_BASE_DN=${DERIVED_DN}|" .env
export LDAP_BASE_DN="$DERIVED_DN"
success "LDAP_BASE_DN set to: $DERIVED_DN"
fi
#=============================================================================
# Hash Gitea OIDC client secret
#=============================================================================
log "Hashing Gitea OIDC client secret..."
# Re-source .env to get GITEA_OIDC_CLIENT_SECRET
source .env
GITEA_OIDC_CLIENT_SECRET_HASH=$(docker run --rm authelia/authelia:latest \
authelia crypto hash generate pbkdf2 --variant sha512 --password "$GITEA_OIDC_CLIENT_SECRET" 2>&1 | \
grep 'Digest:' | awk '{print $2}')
if [ -z "$GITEA_OIDC_CLIENT_SECRET_HASH" ]; then
error "Failed to hash Gitea client secret"
fi
export GITEA_OIDC_CLIENT_SECRET_HASH
success "Gitea client secret hashed"
#=============================================================================
# Set authentication policy
#=============================================================================
if [ "$REQUIRE_2FA" = "true" ]; then
export AUTH_POLICY="two_factor"
else
export AUTH_POLICY="one_factor"
fi
#=============================================================================
# Generate Caddyfile from template
#=============================================================================
log "Generating Caddyfile..."
if [ "$USE_SELF_SIGNED_CERTS" = "true" ]; then
envsubst < Caddyfile.test.template > Caddyfile
success "Generated Caddyfile (self-signed mode)"
else
envsubst < Caddyfile.production.template > Caddyfile
success "Generated Caddyfile (Let's Encrypt mode)"
fi
#=============================================================================
# Generate Authelia configuration
#=============================================================================
log "Generating Authelia configuration..."
# Use appropriate template based on SMTP_ENABLED
if [ "${SMTP_ENABLED:-false}" = "true" ]; then
log "Using SMTP email notifications..."
envsubst < authelia/configuration.yml.smtp.template > authelia/configuration.yml
else
log "Using filesystem notifier..."
envsubst < authelia/configuration.yml.filesystem.template > authelia/configuration.yml
fi
success "Generated authelia/configuration.yml"
#=============================================================================
# Start services
#=============================================================================
log "Starting services..."
docker compose up -d || error "Failed to start services"
success "Services started successfully!"
#=============================================================================
# Display service information
#=============================================================================
echo
echo "======================================="
echo " Deployment Complete!"
echo "======================================="
echo
echo "Services available at:"
echo " • Gitea: https://${GITEA_SUBDOMAIN}.${BASE_DOMAIN}"
echo " • Wiki: https://${WIKI_SUBDOMAIN}.${BASE_DOMAIN}"
echo " • Authelia: https://${AUTHELIA_SUBDOMAIN}.${BASE_DOMAIN}"
echo " • lldap: https://${LLDAP_SUBDOMAIN}.${BASE_DOMAIN}"
echo " • Registration: https://${REGISTRATION_SUBDOMAIN}.${BASE_DOMAIN}"
echo
# Read and display credentials
if [ -f "secrets/lldap/LDAP_USER_PASS" ]; then
LLDAP_PASS=$(cat secrets/lldap/LDAP_USER_PASS)
echo "lldap admin credentials:"
echo " • Username: admin"
echo " • Password: ${LLDAP_PASS}"
echo
fi
if [ -n "$GITEA_OIDC_CLIENT_SECRET" ]; then
echo "Gitea OIDC credentials:"
echo " • Client ID: gitea"
echo " • Client Secret: ${GITEA_OIDC_CLIENT_SECRET}"
echo
fi
echo -e "${YELLOW}⚠${NC} Save these credentials securely!"
echo
REMOTE_SCRIPT
if [ $? -eq 0 ]; then
success "Deployment completed successfully on ${REMOTE_HOST}!"
echo
echo "Next steps:"
echo " 1. Access lldap at https://${LLDAP_SUBDOMAIN}.${BASE_DOMAIN}"
echo " 2. Create user accounts"
echo " 3. Configure Gitea OIDC (see README.md)"
echo
else
error "Deployment failed"
fi

14
jspwiki-custom.properties Normal file
View File

@@ -0,0 +1,14 @@
# Application settings
jspwiki.applicationName = Wiki
jspwiki.workDir = /var/jspwiki/work
jspwiki.pageProvider = VersioningFileProvider
# User and group databases (synced from LLDAP)
jspwiki.xmlUserDatabaseFile = /var/jspwiki/etc/userdatabase.xml
jspwiki.xmlGroupDatabaseFile = /var/jspwiki/etc/groupdatabase.xml
# Container authentication - trust Remote-User header from Authelia
jspwiki.security.container.auth = true
# Note: Pages are stored in the default location /var/jspwiki/pages
# The entire /var/jspwiki directory is persisted via Docker volume

36
jspwiki.policy Normal file
View File

@@ -0,0 +1,36 @@
// JSPWiki security policy - closed wiki with Admin group
// Regular authenticated users can view, edit, comment, and create pages
// Only Admin group members can delete pages and manage groups
// All users can login and manage their own profile
grant principal org.apache.wiki.auth.authorize.Role "All" {
permission org.apache.wiki.auth.permissions.WikiPermission "*", "login";
permission org.apache.wiki.auth.permissions.WikiPermission "*", "editPreferences";
permission org.apache.wiki.auth.permissions.WikiPermission "*", "editProfile";
};
// Authenticated users: standard privileges
grant principal org.apache.wiki.auth.authorize.Role "Authenticated" {
// View all pages
permission org.apache.wiki.auth.permissions.PagePermission "*:*", "view";
// Modify pages (edit + upload)
permission org.apache.wiki.auth.permissions.PagePermission "*:*", "modify";
// Comment on pages
permission org.apache.wiki.auth.permissions.PagePermission "*:*", "comment";
// Rename pages
permission org.apache.wiki.auth.permissions.PagePermission "*:*", "rename";
// Create new pages
permission org.apache.wiki.auth.permissions.WikiPermission "*", "createPages";
// View groups (but NOT edit them)
permission org.apache.wiki.auth.permissions.GroupPermission "*:*", "view";
};
// Admin group: full administrative privileges
grant principal org.apache.wiki.auth.GroupPrincipal "Admin" {
permission org.apache.wiki.auth.permissions.AllPermission "*";
};

28
jspwiki/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
FROM eclipse-temurin:17-jdk AS builder
# Compile Remote-User filter
WORKDIR /build
COPY RemoteUserFilter.java .
RUN javac -cp /usr/local/tomcat/lib/servlet-api.jar:. RemoteUserFilter.java 2>/dev/null || \
wget -q https://repo1.maven.org/maven2/jakarta/servlet/jakarta.servlet-api/6.0.0/jakarta.servlet-api-6.0.0.jar && \
javac -cp jakarta.servlet-api-6.0.0.jar RemoteUserFilter.java && \
jar cf RemoteUserFilter.jar RemoteUserFilter*.class
FROM apache/jspwiki:latest
# Install ldap-utils for LDAP sync
RUN apt-get update && \
apt-get install -y ldap-utils && \
rm -rf /var/lib/apt/lists/*
# Copy compiled filter
COPY --from=builder /build/RemoteUserFilter.jar /usr/local/tomcat/webapps/ROOT/WEB-INF/lib/
# Copy LDAP sync script and custom entrypoint
COPY ldap-sync.sh /usr/local/bin/ldap-sync
COPY configure-web-xml.sh /usr/local/bin/configure-web-xml
COPY entrypoint.sh /custom-entrypoint.sh
RUN chmod +x /usr/local/bin/ldap-sync /usr/local/bin/configure-web-xml /custom-entrypoint.sh
# Use custom entrypoint that runs LDAP sync before starting Tomcat
ENTRYPOINT ["/custom-entrypoint.sh"]

View File

@@ -0,0 +1,63 @@
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import java.io.IOException;
import java.security.Principal;
import java.util.*;
/**
* Servlet Filter that wraps requests to provide Remote-User authentication from Authelia
*/
public class RemoteUserFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// Nothing to initialize
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String remoteUser = httpRequest.getHeader("Remote-User");
if (remoteUser != null && !remoteUser.isEmpty()) {
// Wrap the request to override getRemoteUser() and getUserPrincipal()
HttpServletRequestWrapper wrapper = new HttpServletRequestWrapper(httpRequest) {
@Override
public String getRemoteUser() {
return remoteUser;
}
@Override
public Principal getUserPrincipal() {
return new Principal() {
@Override
public String getName() {
return remoteUser;
}
};
}
@Override
public boolean isUserInRole(String role) {
String remoteGroups = httpRequest.getHeader("Remote-Groups");
if (remoteGroups != null) {
return Arrays.asList(remoteGroups.split(","))
.contains(role);
}
// All authenticated users have "Authenticated" role
return "Authenticated".equals(role);
}
};
chain.doFilter(wrapper, response);
} else {
chain.doFilter(request, response);
}
}
@Override
public void destroy() {
// Nothing to clean up
}
}

View File

@@ -0,0 +1,31 @@
#!/bin/bash
# Configure web.xml to use RemoteUserFilter for SSO
set -e
WEB_XML="/usr/local/tomcat/webapps/ROOT/WEB-INF/web.xml"
MARKER="<!-- REMOTE_USER_FILTER_CONFIGURED -->"
echo "Configuring JSPWiki for Remote-User SSO..." >&2
# Check if already configured
if grep -q "$MARKER" "$WEB_XML" 2>/dev/null; then
echo "✓ RemoteUserFilter already configured" >&2
exit 0
fi
# Add RemoteUserFilter configuration after <web-app> opening tag completes
# Find the line with version="5.0"> which closes the web-app opening tag
sed -i '/version="5.0">/a\
\
<!-- REMOTE_USER_FILTER_CONFIGURED -->\
<!-- Remote-User Filter for Authelia SSO -->\
<filter>\
<filter-name>RemoteUserFilter</filter-name>\
<filter-class>RemoteUserFilter</filter-class>\
</filter>\
<filter-mapping>\
<filter-name>RemoteUserFilter</filter-name>\
<url-pattern>/*</url-pattern>\
</filter-mapping>' "$WEB_XML"
echo "✓ RemoteUserFilter configured in web.xml" >&2

23
jspwiki/entrypoint.sh Normal file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
set -e
# Configure web.xml for container authentication
echo "Configuring JSPWiki for container authentication..." >&2
/usr/local/bin/configure-web-xml
# Read lldap admin password from secret file
if [ -f "/secrets-lldap/LDAP_USER_PASS" ]; then
export LLDAP_ADMIN_PASSWORD=$(cat /secrets-lldap/LDAP_USER_PASS)
fi
# Sync LDAP users to JSPWiki
echo "Syncing LDAP users to JSPWiki..." >&2
if /usr/local/bin/ldap-sync; then
echo "✓ LDAP sync successful" >&2
else
echo "⚠ LDAP sync failed - JSPWiki may not have users initialized" >&2
echo " Check LDAP connection and credentials in secret file" >&2
fi
# Start Tomcat using the official image's entrypoint
exec /usr/local/tomcat/bin/catalina.sh run

159
jspwiki/ldap-sync.sh Normal file
View File

@@ -0,0 +1,159 @@
#!/bin/bash
# Sync LDAP users to JSPWiki XML databases
# Queries LLDAP via ldapsearch and generates JSPWiki XML files
set -euo pipefail
JSPWIKI_USERS="/var/jspwiki/etc/userdatabase.xml"
JSPWIKI_GROUPS="/var/jspwiki/etc/groupdatabase.xml"
# LDAP connection parameters (from environment or defaults)
LDAP_HOST="${LDAP_HOST:-lldap}"
LDAP_PORT="${LDAP_PORT:-3890}"
LDAP_BASE_DN="${LDAP_BASE_DN:-dc=example,dc=com}"
LDAP_BIND_DN="uid=admin,ou=people,${LDAP_BASE_DN}"
LDAP_BIND_PASSWORD="${LLDAP_ADMIN_PASSWORD:-changeme}"
echo "Syncing LDAP users to JSPWiki..." >&2
echo "LDAP Host: ${LDAP_HOST}:${LDAP_PORT}" >&2
echo "Base DN: ${LDAP_BASE_DN}" >&2
# Create JSPWiki etc directory if it doesn't exist
mkdir -p "$(dirname "$JSPWIKI_USERS")"
# Check if ldapsearch is available
if ! command -v ldapsearch &> /dev/null; then
echo "ERROR: ldapsearch not found. Install ldap-utils package." >&2
exit 1
fi
# Test LDAP connection
echo "Testing LDAP connection..." >&2
if ! ldapsearch -x -H "ldap://${LDAP_HOST}:${LDAP_PORT}" -D "${LDAP_BIND_DN}" -w "${LDAP_BIND_PASSWORD}" -b "${LDAP_BASE_DN}" -s base &>/dev/null; then
echo "ERROR: Cannot connect to LDAP server" >&2
exit 1
fi
echo "✓ LDAP connection successful" >&2
# Query all users from LDAP
echo "Querying LDAP users..." >&2
USERS_LDIF=$(ldapsearch -x -LLL -H "ldap://${LDAP_HOST}:${LDAP_PORT}" \
-D "${LDAP_BIND_DN}" -w "${LDAP_BIND_PASSWORD}" \
-b "ou=people,${LDAP_BASE_DN}" \
"(objectClass=person)" uid displayName mail)
# Start building userdatabase.xml
cat > "$JSPWIKI_USERS" << 'EOF_HEADER'
<?xml version="1.0" encoding="UTF-8"?>
<users>
EOF_HEADER
# Parse LDIF and create JSPWiki user entries
echo "$USERS_LDIF" | awk '
BEGIN {
uid=""; displayName=""; mail="";
}
/^dn:/ {
# Save previous user if exists
if (uid) {
print " <user";
print " loginName=\"" uid "\"";
print " fullName=\"" displayName "\"";
print " wikiName=\"" uid "\"";
print " password=\"{SSHA}placeholder\"";
print " email=\"" mail "\" />";
}
uid=""; displayName=""; mail="";
}
/^uid: / { uid = $2; }
/^displayName: / {
displayName = substr($0, 14);
gsub(/^[ \t]+|[ \t]+$/, "", displayName);
}
/^mail: / { mail = $2; }
END {
# Save last user
if (uid) {
print " <user";
print " loginName=\"" uid "\"";
print " fullName=\"" displayName "\"";
print " wikiName=\"" uid "\"";
print " password=\"{SSHA}placeholder\"";
print " email=\"" mail "\" />";
}
}
' >> "$JSPWIKI_USERS"
# Close userdatabase.xml
cat >> "$JSPWIKI_USERS" << 'EOF_FOOTER'
</users>
EOF_FOOTER
USER_COUNT=$(grep -c 'loginName=' "$JSPWIKI_USERS" || echo 0)
echo "✓ Synced $USER_COUNT users" >&2
# Query groups from LDAP
echo "Querying LDAP groups..." >&2
GROUPS_LDIF=$(ldapsearch -x -LLL -H "ldap://${LDAP_HOST}:${LDAP_PORT}" \
-D "${LDAP_BIND_DN}" -w "${LDAP_BIND_PASSWORD}" \
-b "ou=groups,${LDAP_BASE_DN}" \
"(objectClass=groupOfUniqueNames)" cn uniqueMember)
# Build groupdatabase.xml
cat > "$JSPWIKI_GROUPS" << 'EOF_HEADER'
<?xml version="1.0" encoding="UTF-8"?>
<groups>
EOF_HEADER
# Parse groups and create JSPWiki group entries
# Map LLDAP's lldap_admin group to JSPWiki's Admin group
echo "$GROUPS_LDIF" | awk -v base_dn="$LDAP_BASE_DN" '
BEGIN {
cn=""; members=""; in_admin_group=0;
}
/^dn:/ {
# Save previous group if it was lldap_admin
if (cn == "lldap_admin" && members) {
print " <group name=\"Admin\">";
print members;
print " </group>";
}
cn=""; members=""; in_admin_group=0;
}
/^cn: / {
cn = $2;
if (cn == "lldap_admin") in_admin_group=1;
}
/^uniqueMember: / && in_admin_group {
# Extract uid from DN like "uid=admin,ou=people,dc=example,dc=com"
dn = substr($0, 15);
# Extract uid value using portable AWK
if (index(dn, "uid=") > 0) {
uid_part = substr(dn, index(dn, "uid=") + 4);
comma_pos = index(uid_part, ",");
if (comma_pos > 0) {
uid_value = substr(uid_part, 1, comma_pos - 1);
} else {
uid_value = uid_part;
}
members = members " <member principal=\"" uid_value "\" />\n";
}
}
END {
# Save last group if it was lldap_admin
if (cn == "lldap_admin" && members) {
print " <group name=\"Admin\">";
print members;
print " </group>";
}
}
' >> "$JSPWIKI_GROUPS"
# Close groupdatabase.xml
cat >> "$JSPWIKI_GROUPS" << 'EOF_FOOTER'
</groups>
EOF_FOOTER
ADMIN_COUNT=$(grep -c 'member principal=' "$JSPWIKI_GROUPS" || echo 0)
echo "✓ Synced Admin group with $ADMIN_COUNT members" >&2
echo "✓ LDAP sync complete" >&2

222
manage.sh Normal file
View File

@@ -0,0 +1,222 @@
#!/bin/bash
# Management script for Organization Stack
set -e
# Load configuration
if [ -f .env ]; then
set -a
source .env
set +a
fi
# Determine deployment path
get_deploy_path() {
# Handle REMOTE_PATH (supports both absolute and relative paths)
if [ -z "$REMOTE_PATH" ]; then
# Backwards compatibility: use old REMOTE_DIR if REMOTE_PATH not set
REMOTE_PATH="${REMOTE_DIR:-org-stack}"
fi
# Determine if path is absolute or relative
if [[ "$REMOTE_PATH" = /* ]]; then
echo "$REMOTE_PATH"
else
echo "~/$REMOTE_PATH"
fi
}
# Remote execution wrapper
remote_exec() {
if [ -n "$REMOTE_HOST" ] && [ -n "$REMOTE_USER" ]; then
local deploy_path=$(get_deploy_path)
ssh -p ${REMOTE_PORT:-22} ${REMOTE_USER}@${REMOTE_HOST} "cd $deploy_path && $1"
else
eval "$1"
fi
}
function show_help {
cat << EOF
Organization Stack Management Script
Usage: ./manage.sh [command]
Commands:
start Start all services
stop Stop all services
restart Restart all services
status Show status of all services
logs Show logs (add service name to filter: ./manage.sh logs gitea)
update Pull latest images and restart
backup Backup all volumes
restore Restore from backup
reset Stop and remove all containers and volumes (DESTRUCTIVE!)
Examples:
./manage.sh start
./manage.sh logs authelia
./manage.sh backup
EOF
}
function start_services {
echo "Starting services..."
remote_exec "docker compose up -d"
echo "✓ Services started"
if [ -n "$BASE_DOMAIN" ]; then
echo ""
echo "Access points:"
echo " - Gitea: https://git.${BASE_DOMAIN}"
echo " - Wiki: https://wiki.${BASE_DOMAIN}"
echo " - Authelia: https://auth.${BASE_DOMAIN}"
echo " - lldap: https://ldap.${BASE_DOMAIN}"
fi
}
function stop_services {
echo "Stopping services..."
remote_exec "docker compose stop"
echo "✓ Services stopped"
}
function restart_services {
echo "Restarting services..."
remote_exec "docker compose restart"
echo "✓ Services restarted"
}
function show_status {
remote_exec "docker compose ps"
}
function show_logs {
if [ -z "$1" ]; then
remote_exec "docker compose logs -f --tail=100"
else
remote_exec "docker compose logs -f --tail=100 $1"
fi
}
function update_services {
echo "Pulling latest images..."
remote_exec "docker compose pull"
echo ""
echo "Restarting services..."
remote_exec "docker compose up -d"
echo "✓ Services updated"
}
function backup_volumes {
BACKUP_DIR="./backups/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$BACKUP_DIR"
echo "Creating backup in $BACKUP_DIR..."
# Backup each volume
for volume in lldap_data authelia_data gitea_data jspwiki_data; do
echo "Backing up $volume..."
docker run --rm \
-v "org-stack_${volume}:/data" \
-v "$(pwd)/$BACKUP_DIR:/backup" \
alpine \
tar czf "/backup/${volume}.tar.gz" -C /data .
done
echo "✓ Backup completed: $BACKUP_DIR"
}
function restore_volumes {
echo "Available backups:"
ls -1d ./backups/*/ 2>/dev/null || echo "No backups found"
echo ""
read -p "Enter backup directory name (e.g., backups/20240101_120000): " BACKUP_DIR
if [ ! -d "$BACKUP_DIR" ]; then
echo "❌ Backup directory not found"
exit 1
fi
read -p "⚠️ This will overwrite current data. Continue? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Restore cancelled"
exit 1
fi
echo "Stopping services..."
docker compose stop
for volume in lldap_data authelia_data gitea_data jspwiki_data; do
if [ -f "$BACKUP_DIR/${volume}.tar.gz" ]; then
echo "Restoring $volume..."
docker run --rm \
-v "org-stack_${volume}:/data" \
-v "$(pwd)/$BACKUP_DIR:/backup" \
alpine \
sh -c "rm -rf /data/* && tar xzf /backup/${volume}.tar.gz -C /data"
fi
done
echo "Starting services..."
docker compose start
echo "✓ Restore completed"
}
function reset_everything {
echo "⚠️ WARNING: This will remove all containers, volumes, and data!"
read -p "Type 'yes' to confirm: " CONFIRM
if [ "$CONFIRM" != "yes" ]; then
echo "Reset cancelled"
exit 1
fi
echo "Stopping and removing everything..."
remote_exec "docker compose down -v"
echo "✓ Everything removed"
echo ""
echo "To start fresh, run:"
echo " ./deploy.sh"
}
# Main script
case "${1:-}" in
start)
start_services
;;
stop)
stop_services
;;
restart)
restart_services
;;
status)
show_status
;;
logs)
show_logs "${2:-}"
;;
update)
update_services
;;
backup)
backup_volumes
;;
restore)
restore_volumes
;;
reset)
reset_everything
;;
help|--help|-h|"")
show_help
;;
*)
echo "Unknown command: $1"
echo ""
show_help
exit 1
;;
esac

21
registration/Dockerfile Normal file
View 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
View 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'}

View File

@@ -0,0 +1,4 @@
fastapi==0.109.0
uvicorn==0.27.0
python-multipart==0.0.6
jinja2==3.1.3

View 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 %}

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

View 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 %}