diff --git a/.env.example b/.env.example index 1f3b1ce..4c1a14c 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,10 @@ MICHESS_API_PORT=3001 # App-URL (für Next.js Metadaten) NEXT_PUBLIC_APP_URL=http://deine-nas-ip:3000 NEXT_PUBLIC_API_URL=http://deine-nas-ip:3001 + +# Keycloak SSO (OIDC) Integration +SSO_AUTHORITY=https://auth.mischlabs.de/realms/mischlabs +SSO_CLIENT_ID=michess +SSO_CLIENT_SECRET=dein_client_secret_aus_keycloak +SSO_REDIRECT_URI=https://michess.mischlabs.de/v1/auth/sso/callback +APP_URL=https://michess.mischlabs.de diff --git a/brain.old b/brain.old new file mode 100644 index 0000000..31238c9 --- /dev/null +++ b/brain.old @@ -0,0 +1,127 @@ +# Brain.md — MiChess Project Memory + +> Dieses Dokument ist mein aktives Gedächtnis während der Entwicklung von MiChess. +> Ich denke hier, notiere Entscheidungen, Probleme und Fortschritt. + +--- + +## Projektziel + +MiChess = Lichess-inspirierte Schachplattform, self-hosted auf einer NAS, in Docker, mit Stockfish-Engine. + +--- + +## Tech Stack (Entschieden) + +| Schicht | Technologie | Grund | +|---|---|---| +| Frontend | Next.js 14 + React + Tailwind + daisyUI | Von chessu geerbt, modern, SSR-fähig | +| Backend | Node.js + Express + Socket.io | Von chessu geerbt, real-time-ready | +| Datenbank | PostgreSQL | Von chessu geerbt, solide | +| Auth | express-session + argon2 | Bereits vorhanden, sicher | +| Schach-Logik | chess.js | Bereits vorhanden | +| Schachbrett UI | react-chessboard | Bereits vorhanden | +| Stockfish | stockfish npm-Paket | Einfache Integration im Backend | +| Container | Docker Compose | NAS-Deployment | +| Package Manager | pnpm Workspaces | Monorepo-Setup | + +--- + +## Basis-Projekt + +- **Geclont von:** `dotnize/chessu` (MIT-Lizenz) +- **Warum:** TypeScript, React/Next.js, Express, Socket.io, PostgreSQL, chess.js — perfekter Ausgangspunkt +- **Was chessu schon hat:** + - User-Accounts (name, email, password, wins/losses/draws) + - Session-Auth mit argon2-Passwort-Hashing + - Real-time Multiplayer via Socket.io + - Spiele mit PGN-Speicherung + - Public Games Liste + - Archiv (gespielte Partien) + - User-Profilseite + +--- + +## Was ich bauen muss (Delta zu chessu) + +1. **Stockfish AI-Gegner** — verschiedene Schwierigkeitsgrade (Level 1-8, ELO-basiert) +2. **Admin-Panel** — Nutzerverwaltung, Git-Pull-Button, Statistiken +3. **Freundessystem** — Freundschaftsanfragen, Freundesliste, Freunde herausfordern +4. **Branding** — Alles auf "MiChess" umbenennen +5. **Docker NAS-Deployment** — docker-compose für NAS, Auto-Update-Script +6. **DB-Schema Erweiterungen** — admin-Flag, friends-Tabelle, friend_requests-Tabelle +7. **E-Mail-basierte Registrierung** — (email-Feld schon da, braucht UI-Flow: email → unique username wählen) + +--- + +## Architektur-Entscheidungen + +### Stockfish im Backend +- Stockfish läuft als Child-Process auf dem Server (NAS) +- Kommunikation über UCI-Protokoll +- Schwierigkeitsgrade via `Skill Level` (0-20) und `Depth` +- Pro aktiver AI-Partie: eigener Stockfish-Prozess (oder Pool) + +### Admin-Rolle +- `role` Spalte in `user`-Tabelle (default: 'user', admin: 'admin') +- Erster User mit email aus `ADMIN_EMAIL` env var wird automatisch Admin +- Admin-Middleware schützt `/v1/admin/*` Routen + +### Friends-System +- `friends`-Tabelle: user_id_1, user_id_2 (symmetrisch) +- `friend_requests`-Tabelle: from_id, to_id, status (pending/accepted/rejected) +- Nutzer suchen via Username + +### Git-Auto-Update (Admin-Feature) +- Admin-Route: `POST /v1/admin/update` +- Führt `git pull` im Container-Verzeichnis aus +- Triggert danach Neustart (via restart-policy des Containers) +- Update-Script wird ins Docker-Image eingebaut + +### Docker NAS-Setup +- `docker-compose.yml` mit Services: `michess-app` + `postgres` +- Watchtower oder einfaches update-script für automatische Updates +- Alle Credentials via `.env`-Datei + +--- + +## Aktueller Status + +- [x] Basis-Projekt geclont (chessu) +- [x] Brain.md erstellt +- [x] Plan.md erstellt +- [ ] Git remote auf `git.mischlabs.de` gesetzt +- [ ] Projekt auf "MiChess" umbenannt +- [ ] DB-Schema erweitert (admin, friends, friend_requests) +- [ ] Stockfish integriert +- [ ] Admin-Panel gebaut +- [ ] Friends-System gebaut +- [ ] Docker NAS-Deployment finalisiert +- [ ] Alles committed & gepusht + +--- + +## Bekannte Probleme / Offene Fragen + +- chessu's Dockerfile nutzt `CMD ["start"]` — muss für MiChess angepasst werden (Server + Client separat oder zusammen) +- Stockfish binary muss im Docker-Image vorhanden sein (Alpine Linux: `apk add stockfish` oder npm-Paket) +- Admin-Git-Pull: funktioniert nur wenn Container read-write Zugriff auf sein eigenes Verzeichnis hat — besser: Host-Script via webhook triggern + +--- + +## Lessons Learned + +*(wird im Verlauf befüllt)* + +--- + +## Nächste Schritte (immer aktuell) + +1. Git remote setzen → push +2. Umbenennen chessu → MiChess +3. DB-Schema erweitern +4. Stockfish backend einbauen +5. Admin-Panel UI + Backend +6. Friends-System +7. Docker finalisieren +8. Issues aus Git bearbeiten diff --git a/client/src/components/auth/AuthModal.tsx b/client/src/components/auth/AuthModal.tsx index 5b8df13..94d66a4 100644 --- a/client/src/components/auth/AuthModal.tsx +++ b/client/src/components/auth/AuthModal.tsx @@ -4,6 +4,7 @@ import { SessionContext } from "@/context/session"; import { login, register, setGuestSession } from "@/lib/auth"; import type { FormEvent } from "react"; import { useContext, useEffect, useRef, useState } from "react"; +import { API_URL } from "@/config"; import Guest from "./Guest"; import Login from "./Login"; @@ -14,8 +15,20 @@ export default function AuthModal() { const [activeTab, setActiveTab] = useState<"guest" | "login" | "register">("guest"); const [serverMessage, setServerMessage] = useState(null); const [buttonLoading, setButtonLoading] = useState(false); + const [ssoEnabled, setSsoEnabled] = useState(false); const modalToggleRef = useRef(null); + useEffect(() => { + fetch(`${API_URL}/v1/auth/sso/config`, { credentials: "include" }) + .then((res) => res.json()) + .then((data) => { + if (data && data.enabled) { + setSsoEnabled(true); + } + }) + .catch((err) => console.error("Error loading SSO config:", err)); + }, []); + function closeModal() { if (modalToggleRef.current?.checked) { modalToggleRef.current.checked = false; @@ -151,6 +164,30 @@ export default function AuthModal() { {activeTab === "register" && "Register"} + + {activeTab !== "guest" && ssoEnabled && ( + <> +
oder
+ + + + + Login mit Keycloak + + + )} diff --git a/docker-compose.yml b/docker-compose.yml index 0a9f3aa..789cc3c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,11 @@ services: CORS_ORIGIN: ${CORS_ORIGIN:-https://michess.mischlabs.de} ADMIN_EMAIL: ${ADMIN_EMAIL:-} APP_DIR: /opt/michess + SSO_AUTHORITY: ${SSO_AUTHORITY:-} + SSO_CLIENT_ID: ${SSO_CLIENT_ID:-} + SSO_CLIENT_SECRET: ${SSO_CLIENT_SECRET:-} + SSO_REDIRECT_URI: ${SSO_REDIRECT_URI:-} + APP_URL: ${APP_URL:-} ports: - "${MICHESS_PORT:-3000}:3000" - "${MICHESS_API_PORT:-3001}:3001" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1b672b..8842702 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: nanoid: specifier: ^5.0.7 version: 5.1.7 + openid-client: + specifier: ^5.6.0 + version: 5.7.1 pg: specifier: ^8.12.0 version: 8.20.0 @@ -482,6 +485,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -1521,6 +1525,9 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1583,6 +1590,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + map-stream@0.1.0: resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==} @@ -1715,6 +1726,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-hash@2.2.0: + resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} + engines: {node: '>= 6'} + object-hash@3.0.0: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} @@ -1747,6 +1762,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + oidc-token-hash@5.2.0: + resolution: {integrity: sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==} + engines: {node: ^10.13.0 || >=12.0.0} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -1758,6 +1777,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + openid-client@5.7.1: + resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2549,6 +2571,9 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -4175,6 +4200,8 @@ snapshots: jiti@1.21.7: {} + jose@4.15.9: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -4231,6 +4258,10 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + map-stream@0.1.0: {} math-intrinsics@1.1.0: {} @@ -4336,6 +4367,8 @@ snapshots: object-assign@4.1.1: {} + object-hash@2.2.0: {} + object-hash@3.0.0: {} object-inspect@1.13.4: {} @@ -4378,6 +4411,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + oidc-token-hash@5.2.0: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -4388,6 +4423,13 @@ snapshots: dependencies: wrappy: 1.0.2 + openid-client@5.7.1: + dependencies: + jose: 4.15.9 + lru-cache: 6.0.0 + object-hash: 2.2.0 + oidc-token-hash: 5.2.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -5245,6 +5287,8 @@ snapshots: y18n@5.0.8: {} + yallist@4.0.0: {} + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/server/package.json b/server/package.json index a1a1982..8ca4c3f 100644 --- a/server/package.json +++ b/server/package.json @@ -20,7 +20,8 @@ "nanoid": "^5.0.7", "pg": "^8.12.0", "socket.io": "^4.7.5", - "xss": "^1.0.15" + "xss": "^1.0.15", + "openid-client": "^5.6.0" }, "devDependencies": { "@michess/types": "workspace:*", diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 3e394e0..f7173b9 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -2,7 +2,10 @@ import type { User } from "@michess/types"; import { hash, verify } from "argon2"; import type { Request, Response } from "express"; import xss from "xss"; +import crypto from "crypto"; +import { Issuer } from "openid-client"; +import { db } from "../db/index.js"; import { activeGames } from "../db/models/game.model.js"; import UserModel from "../db/models/user.model.js"; import { io } from "../server.js"; @@ -317,3 +320,123 @@ export const updateUser = async (req: Request, res: Response) => { res.status(500).end(); } }; + +// ==================== KEYCLOAK SSO API ==================== + +let oidcClient: any = 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 || "michess"; + const ssoClientSecret = process.env.SSO_CLIENT_SECRET; + const ssoRedirectUri = process.env.SSO_REDIRECT_URI || "https://michess.mischlabs.de/v1/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; +} + +export const getSsoConfig = async (req: Request, res: Response) => { + res.json({ + enabled: !!(process.env.SSO_CLIENT_SECRET && process.env.SSO_CLIENT_ID && process.env.SSO_AUTHORITY) + }); +}; + +export const ssoLogin = async (req: Request, res: Response) => { + try { + const client = await getOidcClient(); + const authorizationUrl = client.authorizationUrl({ + scope: "openid email profile", + state: "mischlabs-state" + }); + res.redirect(authorizationUrl); + } catch (err: any) { + console.error("SSO Login Error:", err); + res.status(500).send("SSO Login Initialisierung fehlgeschlagen: " + err.message); + } +}; + +export const ssoCallback = async (req: Request, res: Response) => { + try { + const client = await getOidcClient(); + const params = client.callbackParams(req); + const ssoRedirectUri = process.env.SSO_REDIRECT_URI || "https://michess.mischlabs.de/v1/auth/sso/callback"; + const tokenSet = await client.callback(ssoRedirectUri, params, { state: "mischlabs-state" }); + const userinfo = await client.userinfo(tokenSet.access_token); + + const username = (userinfo.preferred_username || userinfo.name || userinfo.sub) as string; + const email = userinfo.email as string; + + if (!username) { + return res.status(400).send("Kein Benutzername im OIDC Token gefunden."); + } + + // Check if user exists in Michess database + const users = await UserModel.findByNameEmail({ name: username, email: email }, false, 1); + + const handleUserLogin = (dbUser: User) => { + req.session.user = { + id: dbUser.id, + name: dbUser.name, + email: dbUser.email, + wins: dbUser.wins, + losses: dbUser.losses, + draws: dbUser.draws, + elo: dbUser.elo, + role: dbUser.role + }; + + const appUrl = process.env.APP_URL || "https://michess.mischlabs.de"; + req.session.save(() => { + res.redirect(appUrl); + }); + }; + + if (users && users.length) { + // User exists, log them in + handleUserLogin(users[0]); + } else { + // User does not exist, provision them Just-In-Time (JIT) + const randomPassword = crypto.randomBytes(32).toString("hex"); + const hashedPassword = await hash(randomPassword); + + // Determine role: Option A (automatically make 'MrDiderot' an admin, or the first user in an empty DB) + let role = "user"; + const countRes = await db.query('SELECT COUNT(*) as count FROM "user"'); + const count = parseInt(countRes.rows[0].count, 10); + + if (count === 0 || username.toLowerCase() === "mrdiderot") { + role = "admin"; + } + + // Create user + const newUser = await UserModel.create({ name: username, email: email || "" }, hashedPassword); + if (!newUser || !newUser.id) { + return res.status(500).send("JIT-Benutzererstellung fehlgeschlagen"); + } + + // If we assigned admin role, update it in DB + if (role === "admin") { + await UserModel.adminUpdate(newUser.id as number, { role: "admin" }); + newUser.role = "admin"; + } + + handleUserLogin(newUser); + } + } catch (err: any) { + console.error("SSO Callback Error:", err); + res.status(500).send("SSO Authentifizierung fehlgeschlagen: " + err.message); + } +}; diff --git a/server/src/routes/auth.route.ts b/server/src/routes/auth.route.ts index 886e028..3b0b051 100644 --- a/server/src/routes/auth.route.ts +++ b/server/src/routes/auth.route.ts @@ -14,4 +14,9 @@ router.route("/logout").post(controller.logoutSession); router.route("/register").post(controller.registerUser); router.route("/login").post(controller.loginUser); +// SSO Keycloak +router.route("/sso/config").get(controller.getSsoConfig); +router.route("/sso/login").get(controller.ssoLogin); +router.route("/sso/callback").get(controller.ssoCallback); + export default router;