BaseFrontendBackend
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# ==============================================================================
|
||||
# Void-Genesis - Lobby Backend (Dev Environment)
|
||||
# ==============================================================================
|
||||
|
||||
# Wir nutzen eine leichtgewichtige Alpine-Version der aktuellen Node.js LTS (z.B. 22)
|
||||
# Alpine-Images sind extrem klein und reduzieren die Angriffsfläche.
|
||||
FROM node:22-alpine
|
||||
|
||||
# Setze das Arbeitsverzeichnis innerhalb des Containers auf /app.
|
||||
# Alle folgenden Befehle (RUN, CMD, COPY) werden in diesem Ordner ausgeführt.
|
||||
WORKDIR /app
|
||||
|
||||
# Wir kopieren zuerst NUR die package.json (und package-lock.json, falls vorhanden).
|
||||
# Das ist ein Best-Practice für Docker: Docker speichert diesen Schritt im Cache.
|
||||
# 'npm install' wird beim erneuten Bauen nur dann ausgeführt, wenn sich an den
|
||||
# Abhängigkeiten in der package.json etwas geändert hat. Das spart enorm Zeit!
|
||||
COPY package*.json ./
|
||||
|
||||
# Installiere alle Abhängigkeiten (inklusive Dev-Dependencies wie nodemon)
|
||||
RUN npm install
|
||||
|
||||
# HINWEIS: Den restlichen Quellcode (src/) kopieren wir hier im Dev-Dockerfile
|
||||
# absichtlich nicht mit COPY. Der Code wird stattdessen über die docker-compose.yml
|
||||
# als Volume gemountet. So greifen deine Änderungen am Root-Server sofort im Container.
|
||||
|
||||
# Wir dokumentieren hier, dass der Container auf Port 3000 lauscht.
|
||||
# Das Backend wird diesen Port später nutzen.
|
||||
EXPOSE 3000
|
||||
|
||||
# Der Standardbefehl, wenn der Container startet. Wir rufen das Script "dev" auf,
|
||||
# welches wir gleich im nächsten Schritt in der package.json definieren werden.
|
||||
CMD ["npm", "run", "dev"]
|
||||
Generated
+1618
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "void-genesis-lobby-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend für die Master-Account-Lobby",
|
||||
"main": "src/server.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "nodemon src/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"argon2": "^0.44.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"pg": "^8.11.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import * as authService from '../services/auth.service.js';
|
||||
|
||||
// Ein Standard-Regulärer-Ausdruck (Regex) zur Prüfung eines gültigen E-Mail-Formats
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
/**
|
||||
* Controller-Methode für die Registierung-Route.
|
||||
*/
|
||||
export const register = async (req, res) => {
|
||||
try {
|
||||
// Daten aus dem Body extrahieren
|
||||
const { username, email, password } = req.body;
|
||||
|
||||
// Basis-Validierung (leere Felder, Passwortlänge)
|
||||
if (!username || !email || !password) {
|
||||
return res.status(400).json({
|
||||
// ERR_MISSING_FIELDS: Bitte alle Felder ausfüllen.
|
||||
error: 'ERR_MISSING_FIELDS'
|
||||
});
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({
|
||||
// ERR_PASSWORD_TOO_SHORT: Das Passwort muss mindestens 8 Zeichen lang sein.
|
||||
error: 'ERR_PASSWORD_TOO_SHORT'
|
||||
});
|
||||
}
|
||||
if (username.length < 3) {
|
||||
return res.status(400).json({
|
||||
// ERR_USERNAME_TOO_SHORT: Der Benutzername muss mindestens 3 Zeichen lang sein.
|
||||
error: 'ERR_USERNAME_TOO_SHORT'
|
||||
});
|
||||
}
|
||||
if (!EMAIL_REGEX.test(email)) {
|
||||
return res.status(400).json({
|
||||
/// ERR_INVALID_EMAIL: Das E-Mail-Format ist ungültig.
|
||||
error: 'ERR_INVALID_EMAIL'
|
||||
});
|
||||
}
|
||||
|
||||
// Den Auth-Service aufrufen (die eigenliche Arbeit)
|
||||
const result = await authService.registerUser(username, email, password);
|
||||
|
||||
// Erfolgreiche Antwort an der Frontend senden
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
// MSG_REGISTER_SUCCESS: Registrierung erfolgreich. Bitte prüfe deine E-Mails.
|
||||
message: 'MSG_REGISTER_SUCCESS',
|
||||
accountId: result.accountId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Fehlerbehandlung: Wenn es unser eigener Conflict-Fehler ist (zB Email existiert)
|
||||
if (error.code === 'ERR_USERNAME_TAKEN') {
|
||||
return res.status(409).json({
|
||||
// ERR_USERNAME_TAKEN: Dieser Benutzername ist bereits vergeben.
|
||||
error: 'ERR_USERNAME_TAKEN'
|
||||
});
|
||||
}
|
||||
if (error.code === 'ERR_EMAIL_TAKEN') {
|
||||
return res.status(409).json({
|
||||
// ERR_USERNAME_TAKEN: Diese E-Mail-Addresse ist bereits vergeben.
|
||||
error: 'ERR_EMAIL_TAKEN'
|
||||
});
|
||||
}
|
||||
|
||||
// Bei unerwarteten Fehlern (z.B. Datenbank oder Brovo nicht erreichbar.)
|
||||
console.error('[AuthController] Fehler bei der Registierung:', error);
|
||||
return res.status(500).json({
|
||||
// ERR_INTERNAL_SERVER: Ein interner Serverfehler ist aufgetreten.
|
||||
error: 'ERR_INTERNAL_SERVER'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Controller-Methode für die E-Mail-Verifizierungs-Route
|
||||
*/
|
||||
export const verifyEmail = async (req, res) => {
|
||||
try {
|
||||
// Daten aus dem Request-Body extrahieren
|
||||
const { email , otpCode } = req.body;
|
||||
|
||||
// Vasis-Validierung
|
||||
if (!email || !otpCode) {
|
||||
return res.status(400).json({
|
||||
error: 'ERR_MISSING_FIELDS'
|
||||
});
|
||||
}
|
||||
|
||||
// Den Service aufrufen, um die eigenliche Arbeit zu machen
|
||||
await authService.verifyEmail(email, otpCode);
|
||||
|
||||
// Erfolgreiche Antwort ans Frontend senden
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'MSG_EMAIL_VERIFIED'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (['ERR_USER_NOT_FOUND', 'ERR_ALREADY_VERIFIED', 'ERR_INVALID_OTP', 'ERR_OTP_EXPIRED'].includes(error.code)) {
|
||||
|
||||
return res.status(400).json({
|
||||
error: error.code
|
||||
});
|
||||
}
|
||||
|
||||
console.error('[AuthController] Fehler bei E-Mail-Verifizierung:', error);
|
||||
return res.status(500).json({
|
||||
error: 'ERR_INTERNAL_SERVER'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Controller-Methode für die Login-Route
|
||||
*/
|
||||
export const login = async (req, res) => {
|
||||
try {
|
||||
const { identifier, password } = req.body;
|
||||
|
||||
// Basis-Validierung
|
||||
if (!identifier || !password) {
|
||||
return res.status(400).json({
|
||||
error: 'ERR_MISSING_FIELDS'
|
||||
});
|
||||
}
|
||||
|
||||
// Service aufrufen
|
||||
const result = await authService.loginUser(identifier, password);
|
||||
|
||||
// Erfolgreiche Antwort mit den Tokens
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
// MSG_LOGIN_SUCCESS: Erfolgreich eingeloggt
|
||||
message: 'MSG_LOGIN_SUCCESS',
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
user: result.user
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error.code === 'ERR_INVALID_CREDENTIALS') {
|
||||
return res.status(401).json({
|
||||
error: 'ERR_INVALID_CREDENTIALS'
|
||||
});
|
||||
}
|
||||
if (error.code === 'ERR_EMAIL_NOT_VERIFIED') {
|
||||
return res.status(403).json({
|
||||
error: 'ERR_EMAIL_NOT_VERIFIED'
|
||||
});
|
||||
}
|
||||
|
||||
console.error('[AuthController] Fehler beim Login:', error);
|
||||
return res.status(500).json({
|
||||
error: 'ERR_INTERNAL_SERVER'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller-Methode für die Token-Refresh-Route
|
||||
*/
|
||||
export const refresh = async (req, res) => {
|
||||
try {
|
||||
// Das Refresh-Token aus dem Request-Body holen
|
||||
const { refreshToken } = req.body;
|
||||
|
||||
// Validierung: Is überhaupt ein Token mitgesendet worden ?
|
||||
if (!refreshToken) {
|
||||
// ERR_MISSING_TOKEN: Es wurde kein Refresh-Token übergeben
|
||||
return res.status(400).json({
|
||||
error: 'ERR_MISSING_TOKEN'
|
||||
});
|
||||
}
|
||||
|
||||
// Den Service aufrufen, um das Token zu prüfen und ein neues zu generieren
|
||||
const result = await authService.refreshAccessToken(refreshToken);
|
||||
|
||||
// Das neue Access-Token an das Frontend zurück geben
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
accessToken: result.accessToken
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Ungültige, abgelaufene oder in der DB gelöschte Tokens abfangen
|
||||
if (error.code === 'ERR_UNAUTHORIZED') {
|
||||
return res.status(401).json({
|
||||
// ERR_UNAUTHORIZED: Zugriff verweigert. Die Sitzung ist ungültig.
|
||||
error: 'ERR_UNAUTHORIZED'
|
||||
});
|
||||
}
|
||||
|
||||
// Bei unerwarteten Fehlern (z.B. Datenbank oder Brovo nicht erreichbar.)
|
||||
console.error('[AuthController] Fehler beim Token-Refresh:', error);
|
||||
return res.status(500).json({
|
||||
// ERR_INTERNAL_SERVER: Ein interner Serverfehler ist aufgetreten.
|
||||
error: 'ERR_INTERNAL_SERVER'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// ==============================================================================
|
||||
// Void-Genesis - Datenbank-Konfiguration
|
||||
// ==============================================================================
|
||||
|
||||
import pg from 'pg';
|
||||
|
||||
// Wir nutzen einen Connection Pool. Das ist deutlich effizienter als für jede
|
||||
// Anfrage eine neue Verbindung zur Datenbank aufzubauen. Der Pool hält im
|
||||
// Hintergrund mehrere Verbindungen offen und verteilt sie dynamisch.
|
||||
const { Pool } = pg;
|
||||
|
||||
const pool = new Pool({
|
||||
// Diese Variablen kommen direkt aus unserer docker-compose.yml
|
||||
// (Bereich 'environment' des Backend-Containers)
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
});
|
||||
|
||||
// Wir testen beim Start einmalig, ob die Verbindung klappt.
|
||||
// So sehen wir im Docker-Log sofort, falls z.B. das Passwort falsch ist.
|
||||
pool.connect((err, client, release) => {
|
||||
if (err) {
|
||||
console.error('❌ Fehler beim Verbinden mit der PostgreSQL-Datenbank:', err.stack);
|
||||
} else {
|
||||
console.log('✅ Erfolgreich mit der PostgreSQL-Datenbank (void_lobby) verbunden!');
|
||||
// WICHTIG: Den Client immer direkt wieder freigeben, wenn wir fertig sind
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
// Wir exportieren den Pool, damit wir ihn in anderen Dateien (wie unseren Routes)
|
||||
// für Datenbankabfragen nutzen können.
|
||||
export default pool;
|
||||
@@ -0,0 +1,11 @@
|
||||
import express from 'express';
|
||||
import * as authController from '../controllers/auth.controller.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/register', authController.register);
|
||||
router.post('/verify-email', authController.verifyEmail);
|
||||
router.post('/login', authController.login);
|
||||
router.post('/refresh', authController.refresh);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,109 @@
|
||||
import pool from '../db.js';
|
||||
|
||||
// ==============================================================================
|
||||
// Void-Genesis - Datenbank Initialisierungs-Skript
|
||||
// Führt alle nötigen CREATE TABLE Befehle aus, falls die Tabellen nicht existieren.
|
||||
// ==============================================================================
|
||||
|
||||
const createTablesQuery = `
|
||||
|
||||
DROP TABLE IF EXISTS refresh_tokens;
|
||||
DROP TABLE IF EXISTS auth_otps;
|
||||
DROP TABLE IF EXISTS lobby_accounts;
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- TABLE: lobby_accounts
|
||||
-- Base user data for the entire Void-Genesis universe.
|
||||
-- ------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS lobby_accounts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
|
||||
-- Status flags and balances (English naming)
|
||||
is_email_verified BOOLEAN DEFAULT false,
|
||||
res_premium INT DEFAULT 0,
|
||||
is_admin BOOLEAN DEFAULT false,
|
||||
|
||||
-- Timestamps for record keeping
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- TABLE: auth_otps
|
||||
-- Stores temporary 6-digit codes for registration or password resets.
|
||||
-- ------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS auth_otps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID REFERENCES lobby_accounts(id) ON DELETE CASCADE,
|
||||
otp_code VARCHAR(6) NOT NULL,
|
||||
|
||||
-- Defines the purpose of the OTP (e.g., 'register', 'password_reset')
|
||||
type VARCHAR(20) NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- TABLE: refresh_tokens
|
||||
-- Stores long-lived tokens for the "Keep me logged in" and single-session feature.
|
||||
-- ------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID REFERENCES lobby_accounts(id) ON DELETE CASCADE,
|
||||
token VARCHAR(500) UNIQUE NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- TRIGGER: Auto-update 'updated_at' timestamp
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- This function sets the 'updated_at' field to the current time whenever a row is modified.
|
||||
CREATE OR REPLACE FUNCTION update_modified_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- We only create the trigger if it doesn't exist to avoid errors on multiple runs.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_lobby_accounts_modtime') THEN
|
||||
CREATE TRIGGER update_lobby_accounts_modtime
|
||||
BEFORE UPDATE ON lobby_accounts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_modified_column();
|
||||
END IF;
|
||||
END $$;
|
||||
`;
|
||||
|
||||
// ==============================================================================
|
||||
// Ausführungs-Logik
|
||||
// ==============================================================================
|
||||
async function initDatabase() {
|
||||
console.log('⏳ Starte Datenbank-Initialisierung...');
|
||||
|
||||
// Wir holen uns eine Verbindung aus dem Pool
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
// Wir senden den kompletten SQL-String an die Datenbank
|
||||
await client.query(createTablesQuery);
|
||||
console.log('✅ Tabellen wurden erfolgreich erstellt (oder existierten bereits).');
|
||||
} catch (error) {
|
||||
console.error('❌ Fehler beim Erstellen der Tabellen:', error);
|
||||
} finally {
|
||||
// Ganz wichtig: Verbindung wieder freigeben
|
||||
client.release();
|
||||
// Den Node-Prozess beenden, damit das Skript nicht in der Konsole hängen bleibt
|
||||
process.exit();
|
||||
}
|
||||
}
|
||||
|
||||
// Skript direkt aufrufen
|
||||
initDatabase();
|
||||
@@ -0,0 +1,53 @@
|
||||
// ==============================================================================
|
||||
// Void-Genesis - Lobby Backend Server (Einstiegspunkt)
|
||||
// ==============================================================================
|
||||
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import pool from './db.js';
|
||||
|
||||
import authRoutes from './routes/auth.routes.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
// Unsere bestehende Health-Route
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
message: 'Void-Genesis Lobby Backend läuft!',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Eine Test-Route für die Datenbank
|
||||
app.get('/api/db-test', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT NOW()');
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: 'Datenbankverbindung steht!',
|
||||
db_time: result.rows[0].now
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Fehler bei der DB-Abfrage:', err);
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: 'Datenbankfehler',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Server starten
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Void-Genesis Lobby Backend gestartet auf Port ${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV}`);
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
import argon2 from 'argon2';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import pool from '../db.js';
|
||||
import { sendEmail } from './email.service.js';
|
||||
|
||||
/**
|
||||
* Generiert einen zufälligen 6-stelligen Code als String
|
||||
*/
|
||||
const generateOTP = () => {
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Registriert einen neuen Nutzer, speichert das OTP und löst den E-Mail-Versand aus.
|
||||
*
|
||||
* @param {string} username - Der gewünschte Benutzername
|
||||
* @param {string} email - Die E-Mail-Addresse
|
||||
* @param {string} password - Das Klartext-Passwort
|
||||
* @returns {object} - Die accountId des neuen Nutzers
|
||||
*/
|
||||
export const registerUser = async (username, email, password) => {
|
||||
// Wir holen uns einen dedizierten Client aus dem pool für unsere Transaktion
|
||||
const client = await pool.connect();
|
||||
|
||||
const lowerCaseEmail = email.toLowerCase();
|
||||
|
||||
try {
|
||||
// Transaktion starten: Entweder alles klappt, oder nichts wird gespeichert
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Prüfen, ob der Nutzer oder die E-Mail schon existiert
|
||||
const checkResult = await client.query(`
|
||||
SELECT username, email FROM lobby_accounts
|
||||
WHERE LOWER(username) = LOWER($1) OR email = $2`,
|
||||
[username, lowerCaseEmail]
|
||||
);
|
||||
|
||||
if (checkResult.rows.length > 0) {
|
||||
const existingRecord = checkResult.rows[0];
|
||||
|
||||
if (existingRecord.email === lowerCaseEmail) {
|
||||
const error = new Error('Email already exists');
|
||||
error.code = 'ERR_EMAIL_TAKEN';
|
||||
throw error;
|
||||
} else {
|
||||
const error = new Error('Username already exists');
|
||||
error.code = 'ERR_USERNAME_TAKEN';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Passwort sicher hashen
|
||||
const passwordHash = await argon2.hash(password);
|
||||
|
||||
// Nutzer in de Datenbank eintragen
|
||||
const insertAccount = await client.query(`
|
||||
INSERT INTO lobby_accounts (username, email, password_hash)
|
||||
VALUES ($1, $2, $3) RETURNING id`,
|
||||
[username, lowerCaseEmail, passwordHash]
|
||||
);
|
||||
const accountId = insertAccount.rows[0].id;
|
||||
|
||||
// OTP generieren uns in der auth_otps Tabelle speichern (15min Gültigkeit)
|
||||
const otpCode = generateOTP();
|
||||
await client.query(`
|
||||
INSERT INTO auth_otps (account_id, otp_code, type, expires_at)
|
||||
VALUES ($1, $2, 'register', NOW() + INTERVAL '15 minutes')`,
|
||||
[accountId, otpCode]
|
||||
);
|
||||
|
||||
// Registierungs E-Mail über unseren E-Mail Service versenden
|
||||
const mailHtml = `
|
||||
<h2>Willkommen bei Void-Genesis, ${username}!</h2>
|
||||
<p>Dein Verifizierungscode lautet:</p>
|
||||
<h1 style="color: #00f0ff; letter-spacing: 5px;">${otpCode}</h1>
|
||||
<p>Dieser Code ist 15 Minuten lang gültig.</p>
|
||||
`;
|
||||
await sendEmail(lowerCaseEmail, username, 'Void-Genesis - Aktiviere deinen Account', mailHtml);
|
||||
|
||||
// Wenn wir hier ankommen, lief alles fehlerfrei, wir committen die Daten.
|
||||
await client.query('COMMIT');
|
||||
|
||||
return { accountId };
|
||||
} catch (error) {
|
||||
// bei JEDEM Fehler machen wir einen Rollback. Die Datenbank bleibt sauber.
|
||||
await client.query('ROLLBACK');
|
||||
|
||||
if (error.code === '23505') {
|
||||
if (error.constraint === 'lobby_accounts_username_key') {
|
||||
const customErr = new Error('Username already exists');
|
||||
customErr.code = 'ERR_USERNAME_TAKEN';
|
||||
throw customErr;
|
||||
}
|
||||
if (error.constraint === 'lobby_accounts_email_key') {
|
||||
const customErr = new Error('Email already exists');
|
||||
customErr.code = 'ERR_EMAIL_TAKEN';
|
||||
throw customErr;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifiziert die E-Mail-Adresse eines Nutzers
|
||||
*
|
||||
* @param {string} email - Die E-Mail-Addresse des Nutzers
|
||||
* @param {string} otpCode - Der 6-stellige Code aus der E-Mail
|
||||
* @returns {boolean} - true wenn erfolgreich
|
||||
*/
|
||||
export const verifyEmail = async (email, otpCode) => {
|
||||
const client = await pool.connect();
|
||||
const lowerCaseEmail = email.toLowerCase();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Nutzer anhand der E-Mail suchen un die ID sowie Verifizierungsstatus abfragen
|
||||
const accountResult = await client.query(
|
||||
'SELECT id, is_email_verified FROM lobby_accounts WHERE email = $1',
|
||||
[lowerCaseEmail]
|
||||
);
|
||||
|
||||
if (accountResult.rows.length === 0) {
|
||||
// ERR_USER_NOT_FOUND: Es existiert kein Account mit dieser E-Mail
|
||||
const error = new Error('User not found');
|
||||
error.code = 'ERR_USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const account = accountResult.rows[0];
|
||||
|
||||
// Prüfen, ob der Nutzer nicht ohnehin schon verifiziert ist
|
||||
if (account.is_email_verified) {
|
||||
// ERR_ALREADY_VERIFIED: Diese E-Mail-Addresse wurde bereits bestätigt
|
||||
const error = new Error('Already verified');
|
||||
error.code = 'ERR_ALREADY_VERIFIED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Den passenden OTP-Code in der Datenbank suchen
|
||||
const otpResult = await client.query(`
|
||||
SELECT id, expires_at FROM auth_otps
|
||||
WHERE account_id = $1 AND otp_code = $2 AND type = 'register'`,
|
||||
[account.id, otpCode]
|
||||
);
|
||||
|
||||
if (otpResult.rows.length === 0) {
|
||||
// ERR_INVALID_OTP: Der eingegebene Code ist falsch.
|
||||
const error = new Error('Invalid OTP');
|
||||
error.code = 'ERR_INVALID_OTP';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const otpRecord = otpResult.rows[0];
|
||||
|
||||
// Prüfen, ob der Cide bereits abgelaufen ist (älter als 15 Minuten)
|
||||
if (new Date() > new Date(otpRecord.expires_at)) {
|
||||
// ERR_OTP_EXPIRED: Der Code ist abgelaufen. Bitte fordere einen neuen an.
|
||||
const error = new Error('OTP expired');
|
||||
error.code = 'ERR_OTP_EXPIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Account als verifiziert markieren
|
||||
await client.query(
|
||||
'UPDATE lobby_accounts SET is_email_verified = true WHERE id = $1',
|
||||
[account.id]
|
||||
);
|
||||
|
||||
// Den genutzten OTP-Code aus der Datenbank löschen (Sicherheit und Sauberkeit)
|
||||
await client.query(
|
||||
'DELETE FROM auth_otps WHERE id = $1',
|
||||
[otpRecord.id]
|
||||
);
|
||||
|
||||
// Alles lief perfekt, Änderungen speichern
|
||||
await client.query('COMMIT');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Loggt einen Nutzer ein, verifiziert das Passwort uns erstellt JWT-Tokens
|
||||
*
|
||||
* @param {string} identifier - Kann der Benutzername oder die E-Mail-Address sein
|
||||
* @param {string} password - Das eingegebene Klartext-Passwort
|
||||
* @returns {object} - Enthält accessToken, refreshToken und grundlegende Nutzerdaten
|
||||
*/
|
||||
export const loginUser = async (identifier, password) => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
// Nutzer suchen
|
||||
const accountResult = await client.query(`
|
||||
SELECT id, username, email, password_hash, is_email_verified, is_admin
|
||||
FROM lobby_accounts
|
||||
WHERE LOWER(username) = LOWER($1) OR email = LOWER($1)`,
|
||||
[identifier]
|
||||
);
|
||||
|
||||
// Wenn kein Nutzer gefunden wurde
|
||||
if (accountResult.rows.length === 0) {
|
||||
// ERR_INVALID_CREDENTIALS: Benutzername/E-Mail oder Passwort falsch
|
||||
const error = new Error('Invalid credentials');
|
||||
error.code = 'ERR_INVALID_CREDENTIALS';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const account = accountResult.rows[0];
|
||||
|
||||
// Prüfen, ob die Email bereits bestätigt wurde
|
||||
if (!account.is_email_verified) {
|
||||
// ERR_EMAIL_NOT_VERIFIED: Bitte bestätige zuerst deine E-Mail-Addresse
|
||||
const error = new Error('Email not verified');
|
||||
error.code = 'ERR_EMAIL_NOT_VERIFIED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Passwort vergleichen (argon2)
|
||||
const isPasswordValid = await argon2.verify(account.password_hash, password);
|
||||
if (!isPasswordValid) {
|
||||
const error = new Error('Invalied credentials');
|
||||
error.code = 'ERR_INVALID_CREDENTIALS';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// JWT-Tokens geneieren
|
||||
// Das Access-Token speichert die wichtigsten Infos für schnelle Abfragen
|
||||
const tokenPayload = {
|
||||
accountId: account.id,
|
||||
username: account.username,
|
||||
isAdmin: account.is_admin
|
||||
};
|
||||
|
||||
const accessToken = jwt.sign(
|
||||
tokenPayload,
|
||||
process.env.JWT_ACCESS_SECRET,
|
||||
{ expiresIn: '15m' }
|
||||
);
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
{ accountId: account.id },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
{ expiresIn: '30d' }
|
||||
);
|
||||
|
||||
// Refresh-Token in der Datenbank speichern (Vorbereitung für das Single-Session System)
|
||||
await client.query(`
|
||||
INSERT INTO refresh_tokens (account_id, token, expires_at)
|
||||
VALUES ($1, $2, NOW() + INTERVAL '30 days')`,
|
||||
[account.id, refreshToken]
|
||||
);
|
||||
|
||||
// Erfolgreiche Rückgabe der Daten
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: account.id,
|
||||
username: account.username
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Prüft ein Refresh-Token und generiert ein neues, aktuelles Access-Token
|
||||
*
|
||||
* @param {string} refreshToken - Das langlebige Refresh-Token des Nutzers.
|
||||
* @returns {object} - Enthält das neue accessToken
|
||||
*/
|
||||
export const refreshAccessToken = async (refreshToken) => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
// JWT-Signatur und Ablaufdatum des Refresh-Tokens kryptografisch verifizieren
|
||||
// Wenn das Token manipuliert oder abgelaufen ist, wirft jwt.verify sofort einen Fehler.
|
||||
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
|
||||
const { accountId } = payload;
|
||||
|
||||
// Sicherheits-Check: Existiert diese Token noch in unserer Datenbank?
|
||||
// Wenn sich ein zweites Gerät eingeloggt hat, wurden die altenm Tokens gelöscht.
|
||||
// Dieser Check schlägt dasnn fehl und blockiert den unerlaubten Zugriff!
|
||||
const tokenResult = await client.query(
|
||||
'SELECT id FROM refresh_tokens WHERE account_id = $1 AND token = $2',
|
||||
[accountId, refreshToken]
|
||||
);
|
||||
|
||||
if (tokenResult.rows.length === 0) {
|
||||
const error = new Error('Refresh token was revoked or does not exist');
|
||||
error.code = 'ERR_UNAUTHORIZED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Aktuelle Benutzerdaten frosch aus der Datenbank abfragen
|
||||
// Das behebt das "Stale Data" Problem. Falls der Admin Status geändert wurde,
|
||||
// steht er im neuen Token sofort korrekt drin, ohne die DB bei jedem API.Call zu belasten
|
||||
const accountResult = await client.query(
|
||||
'SELECT id, username, is_admin FROM lobby_accounts WHERE id = $1',
|
||||
[accountId]
|
||||
);
|
||||
|
||||
if (accountResult.rows.length === 0) {
|
||||
const error = new Error('Account no longer exists');
|
||||
error.code = 'ERR_UNAUTHORIZED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const account = accountResult.rows[0];
|
||||
|
||||
// Neues kurzlebiges Access-Token erstellen (wieder für 15 min)
|
||||
const tokenPayload = {
|
||||
accountId: account.id,
|
||||
username: account.username,
|
||||
isAdmin: account.is_admin
|
||||
};
|
||||
|
||||
const accessToken = jwt.sign(
|
||||
tokenPayload,
|
||||
process.env.JWT_ACCESS_SECRET,
|
||||
{ expiresIn: '15m' }
|
||||
);
|
||||
|
||||
return { accessToken };
|
||||
} catch (error) {
|
||||
// Abfangen von JWT-spezifischen Fehlern (z.B. Token abgelaufen oder Signatur falsch)
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError' || error.name === 'SyntaxError') {
|
||||
const customErr = new Error('Invalid, expired or malformed token');
|
||||
customErr.code = 'ERR_UNAUTHORIZED';
|
||||
throw customErr;
|
||||
}
|
||||
|
||||
// Alle anderen Fehhler (zB Datenbank Fehler) weiterwerfen
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Sendet eine E-Mail über die Brevo REST API.
|
||||
*
|
||||
* @param {string} toEmail - Die Ziel E-Mail Adresse
|
||||
* @param {string} toName - Der Benutzername des Empfängers
|
||||
* @param {string} subject - Der Betreff der E-Mail
|
||||
* @param {string} htmlContent - Der HTML-Inhalt der E-Mail
|
||||
* @returns {boolean} - true wenn erfolgreich, wirft einen Fehler wenn nicht.
|
||||
*/
|
||||
export const sendEmail = async (toEmail, toName, subject, htmlContent) => {
|
||||
const mailMode = process.env.MAIL_MODE || 'development';
|
||||
|
||||
// Im Development-Modus loggen wir nur, um das API-Limit nicht zu belasten
|
||||
if (mailMode === 'development') {
|
||||
console.log(`\n[DEV-MAIL-SIMULATION] An: ${toEmail}`);
|
||||
console.log(`[DEV-MAIL-SIMULATION] Betreff: ${subject}`);
|
||||
console.log(`[DEV-MAIL-SIMULATION] Inhalt:\n${htmlContent}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Echter Versand über Brevo
|
||||
try {
|
||||
const response = await fetch('https://api.brevo.com/v3/smtp/email', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'api-key': process.env.BREVO_API_KEY,
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sender: {
|
||||
name: process.env.BREVO_SENDER_NAME,
|
||||
email: process.env.BREVO_SENDER_EMAIL
|
||||
},
|
||||
to: [{
|
||||
email: toEmail,
|
||||
name: toName
|
||||
}],
|
||||
subject: subject,
|
||||
htmlContent: htmlContent
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('Brevo API Fehler-Details:', errorData);
|
||||
throw new Error('Fehler bei der Kommunikation mit dem E-Mail-Provider.');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Fehler im email.service:', error);
|
||||
throw error; // Den Fehler werfen wir weiter, damit der Aufrufer ihn behandeln kann.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user