Pentest 47.1/47.2/47.3: Re-Auth bei sensiblen Operationen + Provider.name-Strip
47.3 MEDIUM (Admin-Passwort-Reset ohne Re-Auth): POST /api/users/:id/password verlangt jetzt currentPassword im Body. Backend prüft per bcrypt.compare gegen den Hash des aufrufenden Admins. Frontend (UserList-Modal): zusätzliches Passwort-Feld wird eingeblendet, sobald für einen User ein neues Passwort gesetzt werden soll. Gestohlener JWT allein reicht damit nicht mehr. 47.1 MEDIUM (Open Redirect / Phishing via provider.portalUrl): Selbes Re-Auth-Pattern für Provider-Endpoints. Nur wenn die Portal-URL-Domain WIRKLICH gewechselt wird (Host-Vergleich) oder beim Create mit URL, ist currentPassword Pflicht. Reine Namens-/Tarif-Edits bleiben friction-frei. Audit-Log bekommt die Portal-URL beim Ändern explizit mitgeloggt (Forensik bei Vorfällen). Frontend ProviderModal zeigt amber- farbenen Bestätigungs-Banner mit Passwort-Eingabe sobald der Host wechselt. 47.2 INFO (provider.name ohne Backend-Sanitization): Neuer Helper stripProviderStrings in provider.service, wendet stripHtml auf name + usernameFieldName + passwordFieldName an – Defense-in-Depth gegen neue Renderpfade (PDF, Mail-Templates). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,34 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import prisma from '../lib/prisma.js';
|
||||||
import * as providerService from '../services/provider.service.js';
|
import * as providerService from '../services/provider.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
|
|
||||||
|
// Pentest 47.1 (MEDIUM, 2026-06-01): Open Redirect / Phishing via
|
||||||
|
// provider.portalUrl. Bei kompromittiertem Admin-Account konnte ein
|
||||||
|
// Angreifer einen Phishing-Link auf einem real existierenden Provider
|
||||||
|
// hinterlegen – jeder Portal-User mit dem Provider sah ihn dauerhaft.
|
||||||
|
// Re-Auth-Pattern analog 47.3 (Staff-Password): bei Änderung der
|
||||||
|
// portalUrl-Domain muss der Admin sein eigenes Passwort mitsenden.
|
||||||
|
async function requireCallerPasswordReAuth(req: AuthRequest, providedPassword: unknown): Promise<{ ok: true } | { ok: false; status: number; error: string }> {
|
||||||
|
const callerId = req.user?.userId;
|
||||||
|
if (!callerId) return { ok: false, status: 401, error: 'Nicht authentifiziert' };
|
||||||
|
if (typeof providedPassword !== 'string' || providedPassword.length === 0) {
|
||||||
|
return { ok: false, status: 400, error: 'Bitte das eigene Passwort zur Bestätigung mitsenden.' };
|
||||||
|
}
|
||||||
|
const caller = await prisma.user.findUnique({ where: { id: callerId }, select: { password: true } });
|
||||||
|
// Timing-Schutz: immer einen bcrypt.compare laufen lassen
|
||||||
|
const callerHash = caller?.password ?? '$2a$10$0000000000000000000000000000000000000000000000000000';
|
||||||
|
const ok = await bcrypt.compare(providedPassword, callerHash);
|
||||||
|
if (!caller || !ok) return { ok: false, status: 403, error: 'Eigenes Passwort ist falsch.' };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostOf(url: string | null | undefined): string | null {
|
||||||
|
if (!url) return null;
|
||||||
|
try { return new URL(url).host.toLowerCase(); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
export async function getProviders(req: Request, res: Response): Promise<void> {
|
export async function getProviders(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -50,11 +77,21 @@ export async function getProvider(req: Request, res: Response): Promise<void> {
|
|||||||
|
|
||||||
export async function createProvider(req: Request, res: Response): Promise<void> {
|
export async function createProvider(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const provider = await providerService.createProvider(req.body);
|
const { currentPassword, ...providerData } = req.body || {};
|
||||||
|
// 47.1: Beim Create mit portalUrl ist Re-Auth Pflicht. Ohne portalUrl
|
||||||
|
// (rein interner Provider-Stammdatensatz) kein Zwang.
|
||||||
|
if (providerData.portalUrl) {
|
||||||
|
const reauth = await requireCallerPasswordReAuth(req as AuthRequest, currentPassword);
|
||||||
|
if (!reauth.ok) {
|
||||||
|
res.status(reauth.status).json({ success: false, error: reauth.error } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const provider = await providerService.createProvider(providerData);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'Provider',
|
req, action: 'CREATE', resourceType: 'Provider',
|
||||||
resourceId: provider.id.toString(),
|
resourceId: provider.id.toString(),
|
||||||
label: `Anbieter ${provider.name} angelegt`,
|
label: `Anbieter ${provider.name} angelegt${provider.portalUrl ? ` mit Portal-URL ${provider.portalUrl}` : ''}`,
|
||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: provider } as ApiResponse);
|
res.status(201).json({ success: true, data: provider } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -67,11 +104,33 @@ export async function createProvider(req: Request, res: Response): Promise<void>
|
|||||||
|
|
||||||
export async function updateProvider(req: Request, res: Response): Promise<void> {
|
export async function updateProvider(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const provider = await providerService.updateProvider(parseInt(req.params.id), req.body);
|
const providerId = parseInt(req.params.id);
|
||||||
|
const { currentPassword, ...providerData } = req.body || {};
|
||||||
|
|
||||||
|
// 47.1: portalUrl-Domain-Wechsel braucht Re-Auth. Wir laden den
|
||||||
|
// bisherigen Wert und vergleichen den Host – nur dann ist es ein
|
||||||
|
// sensible Operation. Reine Namens-/Tarif-Edits bleiben friction-frei.
|
||||||
|
if (providerData.portalUrl !== undefined) {
|
||||||
|
const before = await providerService.getProviderById(providerId);
|
||||||
|
const oldHost = hostOf(before?.portalUrl);
|
||||||
|
const newHost = hostOf(providerData.portalUrl);
|
||||||
|
const isDomainChange = (newHost && newHost !== oldHost) || (oldHost && !newHost);
|
||||||
|
if (isDomainChange) {
|
||||||
|
const reauth = await requireCallerPasswordReAuth(req as AuthRequest, currentPassword);
|
||||||
|
if (!reauth.ok) {
|
||||||
|
res.status(reauth.status).json({ success: false, error: reauth.error } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await providerService.updateProvider(providerId, providerData);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'Provider',
|
req, action: 'UPDATE', resourceType: 'Provider',
|
||||||
resourceId: provider.id.toString(),
|
resourceId: provider.id.toString(),
|
||||||
label: `Anbieter ${provider.name} aktualisiert`,
|
label: providerData.portalUrl !== undefined
|
||||||
|
? `Anbieter ${provider.name} aktualisiert (Portal-URL: ${provider.portalUrl ?? 'entfernt'})`
|
||||||
|
: `Anbieter ${provider.name} aktualisiert`,
|
||||||
});
|
});
|
||||||
res.json({ success: true, data: provider } as ApiResponse);
|
res.json({ success: true, data: provider } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import * as userService from '../services/user.service.js';
|
import * as userService from '../services/user.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { pickUserCreate, pickUserUpdate, isValidEmail } from '../utils/sanitize.js';
|
import { pickUserCreate, pickUserUpdate, isValidEmail } from '../utils/sanitize.js';
|
||||||
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
|
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
|
||||||
|
|
||||||
@@ -189,11 +190,44 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
|||||||
export async function setUserPassword(req: Request, res: Response): Promise<void> {
|
export async function setUserPassword(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = parseInt(req.params.id);
|
const userId = parseInt(req.params.id);
|
||||||
const { password } = req.body || {};
|
const { password, currentPassword } = req.body || {};
|
||||||
if (!password || typeof password !== 'string') {
|
if (!password || typeof password !== 'string') {
|
||||||
res.status(400).json({ success: false, error: 'Passwort erforderlich' } as ApiResponse);
|
res.status(400).json({ success: false, error: 'Passwort erforderlich' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 47.3 (MEDIUM, 2026-06-01): Re-Auth verpflichtend.
|
||||||
|
// Ein gestohlener Admin-JWT reichte bisher, um Staff-Passwörter
|
||||||
|
// umzuschreiben. Jetzt muss der aufrufende Admin sein eigenes
|
||||||
|
// Passwort mitsenden – CSRF/Token-Klau allein reicht nicht mehr.
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const callerId = authReq.user?.userId;
|
||||||
|
if (!callerId) {
|
||||||
|
res.status(401).json({ success: false, error: 'Nicht authentifiziert' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!currentPassword || typeof currentPassword !== 'string') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Bitte das eigene Passwort zur Bestätigung mitsenden.',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const caller = await prisma.user.findUnique({
|
||||||
|
where: { id: callerId },
|
||||||
|
select: { password: true },
|
||||||
|
});
|
||||||
|
// Timing-Schutz: immer einen bcrypt.compare laufen lassen
|
||||||
|
const callerHash = caller?.password ?? '$2a$10$0000000000000000000000000000000000000000000000000000';
|
||||||
|
const reAuthOk = await bcrypt.compare(currentPassword, callerHash);
|
||||||
|
if (!caller || !reAuthOk) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Eigenes Passwort ist falsch.',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const c = validatePasswordComplexity(password, { minLength: STAFF_MIN_PASSWORD_LENGTH });
|
const c = validatePasswordComplexity(password, { minLength: STAFF_MIN_PASSWORD_LENGTH });
|
||||||
if (!c.ok) {
|
if (!c.ok) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
@@ -210,7 +244,7 @@ export async function setUserPassword(req: Request, res: Response): Promise<void
|
|||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'User',
|
req, action: 'UPDATE', resourceType: 'User',
|
||||||
resourceId: user.id.toString(),
|
resourceId: user.id.toString(),
|
||||||
label: `Passwort für Benutzer ${user.firstName} ${user.lastName} (${user.email}) durch Admin gesetzt`,
|
label: `Passwort für Benutzer ${user.firstName} ${user.lastName} (${user.email}) durch Admin gesetzt (Re-Auth bestätigt)`,
|
||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
|
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { stripHtml } from '../utils/sanitize.js';
|
||||||
import { validateHttpUrl } from '../utils/url.js';
|
import { validateHttpUrl } from '../utils/url.js';
|
||||||
|
|
||||||
// Pentest 46.1 (HIGH, 2026-06-01): Stored XSS via provider.portalUrl.
|
// Pentest 46.1 (HIGH, 2026-06-01): Stored XSS via provider.portalUrl.
|
||||||
@@ -46,16 +47,29 @@ export async function getProviderById(id: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 47.2 (INFO, 2026-06-01): provider.name landete roh in der DB.
|
||||||
|
// Aktuell escapt React das Textnode, also kein direkter XSS – aber neue
|
||||||
|
// Renderpfade (PDF, Mail-Templates, Embedded-Strings in URLs) wären
|
||||||
|
// sofort betroffen. Defense-in-depth: schon beim Schreiben strippen.
|
||||||
|
function stripProviderStrings<T extends { name?: string; usernameFieldName?: string; passwordFieldName?: string }>(data: T): T {
|
||||||
|
const out: any = { ...data };
|
||||||
|
if (typeof out.name === 'string') out.name = stripHtml(out.name);
|
||||||
|
if (typeof out.usernameFieldName === 'string') out.usernameFieldName = stripHtml(out.usernameFieldName);
|
||||||
|
if (typeof out.passwordFieldName === 'string') out.passwordFieldName = stripHtml(out.passwordFieldName);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createProvider(data: {
|
export async function createProvider(data: {
|
||||||
name: string;
|
name: string;
|
||||||
portalUrl?: string;
|
portalUrl?: string;
|
||||||
usernameFieldName?: string;
|
usernameFieldName?: string;
|
||||||
passwordFieldName?: string;
|
passwordFieldName?: string;
|
||||||
}) {
|
}) {
|
||||||
const portalUrl = assertValidPortalUrl(data.portalUrl);
|
const clean = stripProviderStrings(data);
|
||||||
|
const portalUrl = assertValidPortalUrl(clean.portalUrl);
|
||||||
return prisma.provider.create({
|
return prisma.provider.create({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...clean,
|
||||||
portalUrl,
|
portalUrl,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
@@ -75,7 +89,7 @@ export async function updateProvider(
|
|||||||
// portalUrl nur validieren wenn explizit mitgesendet (undefined = unverändert).
|
// portalUrl nur validieren wenn explizit mitgesendet (undefined = unverändert).
|
||||||
// Leerstring = "auf null setzen" - hier setzen wir explizit auf null,
|
// Leerstring = "auf null setzen" - hier setzen wir explizit auf null,
|
||||||
// damit Prisma nicht den alten Wert hält.
|
// damit Prisma nicht den alten Wert hält.
|
||||||
const updateData: typeof data = { ...data };
|
const updateData: typeof data = stripProviderStrings(data);
|
||||||
if (data.portalUrl !== undefined) {
|
if (data.portalUrl !== undefined) {
|
||||||
const validated = assertValidPortalUrl(data.portalUrl);
|
const validated = assertValidPortalUrl(data.portalUrl);
|
||||||
(updateData as { portalUrl: string | null }).portalUrl = validated ?? null;
|
(updateData as { portalUrl: string | null }).portalUrl = validated ?? null;
|
||||||
|
|||||||
@@ -293,7 +293,21 @@ function ProviderModal({
|
|||||||
usernameFieldName: '',
|
usernameFieldName: '',
|
||||||
passwordFieldName: '',
|
passwordFieldName: '',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
// Pentest 47.1: bei Portal-URL-Domain-Wechsel muss der aufrufende
|
||||||
|
// Admin sein eigenes Passwort mitsenden – Schutz gegen kompromittierten
|
||||||
|
// JWT, der sonst Phishing-URLs auf existierende Anbieter setzen könnte.
|
||||||
|
currentPassword: '',
|
||||||
});
|
});
|
||||||
|
const originalPortalUrl = provider?.portalUrl ?? '';
|
||||||
|
const hostOf = (u: string) => {
|
||||||
|
try { return new URL(u.trim()).host.toLowerCase(); } catch { return ''; }
|
||||||
|
};
|
||||||
|
const portalUrlHostChanged =
|
||||||
|
formData.portalUrl.trim() !== originalPortalUrl.trim()
|
||||||
|
&& (hostOf(formData.portalUrl) || hostOf(originalPortalUrl))
|
||||||
|
&& hostOf(formData.portalUrl) !== hostOf(originalPortalUrl);
|
||||||
|
const portalUrlSetOnCreate = !provider && !!formData.portalUrl.trim();
|
||||||
|
const needsReAuth = portalUrlHostChanged || portalUrlSetOnCreate;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -304,6 +318,7 @@ function ProviderModal({
|
|||||||
usernameFieldName: provider.usernameFieldName || '',
|
usernameFieldName: provider.usernameFieldName || '',
|
||||||
passwordFieldName: provider.passwordFieldName || '',
|
passwordFieldName: provider.passwordFieldName || '',
|
||||||
isActive: provider.isActive,
|
isActive: provider.isActive,
|
||||||
|
currentPassword: '',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -312,6 +327,7 @@ function ProviderModal({
|
|||||||
usernameFieldName: '',
|
usernameFieldName: '',
|
||||||
passwordFieldName: '',
|
passwordFieldName: '',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
currentPassword: '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,10 +358,17 @@ function ProviderModal({
|
|||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (needsReAuth && !formData.currentPassword) {
|
||||||
|
alert('Bitte das eigene Passwort zur Bestätigung der Portal-URL eingeben.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// currentPassword wird nur mitgesendet wenn überhaupt nötig
|
||||||
|
const payload: any = { ...formData };
|
||||||
|
if (!needsReAuth) delete payload.currentPassword;
|
||||||
if (provider) {
|
if (provider) {
|
||||||
updateMutation.mutate(formData);
|
updateMutation.mutate(payload);
|
||||||
} else {
|
} else {
|
||||||
createMutation.mutate(formData);
|
createMutation.mutate(payload);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -373,6 +396,26 @@ function ProviderModal({
|
|||||||
placeholder="https://kundenportal.anbieter.de/login"
|
placeholder="https://kundenportal.anbieter.de/login"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{needsReAuth && (
|
||||||
|
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg space-y-2">
|
||||||
|
<p className="text-sm text-amber-800">
|
||||||
|
<strong>Bestätigung erforderlich:</strong>{' '}
|
||||||
|
{portalUrlHostChanged
|
||||||
|
? 'Die Portal-URL-Domain wurde geändert. Diese URL ist anschließend für alle Portal-Kunden dieses Anbieters klickbar.'
|
||||||
|
: 'Mit dem Speichern wird die Portal-URL für alle Portal-Kunden dieses Anbieters klickbar.'}
|
||||||
|
{' '}Zur Sicherheit ist eine Bestätigung mit dem eigenen Passwort nötig.
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
label="Eigenes Passwort zur Bestätigung *"
|
||||||
|
type="password"
|
||||||
|
value={formData.currentPassword}
|
||||||
|
onChange={(e) => setFormData({ ...formData, currentPassword: e.target.value })}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="p-3 bg-gray-50 rounded-lg space-y-3">
|
<div className="p-3 bg-gray-50 rounded-lg space-y-3">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
<strong>Auto-Login Felder</strong> (optional)<br />
|
<strong>Auto-Login Felder</strong> (optional)<br />
|
||||||
|
|||||||
@@ -238,6 +238,9 @@ function UserModal({
|
|||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
// Pentest 47.3: bei Passwort-Änderung muss der aufrufende Admin sein
|
||||||
|
// eigenes Passwort zur Bestätigung mitsenden (Re-Auth gegen Token-Klau).
|
||||||
|
currentPassword: '',
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
roleIds: [] as number[],
|
roleIds: [] as number[],
|
||||||
@@ -257,6 +260,7 @@ function UserModal({
|
|||||||
setFormData({
|
setFormData({
|
||||||
email: user.email,
|
email: user.email,
|
||||||
password: '',
|
password: '',
|
||||||
|
currentPassword: '',
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
roleIds: user.roles?.filter((r: any) => !['Developer', 'Kunde', 'DSGVO'].includes(r.name)).map((r: any) => r.id) || [],
|
roleIds: user.roles?.filter((r: any) => !['Developer', 'Kunde', 'DSGVO'].includes(r.name)).map((r: any) => r.id) || [],
|
||||||
@@ -271,6 +275,7 @@ function UserModal({
|
|||||||
setFormData({
|
setFormData({
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
currentPassword: '',
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
roleIds: [],
|
roleIds: [],
|
||||||
@@ -326,9 +331,13 @@ function UserModal({
|
|||||||
// Passwort-Setzen ist serverseitig ein eigener Endpoint (separater
|
// Passwort-Setzen ist serverseitig ein eigener Endpoint (separater
|
||||||
// Audit-Eintrag). Wenn beides gefragt: erst Daten, dann PW.
|
// Audit-Eintrag). Wenn beides gefragt: erst Daten, dann PW.
|
||||||
if (formData.password) {
|
if (formData.password) {
|
||||||
|
if (!formData.currentPassword) {
|
||||||
|
setError('Bitte das eigene Passwort zur Bestätigung eingeben.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateMutation.mutate(updateData, {
|
updateMutation.mutate(updateData, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
userApi.setPassword(user.id, formData.password).catch((err) => {
|
userApi.setPassword(user.id, formData.password, formData.currentPassword).catch((err) => {
|
||||||
alert(err?.response?.data?.error || 'Passwort konnte nicht gesetzt werden');
|
alert(err?.response?.data?.error || 'Passwort konnte nicht gesetzt werden');
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -412,6 +421,23 @@ function UserModal({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{user && formData.password && (
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label="Eigenes Passwort zur Bestätigung *"
|
||||||
|
type="password"
|
||||||
|
value={formData.currentPassword}
|
||||||
|
onChange={(e) => setFormData({ ...formData, currentPassword: e.target.value })}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Sicherheitsmaßnahme: bestätige mit deinem eigenen Login-Passwort,
|
||||||
|
dass diese Änderung wirklich von dir kommt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Messaging-Kanäle (für Datenschutz-Versand)</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Messaging-Kanäle (für Datenschutz-Versand)</label>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -1344,8 +1344,9 @@ export const userApi = {
|
|||||||
},
|
},
|
||||||
// Passwort eines Users zurücksetzen (Admin-Funktion). Separat vom generischen
|
// Passwort eines Users zurücksetzen (Admin-Funktion). Separat vom generischen
|
||||||
// Update, damit der Vorgang einen eigenen Audit-Eintrag bekommt.
|
// Update, damit der Vorgang einen eigenen Audit-Eintrag bekommt.
|
||||||
setPassword: async (id: number, password: string) => {
|
// Pentest 47.3: braucht currentPassword (eigenes Admin-Passwort) als Re-Auth.
|
||||||
const res = await api.post<ApiResponse<void>>(`/users/${id}/password`, { password });
|
setPassword: async (id: number, password: string, currentPassword: string) => {
|
||||||
|
const res = await api.post<ApiResponse<void>>(`/users/${id}/password`, { password, currentPassword });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
delete: async (id: number) => {
|
delete: async (id: number) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user