feat: implement Keycloak SSO integration with dynamic configuration and JIT provisioning
Some checks failed
Build & Push Docker Image to Gitea Registry / build-and-push (push) Failing after 17s

This commit is contained in:
Kroonk
2026-05-21 10:45:22 +02:00
parent d9334dae00
commit 163d8a20f1
4 changed files with 331 additions and 1 deletions

View File

@@ -542,8 +542,15 @@
}
// Login Handling
$.get('/api/auth/sso/config').done(function(config) {
if (config.enabled) {
var $ssoBtn = $('<li id="sso-login-li" style="margin-top: 10px; width: 100%; display: block;"><a href="/api/auth/sso/login" id="sso-login-btn" class="button primary" style="background:#8ee6d2 !important; color:#101316 !important; box-shadow:none !important; border-color:#8ee6d2 !important; font-weight:bold; width: 100%; text-align: center;"><i class="fa fa-key"></i> Login mit Keycloak</a></li>');
$('#login-form .actions').append($ssoBtn);
}
});
$.get('/api/auth/me').done(function(u) {
$('#login-username, #login-password, #login-submit').hide();
$('#login-username, #login-password, #login-submit, #sso-login-li').hide();
$('#logout-btn').show().css('display', 'inline-block');
$('#pw-change-section').show();
loadAvailableFolders();

View File

@@ -6,6 +6,8 @@ const fs = require('fs');
const path = require('path');
const archiver = require('archiver');
const db = require('./database');
const crypto = require('crypto');
const { Issuer } = require('openid-client');
const app = express();
const JWT_SECRET = process.env.JWT_SECRET;
@@ -183,6 +185,116 @@ app.get('/api/auth/me', (req, res) => {
res.json(req.user);
});
// ==================== KEYCLOAK SSO API ====================
let oidcClient = null;
async function getOidcClient() {
if (oidcClient) return oidcClient;
const ssoAuthority = process.env.SSO_AUTHORITY || 'https://auth.mischlabs.de/realms/mischlabs';
const ssoClientId = process.env.SSO_CLIENT_ID || 'pocketbase';
const ssoClientSecret = process.env.SSO_CLIENT_SECRET;
const ssoRedirectUri = process.env.SSO_REDIRECT_URI || 'https://tom.mischlabs.de/api/auth/sso/callback';
if (!ssoClientSecret) {
throw new Error('SSO_CLIENT_SECRET is not configured');
}
const issuer = await Issuer.discover(ssoAuthority);
oidcClient = new issuer.Client({
client_id: ssoClientId,
client_secret: ssoClientSecret,
redirect_uris: [ssoRedirectUri],
response_types: ['code'],
});
return oidcClient;
}
app.get('/api/auth/sso/config', (req, res) => {
res.json({
enabled: !!(process.env.SSO_CLIENT_SECRET && process.env.SSO_CLIENT_ID && process.env.SSO_AUTHORITY),
});
});
app.get('/api/auth/sso/login', async (req, res) => {
try {
const client = await getOidcClient();
const authorizationUrl = client.authorizationUrl({
scope: 'openid email profile',
state: 'mischlabs-state',
});
res.redirect(authorizationUrl);
} catch (err) {
console.error('SSO Login Error:', err);
res.status(500).send('SSO Login Initialisierung fehlgeschlagen: ' + err.message);
}
});
app.get('/api/auth/sso/callback', async (req, res) => {
try {
const client = await getOidcClient();
const params = client.callbackParams(req);
const redirectUri = process.env.SSO_REDIRECT_URI || 'https://tom.mischlabs.de/api/auth/sso/callback';
const tokenSet = await client.callback(redirectUri, params, { state: 'mischlabs-state' });
const userinfo = await client.userinfo(tokenSet.access_token);
const username = userinfo.preferred_username || userinfo.name || userinfo.sub;
const email = userinfo.email;
if (!username) {
return res.status(400).send('Kein Benutzername im OIDC Token gefunden.');
}
db.get('SELECT * FROM users WHERE username = ?', [username], (err, user) => {
if (err) return res.status(500).send('Datenbankfehler');
const handleUserLogin = (dbUser) => {
const token = jwt.sign(
{ id: dbUser.id, username: dbUser.username, role: dbUser.role, created_at: dbUser.created_at },
JWT_SECRET, { expiresIn: '7d' }
);
res.cookie('token', token, { httpOnly: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 });
res.redirect('/admin.html');
};
if (user) {
handleUserLogin(user);
} else {
const randomPassword = crypto.randomBytes(32).toString('hex');
const hash = bcrypt.hashSync(randomPassword, 10);
db.get('SELECT COUNT(*) as count FROM users', [], (countErr, row) => {
let role = 'client';
if ((row && row.count === 0) || username.toLowerCase() === 'mrdiderot') {
role = 'admin';
}
db.run(
'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)',
[username, hash, role],
function (insertErr) {
if (insertErr) {
console.error('Failed JIT provisioning:', insertErr.message);
return res.status(500).send('Fehler bei der Benutzererstellung');
}
const newUserId = this.lastID;
db.get('SELECT * FROM users WHERE id = ?', [newUserId], (getErr, newUser) => {
if (getErr || !newUser) return res.status(500).send('Fehler beim Laden des neuen Benutzers');
handleUserLogin(newUser);
});
}
);
});
}
});
} catch (err) {
console.error('SSO Callback Error:', err);
res.status(500).send('SSO Authentifizierung fehlgeschlagen: ' + err.message);
}
});
app.post('/api/auth/change-password', requireAuth, (req, res) => {
const { old_password, new_password } = req.body;
if (!new_password || new_password.length < 4) {

210
brain.old Normal file
View File

@@ -0,0 +1,210 @@
# Brain.md - Projekt-Wissensdatenbank
## Projektziel
Eine eigene Fotografie-Portfolio-Website basierend auf dem Template von rampatra/photography.
Keine 1:1 Kopie, sondern eine individuell angepasste Version mit eigenen Anspruechen.
## Besitzer & Kontakt
- **Name**: Tom Misch
- **Marke**: Mischkomposition
- **Domain**: tom.mischlabs.de
- **E-Mail**: tom@mischlabs.de
- **Instagram**: @Mischkomposition
- **Gitea Repo**: https://git.mischlabs.de/MrDiderot/Photography.git
- **Lokaler Pfad**: d:\Vibecoding\Website\Photography
---
## Technologie-Stack
| Komponente | Technologie |
|---|---|
| Static Site Generator | **Jekyll** (Ruby-basiert) |
| Backend | **Node.js / Express** (Auth, API, Static Serving) |
| Datenbank | **SQLite** (Nutzerverwaltung, Bildzuweisung) |
| Templating | Liquid Templates |
| Styling | SCSS -> kompiliertes CSS |
| JS Libraries | jQuery, Poptrox (Lightbox), EXIF.js |
| Build-Tool | Gulp (SCSS kompilieren, JS minifizieren, Bilder resizen) |
| Hosting | **UGREEN NAS (Docker)** via Node.js Express |
| Containerisierung | Docker Multi-Stage (Jekyll Build + Node.js Backend) |
| Auto-Update | **Watchtower** (prueft alle 5 Min auf neue Images) |
| Fonts | FontAwesome, Google Fonts (Source Sans Pro) |
| Paketmanager | npm (Node), Bundler (Ruby) |
## Projektstruktur
```
Photography/
├── _config.yml # Zentrale Konfiguration (Titel, Social Links, EXIF etc.)
├── _layouts/
│ └── default.html # Basis-Layout (Head + Body + Scripts)
├── _includes/
│ ├── header.html # <head> Bereich: CSS, Fonts
│ └── footer.html # Script-Einbindungen (jQuery, Poptrox, EXIF, Main)
├── index.html # Hauptseite: Header, Galerie-Grid, Footer/About, Login-Panel
├── admin.html # Admin-Dashboard (Nutzerverwaltung, Bildzuweisung)
├── backend/
│ ├── server.js # Express Backend (Auth, API, Static Files, Port 8090)
│ └── database.js # SQLite Datenbank-Setup (Users, User-Images)
├── assets/
│ ├── css/ # Kompilierte CSS-Dateien (.min.css)
│ ├── sass/ # SCSS Quelldateien
│ │ ├── base/ # Reset, Typography, Page
│ │ ├── components/ # Buttons, Forms, Icons, Panels, Poptrox
│ │ ├── layout/ # Header, Footer, Main, Wrapper
│ │ └── libs/ # Breakpoints, Mixins, Vars, Functions
│ ├── js/ # JavaScript (jQuery, Poptrox, EXIF, Main, Admin)
│ ├── fonts/ # FontAwesome Fonts
│ └── webfonts/ # FA5 Webfonts
├── images/
│ ├── fulls/ # Vollaufloesung Bilder
│ └── thumbs/ # Thumbnails (512px)
├── gulpfile.mjs # Build: SCSS->CSS, JS minify, Image resize
├── package.json # Node Dependencies (Express, SQLite, JWT, bcrypt)
├── Gemfile # Ruby Dependencies (jekyll)
├── Dockerfile # Multi-Stage: Jekyll Build + Node.js Runtime
├── docker-compose.yml # Container + Watchtower
├── entrypoint.sh # Thumbnail-Generierung + Node.js Start
└── nginx.conf # (Legacy, nicht mehr aktiv - Express dient statische Dateien)
```
## Wie die Galerie funktioniert (Dynamisch)
1. **Bilder hochladen**: Fotos in Nextcloud-Ordner `Websitebilder` ablegen
2. **Volume-Mount**: Docker mountet den Nextcloud-Ordner als `/app/public/images/fulls/` (read-only)
3. **Thumbnails**: `entrypoint.sh` generiert automatisch 512px Thumbnails via ImageMagick
4. **Thumbnail-Check**: Alle 60 Sekunden prueft der Container auf neue Bilder
5. **Express API**: `/images/fulls/` liefert JSON-Liste der Bilddateien
6. **JavaScript (main.js)**: Fetcht die JSON-Liste und baut dynamisch `<article class="thumb">` Elemente
7. **Poptrox-Lightbox**: Klick oeffnet Vollbild + EXIF-Daten (clientseitig via exif.js)
8. **Download-Button**: In der Lightbox kann jedes Bild heruntergeladen werden
### Zugriffslogik
- **Nicht eingeloggt**: Nur Root-Bilder (direkt in Websitebilder/) sichtbar
- **Admin**: Alle Bilder (Root + alle Unterordner) sichtbar
- **User (permanent)**: Root-Bilder + zugewiesene Unterordner
- **Client (30 Tage)**: Root-Bilder + zugewiesene Unterordner, Account laeuft nach 30 Tagen ab
### Nextcloud-Integration
- **Nextcloud-Pfad**: `/volume1/nextcloud/data/Tom/files/Websitebilder`
- **Mount im Container**: `/app/public/images/fulls:ro`
- **Thumbnails-Volume**: Docker-Volume `thumbs` (persistent)
- **Datenbank-Volume**: Docker-Volume `database` (persistent, SQLite)
- **Workflow**: Bild in Nextcloud hochladen -> max. 60s warten -> Seite neu laden -> fertig
## Login-System & Nutzerverwaltung
### Architektur
- **Backend**: Node.js/Express auf Port 8090
- **Auth**: JWT-Token als httpOnly Cookie (7 Tage gueltig)
- **Datenbank**: SQLite unter `/app/data/database.sqlite`
- **Passwort-Hashing**: bcryptjs (10 Rounds)
### Rollen
| Rolle | Beschreibung | Ablauf |
|---|---|---|
| `admin` | Voller Zugriff, Nutzerverwaltung, alle Bilder | Nie |
| `user` | Root-Bilder + zugewiesene Ordner | Nie |
| `client` | Root-Bilder + zugewiesene Ordner | 30 Tage nach Erstellung |
### Ordner-basierte Bildzuweisung
- Bilder direkt in `Websitebilder/` = oeffentlich (fuer alle sichtbar)
- Bilder in Unterordnern (z.B. `Websitebilder/Hochzeit/`) = nur fuer zugewiesene Nutzer
- Zuweisung erfolgt auf Ordner-Ebene (nicht einzelne Bilder)
- Tabelle `user_folders` speichert Zuweisungen (user_id, folder_name)
### Initialer Admin
- Es wird kein festes Standardkonto mehr angelegt.
- Bestehende Installationen behalten Nutzer und Rollen im persistenten SQLite-Volume.
- Fuer frische Installationen kann einmalig `INITIAL_ADMIN_USERNAME=MrDiderot` zusammen mit `INITIAL_ADMIN_PASSWORD_HASH` gesetzt werden.
### API-Endpunkte
| Endpunkt | Methode | Beschreibung |
|---|---|---|
| `/api/auth/login` | POST | Login (username, password) |
| `/api/auth/logout` | POST | Logout (Cookie loeschen) |
| `/api/auth/me` | GET | Aktueller User |
| `/api/auth/change-password` | POST | Eigenes Passwort aendern |
| `/api/admin/users` | GET/POST | Nutzer auflisten / erstellen (mit Rolle) |
| `/api/admin/users/:id` | DELETE | Nutzer loeschen (nicht sich selbst) |
| `/api/admin/users/:id/password` | PUT | Admin aendert User-Passwort |
| `/api/admin/folders` | GET | Verfuegbare Unterordner auflisten |
| `/api/admin/assign` | GET/POST | Ordner-Zuweisungen anzeigen / setzen |
| `/images/fulls/` | GET | Bilderliste (gefiltert nach User-Rolle + Ordnern) |
## Wichtige Konfiguration (_config.yml)
- `baseurl`: URL der Website
- `title`, `subtitle`, `author`: Seitentitel und Autor
- `header.title/subtitle`: Header-Anzeige
- `footer.name/bio`: Footer-Infos
- `social_urls`: Links zu Social Media
- `exif`: Welche EXIF-Tags angezeigt werden (Model, FNumber, ExposureTime, ISO)
## Build-Befehle
| Befehl | Aktion |
|---|---|
| `bundle install` | Ruby Dependencies installieren |
| `bundle exec jekyll serve` | Lokaler Dev-Server (nur Frontend) |
| `npm install` | Node Dependencies installieren |
| `gulp build` | SCSS kompilieren + JS minifizieren |
| `gulp resize` | Bilder zu fulls (1024px) + thumbs (512px) |
| `gulp` | Alles (build + resize) |
## Docker / NAS Hosting
- **NAS**: UGREEN mit UGOS PRO
- **Port**: 8090 (intern und extern)
- **Domain**: tom.mischlabs.de (via Cloudflare Tunnel)
### Docker-Dateien
- `Dockerfile` - Multi-Stage: Ruby/Jekyll baut Site -> Node.js Express serviert alles
- `docker-compose.yml` - Photography Container + Watchtower, Volumes fuer Bilder/Thumbs/DB
- `entrypoint.sh` - Thumbnail-Generierung + Node.js Server-Start
- `.dockerignore` - Haelt Build-Context klein (kein .git, node_modules etc.)
### Docker-Befehle (auf NAS via SSH)
| Befehl | Aktion |
|---|---|
| `sudo docker compose up -d` | Container starten |
| `sudo docker compose down` | Container stoppen |
| `sudo docker compose logs -f photography` | Logs anzeigen |
| `sudo docker compose pull && sudo docker compose up -d` | Manuelles Update |
### Deployment-Workflow (automatisch!)
1. Aenderungen lokal machen (Code, Design)
2. `gulp build` ausfuehren (JS + CSS minifizieren)
3. Git commit & push nach master
4. Gitea Actions baut automatisch Docker Image -> lokales Gitea Container Registry
5. Watchtower auf NAS erkennt neues Image (alle 5 Min)
6. Watchtower aktualisiert Container automatisch
7. Fertig - Website aktualisiert ohne SSH!
## Anpassungs-Roadmap
- [x] Titel, Subtitle, Autor anpassen (Mischkomposition / Tom Misch)
- [x] Social Media Links aktualisieren (Instagram: @Mischkomposition)
- [x] Sponsor-Bereich entfernen
- [x] Kontaktformular auf tom@mischlabs.de konfiguriert (Formsubmit.co)
- [x] Google Analytics vom Originalautor entfernt
- [x] Custom Domain: tom.mischlabs.de eingetragen
- [x] Nextcloud-Integration (Bilder aus Websitebilder-Ordner)
- [x] Dynamische Galerie (JS statt Jekyll-Loop)
- [x] Automatische Thumbnail-Generierung
- [x] Login-System mit JWT + SQLite
- [x] Admin-Dashboard (Nutzerverwaltung, Bildzuweisung)
- [x] Kunden-Portal (zugewiesene Bilder + Download)
- [x] Watchtower fuer automatische Updates
- [x] 3-Rollen-System (admin/user/client mit 30-Tage-Ablauf)
- [x] Ordner-basierte Bildzuweisung (Unterordner = private Kundenbilder)
- [x] Passwort-Aenderung (Self-Service + Admin kann fuer andere aendern)
- [ ] Footer Bio-Text schreiben (optional)
- [ ] Farbschema/Design anpassen
- [ ] Eigene Kategorien/Alben erstellen (Feature-Erweiterung)
- [ ] Admin-Passwort aendern!
---
*Letzte Aktualisierung: 2026-04-12*

View File

@@ -27,6 +27,7 @@
"cookie-parser": "^1.4.6",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"openid-client": "^5.6.0",
"sqlite3": "^5.1.7"
},
"devDependencies": {