commit 2866bff217ebc15245c84e0d757cddfedc1ba906 Author: Stefano Manfredi <56640837+stemanfredi@users.noreply.github.com> Date: Mon Dec 1 14:58:40 2025 +0000 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e29bb03 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..573b7ad --- /dev/null +++ b/.gitignore @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..4541cfa --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,1105 @@ +# Architecture Deep-Dive + +This document provides a detailed technical explanation of how the Organization Stack components interact, including protocol details, authentication flows, and design decisions. + +## Table of Contents + +- [System Architecture](#system-architecture) +- [Authentication Protocols](#authentication-protocols) + - [LDAP Integration](#ldap-integration) + - [OIDC Flow](#oidc-flow) + - [Forward-Auth Pattern](#forward-auth-pattern) +- [Component Details](#component-details) +- [Security Model](#security-model) +- [Network Architecture](#network-architecture) +- [Data Flow Examples](#data-flow-examples) +- [Design Decisions](#design-decisions) + +## System Architecture + +``` +┌────────────────────────────────────────────────────────────────────────────────────────┐ +│ Internet / Users │ +│ │ +│ https://git.example.com https://wiki.example.com https://register.example.com │ +└──────────────────────────────────┬─────────────────────────────────────────────────────┘ + │ + │ HTTPS (TLS 1.2+) + ▼ + ┌─────────────────────┐ + │ Caddy │ + │ Reverse Proxy │ + │ - TLS Termination │ + │ - Forward-Auth │ + │ - HTTP Routing │ + └──────────┬──────────┘ + │ + ┌─────────────────────────┼──────────────────────────┬──────────────────────┐ + │ │ │ │ + │ OIDC │ Forward-Auth │ Forward-Auth │ Public + Forward-Auth + ▼ ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Gitea │ │ JSPWiki │ │ lldap (Web) │ │ Registration │ +│ │ │ │ │ │ │ │ +│ Port: 3000 │ │ Port: 8080 │ │ Port: 17170 │ │ Port: 5000 │ +│ │ │ │ │ │ │ │ +│ Auth: OIDC │ │ Auth: Remote- │ │ Auth: Remote- │ │ / - Public │ +│ (OAuth2) │ │ User Header │ │ User Header │ │ /admin - Auth │ +└────────┬────────┘ └────────┬────────┘ └─────────────────┘ └────────┬────────┘ + │ │ │ + │ OIDC Protocol │ LDAP Query LDAP API│ + │ │ (User Sync) (Create User) + ▼ ▼ │ +┌────────────────────────────────────────────┐ │ +│ Authelia │ │ +│ │ │ +│ - OIDC Provider (/.well-known/...) │ │ +│ - Forward-Auth Endpoint (/api/authz/...) │ │ +│ - Session Management │ │ +│ - 2FA/TOTP Validation │ │ +│ - Access Control Policies │ │ +│ │ │ +│ Port: 9091 │ │ +└──────────────────┬─────────────────────────┘ │ + │ │ + │ LDAP Protocol │ + │ (Credential Validation) │ + ▼ ▼ + ┌──────────────────────┐ ◄─────────────────────────────────────────────────┘ + │ lldap │ + │ │ + │ User Database │ + │ - Users │ + │ - Groups │ + │ - Credentials │ + │ │ + │ LDAP: 3890 │ + │ HTTP: 17170 │ + └──────────────────────┘ +``` + +## Authentication Protocols + +### LDAP Integration + +**What is LDAP?** + +LDAP (Lightweight Directory Access Protocol) is a protocol for accessing and maintaining distributed directory information services. Think of it as a specialized database optimized for read-heavy operations on hierarchical data structures. + +**How we use it:** + +``` +lldap Storage Structure: +dc=example,dc=com (Base DN) +├── ou=people (Users) +│ ├── uid=alice (User Entry) +│ │ ├── cn: Alice Smith +│ │ ├── mail: alice@example.com +│ │ ├── userPassword: {SSHA}... +│ │ └── objectClass: person +│ └── uid=bob +│ ├── cn: Bob Jones +│ └── ... +└── ou=groups (Groups) + └── cn=lldap_admin (Admin Group) + ├── member: uid=alice,ou=people,dc=example,dc=com + └── objectClass: groupOfUniqueNames +``` + +**Authentication Flow:** + +1. Authelia connects to lldap using bind credentials: + ``` + BIND uid=admin,ou=people,dc=example,dc=com + PASSWORD: + ``` + +2. User attempts login with username "alice" + +3. Authelia performs LDAP search: + ```ldap + SEARCH base_dn: ou=people,dc=example,dc=com + FILTER: (&(uid=alice)(objectClass=person)) + ``` + +4. Authelia validates password by attempting bind as user: + ``` + BIND uid=alice,ou=people,dc=example,dc=com + PASSWORD: + ``` + +5. If successful, Authelia queries groups: + ```ldap + SEARCH base_dn: ou=groups,dc=example,dc=com + FILTER: (member=uid=alice,ou=people,dc=example,dc=com) + ``` + +**References:** +- [RFC 4511 - LDAP Protocol](https://tools.ietf.org/html/rfc4511) +- [lldap Documentation](https://github.com/lldap/lldap) + +### OIDC Flow + +**What is OpenID Connect?** + +OIDC is an identity layer built on top of OAuth 2.0. It allows clients to verify user identity and obtain basic profile information in an interoperable REST-like manner. + +**Components:** +- **Provider (OP)**: Authelia - issues tokens, manages authentication +- **Relying Party (RP)**: Gitea - trusts tokens from provider + +**Authorization Code Flow (Detailed):** + +``` +User Gitea Authelia lldap + │ │ │ │ + │ 1. GET /login │ │ │ + ├──────────────────────►│ │ │ + │ │ │ │ + │ │ 2. Redirect to Authelia │ │ + │ │ /authorize? │ │ + │ │ client_id=gitea │ │ + │ │ &redirect_uri=... │ │ + │ │ &scope=openid... │ │ + │◄──────────────────────┤ │ │ + │ │ │ │ + │ 3. GET /authorize │ │ │ + ├───────────────────────┴─────────────────────────► │ + │ │ │ + │ 4. Login form (if not authenticated) │ │ + │◄────────────────────────────────────────────────┤ │ + │ │ │ + │ 5. POST credentials │ │ + ├─────────────────────────────────────────────────► │ + │ │ │ + │ │ 6. Validate password │ + │ ├─────────────────────►│ + │ │ LDAP BIND │ + │ │◄─────────────────────┤ + │ │ SUCCESS │ + │ │ │ + │ 7. TOTP prompt (if 2FA enabled) │ │ + │◄────────────────────────────────────────────────┤ │ + │ │ │ + │ 8. POST TOTP code │ │ + ├─────────────────────────────────────────────────► │ + │ │ │ + │ 9. Redirect to Gitea with code │ │ + │ /callback?code=abc123 │ │ + │◄────────────────────────────────────────────────┤ │ + │ │ │ │ + │ 10. GET /callback? │ │ │ + │ code=abc123 │ │ │ + ├──────────────────────►│ │ │ + │ │ │ │ + │ │ 11. POST /token │ │ + │ │ code=abc123 │ │ + │ │ client_secret=... │ │ + │ ├─────────────────────────► │ + │ │ │ │ + │ │ 12. Access token + │ │ + │ │ ID token │ │ + │ │◄────────────────────────┤ │ + │ │ │ │ + │ │ 13. GET /userinfo │ │ + │ ├─────────────────────────► │ + │ │ │ │ + │ │ 14. User profile │ │ + │ │◄────────────────────────┤ │ + │ │ │ │ + │ 15. Logged in │ │ │ + │◄──────────────────────┤ │ │ +``` + +**Key Endpoints:** + +Authelia provides these OIDC endpoints: +- `/.well-known/openid-configuration` - Discovery document +- `/api/oidc/authorization` - Authorization endpoint +- `/api/oidc/token` - Token endpoint +- `/api/oidc/userinfo` - User info endpoint +- `/api/oidc/jwks` - Public keys for token verification + +**Token Format (JWT):** + +```json +{ + "header": { + "alg": "RS256", + "kid": "key-id" + }, + "payload": { + "iss": "https://auth.example.com", + "sub": "alice", + "aud": "gitea", + "exp": 1234567890, + "iat": 1234564290, + "email": "alice@example.com", + "preferred_username": "alice", + "groups": ["lldap_admin"] + }, + "signature": "..." +} +``` + +**References:** +- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) +- [OAuth 2.0 Authorization Code Flow](https://tools.ietf.org/html/rfc6749#section-4.1) +- [Authelia OIDC Documentation](https://www.authelia.com/integration/openid-connect/introduction/) + +### Forward-Auth Pattern + +**What is Forward-Auth?** + +Forward-Auth is a proxy-level authentication pattern where a reverse proxy delegates authentication decisions to an external service before forwarding requests to backends. + +**How it works:** + +``` +User Caddy Authelia JSPWiki + │ │ │ │ + │ 1. GET /wiki/Main │ │ │ + ├────────────────────►│ │ │ + │ │ │ │ + │ │ 2. Sub-request to │ │ + │ │ /api/authz/ │ │ + │ │ forward-auth │ │ + │ ├────────────────────────► │ + │ │ (with all cookies) │ │ + │ │ │ │ + │ │ 3a. If NOT authenticated │ + │ │ 401 + redirect URL │ │ + │ │◄───────────────────────┤ │ + │ │ │ │ + │ 4. Redirect to │ │ │ + │ Authelia login │ │ │ + │◄────────────────────┤ │ │ + │ │ │ │ + │ [User logs in with │ │ │ + │ credentials + 2FA] │ │ │ + │ │ │ │ + │ 5. Redirect back │ │ │ + │ to /wiki/Main │ │ │ + ├────────────────────►│ │ │ + │ │ │ │ + │ │ 6. Sub-request to │ │ + │ │ /api/authz/ │ │ + │ │ forward-auth │ │ + │ ├────────────────────────► │ + │ │ (with session │ │ + │ │ cookie) │ │ + │ │ │ │ + │ │ 7. 200 OK + │ │ + │ │ Remote-User: alice │ │ + │ │ Remote-Groups: ... │ │ + │ │◄───────────────────────┤ │ + │ │ │ │ + │ │ 8. Forward to JSPWiki │ │ + │ │ with Remote-User │ │ + │ │ header │ │ + │ ├──────────────────────────────────────────────►│ + │ │ │ │ + │ │ 9. Response │ │ + │ │◄──────────────────────────────────────────────┤ + │ │ │ │ + │ 10. Page delivered │ │ │ + │◄────────────────────┤ │ │ +``` + +**Caddyfile Configuration:** + +```caddyfile +# Reusable forward-auth snippet +(auth) { + forward_auth authelia:9091 { + uri /api/authz/forward-auth + copy_headers Remote-User Remote-Groups Remote-Email Remote-Name + } +} + +# Wiki with authentication +wiki.example.com { + import auth + reverse_proxy jspwiki:8080 +} +``` + +**Headers Passed:** + +When authentication succeeds, Authelia returns these headers: +- `Remote-User`: Username (e.g., "alice") +- `Remote-Groups`: Comma-separated groups (e.g., "lldap_admin,developers") +- `Remote-Email`: User's email +- `Remote-Name`: Display name + +**JSPWiki Container Authentication:** + +JSPWiki is configured to trust the `Remote-User` header via our custom `RemoteUserFilter`: + +```java +// RemoteUserFilter.java +public class RemoteUserFilter implements Filter { + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { + HttpServletRequest httpRequest = (HttpServletRequest) request; + String remoteUser = httpRequest.getHeader("Remote-User"); + + if (remoteUser != null && !remoteUser.isEmpty()) { + // Wrap request to return Remote-User as getRemoteUser() + HttpServletRequestWrapper wrapper = new HttpServletRequestWrapper(httpRequest) { + @Override + public String getRemoteUser() { + return remoteUser; + } + @Override + public Principal getUserPrincipal() { + return new Principal() { + public String getName() { return remoteUser; } + }; + } + }; + chain.doFilter(wrapper, response); + } else { + chain.doFilter(request, response); + } + } +} +``` + +**Security Considerations:** + +⚠️ **Critical**: The backend MUST NOT be accessible except through the proxy. If an attacker can reach JSPWiki directly, they can forge the `Remote-User` header. + +Our setup ensures this by: +1. All services on internal Docker network +2. Only Caddy exposes external ports +3. JSPWiki port 8080 not exposed to host + +**References:** +- [Authelia Forward-Auth Docs](https://www.authelia.com/integration/proxies/fowarded-headers/) +- [Caddy forward_auth](https://caddyserver.com/docs/caddyfile/directives/forward_auth) + +## Component Details + +### lldap - LDAP Directory + +**Purpose**: Central user and group database + +**Why LDAP?** +- Industry standard protocol +- Supported by virtually all enterprise software +- Hierarchical data model perfect for organizational structure +- Read-optimized for authentication use cases + +**Why lldap specifically?** +- Lightweight alternative to heavyweight solutions (OpenLDAP, Active Directory) +- Simple web UI for user management +- SQLite backend (no PostgreSQL/MySQL dependency) +- Built in Rust for memory safety + +**Schema:** +``` +objectClass: person +├── uid (username) +├── cn (common name / display name) +├── mail (email address) +├── userPassword (hashed password) +└── ... (standard LDAP attributes) + +objectClass: groupOfUniqueNames +├── cn (group name) +└── member (DN of member users) +``` + +**Ports:** +- 3890: LDAP protocol +- 17170: Web admin interface + +### Authelia - SSO Server + +**Purpose**: Central authentication authority, session management, 2FA enforcement + +**Why Authelia?** +- Supports multiple auth methods (LDAP, file, etc.) +- Built-in TOTP/2FA support +- Both OIDC provider AND forward-auth endpoint +- Flexible access control policies +- Session management with Redis or SQLite + +**Key Features:** +1. **Multi-factor Authentication**: TOTP (RFC 6238), WebAuthn planned +2. **Single Sign-On**: One login session across all services +3. **Access Control**: Domain-based policies (bypass, one_factor, two_factor) +4. **Session Management**: Configurable lifespans, remember-me +5. **Rate Limiting**: Brute-force protection + +**Configuration Structure:** +```yaml +authentication_backend: # How to validate credentials + ldap: # Use LDAP (lldap) + address: ldap://lldap:3890 + base_dn: dc=example,dc=com + +access_control: # Who can access what + default_policy: deny # Deny by default + rules: # Explicit allow rules + - domain: wiki.example.com + policy: two_factor # Require 2FA + +identity_providers: # What protocols to provide + oidc: # OIDC provider config + clients: + - client_id: gitea + authorization_policy: two_factor +``` + +**Storage:** +- SQLite database in `/data/db.sqlite3` +- Contains: sessions, 2FA registrations, access logs + +### Gitea - Git Hosting + +**Purpose**: Self-hosted Git repository platform + +**Authentication Method**: OIDC (OAuth 2.0 + OpenID Connect) + +**Why OIDC for Gitea?** +- Gitea has native OIDC support +- Allows full SSO experience (no double-login) +- Automatic user provisioning from OIDC claims +- Group synchronization via OIDC groups claim + +**Configuration:** +```ini +[auth] +ENABLE_OAUTH2 = true + +[openid] +ENABLE_OPENID_SIGNIN = true +ENABLE_OPENID_SIGNUP = true +``` + +Configured via web UI to use Authelia as OIDC provider. + +⚠️ **Important**: The authentication source name in Gitea must be exactly `authelia` (lowercase). Gitea constructs the redirect URI using this name: `/user/oauth2/{name}/callback`. Since OAuth2 redirect URIs are case-sensitive, using "Authelia" (capital A) will cause authentication to fail with an `invalid_request` error. + +### JSPWiki - Wiki Platform + +**Purpose**: Collaborative wiki and knowledge base + +**Authentication Method**: Container authentication via forward-auth + +**Why not OIDC for JSPWiki?** +JSPWiki doesn't natively support OIDC, but it does support "container authentication" (trusting the servlet container's authentication). + +**Our Solution:** +1. Caddy enforces authentication via forward-auth +2. Caddy passes `Remote-User` header +3. Our custom `RemoteUserFilter` servlet filter wraps the request +4. JSPWiki sees an authenticated user via standard servlet APIs (`request.getRemoteUser()`) + +**LDAP Synchronization:** + +JSPWiki needs users in its own XML databases for permissions. Our `ldap-sync.sh` script: +1. Queries lldap via `ldapsearch` +2. Generates `userdatabase.xml` with all LDAP users +3. Maps `lldap_admin` group to JSPWiki's `Admin` group +4. Runs on container startup + +**Files:** +- `/var/jspwiki/pages/` - Wiki pages (markdown) +- `/var/jspwiki/etc/userdatabase.xml` - User database (synced from LDAP) +- `/var/jspwiki/etc/groupdatabase.xml` - Group mappings + +### Registration Service - User Self-Provisioning + +**Purpose**: Allow users to request accounts without admin intervention in lldap + +**Architecture**: FastAPI + SQLite + pure LDAP operations + +**Features:** +- Public registration form +- Admin approval workflow +- Audit trail of all decisions +- Automatic user creation in lldap +- Secure random password generation + +**Authentication Methods**: +1. **Public Route** (`/`) - Registration form (no auth required) +2. **Protected Route** (`/admin`) - Admin dashboard (forward-auth via Authelia) + +**Workflow:** + +``` +┌─────────────┐ +│ User │ +│ (Public) │ +└──────┬──────┘ + │ + │ 1. Fill registration form + │ (username, email, name, reason) + ▼ +┌────────────────────┐ +│ Registration │ +│ Service │ +│ (FastAPI) │ +└─────┬──────────────┘ + │ + │ 2. Store in SQLite + │ status='pending' + │ + │ 3. Notify admin (email) + │ + ▼ +┌────────────────────┐ +│ Admin │ +│ (Authenticated) │ +└─────┬──────────────┘ + │ + │ 4. Review at /admin + │ (protected by Authelia) + │ + │ 5. Approve or Reject + │ + ▼ +┌────────────────────┐ +│ Registration │ +│ Service │ +│ (LDAP Client) │ +└─────┬──────────────┘ + │ + │ 6. If approved: + │ - Generate random password + │ - Create user via LDAP + │ + ▼ +┌────────────────────┐ +│ lldap │ +│ (LDAP Server) │ +└─────┬──────────────┘ + │ + │ 7. User created + │ + ▼ +┌────────────────────┐ +│ User │ +│ (Email) │ +└────────────────────┘ + 8. Receive credentials + via email +``` + +**Database Schema:** + +```sql +-- Pending registration requests +CREATE TABLE 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 +CREATE TABLE 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 +); +``` + +Workflow: Request submitted → Pending queue → Approve/Reject → Audit log + +**lldap LDAP Integration:** + +The service creates users in lldap via pure LDAP operations using the admin credentials from `/secrets-lldap/LDAP_USER_PASS`: + +```python +# 1. Create user entry with ldapadd +LDIF = ''' +dn: uid=newuser,ou=people,dc=example,dc=com +objectClass: person +objectClass: inetOrgPerson +uid: newuser +cn: New User +sn: User +givenName: New +mail: user@example.com +''' + +subprocess.run([ + 'ldapadd', + '-H', 'ldap://lldap:3890', + '-D', 'uid=admin,ou=people,dc=example,dc=com', + '-w', '', + '-x' +], input=LDIF) + +# 2. Set password with ldappasswd +subprocess.run([ + 'ldappasswd', + '-H', 'ldap://lldap:3890', + '-D', 'uid=admin,ou=people,dc=example,dc=com', + '-w', '', + '-s', '', + 'uid=newuser,ou=people,dc=example,dc=com' +]) +``` + +**Benefits of Pure LDAP Approach:** +- No HTTP client dependency (removed httpx) +- Standard LDAP tools (`ldapadd`, `ldappasswd`) +- Simpler code and fewer external dependencies +- Works with any LDAP-compliant directory server + +**Configuration** (`.env`): +- `REGISTRATION_SUBDOMAIN=register` +- `REGISTRATION_ADMIN_EMAIL` - For admin notifications +- `SMTP_ENABLED=false` - Enable email notifications + +**Security:** +- **LDAP Injection Prevention**: + - RFC 4514 compliant DN escaping + - Strict username validation (alphanumeric + underscore only) + - Email format validation + - Defense-in-depth: validation at form submission AND before LDAP operations +- **Input Validation**: + - Username: 2-64 chars, must start with letter, lowercase only + - Email: basic format validation + - Names: max 100 characters +- **Authentication**: Admin dashboard protected by Authelia +- **Secrets**: File-based, read-only mounts +- **Password Generation**: Cryptographically secure (20 chars) +- **Audit Trail**: Full logging of all approval/rejection decisions + +**Storage:** +- SQLite database: `/data/registrations.db` +- Backed up with Docker volume `registration_data` + +**Port:** 5000 (internal, not exposed - accessed via Caddy) + +#### User Management + +**Single Source of Truth: lldap** + +The registration service handles the approval workflow only. Once approved, users are created in lldap and managed there exclusively. + +``` +Registration Service → Handles approval workflow + Creates users in lldap when approved + +lldap → Manages all active users + User attributes, groups, credentials +``` + +**Admin Workflow:** + +1. Review pending requests at `/admin` +2. Approve → User created in lldap automatically +3. Reject → Logged to audit trail +4. Manage active users in lldap web interface + +**API Endpoints:** + +- `GET /admin` - View pending requests and audit log +- `POST /admin/approve/{id}` - Create user in lldap, log approval +- `POST /admin/reject/{id}` - Log rejection with reason + +### Caddy - Reverse Proxy + +**Purpose**: TLS termination, routing, authentication enforcement + +**Why Caddy?** +- Automatic HTTPS via Let's Encrypt (or self-signed for testing) +- Simple configuration +- Built-in `forward_auth` directive +- HTTP/2 and HTTP/3 support +- Zero-downtime config reloads + +**Key Functions:** +1. **TLS Termination**: Handles HTTPS, obtains/renews certificates +2. **Routing**: Routes requests to correct backend service +3. **Auth Enforcement**: Forward-auth for wiki and lldap +4. **Header Injection**: Adds `Remote-User` to backend requests + +**Snippet Pattern:** + +We use Caddy's snippet feature for DRY configuration: +```caddyfile +(auth) { + forward_auth authelia:9091 { + uri /api/authz/forward-auth + copy_headers Remote-User Remote-Groups Remote-Email Remote-Name + } +} + +wiki.example.com { + import auth # Reuse auth snippet + reverse_proxy jspwiki:8080 +} +``` + +## Security Model + +### Hardened Network Architecture + +**Exposed Ports (Minimal Attack Surface):** +- `80/443` (HTTP/HTTPS) - Caddy reverse proxy only +- `2222` (SSH) - Git operations only +- **All services**: Zero direct external access + +**Port Matrix:** + +| Service | Internal Port | External Access | Authentication | +|---------|---------------|-----------------|----------------| +| Caddy | 80, 443 | ✅ Public | N/A (proxy) | +| Gitea SSH | 22 → 2222 | ✅ Public | SSH keys | +| Authelia | 9091 | ❌ Via Caddy | None (login service) | +| lldap Web | 17170 | ❌ Via Caddy | Authelia + lldap password | +| lldap LDAP | 3890 | ❌ Internal only | LDAP bind | +| Gitea Web | 3000 | ❌ Via Caddy | OIDC (Authelia) | +| JSPWiki | 8080 | ❌ Via Caddy | Forward-Auth (Authelia) | +| Registration | 5000 | ❌ Via Caddy | Mixed (public form + auth admin) | + +### Defense in Depth + +**Layer 1: Network Isolation** +- All services on internal Docker bridge network (`org-network`) +- Services communicate via Docker DNS hostnames (e.g., `lldap:3890`, `authelia:9091`) +- **Zero direct port exposure** except Caddy (80/443) and Git SSH (2222) +- No localhost or 127.0.0.1 bindings + +**Layer 2: Reverse Proxy (Caddy)** +- Single entry point for all web traffic +- TLS 1.3 with automatic certificate management +- HTTP → HTTPS redirect (except ACME challenges) +- Forward-auth integration with Authelia + +**Layer 3: Authentication (Authelia)** +- Centralized authentication for all services +- LDAP credential validation via lldap +- Session management with secure cookies +- Failed attempt tracking and auto-ban + +**Layer 4: Authorization (Authelia)** +- Domain-based access policies +- Group-based permissions (lldap_admin) +- Admin-only access controls + +**Layer 5: Multi-Factor Authentication** +- TOTP (Time-based One-Time Password) +- Per-user 2FA registration +- Policy-enforced (configurable) + +**Layer 6: Application-Level Security** +- Gitea: OIDC token validation + internal OIDC auth +- JSPWiki: Container auth (trusts Remote-User header) +- lldap: Admin password required after Authelia auth +- Registration: Public form (unauthenticated) + protected admin dashboard + +### Secret Management + +**File-Based Secrets (Docker Best Practice):** + +Instead of environment variables, we use Docker secret files: + +```yaml +# compose.yml +authelia: + environment: + - AUTHELIA_SESSION_SECRET_FILE=/secrets/SESSION_SECRET + volumes: + - ./secrets/authelia:/secrets:ro # Read-only mount +``` + +**Benefits:** +- Not visible in `docker inspect` +- Not visible in process listing (`ps aux`) +- Individual file permissions (600) +- Easier to rotate (replace file, restart container) +- Not logged accidentally + +**Secret Types:** +- **Symmetric Keys**: SESSION_SECRET, STORAGE_ENCRYPTION_KEY (AES-256) +- **HMAC Keys**: OIDC_HMAC_SECRET, JWT_SECRET (SHA-256) +- **Asymmetric Keys**: OIDC_PRIVATE_KEY (RSA-2048) +- **Passwords**: LDAP_USER_PASS (bcrypt/SSHA) + +**Generation:** +```bash +# Strong random secrets +openssl rand -hex 32 + +# RSA key pair +authelia crypto pair rsa generate --bits 2048 +``` + +### Session Security + +**Session Lifecycle:** +1. User authenticates → Authelia creates session +2. Session stored in SQLite with unique ID +3. Cookie sent to user: `authelia_session=` +4. Cookie attributes: + - `HttpOnly`: Not accessible via JavaScript + - `Secure`: Only sent over HTTPS + - `SameSite=Lax`: CSRF protection + - `Domain=.example.com`: Shared across subdomains + +**Session Expiration:** +- `SESSION_EXPIRATION`: Absolute lifetime (e.g., 1h) +- `SESSION_INACTIVITY`: Idle timeout (e.g., 5m) +- `SESSION_REMEMBER_ME`: Extended lifetime if opted in (e.g., 1M) + +**Session Invalidation:** +- Logout: Immediate deletion +- Password change: All sessions invalidated +- Inactivity timeout: Automatic cleanup + +## Network Architecture + +**Docker Network Topology:** + +``` +External Internet + │ + │ :80, :443 + ▼ + ┌───────────┐ + │ Caddy │ (ports published) + └─────┬─────┘ + │ org-network (172.x.0.0/16) + │ + ├─────► lldap:17170 (web UI) + ├─────► lldap:3890 (LDAP) + ├─────► authelia:9091 + ├─────► gitea:3000 + └─────► jspwiki:8080 +``` + +**Service Discovery:** + +Docker's internal DNS resolves service names: +- `lldap` → container IP (e.g., 172.19.0.2) +- `authelia` → container IP (e.g., 172.19.0.3) +- etc. + +**Why this matters:** +- Services use service names in config (e.g., `ldap://lldap:3890`) +- Works in any environment (local, cloud, etc.) +- No hardcoded IPs +- Automatic failover if container restarts + +## Data Flow Examples + +### Example 1: First-Time Login to Wiki + +``` +1. User: Navigate to https://wiki.example.com +2. Browser → Caddy: GET / +3. Caddy → Authelia: Forward-auth subrequest +4. Authelia: No session cookie → 401 Unauthorized +5. Caddy → Browser: 302 Redirect to https://auth.example.com?rd=... +6. Browser → Authelia: GET /login?rd=... +7. Authelia → Browser: Login form +8. User: Enter username + password +9. Browser → Authelia: POST /login (credentials) +10. Authelia → lldap: LDAP BIND (validate password) +11. lldap → Authelia: BIND successful +12. Authelia → Browser: TOTP form (2FA) +13. User: Enter TOTP code from authenticator app +14. Browser → Authelia: POST /2fa (TOTP code) +15. Authelia: Validate TOTP, create session +16. Authelia → Browser: 302 Redirect to wiki + session cookie +17. Browser → Caddy: GET / (with session cookie) +18. Caddy → Authelia: Forward-auth subrequest (with cookie) +19. Authelia: Validate session → 200 OK + Remote-User: alice +20. Caddy → JSPWiki: GET / (with Remote-User header) +21. JSPWiki: Trust Remote-User, render page as alice +22. JSPWiki → Caddy: HTML response +23. Caddy → Browser: HTML response +24. User: Sees wiki page, logged in as alice +``` + +### Example 2: Gitea SSO via OIDC + +``` +1. User: Click "Sign in with OpenID Connect" on Gitea +2. Gitea → Browser: Redirect to Authelia authorize endpoint +3. Browser → Authelia: GET /api/oidc/authorization?client_id=gitea&... +4. Authelia: Check session → Valid (from wiki login earlier) +5. Authelia: Check consent → Not required (trusted client) +6. Authelia → Browser: Redirect to Gitea callback with code +7. Browser → Gitea: GET /callback?code=abc123 +8. Gitea → Authelia: POST /api/oidc/token (exchange code for tokens) +9. Authelia: Validate code, issue access_token + id_token +10. Authelia → Gitea: Tokens (JWT) +11. Gitea: Decode id_token, extract user info +12. Gitea: Create/update user account for alice +13. Gitea: Create Gitea session +14. Gitea → Browser: Set Gitea session cookie, redirect to dashboard +15. User: Logged into Gitea without entering credentials again (SSO!) +``` + +## Design Decisions + +### Why File-Based Secrets? + +**Decision**: Use individual files for secrets instead of environment variables. + +**Rationale:** +- **Security**: Not visible in `docker inspect` or process listing +- **Auditability**: File permissions and access can be monitored +- **Rotation**: Easy to update (replace file + restart) +- **Best Practice**: Recommended by Docker, Kubernetes + +**Trade-off**: Slightly more complex volume mounts, but worth it for security. + +### Why Both OIDC and Forward-Auth? + +**Decision**: Use OIDC for Gitea, forward-auth for JSPWiki/lldap. + +**Rationale:** +- **Native Support**: Gitea has excellent OIDC support, provides better UX +- **SSO Experience**: OIDC gives true SSO, no credential re-entry +- **Compatibility**: JSPWiki doesn't support OIDC, forward-auth is universal +- **Flexibility**: Demonstrates both patterns for learning purposes + +**Trade-off**: Two authentication flows to understand, but appropriate for each service. + +### Why JSPWiki LDAP Sync? + +**Decision**: Synchronize LDAP users to JSPWiki's XML database on startup. + +**Rationale:** +- **Permission Model**: JSPWiki's permission system needs users in its database +- **Group Mapping**: Allows mapping LDAP groups to JSPWiki roles +- **Offline Operation**: JSPWiki can function if LDAP is temporarily down + +**Trade-off**: Users must be synced before they can access wiki, but this happens automatically on container start. + +### Why SQLite for Production? + +**Decision**: Use SQLite for Gitea and Authelia databases (optional PostgreSQL upgrade path). + +**Rationale:** +- **Simplicity**: No external database to manage +- **Sufficient for Small Orgs**: Handles thousands of users easily +- **Backup Simplicity**: Single file to backup +- **Zero Config**: Works out of the box + +**Trade-off**: +- For large installations (>10k repos, >100 concurrent users), PostgreSQL recommended +- Easy migration path available if needed + +### Why Automatic LDAP_BASE_DN Derivation? + +**Decision**: Auto-generate `dc=example,dc=com` from `BASE_DOMAIN=example.com`. + +**Rationale:** +- **DRY**: Don't repeat yourself - one config value +- **Fewer Errors**: Less to configure = fewer mistakes +- **Convention**: Standard LDAP DN format from domain + +**Trade-off**: Less flexibility if you need non-standard DN, but can override with manual setting. + +## Performance Considerations + +### Bottlenecks + +1. **Authelia LDAP Lookups**: Each login queries LDAP + - *Mitigation*: Authelia caches results per session + - *Scale*: LDAP is read-optimized, handles thousands of auth/sec + +2. **OIDC Token Validation**: Gitea validates tokens on each request + - *Mitigation*: JWTs are stateless, validation is local + - *Scale*: CPU-bound, scales horizontally + +3. **SQLite Lock Contention**: Multiple writers can cause locking + - *Mitigation*: WAL mode enabled by default + - *Scale*: If bottleneck appears, migrate to PostgreSQL + +### Optimization Recommendations + +**For Heavy Load:** +1. Deploy Redis for Authelia session storage (instead of SQLite) +2. Use PostgreSQL for Gitea database +3. Enable HTTP/3 (QUIC) in Caddy +4. Increase Authelia session cache size +5. Deploy multiple Authelia instances behind load balancer + +**For Low Latency:** +1. Enable HTTP/2 server push in Caddy +2. Tune session expiration for use case +3. Use CDN for static assets (Gitea avatars, etc.) + +## Monitoring and Observability + +**Logs:** +All containers log to stdout/stderr, accessible via: +```bash +docker compose logs -f [service] +``` + +**Metrics:** +- Authelia exposes Prometheus metrics on `/metrics` +- Caddy exposes metrics via admin API +- Gitea has built-in metrics endpoint + +**Recommended Monitoring:** +1. Container health checks (Authelia provides `/api/health`) +2. Certificate expiration monitoring (Caddy auto-renews, but monitor anyway) +3. Failed login attempts (Authelia logs) +4. Session counts (Authelia database) +5. Repository count/size (Gitea admin panel) + +## Disaster Recovery + +**Backup Strategy:** + +**Critical Data:** +1. `lldap_data` - User database (highest priority) +2. `gitea_data` - All repositories +3. `jspwiki_data` - Wiki content +4. `authelia_data` - Sessions, 2FA registrations +5. `caddy_data` - TLS certificates +6. `secrets/` directory - All secrets + +**Recovery Procedure:** +1. Restore `secrets/` directory +2. Restore Docker volumes from backup +3. Deploy stack: `./deploy.sh` +4. Verify all services start +5. Test authentication flow + +**RPO (Recovery Point Objective)**: How much data loss is acceptable? +- Recommended: Daily backups of volumes +- Critical orgs: Hourly backups + off-site replication + +**RTO (Recovery Time Objective)**: How quickly to recover? +- With backups: ~15 minutes +- From scratch (users recreate accounts): Several hours + +## Conclusion + +This architecture demonstrates modern authentication and authorization patterns using open-source components. The system is designed for: +- **Security**: Defense in depth, modern protocols +- **Usability**: Single sign-on, familiar UX +- **Maintainability**: Simple deployment, clear structure +- **Scalability**: Can grow from 10 to 10,000 users with appropriate tuning + +For questions or improvements, see the main [README.md](./README.md) or open an issue on GitHub. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1a74c13 --- /dev/null +++ b/CLAUDE.md @@ -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! 🚀 diff --git a/Caddyfile.production.template b/Caddyfile.production.template new file mode 100644 index 0000000..ced715f --- /dev/null +++ b/Caddyfile.production.template @@ -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 + } +} diff --git a/Caddyfile.test.template b/Caddyfile.test.template new file mode 100644 index 0000000..9920730 --- /dev/null +++ b/Caddyfile.test.template @@ -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 + } +} diff --git a/MULTI_ADMIN_SETUP.md b/MULTI_ADMIN_SETUP.md new file mode 100644 index 0000000..1364a3f --- /dev/null +++ b/MULTI_ADMIN_SETUP.md @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f450e2d --- /dev/null +++ b/README.md @@ -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) diff --git a/SMTP_SETUP.md b/SMTP_SETUP.md new file mode 100644 index 0000000..407fbcb --- /dev/null +++ b/SMTP_SETUP.md @@ -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) diff --git a/authelia/configuration.yml.filesystem.template b/authelia/configuration.yml.filesystem.template new file mode 100644 index 0000000..7129582 --- /dev/null +++ b/authelia/configuration.yml.filesystem.template @@ -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 diff --git a/authelia/configuration.yml.smtp.template b/authelia/configuration.yml.smtp.template new file mode 100644 index 0000000..cdf0530 --- /dev/null +++ b/authelia/configuration.yml.smtp.template @@ -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 diff --git a/authelia/configuration.yml.template b/authelia/configuration.yml.template new file mode 100644 index 0000000..c0a422e --- /dev/null +++ b/authelia/configuration.yml.template @@ -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 diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..edb8c35 --- /dev/null +++ b/compose.yml @@ -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 diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..765df4e --- /dev/null +++ b/deploy.sh @@ -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 diff --git a/jspwiki-custom.properties b/jspwiki-custom.properties new file mode 100644 index 0000000..5c2f8f0 --- /dev/null +++ b/jspwiki-custom.properties @@ -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 diff --git a/jspwiki.policy b/jspwiki.policy new file mode 100644 index 0000000..f9b8ac0 --- /dev/null +++ b/jspwiki.policy @@ -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 "*"; +}; diff --git a/jspwiki/Dockerfile b/jspwiki/Dockerfile new file mode 100644 index 0000000..29df083 --- /dev/null +++ b/jspwiki/Dockerfile @@ -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"] diff --git a/jspwiki/RemoteUserFilter.java b/jspwiki/RemoteUserFilter.java new file mode 100644 index 0000000..6a5bb5e --- /dev/null +++ b/jspwiki/RemoteUserFilter.java @@ -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 + } +} diff --git a/jspwiki/configure-web-xml.sh b/jspwiki/configure-web-xml.sh new file mode 100644 index 0000000..33e7507 --- /dev/null +++ b/jspwiki/configure-web-xml.sh @@ -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="" + +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 opening tag completes +# Find the line with version="5.0"> which closes the web-app opening tag +sed -i '/version="5.0">/a\ +\ + \ + \ + \ + RemoteUserFilter\ + RemoteUserFilter\ + \ + \ + RemoteUserFilter\ + /*\ + ' "$WEB_XML" + +echo "✓ RemoteUserFilter configured in web.xml" >&2 diff --git a/jspwiki/entrypoint.sh b/jspwiki/entrypoint.sh new file mode 100644 index 0000000..3c0acad --- /dev/null +++ b/jspwiki/entrypoint.sh @@ -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 diff --git a/jspwiki/ldap-sync.sh b/jspwiki/ldap-sync.sh new file mode 100644 index 0000000..47f4c0b --- /dev/null +++ b/jspwiki/ldap-sync.sh @@ -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' + + +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 " "; + } + 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 " "; + } +} +' >> "$JSPWIKI_USERS" + +# Close userdatabase.xml +cat >> "$JSPWIKI_USERS" << 'EOF_FOOTER' + +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' + + +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 " "; + print members; + print " "; + } + 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 " \n"; + } +} +END { + # Save last group if it was lldap_admin + if (cn == "lldap_admin" && members) { + print " "; + print members; + print " "; + } +} +' >> "$JSPWIKI_GROUPS" + +# Close groupdatabase.xml +cat >> "$JSPWIKI_GROUPS" << 'EOF_FOOTER' + +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 diff --git a/manage.sh b/manage.sh new file mode 100644 index 0000000..b84fe14 --- /dev/null +++ b/manage.sh @@ -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 diff --git a/registration/Dockerfile b/registration/Dockerfile new file mode 100644 index 0000000..15c9c8e --- /dev/null +++ b/registration/Dockerfile @@ -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"] diff --git a/registration/app.py b/registration/app.py new file mode 100644 index 0000000..2a10504 --- /dev/null +++ b/registration/app.py @@ -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'} diff --git a/registration/requirements.txt b/registration/requirements.txt new file mode 100644 index 0000000..d941c8f --- /dev/null +++ b/registration/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.109.0 +uvicorn==0.27.0 +python-multipart==0.0.6 +jinja2==3.1.3 diff --git a/registration/templates/admin.html b/registration/templates/admin.html new file mode 100644 index 0000000..8ad6fa5 --- /dev/null +++ b/registration/templates/admin.html @@ -0,0 +1,109 @@ +{% extends "base.html" %} + +{% block title %}Admin Dashboard - User Registration{% endblock %} + +{% block content %} +
+

Registration Requests

+ +
+ +

+ Note: lldap is the single source of truth for user management. + Approve requests to create users in lldap. Manage active users directly in lldap. +

+ +

Pending Requests ({{ pending|length }})

+{% if pending %} + + + + + + + + + + + + + {% for req in pending %} + + + + + + + + + {% endfor %} + +
UsernameNameEmailReasonSubmittedActions
{{ req.username }}{{ req.first_name }} {{ req.last_name }}{{ req.email }}{{ req.reason or '-' }}{{ req.created_at[:19] }} +
+ +
+
+ + +
+
+{% else %} +
No pending requests
+{% endif %} + +

Audit Log ({{ audit_log|length }} recent)

+

+ Historical record of all approval and rejection decisions. + To manage active users, use the lldap admin interface. +

+ +{% if audit_log %} + + + + + + + + + + + + + + {% for entry in audit_log %} + + + + + + + + + + {% endfor %} + +
ActionUsernameNameEmailReviewed ByDateReason
+ {% if entry.action == 'APPROVED' %} + Approved + {% else %} + Rejected + {% endif %} + {{ entry.username }}{{ entry.first_name }} {{ entry.last_name }}{{ entry.email }}{{ entry.performed_by }}{{ entry.reviewed_at[:19] }} + {% if entry.action == 'REJECTED' %} + {{ entry.rejection_reason or '-' }} + {% else %} + - + {% endif %} +
+{% else %} +
No audit log entries yet
+{% endif %} +{% endblock %} diff --git a/registration/templates/base.html b/registration/templates/base.html new file mode 100644 index 0000000..e324fba --- /dev/null +++ b/registration/templates/base.html @@ -0,0 +1,251 @@ + + + + + + {% block title %}User Registration{% endblock %} + + {% block extra_style %}{% endblock %} + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/registration/templates/register.html b/registration/templates/register.html new file mode 100644 index 0000000..0b80040 --- /dev/null +++ b/registration/templates/register.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} + +{% block title %}Register - User Registration{% endblock %} + +{% block content %} +

Request Account

+ +{% if success %} +
{{ success }}
+{% endif %} + +{% if error %} +
{{ error }}
+{% endif %} + +
+ Note: Your password will be automatically generated and sent to you via email upon approval. + You can change it after your first login. +
+ +
+
+
+ + +
Only lowercase letters, numbers, and underscores.
+
+ +
+ + +
Your temporary password will be sent to this address upon approval.
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
Optional: Help administrators understand your request.
+
+ + +
+
+{% endblock %}