Void-Genesis: JWT-Handling, Szenenwechsel & Lobby-UI

This commit is contained in:
2026-05-31 18:07:00 +02:00
parent 7fadab8cd6
commit 4a4bccccc1
14 changed files with 1748 additions and 78 deletions
@@ -146,7 +146,8 @@ export const login = async (req, res) => {
}
if (error.code === 'ERR_EMAIL_NOT_VERIFIED') {
return res.status(403).json({
error: 'ERR_EMAIL_NOT_VERIFIED'
error: 'ERR_EMAIL_NOT_VERIFIED',
email: error.email
});
}
@@ -179,7 +180,8 @@ export const refresh = async (req, res) => {
// Das neue Access-Token an das Frontend zurück geben
return res.status(200).json({
success: true,
accessToken: result.accessToken
accessToken: result.accessToken,
user: result.user
});
} catch (error) {
@@ -198,4 +200,119 @@ export const refresh = async (req, res) => {
error: 'ERR_INTERNAL_SERVER'
});
}
}
}
/**
* Controller-Methode für die Resend-OTP-Route
* Wird aufgerufen, wenn der Nutzer im Frontend auf "Code erneut senden" klickt
*/
export const resendOTP = async (req, res) => {
try {
const { email } = req.body;
if (!email) {
return res.status(400).json({
error: 'ERR_MISSING_FIELDS'
});
}
// Aufruf unseres Service
await authService.resendOTP(email);
return res.status(200).json({
success: true,
// MSG_OTP_RESENT: Ein neuer Code wurde an deine E-Mail gesendet.
message: 'MSG_OTP_RESENT'
});
} catch (error) {
if (['ERR_USER_NOT_FOUND', 'ERR_ALREADY_VERIFIED', 'ERR_COOLDOWN_ACTIVE'].includes(error.code)) {
const statusCode = error.code === 'ERR_COOLDOWN_ACTIVE' ? 429 : 400;
return res.status(error.code).json({
error: error.code
});
}
console.error('[AuthController] Fehler beim erneuten Senden des OTP:', error);
return res.status(500).json({
error: 'ERR_INTERNAL_SERVER'
});
}
};
/**
* Controller-Methode für die Passwort vergessen anfrage
*/
export const requestPasswordReset = async (req, res) => {
try {
const { email } = req.body;
if (!email) {
return res.status(400).json({
error: 'ERR_MISSING_FIELDS'
});
}
// Auch wenn die E-Mail nicht existiert, geben wir oft ein Success zurück,
// damit Angreifer nicht testen können, welche E-Mails registriert sind.
// In unserem Service werfen wir aber ERR_USER_NOT_FOUND. Wir fangen das unten ab.
return res.status(200).json({
success: true,
message: 'MSG_RESET_EMAIL_SENT' // Muss später ins i18n
});
} catch (error) {
// Wenn der Nutzer nicht existiert, tun wir für das Frontend trotzdem so, als wäre
// die E-Mail rausgegangen (Sicherheitsbest-Practice).
if (error.code === 'ERR_USER_NOT_FOUND') {
return res.status(200).json({
success: true,
message: 'MSG_RESET_EMAIL_SENT'
});
}
if (error.code === 'ERR_COOLDOWN_ACTIVE') {
return res.status(429).json({
error: error.code
});
}
console.error('[AuthController] Fehler bei requestPasswordReset:', error);
return res.status(500).json({ error: 'ERR_INTERNAL_SERVER' });
}
};
/**
* Controller-Methode für das finale Setzen des neuen Passworts.
*/
export const resetPassword = async (req, res) => {
try {
const { email, otpCode, newPassword } = req.body;
// Basis-Validierung
if (!email || !otpCode || !newPassword) {
return res.status(400).json({ error: 'ERR_MISSING_FIELDS' });
}
// Passwort-Richtlinie prüfen (wie bei der Registrierung)
if (newPassword.length < 8) {
return res.status(400).json({ error: 'ERR_PASSWORD_TOO_SHORT' });
}
// Service aufrufen
await authService.resetPassword(email, otpCode, newPassword);
return res.status(200).json({
success: true,
message: 'MSG_PASSWORD_RESET_SUCCESS' // Muss später ins i18n
});
} catch (error) {
if (['ERR_USER_NOT_FOUND', 'ERR_INVALID_OTP', 'ERR_OTP_EXPIRED'].includes(error.code)) {
return res.status(400).json({ error: error.code });
}
console.error('[AuthController] Fehler bei resetPassword:', error);
return res.status(500).json({ error: 'ERR_INTERNAL_SERVER' });
}
};
@@ -7,5 +7,8 @@ router.post('/register', authController.register);
router.post('/verify-email', authController.verifyEmail);
router.post('/login', authController.login);
router.post('/refresh', authController.refresh);
router.post('/resend-otp', authController.resendOTP);
router.post('/forgot-password', authController.requestPasswordReset);
router.post('/reset-password', authController.resetPassword);
export default router;
+261 -5
View File
@@ -202,7 +202,7 @@ export const loginUser = async (identifier, password) => {
try {
// Nutzer suchen
const accountResult = await client.query(`
SELECT id, username, email, password_hash, is_email_verified, is_admin
SELECT id, username, email, password_hash, is_email_verified, is_admin, res_premium
FROM lobby_accounts
WHERE LOWER(username) = LOWER($1) OR email = LOWER($1)`,
[identifier]
@@ -223,6 +223,7 @@ export const loginUser = async (identifier, password) => {
// 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';
error.email = account.email;
throw error;
}
@@ -267,7 +268,9 @@ export const loginUser = async (identifier, password) => {
refreshToken,
user: {
id: account.id,
username: account.username
username: account.username,
email: account.email,
res_premium: account.res_premium
}
};
} finally {
@@ -308,7 +311,7 @@ export const refreshAccessToken = async (refreshToken) => {
// 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',
'SELECT id, username, email, is_admin, res_premium FROM lobby_accounts WHERE id = $1',
[accountId]
);
@@ -333,7 +336,15 @@ export const refreshAccessToken = async (refreshToken) => {
{ expiresIn: '15m' }
);
return { accessToken };
return {
accessToken,
user: {
id: account.id,
username: account.username,
email: account.email,
res_premium: res_premium
}
};
} catch (error) {
// Abfangen von JWT-spezifischen Fehlern (z.B. Token abgelaufen oder Signatur falsch)
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError' || error.name === 'SyntaxError') {
@@ -347,4 +358,249 @@ export const refreshAccessToken = async (refreshToken) => {
} finally {
client.release();
}
}
}
/**
* Sendet einen neuen OTP-Code an den Nutzer, falls der alte abgelaufen ist oder nicht ankam.
* Vorherige, ungenutzte Codes werdeb zur Sicherheit invalidiert (gelöscht)
*
* @param {string} email - Die E-Mail Addresse des Nutzers
* @returns {boolean} - true, wenn die E-Mail erfolgreich versendet wurde.
*/
export const resendOTP = async (email) => {
const client = await pool.connect();
const lowerCaseEmail = email.toLowerCase();
try {
await client.query('BEGIN');
// Prüfen, ob der Nutzer überhaupt existiert
const accountResult = await client.query(
'SELECT id, username, is_email_verified FROM lobby_accounts WHERE email = $1',
[lowerCaseEmail]
);
if (accountResult.rows.length === 0) {
// Aus Sicherheitsgründen verraten wir dem Frontend nicht zwingend,
// dass die E-Mail nicht existieren, aber für unser internes Error-Handling
// werfen wir einen spezifischen Fehler.
const error = new Error('User nor found');
error.code = 'ERR:USER_NOT_FOUND';
throw error;
}
const account = accountResult.rows[0];
// Prüfen, ob der Account nicht vielleicht schon verifiziert ist
// Wenn ja, macht ein neuer Code keinen Sinn.
if (account.is_email_verified) {
const error = new Error('Already verified');
error.code = 'ERR_ALREADY_VERIFIED';
throw error;
}
const lastOtpResult = await client.query(`
SELECT created_at FROM auth_otps WHERE account_id = $1 AND type = 'register' ORDER BY created_at DESC LIMIT 1`,
[account.id]
);
if (lastOtpResult.rows.length > 0) {
const lastCreatedTime = new Date(lastOtpResult.rows[0].created_at).getTime();
const currentTime = new Date().getTime();
const timeDifference = currentTime - lastCreatedTime;
if (timeDifference < 120000) {
const error = new Error('Cooldown active');
error.code = 'ERR_COOLDOWN_ACTIVE';
throw error;
}
}
// Zur Sichherheit alle alten, noch aktiven Registierungs-OTPs dieses Nutzers löschen.
// So verhindern wir, dass alte Codes in Postfächern noch funktionieren und minimieren
// Spam-Risiken.
await client.query(
`DELETE FROM auth_otps WHERE account_id = $1 AND type = 'register'`,
[account.id]
);
// Einen frischen, 6-stelligen Cide generieren
const newOtpCode = generateOTP();
// Den neuen Code in die Datenbank eintragen
await client.query(`
INSERT INTO auth_otps (account_id, otp_code, type, expires_at)
VALUES ($1, $2, 'register', NOW() + INTERVAL '15 minutes')`,
[account.id, newOtpCode]
);
// 6. Die neue E-Mail zusammenbauen und versenden
const mailHtml = `
<h2>Hallo ${account.username}, hier ist dein neuer Code!</h2>
<p>Du hast einen neuen Verifizierungscode für Void-Genesis angefordert:</p>
<h1 style="color: #00f0ff; letter-spacing: 5px;">${newOtpCode}</h1>
<p>Dieser Code ist ab jetzt wieder 15 Minuten lang gültig.</p>
`;
await sendEmail(lowerCaseEmail, account.username, 'Void-Genesis - Dein neue Code', mailHtml);
await client.query('COMMIT');
return true;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
/**
* Startet den Prozess zum Zurücksetzten des Passworts.
* Generiert einen speziellen OTP-Code und sendet ihn per E-Mail.
*
* @param {string} email - Die Email-Adresse des Nutzers
* @returns {boolean} - true, wenn die E-Mail erfolgreich versendet wurde.
*/
export const requestPasswordReset = async (email) => {
const client = await pool.connect();
const lowerCaseEmail = email.toLowerCase();
try {
await client.query('BEGIN');
const accountResult = await client.query(
'SELECT id, username FROM lobby_accounts WHERE email = $1',
[lowerCaseEmail]
);
if (accountResult.rows.length === 0) {
const error = new Error('User not found');
error.code = 'ERR_USER_NOT_FOUND';
throw error;
}
const lastOtpResult = await client.query(`
SELECT created_at FROM auth_otps WHERE account_id = $1 AND type = 'password_reset' ORDER BY created_at DESC LIMIT 1`,
[account.id]
);
if (lastOtpResult.rows.length > 0) {
const lastCreatedTime = new Date(lastOtpResult.rows[0].created_at).getTime();
const currentTime = new Date().getTime();
const timeDifference = currentTime - lastCreatedTime;
if (timeDifference < 120000) {
const error = new Error('Cooldown active');
error.code = 'ERR_COOLDOWN_ACTIVE';
throw error;
}
}
const account = accountResult.rows[0];
await client.query(
`DELETE FROM auth_otps WHERE account_id = $1 AND type = 'password_reset'`,
[account.id]
);
const resetCode = generateOTP();
await client.query(`
INSERT INTO auth_otps (account_id, otp_code, type, expires_at)
VALUES ($1, $2m 'password_reset' NOW() + INTERVAL '15 minutes')`,
[account.id, resetCode]
);
const mailHtml = `
<h2>Hallo ${account.username},</h2>
<p>Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt.</p>
<p>Dein Code lautet:</p>
<h1 style="color: #00f0ff; letter-spacing: 5px;">${resetCode}</h1>
<p>Dieser Code ist 15 Minuten lang gültig. Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.</p>
`;
await sendEmail(lowerCaseEmail, account.username, 'Void-Genesis - Passwort zurücksetzen', mailHtml);
await client.query('COMMIT');
return true;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
/**
* Setzt das neue Passwort, soffern der OTP-Code korrekt und nicht abgelaufen ist
*
* @param {string} email - Die E-Mail Addresse des Nutzers
* @param {string} otpCode - Der 6-stellige Code aus der E-Mail
* @param {string} newPassword - das neue Klartext passwort
* @returns {boolean} - true wenn das Passwort erfolgreich geändert wurde
*/
export const resetPassword = async (email, otpCode, newPassword) => {
const client = await pool.connect();
const lowerCaseEmail = email.toLowerCase();
try {
await client.query('BEGIN');
const accountResult = await client.query(
'SELECT id FROM lobby_accounts WHERE email = $1'
[lowerCaseEmail]
);
if (accountResult.rows.length === 0) {
const error = new Error('User not found');
error.code = 'ERR_USER_NOT_FOUND';
throw error;
}
const account = accountResult.rows[0];
const otpResult = await client.query(`
SELECT id, expires_at FROM auth_otps
WHERE account_id = $1, otp_code = $2, AND type = 'password_reset'`,
[account.id, otpCo0de]
);
if (otpResult.rows.length === 0) {
const error = new Error('Invalid OTP');
error.code = 'ERR_INVALID_OTP';
throw error;
}
const otpRecord = otpResult.rows[0];
if (new Date() > new Date(otpRecord.expires_at)) {
const error = new Error('OTP expired');
error.code = 'ERR_OTP_EXPIRED';
throw error;
}
const passwordHash = await argon2.hash(newPassword);
await client.query(
'UPDATE lobby_accounts SET password_hash = $1 WHERE id = $2',
[passwordHash, account.id]
);
await client.query(
'DELETE FROM refresh_tokens WHERE account_id = $1',
[account.id]
);
await client.query('COMMIT');
return true;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};