first commit
This commit is contained in:
158
.env.example
Normal file
158
.env.example
Normal 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
32
.gitignore
vendored
Normal 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
1105
ARCHITECTURE.md
Normal file
File diff suppressed because it is too large
Load Diff
572
CLAUDE.md
Normal file
572
CLAUDE.md
Normal 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! 🚀
|
||||
63
Caddyfile.production.template
Normal file
63
Caddyfile.production.template
Normal 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
69
Caddyfile.test.template
Normal 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
409
MULTI_ADMIN_SETUP.md
Normal 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
465
README.md
Normal 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
157
SMTP_SETUP.md
Normal 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)
|
||||
105
authelia/configuration.yml.filesystem.template
Normal file
105
authelia/configuration.yml.filesystem.template
Normal 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
|
||||
114
authelia/configuration.yml.smtp.template
Normal file
114
authelia/configuration.yml.smtp.template
Normal 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
|
||||
114
authelia/configuration.yml.template
Normal file
114
authelia/configuration.yml.template
Normal 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
216
compose.yml
Normal 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
346
deploy.sh
Normal 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
14
jspwiki-custom.properties
Normal 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
36
jspwiki.policy
Normal 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
28
jspwiki/Dockerfile
Normal 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"]
|
||||
63
jspwiki/RemoteUserFilter.java
Normal file
63
jspwiki/RemoteUserFilter.java
Normal 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
|
||||
}
|
||||
}
|
||||
31
jspwiki/configure-web-xml.sh
Normal file
31
jspwiki/configure-web-xml.sh
Normal 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
23
jspwiki/entrypoint.sh
Normal 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
159
jspwiki/ldap-sync.sh
Normal 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
222
manage.sh
Normal 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
21
registration/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM python:3.11-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install LDAP tools and dependencies
|
||||
RUN apk add --no-cache openldap-clients
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY app.py .
|
||||
COPY templates/ templates/
|
||||
|
||||
# Create data directory for SQLite database
|
||||
RUN mkdir -p /data && chmod 777 /data
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000"]
|
||||
572
registration/app.py
Normal file
572
registration/app.py
Normal file
@@ -0,0 +1,572 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
User Self-Provisioning Service for org-stack
|
||||
|
||||
Provides a public registration form and admin approval dashboard.
|
||||
Approved users are automatically created in lldap.
|
||||
|
||||
Workflow:
|
||||
- Public registration form at /
|
||||
- Admin dashboard at /admin (protected by Authelia forward-auth)
|
||||
- Pending requests → Approve (creates in lldap) or Reject → Audit log
|
||||
- lldap is the single source of truth for all active users
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import secrets
|
||||
import string
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, Request, Form, HTTPException
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
app = FastAPI(title="User Registration Service")
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# Configuration from environment
|
||||
DATABASE = os.environ.get('DATABASE_PATH', '/data/registrations.db')
|
||||
LLDAP_ADMIN_USER = os.environ.get('LLDAP_ADMIN_USER', 'admin')
|
||||
LLDAP_BASE_DN = os.environ.get('LDAP_BASE_DN', 'dc=example,dc=com')
|
||||
LDAP_HOST = 'ldap://lldap:3890'
|
||||
ADMIN_EMAIL = os.environ.get('ADMIN_EMAIL', '')
|
||||
SMTP_ENABLED = os.environ.get('SMTP_ENABLED', 'false').lower() == 'true'
|
||||
SMTP_HOST = os.environ.get('SMTP_HOST', 'localhost')
|
||||
SMTP_PORT = int(os.environ.get('SMTP_PORT', '587'))
|
||||
SMTP_USER = os.environ.get('SMTP_USER', '')
|
||||
SMTP_PASSWORD = os.environ.get('SMTP_PASSWORD', '')
|
||||
SMTP_FROM = os.environ.get('SMTP_FROM', ADMIN_EMAIL)
|
||||
SMTP_USE_TLS = os.environ.get('SMTP_USE_TLS', 'true').lower() == 'true'
|
||||
EMAIL_LOG_FILE = '/data/emails.log'
|
||||
|
||||
# =============================================================================
|
||||
# Database Functions
|
||||
# =============================================================================
|
||||
|
||||
@contextmanager
|
||||
def get_db():
|
||||
"""Context manager for database connections"""
|
||||
conn = sqlite3.connect(DATABASE)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
init_db(conn)
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def init_db(db: sqlite3.Connection):
|
||||
"""Initialize database tables"""
|
||||
# Pending registration requests
|
||||
db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS registration_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# Historical audit log of all approved/rejected requests
|
||||
db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
action TEXT NOT NULL,
|
||||
performed_by TEXT,
|
||||
rejection_reason TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP,
|
||||
reviewed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
db.commit()
|
||||
|
||||
# =============================================================================
|
||||
# LDAP Security - Injection Prevention
|
||||
# =============================================================================
|
||||
|
||||
def escape_ldap_dn(value: str) -> str:
|
||||
"""Escape LDAP DN special characters per RFC 4514"""
|
||||
value = value.replace('\\', '\\\\')
|
||||
replacements = {',': '\\,', '#': '\\#', '+': '\\+', '<': '\\<',
|
||||
'>': '\\>', ';': '\\;', '"': '\\"', '=': '\\='}
|
||||
for char, escaped in replacements.items():
|
||||
value = value.replace(char, escaped)
|
||||
if value.startswith(' '):
|
||||
value = '\\' + value
|
||||
if value.endswith(' '):
|
||||
value = value[:-1] + '\\ '
|
||||
return value
|
||||
|
||||
def validate_username_strict(username: str) -> bool:
|
||||
"""Validate username: 2-64 chars, lowercase alphanumeric + underscore, must start with letter"""
|
||||
if not username or len(username) < 2 or len(username) > 64:
|
||||
return False
|
||||
if not username.replace('_', '').isalnum() or not username.islower():
|
||||
return False
|
||||
if not username[0].isalpha():
|
||||
return False
|
||||
return True
|
||||
|
||||
def validate_email_basic(email: str) -> bool:
|
||||
"""Basic email validation"""
|
||||
if not email or '@' not in email:
|
||||
return False
|
||||
if len(email) > 255:
|
||||
return False
|
||||
# Basic format check
|
||||
parts = email.split('@')
|
||||
if len(parts) != 2:
|
||||
return False
|
||||
local, domain = parts
|
||||
if not local or not domain or '.' not in domain:
|
||||
return False
|
||||
return True
|
||||
|
||||
# =============================================================================
|
||||
# lldap LDAP Integration
|
||||
# =============================================================================
|
||||
|
||||
def get_lldap_admin_password() -> str:
|
||||
"""Read lldap admin password from secret file"""
|
||||
secret_file = '/secrets-lldap/LDAP_USER_PASS'
|
||||
if os.path.exists(secret_file):
|
||||
with open(secret_file, 'r') as f:
|
||||
return f.read().strip()
|
||||
return os.environ.get('LLDAP_ADMIN_PASSWORD', '')
|
||||
|
||||
async def create_lldap_user(username: str, email: str, first_name: str, last_name: str) -> tuple[bool, str, str]:
|
||||
"""Create user in lldap via LDAP. Returns (success, password, error)"""
|
||||
try:
|
||||
# Validate inputs
|
||||
if not validate_username_strict(username):
|
||||
print(f'[SECURITY] Invalid username: {username}')
|
||||
return False, '', 'Invalid username format'
|
||||
|
||||
if not validate_email_basic(email):
|
||||
return False, '', 'Invalid email format'
|
||||
|
||||
if not first_name or len(first_name) > 100 or not last_name or len(last_name) > 100:
|
||||
return False, '', 'Invalid name fields'
|
||||
|
||||
# Generate random password
|
||||
password = ''.join(secrets.choice(
|
||||
string.ascii_letters + string.digits + string.punctuation
|
||||
) for _ in range(20))
|
||||
|
||||
admin_password = get_lldap_admin_password()
|
||||
|
||||
# Escape LDAP DN components
|
||||
user_dn = f'uid={escape_ldap_dn(username)},ou=people,{LLDAP_BASE_DN}'
|
||||
admin_dn = f'uid={escape_ldap_dn(LLDAP_ADMIN_USER)},ou=people,{LLDAP_BASE_DN}'
|
||||
|
||||
# Create LDIF for new user
|
||||
ldif_content = f'''dn: {user_dn}
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
uid: {escape_ldap_dn(username)}
|
||||
cn: {escape_ldap_dn(first_name)} {escape_ldap_dn(last_name)}
|
||||
sn: {escape_ldap_dn(last_name)}
|
||||
givenName: {escape_ldap_dn(first_name)}
|
||||
mail: {escape_ldap_dn(email)}
|
||||
'''
|
||||
|
||||
# Step 1: Create user with ldapadd
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['ldapadd', '-H', LDAP_HOST, '-D', admin_dn, '-w', admin_password, '-x'],
|
||||
input=ldif_content, capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f'[ERROR] ldapadd failed: {result.stderr}')
|
||||
return False, '', f'Failed to create user: {result.stderr}'
|
||||
|
||||
print(f'[SUCCESS] User {username} created in lldap')
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, '', 'LDAP operation timed out'
|
||||
except Exception as e:
|
||||
return False, '', f'Failed to create user: {str(e)}'
|
||||
|
||||
# Step 2: Set password with ldappasswd
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['ldappasswd', '-H', LDAP_HOST, '-D', admin_dn, '-w', admin_password, '-s', password, user_dn],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f'[ERROR] ldappasswd failed: {result.stderr}')
|
||||
# Cleanup: delete user entry
|
||||
subprocess.run(['ldapdelete', '-H', LDAP_HOST, '-D', admin_dn, '-w', admin_password, user_dn],
|
||||
capture_output=True, timeout=10)
|
||||
return False, '', f'Failed to set password: {result.stderr}'
|
||||
|
||||
print(f'[SUCCESS] Password set for user {username}')
|
||||
return True, password, ''
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, '', 'LDAP operation timed out'
|
||||
except Exception as e:
|
||||
return False, '', f'Failed to set password: {str(e)}'
|
||||
|
||||
except Exception as e:
|
||||
return False, '', str(e)
|
||||
|
||||
# =============================================================================
|
||||
# LDAP Validation Functions
|
||||
# =============================================================================
|
||||
|
||||
def check_ldap_user_exists(username: str = None, email: str = None) -> tuple[bool, str]:
|
||||
"""
|
||||
Check if username or email already exists in lldap.
|
||||
Returns (exists: bool, error_message: str)
|
||||
"""
|
||||
try:
|
||||
admin_password = open('/secrets-lldap/LDAP_USER_PASS').read().strip()
|
||||
admin_dn = f'uid={LLDAP_ADMIN_USER},ou=people,{LLDAP_BASE_DN}'
|
||||
|
||||
# Check username exists
|
||||
if username:
|
||||
result = subprocess.run(
|
||||
['ldapsearch', '-x', '-LLL', '-H', LDAP_HOST, '-D', admin_dn, '-w', admin_password,
|
||||
'-b', f'ou=people,{LLDAP_BASE_DN}', f'(uid={escape_ldap_dn(username)})', 'dn'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return True, f'Username "{username}" is already taken'
|
||||
|
||||
# Check email exists
|
||||
if email:
|
||||
result = subprocess.run(
|
||||
['ldapsearch', '-x', '-LLL', '-H', LDAP_HOST, '-D', admin_dn, '-w', admin_password,
|
||||
'-b', f'ou=people,{LLDAP_BASE_DN}', f'(mail={escape_ldap_dn(email)})', 'dn'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return True, f'Email "{email}" is already registered'
|
||||
|
||||
return False, ''
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print('[ERROR] LDAP search timed out')
|
||||
return False, '' # Fail open - allow registration if LDAP is slow
|
||||
except Exception as e:
|
||||
print(f'[ERROR] Failed to check LDAP: {str(e)}')
|
||||
return False, '' # Fail open - allow registration if LDAP check fails
|
||||
|
||||
# =============================================================================
|
||||
# Email Notifications
|
||||
# =============================================================================
|
||||
|
||||
def log_email_to_file(to: str, subject: str, body: str):
|
||||
"""Log email to file when SMTP is disabled"""
|
||||
timestamp = datetime.now().isoformat()
|
||||
with open(EMAIL_LOG_FILE, 'a') as f:
|
||||
f.write(f'\n{"="*80}\n')
|
||||
f.write(f'Timestamp: {timestamp}\n')
|
||||
f.write(f'To: {to}\n')
|
||||
f.write(f'Subject: {subject}\n')
|
||||
f.write(f'Body:\n{body}\n')
|
||||
f.write(f'{"="*80}\n')
|
||||
|
||||
def send_email(to: str, subject: str, body: str) -> bool:
|
||||
"""Send email notification via SMTP or log to file"""
|
||||
if not SMTP_ENABLED:
|
||||
log_email_to_file(to, subject, body)
|
||||
print(f'[EMAIL] Logged to {EMAIL_LOG_FILE}: {to} - {subject}')
|
||||
return True
|
||||
|
||||
try:
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = SMTP_FROM
|
||||
msg['To'] = to
|
||||
msg['Subject'] = subject
|
||||
msg.attach(MIMEText(body, 'plain'))
|
||||
|
||||
server = smtplib.SMTP(SMTP_HOST, SMTP_PORT)
|
||||
if SMTP_USE_TLS:
|
||||
server.starttls()
|
||||
|
||||
if SMTP_USER and SMTP_PASSWORD:
|
||||
server.login(SMTP_USER, SMTP_PASSWORD)
|
||||
|
||||
server.send_message(msg)
|
||||
server.quit()
|
||||
|
||||
print(f'[EMAIL] Sent to {to}: {subject}')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f'[ERROR] Failed to send email to {to}: {e}')
|
||||
# Fallback: log to file
|
||||
log_email_to_file(to, subject, body)
|
||||
return False
|
||||
|
||||
def notify_admin_new_request(username: str, email: str, reason: str):
|
||||
"""Send email to admin about new registration request"""
|
||||
subject = f'New registration request: {username}'
|
||||
body = f'''
|
||||
A new user has requested an account:
|
||||
|
||||
Username: {username}
|
||||
Email: {email}
|
||||
Reason: {reason}
|
||||
|
||||
Please review and approve/reject at the admin dashboard.
|
||||
'''
|
||||
send_email(ADMIN_EMAIL, subject, body)
|
||||
|
||||
def notify_user_approved(email: str, username: str, password: str):
|
||||
"""Send email to user with their credentials after approval"""
|
||||
subject = 'Account approved'
|
||||
body = f'''
|
||||
Your account request has been approved!
|
||||
|
||||
Username: {username}
|
||||
Temporary Password: {password}
|
||||
|
||||
Please login and change your password immediately after your first login.
|
||||
For security, this password is randomly generated and should be changed.
|
||||
'''
|
||||
send_email(email, subject, body)
|
||||
|
||||
def notify_user_rejected(email: str, username: str, reason: str):
|
||||
"""Send email to user about rejection"""
|
||||
subject = 'Account request rejected'
|
||||
body = f'''
|
||||
Your account request for username '{username}' has been rejected.
|
||||
|
||||
Reason: {reason}
|
||||
|
||||
If you believe this was an error, please contact the administrator.
|
||||
'''
|
||||
send_email(email, subject, body)
|
||||
|
||||
# =============================================================================
|
||||
# Routes
|
||||
# =============================================================================
|
||||
|
||||
@app.get('/', response_class=HTMLResponse)
|
||||
async def register_form(request: Request, success: Optional[str] = None, error: Optional[str] = None):
|
||||
"""Public registration form"""
|
||||
return templates.TemplateResponse(
|
||||
'register.html',
|
||||
{'request': request, 'success': success, 'error': error}
|
||||
)
|
||||
|
||||
@app.post('/', response_class=HTMLResponse)
|
||||
async def register_submit(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
email: str = Form(...),
|
||||
first_name: str = Form(...),
|
||||
last_name: str = Form(...),
|
||||
reason: str = Form(default='')
|
||||
):
|
||||
"""Handle registration form submission"""
|
||||
username = username.strip().lower()
|
||||
email = email.strip().lower()
|
||||
first_name = first_name.strip()
|
||||
last_name = last_name.strip()
|
||||
reason = reason.strip()
|
||||
|
||||
if not all([username, email, first_name, last_name]):
|
||||
return templates.TemplateResponse(
|
||||
'register.html',
|
||||
{'request': request, 'error': 'All fields except reason are required'}
|
||||
)
|
||||
|
||||
if not validate_username_strict(username):
|
||||
return templates.TemplateResponse(
|
||||
'register.html',
|
||||
{'request': request, 'error': 'Username must be 2-64 characters, start with a letter, and contain only lowercase letters, numbers, and underscores'}
|
||||
)
|
||||
|
||||
if not validate_email_basic(email):
|
||||
return templates.TemplateResponse(
|
||||
'register.html',
|
||||
{'request': request, 'error': 'Invalid email address'}
|
||||
)
|
||||
|
||||
if len(first_name) > 100 or len(last_name) > 100:
|
||||
return templates.TemplateResponse(
|
||||
'register.html',
|
||||
{'request': request, 'error': 'Names must be less than 100 characters'}
|
||||
)
|
||||
|
||||
# Check if username or email already exists in lldap
|
||||
exists, error_msg = check_ldap_user_exists(username=username, email=email)
|
||||
if exists:
|
||||
return templates.TemplateResponse(
|
||||
'register.html',
|
||||
{'request': request, 'error': error_msg}
|
||||
)
|
||||
|
||||
# Check for pending registration and insert in single transaction
|
||||
with get_db() as db:
|
||||
# Check if username or email already has a pending request
|
||||
existing = db.execute(
|
||||
'SELECT username, email FROM registration_requests WHERE username = ? OR email = ?',
|
||||
(username, email)
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
if existing[0] == username:
|
||||
return templates.TemplateResponse(
|
||||
'register.html',
|
||||
{'request': request, 'error': f'Username "{username}" already has a pending registration request'}
|
||||
)
|
||||
else:
|
||||
return templates.TemplateResponse(
|
||||
'register.html',
|
||||
{'request': request, 'error': f'Email "{email}" already has a pending registration request'}
|
||||
)
|
||||
|
||||
# Insert new registration request
|
||||
try:
|
||||
db.execute(
|
||||
'''INSERT INTO registration_requests
|
||||
(username, email, first_name, last_name, reason, ip_address, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)''',
|
||||
(username, email, first_name, last_name, reason,
|
||||
request.client.host, request.headers.get('User-Agent', ''))
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Notify admin
|
||||
if ADMIN_EMAIL:
|
||||
notify_admin_new_request(username, email, reason)
|
||||
|
||||
return RedirectResponse(
|
||||
url='/?success=Registration request submitted! An administrator will review it shortly.',
|
||||
status_code=303
|
||||
)
|
||||
|
||||
except sqlite3.IntegrityError:
|
||||
return templates.TemplateResponse(
|
||||
'register.html',
|
||||
{'request': request, 'error': 'Username already exists or is pending approval'}
|
||||
)
|
||||
|
||||
@app.get('/admin', response_class=HTMLResponse)
|
||||
async def admin_dashboard(request: Request):
|
||||
"""Admin dashboard for reviewing registration requests"""
|
||||
admin_user = request.headers.get('Remote-User', 'unknown')
|
||||
|
||||
with get_db() as db:
|
||||
pending = db.execute(
|
||||
'SELECT * FROM registration_requests ORDER BY created_at DESC'
|
||||
).fetchall()
|
||||
|
||||
audit_log = db.execute(
|
||||
'SELECT * FROM audit_log ORDER BY reviewed_at DESC LIMIT 50'
|
||||
).fetchall()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
'admin.html',
|
||||
{
|
||||
'request': request,
|
||||
'pending': pending,
|
||||
'audit_log': audit_log,
|
||||
'admin_user': admin_user
|
||||
}
|
||||
)
|
||||
|
||||
@app.post('/admin/approve/{request_id}')
|
||||
async def approve_request(request_id: int, request: Request):
|
||||
"""Approve request: create user in lldap, move to audit log"""
|
||||
admin_user = request.headers.get('Remote-User', 'unknown')
|
||||
|
||||
with get_db() as db:
|
||||
req = db.execute('SELECT * FROM registration_requests WHERE id = ?', (request_id,)).fetchone()
|
||||
|
||||
if not req:
|
||||
raise HTTPException(status_code=404, detail='Request not found')
|
||||
|
||||
# Create user in lldap with generated password
|
||||
success, password, error = await create_lldap_user(
|
||||
req['username'],
|
||||
req['email'],
|
||||
req['first_name'],
|
||||
req['last_name']
|
||||
)
|
||||
|
||||
if success:
|
||||
# Move to audit log
|
||||
db.execute(
|
||||
'''INSERT INTO audit_log
|
||||
(username, email, first_name, last_name, reason, action, performed_by,
|
||||
ip_address, user_agent, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'APPROVED', ?, ?, ?, ?)''',
|
||||
(req['username'], req['email'], req['first_name'], req['last_name'],
|
||||
req['reason'], admin_user, req['ip_address'], req['user_agent'], req['created_at'])
|
||||
)
|
||||
|
||||
# Remove from pending queue
|
||||
db.execute('DELETE FROM registration_requests WHERE id = ?', (request_id,))
|
||||
db.commit()
|
||||
|
||||
print(f'[SUCCESS] User {req["username"]} approved and created in lldap by {admin_user}')
|
||||
|
||||
# Notify user
|
||||
notify_user_approved(req['email'], req['username'], password)
|
||||
else:
|
||||
print(f'[ERROR] Failed to create user {req["username"]}: {error}')
|
||||
|
||||
return RedirectResponse(url='/admin', status_code=303)
|
||||
|
||||
@app.post('/admin/reject/{request_id}')
|
||||
async def reject_request(request_id: int, request: Request, reason: str = Form(default='No reason provided')):
|
||||
"""Reject request: move to audit log with reason"""
|
||||
admin_user = request.headers.get('Remote-User', 'unknown')
|
||||
|
||||
with get_db() as db:
|
||||
req = db.execute('SELECT * FROM registration_requests WHERE id = ?', (request_id,)).fetchone()
|
||||
|
||||
if not req:
|
||||
raise HTTPException(status_code=404, detail='Request not found')
|
||||
|
||||
# Move to audit log
|
||||
db.execute(
|
||||
'''INSERT INTO audit_log
|
||||
(username, email, first_name, last_name, reason, action, performed_by,
|
||||
rejection_reason, ip_address, user_agent, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'REJECTED', ?, ?, ?, ?, ?)''',
|
||||
(req['username'], req['email'], req['first_name'], req['last_name'],
|
||||
req['reason'], admin_user, reason, req['ip_address'], req['user_agent'], req['created_at'])
|
||||
)
|
||||
|
||||
# Remove from pending queue
|
||||
db.execute('DELETE FROM registration_requests WHERE id = ?', (request_id,))
|
||||
db.commit()
|
||||
|
||||
print(f'[INFO] User {req["username"]} rejected by {admin_user}: {reason}')
|
||||
|
||||
# Notify user
|
||||
notify_user_rejected(req['email'], req['username'], reason)
|
||||
|
||||
return RedirectResponse(url='/admin', status_code=303)
|
||||
|
||||
@app.get('/health')
|
||||
async def health():
|
||||
"""Health check endpoint"""
|
||||
return {'status': 'healthy'}
|
||||
4
registration/requirements.txt
Normal file
4
registration/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn==0.27.0
|
||||
python-multipart==0.0.6
|
||||
jinja2==3.1.3
|
||||
109
registration/templates/admin.html
Normal file
109
registration/templates/admin.html
Normal file
@@ -0,0 +1,109 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Dashboard - User Registration{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="header">
|
||||
<h1>Registration Requests</h1>
|
||||
<div class="user-info">Logged in as: <strong>{{ admin_user }}</strong></div>
|
||||
</div>
|
||||
|
||||
<p class="info-text">
|
||||
<strong>Note:</strong> lldap is the single source of truth for user management.
|
||||
Approve requests to create users in lldap. Manage active users directly in lldap.
|
||||
</p>
|
||||
|
||||
<h2>Pending Requests ({{ pending|length }})</h2>
|
||||
{% if pending %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Reason</th>
|
||||
<th>Submitted</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for req in pending %}
|
||||
<tr>
|
||||
<td><strong>{{ req.username }}</strong></td>
|
||||
<td>{{ req.first_name }} {{ req.last_name }}</td>
|
||||
<td>{{ req.email }}</td>
|
||||
<td>{{ req.reason or '-' }}</td>
|
||||
<td>{{ req.created_at[:19] }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/approve/{{ req.id }}" style="display: inline; margin-right: 0.5rem;">
|
||||
<button type="submit" class="btn-success"
|
||||
onclick="return confirm('Approve this user and create account in lldap?')">
|
||||
Approve
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/reject/{{ req.id }}" style="display: inline;">
|
||||
<input type="text" name="reason" placeholder="Rejection reason (optional)"
|
||||
style="width: 200px; margin-right: 0.5rem;">
|
||||
<button type="submit" class="btn-danger"
|
||||
onclick="return confirm('Reject this request?')">
|
||||
Reject
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">No pending requests</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>Audit Log ({{ audit_log|length }} recent)</h2>
|
||||
<p class="info-text" style="font-size: 0.9rem; color: #666;">
|
||||
Historical record of all approval and rejection decisions.
|
||||
To manage active users, use the lldap admin interface.
|
||||
</p>
|
||||
|
||||
{% if audit_log %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Action</th>
|
||||
<th>Username</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Reviewed By</th>
|
||||
<th>Date</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in audit_log %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if entry.action == 'APPROVED' %}
|
||||
<span class="badge badge-success">Approved</span>
|
||||
{% else %}
|
||||
<span class="badge badge-danger">Rejected</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><strong>{{ entry.username }}</strong></td>
|
||||
<td>{{ entry.first_name }} {{ entry.last_name }}</td>
|
||||
<td>{{ entry.email }}</td>
|
||||
<td>{{ entry.performed_by }}</td>
|
||||
<td>{{ entry.reviewed_at[:19] }}</td>
|
||||
<td>
|
||||
{% if entry.action == 'REJECTED' %}
|
||||
{{ entry.rejection_reason or '-' }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">No audit log entries yet</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
251
registration/templates/base.html
Normal file
251
registration/templates/base.html
Normal file
@@ -0,0 +1,251 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}User Registration{% endblock %}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--text-primary: #212529;
|
||||
--text-secondary: #6c757d;
|
||||
--border-color: #dee2e6;
|
||||
--primary-color: #0d6efd;
|
||||
--primary-hover: #0b5ed7;
|
||||
--success-color: #198754;
|
||||
--success-hover: #157347;
|
||||
--danger-color: #dc3545;
|
||||
--danger-hover: #bb2d3b;
|
||||
--info-bg: #cfe2ff;
|
||||
--success-bg: #d1e7dd;
|
||||
--danger-bg: #f8d7da;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
margin: 2rem 0 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
button, .btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: var(--success-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--danger-hover);
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: var(--success-bg);
|
||||
color: #0f5132;
|
||||
border: 1px solid #badbcc;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: var(--danger-bg);
|
||||
color: #842029;
|
||||
border: 1px solid #f5c2c7;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
background: #f8f9fa;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #d1e7dd;
|
||||
color: #0f5132;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: #f8d7da;
|
||||
color: #842029;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.info-text {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: #e7f3ff;
|
||||
border-left: 4px solid var(--primary-color);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% block extra_style %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
57
registration/templates/register.html
Normal file
57
registration/templates/register.html
Normal file
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Register - User Registration{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Request Account</h1>
|
||||
|
||||
{% if success %}
|
||||
<div class="alert alert-success">{{ success }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="alert alert-info" style="background: var(--info-bg); color: #084298; border: 1px solid #b6d4fe; margin-bottom: 1.5rem;">
|
||||
<strong>Note:</strong> Your password will be automatically generated and sent to you via email upon approval.
|
||||
You can change it after your first login.
|
||||
</div>
|
||||
|
||||
<div style="max-width: 600px;">
|
||||
<form method="post">
|
||||
<div class="form-group">
|
||||
<label for="username">Username <span class="required">*</span></label>
|
||||
<input type="text" id="username" name="username" required pattern="[a-z0-9_]+"
|
||||
title="Only lowercase letters, numbers, and underscores allowed"
|
||||
style="text-transform: lowercase;"
|
||||
oninput="this.value = this.value.toLowerCase()">
|
||||
<div class="help-text">Only lowercase letters, numbers, and underscores.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email <span class="required">*</span></label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
<div class="help-text">Your temporary password will be sent to this address upon approval.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="first_name">First Name <span class="required">*</span></label>
|
||||
<input type="text" id="first_name" name="first_name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="last_name">Last Name <span class="required">*</span></label>
|
||||
<input type="text" id="last_name" name="last_name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="reason">Reason for Access (Optional)</label>
|
||||
<textarea id="reason" name="reason" placeholder="Why do you need an account?"></textarea>
|
||||
<div class="help-text">Optional: Help administrators understand your request.</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">Submit Request</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user