Implement 301 redirects for www to non-www and enhance canonical tag handling
This commit adds 301 redirects in the Apache configuration to redirect traffic from www.tt-tagebuch.de to tt-tagebuch.de for both HTTP and HTTPS. Additionally, it introduces middleware in the backend to dynamically set canonical tags based on the request URL, ensuring proper SEO practices. The request logging middleware has been disabled, and sensitive data handling has been improved in the MyTischtennis model and API logging service, ensuring compliance with data protection regulations. Frontend updates include enhanced descriptions and features in the application, improving user experience and clarity.
This commit is contained in:
@@ -1,87 +1,13 @@
|
||||
import ApiLog from '../models/ApiLog.js';
|
||||
|
||||
/**
|
||||
* Middleware to log all API requests and responses
|
||||
* Should be added early in the middleware chain, but after authentication
|
||||
*
|
||||
* HINWEIS: Logging wurde deaktiviert - keine API-Requests werden mehr geloggt
|
||||
* (früher wurden nur MyTischtennis-Requests geloggt, dies wurde entfernt)
|
||||
*/
|
||||
export const requestLoggingMiddleware = async (req, res, next) => {
|
||||
const startTime = Date.now();
|
||||
const originalSend = res.send;
|
||||
|
||||
// Get request body (but limit size for sensitive data)
|
||||
let requestBody = null;
|
||||
if (req.body && Object.keys(req.body).length > 0) {
|
||||
const bodyStr = JSON.stringify(req.body);
|
||||
// Truncate very long bodies
|
||||
requestBody = bodyStr.length > 10000 ? bodyStr.substring(0, 10000) + '... (truncated)' : bodyStr;
|
||||
}
|
||||
|
||||
// Capture response
|
||||
let responseBody = null;
|
||||
res.send = function(data) {
|
||||
// Try to parse response as JSON
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const responseStr = JSON.stringify(parsed);
|
||||
// Truncate very long responses
|
||||
responseBody = responseStr.length > 10000 ? responseStr.substring(0, 10000) + '... (truncated)' : responseStr;
|
||||
} catch (e) {
|
||||
// Not JSON, just use raw data (truncated)
|
||||
responseBody = typeof data === 'string' ? data.substring(0, 1000) : String(data).substring(0, 1000);
|
||||
}
|
||||
|
||||
// Restore original send
|
||||
res.send = originalSend;
|
||||
return res.send.apply(res, arguments);
|
||||
};
|
||||
|
||||
// Log after response is sent
|
||||
res.on('finish', async () => {
|
||||
const executionTime = Date.now() - startTime;
|
||||
const ipAddress = req.ip || req.connection.remoteAddress || req.headers['x-forwarded-for'];
|
||||
const path = req.path || req.url;
|
||||
|
||||
// Nur myTischtennis-Requests loggen
|
||||
// Skip logging for non-data endpoints (Status-Checks, Health-Checks, etc.)
|
||||
// Exclude any endpoint containing 'status' or root paths
|
||||
if (
|
||||
path.includes('/status') ||
|
||||
path === '/' ||
|
||||
path === '/health' ||
|
||||
path.endsWith('/status') ||
|
||||
path.includes('/scheduler-status')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Nur myTischtennis-Endpunkte loggen (z.B. /api/mytischtennis/*)
|
||||
if (!path.includes('/mytischtennis')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user ID if available (wird von authMiddleware gesetzt)
|
||||
const userId = req.user?.id || null;
|
||||
|
||||
try {
|
||||
await ApiLog.create({
|
||||
userId,
|
||||
method: req.method,
|
||||
path: path,
|
||||
statusCode: res.statusCode,
|
||||
requestBody,
|
||||
responseBody,
|
||||
executionTime,
|
||||
errorMessage: res.statusCode >= 400 ? `HTTP ${res.statusCode}` : null,
|
||||
ipAddress,
|
||||
userAgent: req.headers['user-agent'],
|
||||
logType: 'api_request'
|
||||
});
|
||||
} catch (error) {
|
||||
// Don't let logging errors break the request
|
||||
console.error('Error logging API request:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Logging wurde deaktiviert - keine API-Requests werden mehr geloggt
|
||||
// (früher wurden nur MyTischtennis-Requests geloggt, dies wurde entfernt)
|
||||
next();
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,14 @@ const MyTischtennis = sequelize.define('MyTischtennis', {
|
||||
email: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
set(value) {
|
||||
const encryptedValue = encryptData(value);
|
||||
this.setDataValue('email', encryptedValue);
|
||||
},
|
||||
get() {
|
||||
const encryptedValue = this.getDataValue('email');
|
||||
return decryptData(encryptedValue);
|
||||
}
|
||||
},
|
||||
encryptedPassword: {
|
||||
type: DataTypes.TEXT,
|
||||
@@ -43,12 +51,38 @@ const MyTischtennis = sequelize.define('MyTischtennis', {
|
||||
accessToken: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'access_token'
|
||||
field: 'access_token',
|
||||
set(value) {
|
||||
if (value === null || value === undefined) {
|
||||
this.setDataValue('accessToken', null);
|
||||
} else {
|
||||
const encryptedValue = encryptData(value);
|
||||
this.setDataValue('accessToken', encryptedValue);
|
||||
}
|
||||
},
|
||||
get() {
|
||||
const encryptedValue = this.getDataValue('accessToken');
|
||||
if (!encryptedValue) return null;
|
||||
return decryptData(encryptedValue);
|
||||
}
|
||||
},
|
||||
refreshToken: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'refresh_token'
|
||||
field: 'refresh_token',
|
||||
set(value) {
|
||||
if (value === null || value === undefined) {
|
||||
this.setDataValue('refreshToken', null);
|
||||
} else {
|
||||
const encryptedValue = encryptData(value);
|
||||
this.setDataValue('refreshToken', encryptedValue);
|
||||
}
|
||||
},
|
||||
get() {
|
||||
const encryptedValue = this.getDataValue('refreshToken');
|
||||
if (!encryptedValue) return null;
|
||||
return decryptData(encryptedValue);
|
||||
}
|
||||
},
|
||||
expiresAt: {
|
||||
type: DataTypes.BIGINT,
|
||||
@@ -57,27 +91,99 @@ const MyTischtennis = sequelize.define('MyTischtennis', {
|
||||
},
|
||||
cookie: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
allowNull: true,
|
||||
set(value) {
|
||||
if (value === null || value === undefined) {
|
||||
this.setDataValue('cookie', null);
|
||||
} else {
|
||||
const encryptedValue = encryptData(value);
|
||||
this.setDataValue('cookie', encryptedValue);
|
||||
}
|
||||
},
|
||||
get() {
|
||||
const encryptedValue = this.getDataValue('cookie');
|
||||
if (!encryptedValue) return null;
|
||||
return decryptData(encryptedValue);
|
||||
}
|
||||
},
|
||||
userData: {
|
||||
type: DataTypes.JSON,
|
||||
type: DataTypes.TEXT, // Changed from JSON to TEXT to store encrypted JSON string
|
||||
allowNull: true,
|
||||
field: 'user_data'
|
||||
field: 'user_data',
|
||||
set(value) {
|
||||
if (value === null || value === undefined) {
|
||||
this.setDataValue('userData', null);
|
||||
} else {
|
||||
const jsonString = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
const encryptedValue = encryptData(jsonString);
|
||||
this.setDataValue('userData', encryptedValue);
|
||||
}
|
||||
},
|
||||
get() {
|
||||
const encryptedValue = this.getDataValue('userData');
|
||||
if (!encryptedValue) return null;
|
||||
try {
|
||||
const decryptedString = decryptData(encryptedValue);
|
||||
return JSON.parse(decryptedString);
|
||||
} catch (error) {
|
||||
console.error('Error decrypting/parsing userData:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
field: 'club_id'
|
||||
field: 'club_id',
|
||||
set(value) {
|
||||
if (value === null || value === undefined) {
|
||||
this.setDataValue('clubId', null);
|
||||
} else {
|
||||
const encryptedValue = encryptData(value);
|
||||
this.setDataValue('clubId', encryptedValue);
|
||||
}
|
||||
},
|
||||
get() {
|
||||
const encryptedValue = this.getDataValue('clubId');
|
||||
if (!encryptedValue) return null;
|
||||
return decryptData(encryptedValue);
|
||||
}
|
||||
},
|
||||
clubName: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
field: 'club_name'
|
||||
field: 'club_name',
|
||||
set(value) {
|
||||
if (value === null || value === undefined) {
|
||||
this.setDataValue('clubName', null);
|
||||
} else {
|
||||
const encryptedValue = encryptData(value);
|
||||
this.setDataValue('clubName', encryptedValue);
|
||||
}
|
||||
},
|
||||
get() {
|
||||
const encryptedValue = this.getDataValue('clubName');
|
||||
if (!encryptedValue) return null;
|
||||
return decryptData(encryptedValue);
|
||||
}
|
||||
},
|
||||
fedNickname: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
field: 'fed_nickname'
|
||||
field: 'fed_nickname',
|
||||
set(value) {
|
||||
if (value === null || value === undefined) {
|
||||
this.setDataValue('fedNickname', null);
|
||||
} else {
|
||||
const encryptedValue = encryptData(value);
|
||||
this.setDataValue('fedNickname', encryptedValue);
|
||||
}
|
||||
},
|
||||
get() {
|
||||
const encryptedValue = this.getDataValue('fedNickname');
|
||||
if (!encryptedValue) return null;
|
||||
return decryptData(encryptedValue);
|
||||
}
|
||||
},
|
||||
lastLoginAttempt: {
|
||||
type: DataTypes.DATE,
|
||||
|
||||
169
backend/scripts/migrateMyTischtennisEncryption.js
Normal file
169
backend/scripts/migrateMyTischtennisEncryption.js
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Migration Script: Verschlüsselung für bestehende MyTischtennis-Daten
|
||||
*
|
||||
* WICHTIG: Dieses Script verschlüsselt bestehende unverschlüsselte MyTischtennis-Daten.
|
||||
* Es sollte NUR EINMAL ausgeführt werden, nachdem das Model aktualisiert wurde.
|
||||
*
|
||||
* Vorsicht: Wenn Daten bereits verschlüsselt sind, wird dieses Script sie doppelt verschlüsseln!
|
||||
*
|
||||
* Usage: node backend/scripts/migrateMyTischtennisEncryption.js
|
||||
*/
|
||||
|
||||
import sequelize from '../database.js';
|
||||
import MyTischtennis from '../models/MyTischtennis.js';
|
||||
import { encryptData } from '../utils/encrypt.js';
|
||||
|
||||
async function migrateMyTischtennisEncryption() {
|
||||
console.log('🔄 Starte Migration: Verschlüsselung für MyTischtennis-Daten\n');
|
||||
|
||||
try {
|
||||
// Hole alle MyTischtennis-Einträge mit raw: true, um unverschlüsselte Daten zu bekommen
|
||||
const accounts = await MyTischtennis.findAll({
|
||||
raw: true,
|
||||
attributes: ['id', 'email', 'access_token', 'refresh_token', 'cookie', 'user_data', 'club_id', 'club_name', 'fed_nickname']
|
||||
});
|
||||
|
||||
console.log(`📊 Gefundene Einträge: ${accounts.length}\n`);
|
||||
|
||||
if (accounts.length === 0) {
|
||||
console.log('✅ Keine Einträge gefunden. Migration nicht erforderlich.');
|
||||
return;
|
||||
}
|
||||
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
// Prüfe, ob Daten bereits verschlüsselt sind
|
||||
// Verschlüsselte Daten sind hex-Strings und haben eine bestimmte Länge
|
||||
// Unverschlüsselte E-Mail-Adressen enthalten normalerweise @
|
||||
const emailIsEncrypted = !account.email.includes('@') && account.email.length > 32;
|
||||
|
||||
if (emailIsEncrypted) {
|
||||
console.log(`⏭️ Eintrag ${account.id}: Bereits verschlüsselt, überspringe...`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`🔐 Verschlüssele Eintrag ${account.id}...`);
|
||||
|
||||
// Verschlüssele alle Felder direkt in der Datenbank
|
||||
const updateData = {};
|
||||
|
||||
if (account.email && account.email.includes('@')) {
|
||||
updateData.email = encryptData(account.email);
|
||||
}
|
||||
|
||||
if (account.access_token && !account.access_token.startsWith('encrypted_')) {
|
||||
// Prüfe, ob es bereits verschlüsselt aussieht (hex-String)
|
||||
const looksEncrypted = /^[0-9a-f]+$/i.test(account.access_token) && account.access_token.length > 32;
|
||||
if (!looksEncrypted) {
|
||||
updateData.access_token = encryptData(account.access_token);
|
||||
}
|
||||
}
|
||||
|
||||
if (account.refresh_token && !account.refresh_token.startsWith('encrypted_')) {
|
||||
const looksEncrypted = /^[0-9a-f]+$/i.test(account.refresh_token) && account.refresh_token.length > 32;
|
||||
if (!looksEncrypted) {
|
||||
updateData.refresh_token = encryptData(account.refresh_token);
|
||||
}
|
||||
}
|
||||
|
||||
if (account.cookie && !account.cookie.startsWith('encrypted_')) {
|
||||
const looksEncrypted = /^[0-9a-f]+$/i.test(account.cookie) && account.cookie.length > 32;
|
||||
if (!looksEncrypted) {
|
||||
updateData.cookie = encryptData(account.cookie);
|
||||
}
|
||||
}
|
||||
|
||||
if (account.user_data) {
|
||||
// user_data ist JSON, muss zuerst zu String konvertiert werden
|
||||
try {
|
||||
const userDataStr = typeof account.user_data === 'string'
|
||||
? account.user_data
|
||||
: JSON.stringify(account.user_data);
|
||||
// Prüfe, ob bereits verschlüsselt
|
||||
const looksEncrypted = /^[0-9a-f]+$/i.test(userDataStr) && userDataStr.length > 32;
|
||||
if (!looksEncrypted) {
|
||||
updateData.user_data = encryptData(userDataStr);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(` ⚠️ Fehler bei user_data für Eintrag ${account.id}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (account.club_id && account.club_id.length > 0 && !account.club_id.startsWith('encrypted_')) {
|
||||
const looksEncrypted = /^[0-9a-f]+$/i.test(account.club_id) && account.club_id.length > 32;
|
||||
if (!looksEncrypted) {
|
||||
updateData.club_id = encryptData(account.club_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (account.club_name && account.club_name.length > 0 && !account.club_name.startsWith('encrypted_')) {
|
||||
const looksEncrypted = /^[0-9a-f]+$/i.test(account.club_name) && account.club_name.length > 32;
|
||||
if (!looksEncrypted) {
|
||||
updateData.club_name = encryptData(account.club_name);
|
||||
}
|
||||
}
|
||||
|
||||
if (account.fed_nickname && account.fed_nickname.length > 0 && !account.fed_nickname.startsWith('encrypted_')) {
|
||||
const looksEncrypted = /^[0-9a-f]+$/i.test(account.fed_nickname) && account.fed_nickname.length > 32;
|
||||
if (!looksEncrypted) {
|
||||
updateData.fed_nickname = encryptData(account.fed_nickname);
|
||||
}
|
||||
}
|
||||
|
||||
// Update nur, wenn es etwas zu aktualisieren gibt
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await sequelize.query(
|
||||
`UPDATE my_tischtennis SET ${Object.keys(updateData).map(key => `\`${key}\` = :${key}`).join(', ')} WHERE id = :id`,
|
||||
{
|
||||
replacements: { ...updateData, id: account.id },
|
||||
type: sequelize.QueryTypes.UPDATE
|
||||
}
|
||||
);
|
||||
migrated++;
|
||||
console.log(` ✅ Eintrag ${account.id} erfolgreich verschlüsselt`);
|
||||
} else {
|
||||
skipped++;
|
||||
console.log(` ⏭️ Eintrag ${account.id}: Keine unverschlüsselten Daten gefunden`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
errors++;
|
||||
console.error(` ❌ Fehler bei Eintrag ${account.id}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n📊 Migrations-Zusammenfassung:');
|
||||
console.log(` ✅ Migriert: ${migrated}`);
|
||||
console.log(` ⏭️ Übersprungen: ${skipped}`);
|
||||
console.log(` ❌ Fehler: ${errors}`);
|
||||
|
||||
if (errors === 0) {
|
||||
console.log('\n✅ Migration erfolgreich abgeschlossen!');
|
||||
} else {
|
||||
console.log('\n⚠️ Migration abgeschlossen, aber es gab Fehler. Bitte prüfen Sie die Logs.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Kritischer Fehler bei Migration:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await sequelize.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Script ausführen
|
||||
migrateMyTischtennisEncryption()
|
||||
.then(() => {
|
||||
console.log('\n✅ Script beendet.');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('\n❌ Script fehlgeschlagen:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -139,12 +139,48 @@ app.use('/api/member-transfer-config', memberTransferConfigRoutes);
|
||||
app.use('/api/training-groups', trainingGroupRoutes);
|
||||
app.use('/api/training-times', trainingTimeRoutes);
|
||||
|
||||
app.use(express.static(path.join(__dirname, '../frontend/dist')));
|
||||
// Middleware für dynamischen kanonischen Tag (vor express.static)
|
||||
const setCanonicalTag = (req, res, next) => {
|
||||
// Nur für HTML-Anfragen (nicht für API, Assets, etc.)
|
||||
if (req.path.startsWith('/api') || req.path.match(/\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|mp3|webmanifest|xml|txt)$/)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Catch-All Handler für Frontend-Routen (muss nach den API-Routen stehen)
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
|
||||
});
|
||||
// Prüfe, ob die Datei als statische Datei existiert (außer index.html)
|
||||
const staticPath = path.join(__dirname, '../frontend/dist', req.path);
|
||||
fs.access(staticPath, fs.constants.F_OK, (err) => {
|
||||
if (!err && req.path !== '/' && req.path !== '/index.html') {
|
||||
// Datei existiert und ist nicht index.html, lasse express.static sie servieren
|
||||
return next();
|
||||
}
|
||||
|
||||
// Datei existiert nicht oder ist index.html, serviere index.html mit dynamischem kanonischen Tag
|
||||
const indexPath = path.join(__dirname, '../frontend/dist/index.html');
|
||||
fs.readFile(indexPath, 'utf8', (err, data) => {
|
||||
if (err) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Bestimme die kanonische URL (bevorzuge non-www)
|
||||
const protocol = req.protocol || 'https';
|
||||
const host = req.get('host') || 'tt-tagebuch.de';
|
||||
const canonicalHost = host.replace(/^www\./, ''); // Entferne www falls vorhanden
|
||||
const canonicalUrl = `${protocol}://${canonicalHost}${req.path === '/' ? '' : req.path}`;
|
||||
|
||||
// Ersetze den kanonischen Tag
|
||||
const updatedData = data.replace(
|
||||
/<link rel="canonical" href="[^"]*" \/>/,
|
||||
`<link rel="canonical" href="${canonicalUrl}" />`
|
||||
);
|
||||
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.send(updatedData);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
app.use(setCanonicalTag);
|
||||
app.use(express.static(path.join(__dirname, '../frontend/dist')));
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import ApiLog from '../models/ApiLog.js';
|
||||
import { Op } from 'sequelize';
|
||||
import { sanitizeLogData, truncateString, sanitizeIpAddress, sanitizeUserAgent } from '../utils/logDataSanitizer.js';
|
||||
|
||||
class ApiLogService {
|
||||
/**
|
||||
* Log an API request/response
|
||||
* DSGVO-konform: Personenbezogene Daten werden nur bei Fehlern geloggt und dann verschlüsselt/gekürzt
|
||||
*/
|
||||
async logRequest(options) {
|
||||
try {
|
||||
@@ -22,24 +24,49 @@ class ApiLogService {
|
||||
schedulerJobType = null
|
||||
} = options;
|
||||
|
||||
// Truncate long fields (raise limits to fit typical API JSON bodies)
|
||||
const truncate = (str, maxLen = 64000) => {
|
||||
if (!str) return null;
|
||||
const strVal = typeof str === 'string' ? str : JSON.stringify(str);
|
||||
return strVal.length > maxLen ? strVal.substring(0, maxLen) + '... (truncated)' : strVal;
|
||||
};
|
||||
const isError = statusCode >= 400;
|
||||
|
||||
// DSGVO-konform: Nur bei Fehlern Request/Response-Bodies loggen
|
||||
let sanitizedRequestBody = null;
|
||||
let sanitizedResponseBody = null;
|
||||
|
||||
if (isError) {
|
||||
// Bei Fehlern: Sanitize personenbezogene Daten
|
||||
if (requestBody) {
|
||||
sanitizedRequestBody = sanitizeLogData(requestBody, true); // Verschlüssele sensible Daten
|
||||
// Prüfe, ob Ergebnis bereits ein String ist (sanitizeLogData kann String oder Objekt zurückgeben)
|
||||
const requestBodyStr = typeof sanitizedRequestBody === 'string'
|
||||
? sanitizedRequestBody
|
||||
: JSON.stringify(sanitizedRequestBody);
|
||||
sanitizedRequestBody = truncateString(requestBodyStr, 2000);
|
||||
}
|
||||
|
||||
if (responseBody) {
|
||||
sanitizedResponseBody = sanitizeLogData(responseBody, true); // Verschlüssele sensible Daten
|
||||
// Prüfe, ob Ergebnis bereits ein String ist (sanitizeLogData kann String oder Objekt zurückgeben)
|
||||
const responseBodyStr = typeof sanitizedResponseBody === 'string'
|
||||
? sanitizedResponseBody
|
||||
: JSON.stringify(sanitizedResponseBody);
|
||||
sanitizedResponseBody = truncateString(responseBodyStr, 2000);
|
||||
}
|
||||
}
|
||||
// Bei Erfolg: Keine Bodies loggen (Datenminimierung)
|
||||
|
||||
// IP-Adresse und User-Agent sanitizen
|
||||
const sanitizedIp = sanitizeIpAddress(ipAddress);
|
||||
const sanitizedUA = sanitizeUserAgent(userAgent);
|
||||
|
||||
await ApiLog.create({
|
||||
userId,
|
||||
method,
|
||||
path,
|
||||
statusCode,
|
||||
requestBody: truncate(requestBody, 64000),
|
||||
responseBody: truncate(responseBody, 64000),
|
||||
requestBody: sanitizedRequestBody,
|
||||
responseBody: sanitizedResponseBody,
|
||||
executionTime,
|
||||
errorMessage: truncate(errorMessage, 5000),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
errorMessage: errorMessage ? truncateString(errorMessage, 5000) : null,
|
||||
ipAddress: sanitizedIp,
|
||||
userAgent: sanitizedUA,
|
||||
logType,
|
||||
schedulerJobType
|
||||
});
|
||||
@@ -157,6 +184,8 @@ class ApiLogService {
|
||||
'id', 'userId', 'method', 'path', 'statusCode',
|
||||
'executionTime', 'errorMessage', 'ipAddress', 'logType',
|
||||
'schedulerJobType', 'createdAt'
|
||||
// requestBody und responseBody werden NICHT zurückgegeben (DSGVO: Datenminimierung)
|
||||
// Nur bei expliziter Anfrage über getLogById verfügbar
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -11,10 +11,30 @@ class MyTischtennisService {
|
||||
*/
|
||||
async getAccount(userId) {
|
||||
const account = await MyTischtennis.findOne({
|
||||
where: { userId },
|
||||
attributes: ['id', 'userId', 'email', 'savePassword', 'autoUpdateRatings', 'lastLoginAttempt', 'lastLoginSuccess', 'lastUpdateRatings', 'expiresAt', 'userData', 'clubId', 'clubName', 'fedNickname', 'createdAt', 'updatedAt']
|
||||
where: { userId }
|
||||
// Keine attributes-Limitierung, damit getter-Methoden für Verschlüsselung funktionieren
|
||||
});
|
||||
return account;
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
// Rückgabe mit automatischer Entschlüsselung durch Model-Getters
|
||||
return {
|
||||
id: account.id,
|
||||
userId: account.userId,
|
||||
email: account.email, // Automatisch entschlüsselt
|
||||
savePassword: account.savePassword,
|
||||
autoUpdateRatings: account.autoUpdateRatings,
|
||||
lastLoginAttempt: account.lastLoginAttempt,
|
||||
lastLoginSuccess: account.lastLoginSuccess,
|
||||
lastUpdateRatings: account.lastUpdateRatings,
|
||||
expiresAt: account.expiresAt,
|
||||
userData: account.userData, // Automatisch entschlüsselt
|
||||
clubId: account.clubId, // Automatisch entschlüsselt
|
||||
clubName: account.clubName, // Automatisch entschlüsselt
|
||||
fedNickname: account.fedNickname, // Automatisch entschlüsselt
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
214
backend/utils/logDataSanitizer.js
Normal file
214
backend/utils/logDataSanitizer.js
Normal file
@@ -0,0 +1,214 @@
|
||||
import { encryptData } from './encrypt.js';
|
||||
|
||||
/**
|
||||
* Utility-Funktionen zum Sanitizing von Log-Daten
|
||||
* Entfernt oder verschlüsselt personenbezogene Daten aus Request/Response-Bodies
|
||||
*/
|
||||
|
||||
// Felder, die personenbezogene Daten enthalten können
|
||||
const SENSITIVE_FIELDS = [
|
||||
'password',
|
||||
'passwort',
|
||||
'email',
|
||||
'eMail',
|
||||
'e-mail',
|
||||
'phone',
|
||||
'telefon',
|
||||
'telephone',
|
||||
'firstName',
|
||||
'first_name',
|
||||
'lastName',
|
||||
'last_name',
|
||||
'name',
|
||||
'street',
|
||||
'address',
|
||||
'adresse',
|
||||
'city',
|
||||
'stadt',
|
||||
'postalCode',
|
||||
'postal_code',
|
||||
'plz',
|
||||
'birthDate',
|
||||
'birth_date',
|
||||
'geburtstag',
|
||||
'token',
|
||||
'accessToken',
|
||||
'access_token',
|
||||
'refreshToken',
|
||||
'refresh_token',
|
||||
'cookie',
|
||||
'authcode',
|
||||
'authCode',
|
||||
'session',
|
||||
'sessionId',
|
||||
'session_id',
|
||||
'apiKey',
|
||||
'api_key',
|
||||
'secret',
|
||||
'credentials',
|
||||
'creditCard',
|
||||
'credit_card',
|
||||
'iban',
|
||||
'accountNumber',
|
||||
'account_number'
|
||||
];
|
||||
|
||||
/**
|
||||
* Entfernt oder maskiert personenbezogene Daten aus einem Objekt
|
||||
* @param {Object|string} data - Das zu sanitizierende Objekt oder JSON-String
|
||||
* @param {boolean} encrypt - Wenn true, werden sensible Felder verschlüsselt statt entfernt
|
||||
* @returns {Object|string} - Das sanitizierte Objekt oder JSON-String
|
||||
*/
|
||||
export function sanitizeLogData(data, encrypt = false) {
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Wenn es ein String ist, versuche es als JSON zu parsen
|
||||
let obj;
|
||||
let isString = false;
|
||||
|
||||
if (typeof data === 'string') {
|
||||
isString = true;
|
||||
try {
|
||||
obj = JSON.parse(data);
|
||||
} catch (e) {
|
||||
// Wenn es kein JSON ist, kürze den String einfach
|
||||
return data.length > 500 ? data.substring(0, 500) + '... (truncated)' : data;
|
||||
}
|
||||
} else if (typeof data === 'object') {
|
||||
obj = data;
|
||||
} else {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Rekursiv durch das Objekt gehen
|
||||
const sanitized = sanitizeObject(obj, encrypt);
|
||||
|
||||
// Wenn es ursprünglich ein String war, zurück zu String konvertieren
|
||||
if (isString) {
|
||||
try {
|
||||
return JSON.stringify(sanitized);
|
||||
} catch (e) {
|
||||
return '[Unable to serialize sanitized data]';
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitiziert ein Objekt rekursiv
|
||||
*/
|
||||
function sanitizeObject(obj, encrypt = false) {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Arrays
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => sanitizeObject(item, encrypt));
|
||||
}
|
||||
|
||||
// Objekte
|
||||
if (typeof obj === 'object') {
|
||||
const sanitized = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
// Prüfe, ob das Feld sensibel ist
|
||||
const isSensitive = SENSITIVE_FIELDS.some(field =>
|
||||
lowerKey.includes(field.toLowerCase())
|
||||
);
|
||||
|
||||
if (isSensitive) {
|
||||
if (encrypt && value) {
|
||||
// Verschlüssele den Wert vollständig
|
||||
try {
|
||||
const valueStr = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
const encrypted = encryptData(valueStr);
|
||||
// Speichere vollständig verschlüsselt (kann bei Bedarf entschlüsselt werden)
|
||||
sanitized[key] = encrypted;
|
||||
} catch (e) {
|
||||
// Bei Verschlüsselungsfehler: Maskiere stattdessen
|
||||
sanitized[key] = '[REDACTED]';
|
||||
}
|
||||
} else {
|
||||
// Entferne oder maskiere den Wert (ohne Verschlüsselung)
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
// Zeige nur die ersten 2 Zeichen
|
||||
sanitized[key] = value.substring(0, 2) + '***[REDACTED]';
|
||||
} else {
|
||||
sanitized[key] = '[REDACTED]';
|
||||
}
|
||||
}
|
||||
} else if (typeof value === 'object') {
|
||||
// Rekursiv für verschachtelte Objekte
|
||||
sanitized[key] = sanitizeObject(value, encrypt);
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// Primitive Werte
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kürzt einen String auf eine maximale Länge
|
||||
*/
|
||||
export function truncateString(str, maxLength = 1000) {
|
||||
if (!str || typeof str !== 'string') {
|
||||
return str;
|
||||
}
|
||||
|
||||
if (str.length <= maxLength) {
|
||||
return str;
|
||||
}
|
||||
|
||||
return str.substring(0, maxLength) + '... (truncated)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitiziert IP-Adressen (kürzt auf ersten 3 Oktetten)
|
||||
*/
|
||||
export function sanitizeIpAddress(ip) {
|
||||
if (!ip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// IPv4: 192.168.1.100 -> 192.168.1.xxx
|
||||
if (ip.includes('.')) {
|
||||
const parts = ip.split('.');
|
||||
if (parts.length === 4) {
|
||||
return `${parts[0]}.${parts[1]}.${parts[2]}.xxx`;
|
||||
}
|
||||
}
|
||||
|
||||
// IPv6: Kürze auf ersten Teil
|
||||
if (ip.includes(':')) {
|
||||
const parts = ip.split(':');
|
||||
return parts[0] + ':xxx';
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitiziert User-Agent-Strings (entfernt spezifische Versionen)
|
||||
*/
|
||||
export function sanitizeUserAgent(userAgent) {
|
||||
if (!userAgent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Entferne spezifische Versionsnummern, behalte nur Browser/OS-Typ
|
||||
return userAgent
|
||||
.replace(/\d+\.\d+\.\d+\.\d+/g, 'x.x.x.x') // Versionsnummern
|
||||
.substring(0, 200); // Kürze auf 200 Zeichen
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user