feat: implement Keycloak SSO integration with JIT provisioning and Next.js login button
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 2m31s
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 2m31s
This commit is contained in:
@@ -24,3 +24,10 @@ MICHESS_API_PORT=3001
|
|||||||
# App-URL (für Next.js Metadaten)
|
# App-URL (für Next.js Metadaten)
|
||||||
NEXT_PUBLIC_APP_URL=http://deine-nas-ip:3000
|
NEXT_PUBLIC_APP_URL=http://deine-nas-ip:3000
|
||||||
NEXT_PUBLIC_API_URL=http://deine-nas-ip:3001
|
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
|
||||||
|
|||||||
127
brain.old
Normal file
127
brain.old
Normal file
@@ -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
|
||||||
@@ -4,6 +4,7 @@ import { SessionContext } from "@/context/session";
|
|||||||
import { login, register, setGuestSession } from "@/lib/auth";
|
import { login, register, setGuestSession } from "@/lib/auth";
|
||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import { useContext, useEffect, useRef, useState } from "react";
|
import { useContext, useEffect, useRef, useState } from "react";
|
||||||
|
import { API_URL } from "@/config";
|
||||||
|
|
||||||
import Guest from "./Guest";
|
import Guest from "./Guest";
|
||||||
import Login from "./Login";
|
import Login from "./Login";
|
||||||
@@ -14,8 +15,20 @@ export default function AuthModal() {
|
|||||||
const [activeTab, setActiveTab] = useState<"guest" | "login" | "register">("guest");
|
const [activeTab, setActiveTab] = useState<"guest" | "login" | "register">("guest");
|
||||||
const [serverMessage, setServerMessage] = useState<string | null>(null);
|
const [serverMessage, setServerMessage] = useState<string | null>(null);
|
||||||
const [buttonLoading, setButtonLoading] = useState(false);
|
const [buttonLoading, setButtonLoading] = useState(false);
|
||||||
|
const [ssoEnabled, setSsoEnabled] = useState(false);
|
||||||
const modalToggleRef = useRef<HTMLInputElement>(null);
|
const modalToggleRef = useRef<HTMLInputElement>(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() {
|
function closeModal() {
|
||||||
if (modalToggleRef.current?.checked) {
|
if (modalToggleRef.current?.checked) {
|
||||||
modalToggleRef.current.checked = false;
|
modalToggleRef.current.checked = false;
|
||||||
@@ -151,6 +164,30 @@ export default function AuthModal() {
|
|||||||
{activeTab === "register" && "Register"}
|
{activeTab === "register" && "Register"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeTab !== "guest" && ssoEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="divider opacity-70">oder</div>
|
||||||
|
<a
|
||||||
|
href={`${API_URL}/v1/auth/sso/login`}
|
||||||
|
className="btn btn-accent btn-outline w-full gap-2"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="h-5 w-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M12 1.5a5.25 5.25 0 0 0-5.25 5.25v3a3 3 0 0 0-3 3v6.75a3 3 0 0 0 3 3h10.5a3 3 0 0 0 3-3v-6.75a3 3 0 0 0-3-3v-3c0-2.9-2.35-5.25-5.25-5.25Zm3.75 8.25v-3a3.75 3.75 0 1 0-7.5 0v3h7.5Z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Login mit Keycloak
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</label>
|
</label>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ services:
|
|||||||
CORS_ORIGIN: ${CORS_ORIGIN:-https://michess.mischlabs.de}
|
CORS_ORIGIN: ${CORS_ORIGIN:-https://michess.mischlabs.de}
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
||||||
APP_DIR: /opt/michess
|
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:
|
ports:
|
||||||
- "${MICHESS_PORT:-3000}:3000"
|
- "${MICHESS_PORT:-3000}:3000"
|
||||||
- "${MICHESS_API_PORT:-3001}:3001"
|
- "${MICHESS_API_PORT:-3001}:3001"
|
||||||
|
|||||||
44
pnpm-lock.yaml
generated
44
pnpm-lock.yaml
generated
@@ -105,6 +105,9 @@ importers:
|
|||||||
nanoid:
|
nanoid:
|
||||||
specifier: ^5.0.7
|
specifier: ^5.0.7
|
||||||
version: 5.1.7
|
version: 5.1.7
|
||||||
|
openid-client:
|
||||||
|
specifier: ^5.6.0
|
||||||
|
version: 5.7.1
|
||||||
pg:
|
pg:
|
||||||
specifier: ^8.12.0
|
specifier: ^8.12.0
|
||||||
version: 8.20.0
|
version: 8.20.0
|
||||||
@@ -482,6 +485,7 @@ packages:
|
|||||||
|
|
||||||
'@ungap/structured-clone@1.3.0':
|
'@ungap/structured-clone@1.3.0':
|
||||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
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':
|
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
|
||||||
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
|
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
|
||||||
@@ -1521,6 +1525,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jose@4.15.9:
|
||||||
|
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
@@ -1583,6 +1590,10 @@ packages:
|
|||||||
lru-cache@10.4.3:
|
lru-cache@10.4.3:
|
||||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
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:
|
map-stream@0.1.0:
|
||||||
resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==}
|
resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==}
|
||||||
|
|
||||||
@@ -1715,6 +1726,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
object-hash@2.2.0:
|
||||||
|
resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
object-hash@3.0.0:
|
object-hash@3.0.0:
|
||||||
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
|
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -1747,6 +1762,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
|
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
on-finished@2.4.1:
|
||||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -1758,6 +1777,9 @@ packages:
|
|||||||
once@1.4.0:
|
once@1.4.0:
|
||||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
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:
|
optionator@0.9.4:
|
||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -2549,6 +2571,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
yallist@4.0.0:
|
||||||
|
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
|
||||||
|
|
||||||
yargs-parser@21.1.1:
|
yargs-parser@21.1.1:
|
||||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -4175,6 +4200,8 @@ snapshots:
|
|||||||
|
|
||||||
jiti@1.21.7: {}
|
jiti@1.21.7: {}
|
||||||
|
|
||||||
|
jose@4.15.9: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-yaml@4.1.1:
|
js-yaml@4.1.1:
|
||||||
@@ -4231,6 +4258,10 @@ snapshots:
|
|||||||
|
|
||||||
lru-cache@10.4.3: {}
|
lru-cache@10.4.3: {}
|
||||||
|
|
||||||
|
lru-cache@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
yallist: 4.0.0
|
||||||
|
|
||||||
map-stream@0.1.0: {}
|
map-stream@0.1.0: {}
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
@@ -4336,6 +4367,8 @@ snapshots:
|
|||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
|
object-hash@2.2.0: {}
|
||||||
|
|
||||||
object-hash@3.0.0: {}
|
object-hash@3.0.0: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
@@ -4378,6 +4411,8 @@ snapshots:
|
|||||||
define-properties: 1.2.1
|
define-properties: 1.2.1
|
||||||
es-object-atoms: 1.1.1
|
es-object-atoms: 1.1.1
|
||||||
|
|
||||||
|
oidc-token-hash@5.2.0: {}
|
||||||
|
|
||||||
on-finished@2.4.1:
|
on-finished@2.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
ee-first: 1.1.1
|
ee-first: 1.1.1
|
||||||
@@ -4388,6 +4423,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
wrappy: 1.0.2
|
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:
|
optionator@0.9.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
deep-is: 0.1.4
|
deep-is: 0.1.4
|
||||||
@@ -5245,6 +5287,8 @@ snapshots:
|
|||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
|
yallist@4.0.0: {}
|
||||||
|
|
||||||
yargs-parser@21.1.1: {}
|
yargs-parser@21.1.1: {}
|
||||||
|
|
||||||
yargs@17.7.2:
|
yargs@17.7.2:
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
"socket.io": "^4.7.5",
|
"socket.io": "^4.7.5",
|
||||||
"xss": "^1.0.15"
|
"xss": "^1.0.15",
|
||||||
|
"openid-client": "^5.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@michess/types": "workspace:*",
|
"@michess/types": "workspace:*",
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import type { User } from "@michess/types";
|
|||||||
import { hash, verify } from "argon2";
|
import { hash, verify } from "argon2";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import xss from "xss";
|
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 { activeGames } from "../db/models/game.model.js";
|
||||||
import UserModel from "../db/models/user.model.js";
|
import UserModel from "../db/models/user.model.js";
|
||||||
import { io } from "../server.js";
|
import { io } from "../server.js";
|
||||||
@@ -317,3 +320,123 @@ export const updateUser = async (req: Request, res: Response) => {
|
|||||||
res.status(500).end();
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -14,4 +14,9 @@ router.route("/logout").post(controller.logoutSession);
|
|||||||
router.route("/register").post(controller.registerUser);
|
router.route("/register").post(controller.registerUser);
|
||||||
router.route("/login").post(controller.loginUser);
|
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;
|
export default router;
|
||||||
|
|||||||
Reference in New Issue
Block a user