diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationScreens.kt index 0ca022d..a77ff90 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationScreens.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationScreens.kt @@ -49,7 +49,7 @@ fun PasswordResetScreen( val state by viewModel.state.collectAsState() AuthFormPage( title = "Passwort zurücksetzen", - subtitle = "Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen.", + subtitle = "Geben Sie Ihre E-Mail-Adresse ein, um einen Reset-Link zu erhalten.", onBack = { navController.navigate(Destinations.Login.route) }, showBackNavigation = showBackNavigation, ) { @@ -68,7 +68,7 @@ fun PasswordResetScreen( TextButton(onClick = { navController.navigate(Destinations.Login.route) }, modifier = Modifier.fillMaxWidth()) { Text("Zurück zum Login") } - AuthNotice("Sie erhalten eine E-Mail mit einem temporären Passwort, sofern ein Konto vorhanden ist.") + AuthNotice("Sie erhalten eine E-Mail mit einem Reset-Link, sofern ein Konto vorhanden ist. Ihr bisheriges Passwort bleibt bis zur Änderung gültig.") } } diff --git a/android-app/gradle.properties b/android-app/gradle.properties index 2d5c2ad..9f2cfc4 100644 --- a/android-app/gradle.properties +++ b/android-app/gradle.properties @@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/ # Android app versioning for Play Store uploads -ANDROID_VERSION_CODE=23 -ANDROID_VERSION_NAME=0.9.18 +ANDROID_VERSION_CODE=24 +ANDROID_VERSION_NAME=0.9.19 # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. RELEASE_MINIFY_ENABLED=false diff --git a/pages/passwort-vergessen.vue b/pages/passwort-vergessen.vue index 6101a32..45d9f40 100644 --- a/pages/passwort-vergessen.vue +++ b/pages/passwort-vergessen.vue @@ -6,7 +6,7 @@ Passwort zurücksetzen

- Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen + Geben Sie Ihre E-Mail-Adresse ein, um einen Reset-Link zu erhalten

diff --git a/pages/passwort-zuruecksetzen.vue b/pages/passwort-zuruecksetzen.vue new file mode 100644 index 0000000..80137e9 --- /dev/null +++ b/pages/passwort-zuruecksetzen.vue @@ -0,0 +1,125 @@ + + + diff --git a/server/api/auth/reset-password.post.js b/server/api/auth/reset-password.post.js index 1541ff7..c05865c 100644 --- a/server/api/auth/reset-password.post.js +++ b/server/api/auth/reset-password.post.js @@ -1,10 +1,45 @@ -import { readUsers, hashPassword, writeUsers, revokeRefreshSessionsForUser } from '../../utils/auth.js' +import { readUsers, writeUsers } from '../../utils/auth.js' import nodemailer from 'nodemailer' import crypto from 'crypto' import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js' import { writeAuditLog } from '../../utils/audit-log.js' import { maskResetEmail, normalizeResetEmail, writePasswordResetLog } from '../../utils/password-reset-log.js' +const RESET_TOKEN_TTL_MINUTES = Number(process.env.PASSWORD_RESET_TTL_MIN || 60) +const RESET_TOKEN_MAX_AGE_MS = RESET_TOKEN_TTL_MINUTES * 60 * 1000 + +function generateResetToken() { + return crypto.randomBytes(32).toString('base64url') +} + +function hashResetToken(token) { + return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex') +} + +function getResetBaseUrl(event) { + const configured = process.env.NUXT_PUBLIC_BASE_URL + if (configured) return configured.replace(/\/$/, '') + + const requestUrl = getRequestURL(event) + return `${requestUrl.protocol}//${requestUrl.host}` +} + +function prunePasswordResetTokens(user) { + const now = Date.now() + user.passwordResetTokens = (Array.isArray(user.passwordResetTokens) ? user.passwordResetTokens : []) + .filter(token => !token.usedAt && new Date(token.expiresAt).getTime() > now) + .slice(-4) +} + +function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + export default defineEventHandler(async (event) => { const requestId = crypto.randomUUID() let emailKey = '' @@ -34,7 +69,6 @@ export default defineEventHandler(async (event) => { }) } - // Rate Limiting (IP + Account) await logStep('rate_limit', 'checking') try { assertRateLimit(event, { @@ -57,7 +91,6 @@ export default defineEventHandler(async (event) => { } await logStep('rate_limit', 'passed') - // Find user let users try { users = await readUsers() @@ -67,7 +100,6 @@ export default defineEventHandler(async (event) => { } const user = users.find(u => normalizeResetEmail(u.email) === emailKey) - // Always return success (security: don't reveal if email exists) if (!user) { await logStep('user_lookup', 'not_found') await registerRateLimitFailure(event, { name: 'auth:reset:ip', keyParts: [ip] }) @@ -81,82 +113,84 @@ export default defineEventHandler(async (event) => { } await logStep('user_lookup', 'found', { userId: user.id }) - // Generate temporary password - const tempPassword = crypto.randomBytes(8).toString('hex') - const hashedPassword = await hashPassword(tempPassword) - await logStep('temporary_password', 'generated', { userId: user.id }) - - // Send email with temporary password const smtpUser = process.env.SMTP_USER const smtpPass = process.env.SMTP_PASS - + if (!smtpUser || !smtpPass) { await logStep('mail_configuration', 'failed', { userId: user.id, reason: 'smtp_credentials_missing' }) console.warn('SMTP-Credentials fehlen! E-Mail-Versand wird übersprungen.') console.warn(`SMTP_USER=${smtpUser ? 'gesetzt' : 'FEHLT'}, SMTP_PASS=${smtpPass ? 'gesetzt' : 'FEHLT'}`) throw new Error('SMTP-Konfiguration fuer Passwort-Reset fehlt') - } else { - await logStep('mail_configuration', 'passed', { userId: user.id }) - const transporter = nodemailer.createTransport({ - host: process.env.SMTP_HOST || 'smtp.gmail.com', - port: process.env.SMTP_PORT || 587, - secure: false, - auth: { - user: smtpUser, - pass: smtpPass - } - }) - - const mailOptions = { - from: process.env.SMTP_FROM || 'noreply@harheimertc.de', - to: user.email, - subject: 'Passwort zurücksetzen - Harheimer TC', - html: ` -

Passwort zurücksetzen

-

Hallo ${user.name},

-

Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.

-

Ihr temporäres Passwort lautet: ${tempPassword}

-

Bitte melden Sie sich damit an und ändern Sie Ihr Passwort im Mitgliederbereich.

-
-

Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.

-
-

Mit sportlichen Grüßen,
Ihr Harheimer TC

- ` - } - - await logStep('mail_send', 'started', { userId: user.id }) - try { - await transporter.sendMail(mailOptions) - } catch (error) { - await logStep('mail_send', 'failed', { userId: user.id, error }) - throw error - } - await logStep('mail_send', 'completed', { userId: user.id }) } - // Erst nach erfolgreichem Versand das zugesandte Passwort aktivieren. - user.password = hashedPassword - user.passwordResetRequired = true + await logStep('mail_configuration', 'passed', { userId: user.id }) + + const token = generateResetToken() + const tokenHash = hashResetToken(token) + const nowIso = new Date().toISOString() + const expiresAt = new Date(Date.now() + RESET_TOKEN_MAX_AGE_MS).toISOString() + prunePasswordResetTokens(user) + user.passwordResetTokens.push({ + tokenHash, + createdAt: nowIso, + expiresAt, + usedAt: null + }) + await logStep('reset_token', 'generated', { userId: user.id, expiresAt }) + const updatedUsers = users.map(u => u.id === user.id ? user : u) - let passwordStored = false + let tokenStored = false try { - passwordStored = await writeUsers(updatedUsers) + tokenStored = await writeUsers(updatedUsers) } catch (error) { - await logStep('password_storage', 'failed', { userId: user.id, error }) + await logStep('token_storage', 'failed', { userId: user.id, error }) throw error } - if (!passwordStored) { - await logStep('password_storage', 'failed', { userId: user.id, reason: 'write_failed' }) - throw new Error('Passwort konnte nach E-Mail-Versand nicht gespeichert werden') + if (!tokenStored) { + await logStep('token_storage', 'failed', { userId: user.id, reason: 'write_failed' }) + throw new Error('Reset-Token konnte nicht gespeichert werden') } - await logStep('password_storage', 'completed', { userId: user.id }) + await logStep('token_storage', 'completed', { userId: user.id }) + + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST || 'smtp.gmail.com', + port: process.env.SMTP_PORT || 587, + secure: process.env.SMTP_SECURE === 'true', + auth: { + user: smtpUser, + pass: smtpPass + } + }) + + const resetUrl = `${getResetBaseUrl(event)}/passwort-zuruecksetzen?token=${encodeURIComponent(token)}` + const displayName = escapeHtml(user.name || 'Mitglied') + + const mailOptions = { + from: process.env.SMTP_FROM || 'noreply@harheimertc.de', + to: user.email, + subject: 'Passwort zurücksetzen - Harheimer TC', + html: ` +

Passwort zurücksetzen

+

Hallo ${displayName},

+

Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.

+

Bitte klicken Sie auf den folgenden Link und vergeben Sie dort ein neues Passwort. Der Link ist ${RESET_TOKEN_TTL_MINUTES} Minuten gültig:

+

Neues Passwort setzen

+

Ihr bisheriges Passwort bleibt gültig, bis Sie über diesen Link ein neues Passwort gesetzt haben.

+
+

Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.

+
+

Mit sportlichen Grüßen,
Ihr Harheimer TC

+ ` + } + + await logStep('mail_send', 'started', { userId: user.id }) try { - await revokeRefreshSessionsForUser(user.id, 'password_reset') + await transporter.sendMail(mailOptions) } catch (error) { - await logStep('session_revocation', 'failed', { userId: user.id, error }) + await logStep('mail_send', 'failed', { userId: user.id, error }) throw error } - await logStep('session_revocation', 'completed', { userId: user.id }) + await logStep('mail_send', 'completed', { userId: user.id }) registerRateLimitSuccess(event, { name: 'auth:reset:account', keyParts: [emailKey] }) await writeAuditLog('auth.reset.request', { ip, emailMasked: maskResetEmail(emailKey), userFound: true, userId: user.id, requestId }) @@ -168,7 +202,6 @@ export default defineEventHandler(async (event) => { } catch (error) { await logStep('request_completed', 'failed', { error }) console.error('Password-Reset-Fehler:', { requestId, code: error?.code || error?.name || 'Error' }) - // Don't reveal errors to prevent email enumeration return { success: true, message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.' diff --git a/server/api/auth/reset-password/complete.post.js b/server/api/auth/reset-password/complete.post.js new file mode 100644 index 0000000..df89f66 --- /dev/null +++ b/server/api/auth/reset-password/complete.post.js @@ -0,0 +1,82 @@ +import crypto from 'crypto' +import { hashPassword, readUsers, revokeRefreshSessionsForUser, writeUsers } from '../../../utils/auth.js' +import { getClientIp } from '../../../utils/rate-limit.js' +import { writeAuditLog } from '../../../utils/audit-log.js' +import { writePasswordResetLog } from '../../../utils/password-reset-log.js' + +function hashResetToken(token) { + return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex') +} + +function isStrongEnoughPassword(password) { + return typeof password === 'string' && password.length >= 8 +} + +export default defineEventHandler(async (event) => { + const requestId = crypto.randomUUID() + const ip = getClientIp(event) + const body = await readBody(event) + const token = String(body?.token || '').trim() + const password = String(body?.password || '') + + const logStep = async (step, status, detail = {}) => { + try { + await writePasswordResetLog({ requestId, email: detail.email || '', ip, step, status, ...detail }) + } catch (logError) { + console.error('Password-Reset-Diagnoselog-Fehler:', logError) + } + } + + if (!token) { + await logStep('complete_validation', 'failed', { reason: 'token_missing' }) + throw createError({ statusCode: 400, message: 'Reset-Link fehlt.' }) + } + + if (!isStrongEnoughPassword(password)) { + await logStep('complete_validation', 'failed', { reason: 'password_too_short' }) + throw createError({ statusCode: 400, message: 'Das Passwort muss mindestens 8 Zeichen lang sein.' }) + } + + const users = await readUsers() + const tokenHash = hashResetToken(token) + const now = Date.now() + let matchedUser = null + let matchedToken = null + + for (const user of users) { + const tokens = Array.isArray(user.passwordResetTokens) ? user.passwordResetTokens : [] + const candidate = tokens.find(entry => entry.tokenHash === tokenHash) + if (candidate) { + matchedUser = user + matchedToken = candidate + break + } + } + + if (!matchedUser || !matchedToken || matchedToken.usedAt || new Date(matchedToken.expiresAt).getTime() <= now) { + await logStep('complete_token', 'failed', { reason: 'invalid_or_expired' }) + throw createError({ statusCode: 400, message: 'Der Reset-Link ist ungültig oder abgelaufen.' }) + } + + const nowIso = new Date().toISOString() + matchedUser.password = await hashPassword(password) + matchedUser.passwordResetRequired = false + matchedToken.usedAt = nowIso + matchedUser.passwordResetTokens = (Array.isArray(matchedUser.passwordResetTokens) ? matchedUser.passwordResetTokens : []) + .filter(entry => entry.usedAt || new Date(entry.expiresAt).getTime() > now) + + const stored = await writeUsers(users) + if (!stored) { + await logStep('complete_password_storage', 'failed', { userId: matchedUser.id, email: matchedUser.email, reason: 'write_failed' }) + throw createError({ statusCode: 500, message: 'Das neue Passwort konnte nicht gespeichert werden.' }) + } + + await revokeRefreshSessionsForUser(matchedUser.id, 'password_reset_completed') + await writeAuditLog('auth.reset.complete', { ip, userId: matchedUser.id, requestId }) + await logStep('complete_password_storage', 'completed', { userId: matchedUser.id, email: matchedUser.email }) + + return { + success: true, + message: 'Ihr Passwort wurde geändert. Sie können sich jetzt mit dem neuen Passwort anmelden.' + } +}) diff --git a/tests/auth-endpoints.spec.ts b/tests/auth-endpoints.spec.ts index 2782d6b..2377f18 100644 --- a/tests/auth-endpoints.spec.ts +++ b/tests/auth-endpoints.spec.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createEvent, mockSuccessReadBody } from './setup' import { readFileSync } from 'fs' +import crypto from 'crypto' vi.mock('../server/utils/auth.js', () => { return { @@ -74,6 +75,7 @@ import logoutHandler from '../server/api/auth/logout.post.js' import refreshHandler from '../server/api/auth/refresh.post.js' import registerHandler from '../server/api/auth/register.post.js' import resetPasswordHandler from '../server/api/auth/reset-password.post.js' +import completePasswordResetHandler from '../server/api/auth/reset-password/complete.post.js' import statusHandler from '../server/api/auth/status.get.js' import versionHandler from '../server/api/app/version.get.js' @@ -81,12 +83,15 @@ describe('Auth API Endpoints', () => { afterEach(() => { delete process.env.NODE_ENV delete process.env.APP_ENV + delete process.env.NUXT_PUBLIC_BASE_URL + delete process.env.PASSWORD_RESET_TTL_MIN }) beforeEach(() => { // Setze SMTP-Credentials für Tests process.env.SMTP_USER = 'test@example.com' process.env.SMTP_PASS = 'test-password' + process.env.NUXT_PUBLIC_BASE_URL = 'https://harheimertc.de' vi.clearAllMocks() }) @@ -300,7 +305,7 @@ describe('Auth API Endpoints', () => { }) describe('POST /api/auth/reset-password', () => { - it('prüft Pflichtfelder', async () => { + it('prüft Pflichtfelder ohne öffentliche Fehlermeldung', async () => { const event = createEvent() mockSuccessReadBody({}) @@ -308,18 +313,27 @@ describe('Auth API Endpoints', () => { expect(response.success).toBe(true) }) - it('aktualisiert Passwort bei vorhandenem Benutzer', async () => { + it('speichert einen gehashten Reset-Token und lässt das alte Passwort unverändert', async () => { const event = createEvent() - const user = { id: '1', email: 'user@example.com', name: 'User', password: 'hash' } + const user = { id: '1', email: 'user@example.com', name: 'User', password: 'old-hash' } mockSuccessReadBody({ email: user.email }) authUtils.readUsers.mockResolvedValue([user]) - authUtils.hashPassword.mockResolvedValue('new-hash') authUtils.writeUsers.mockResolvedValue(true) const response = await resetPasswordHandler(event) + expect(response.success).toBe(true) - expect(authUtils.writeUsers).toHaveBeenCalled() - expect(authUtils.revokeRefreshSessionsForUser).toHaveBeenCalledWith('1', 'password_reset') + expect(authUtils.hashPassword).not.toHaveBeenCalled() + expect(authUtils.revokeRefreshSessionsForUser).not.toHaveBeenCalled() + const writtenUser = authUtils.writeUsers.mock.calls[0][0][0] + expect(writtenUser.password).toBe('old-hash') + expect(writtenUser.passwordResetTokens).toHaveLength(1) + expect(writtenUser.passwordResetTokens[0]).toMatchObject({ usedAt: null }) + expect(writtenUser.passwordResetTokens[0].tokenHash).toMatch(/^[a-f0-9]{64}$/) + const transporter = nodemailer.default.createTransport.mock.results[0].value + expect(transporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({ + html: expect.stringContaining('https://harheimertc.de/passwort-zuruecksetzen?token=') + })) expect(passwordResetLog.writePasswordResetLog).toHaveBeenCalledWith(expect.objectContaining({ email: 'user@example.com', step: 'mail_send', @@ -332,7 +346,6 @@ describe('Auth API Endpoints', () => { const user = { id: '1', email: 'user@example.com', name: 'User', password: 'hash' } mockSuccessReadBody({ email: ' User@Example.com ' }) authUtils.readUsers.mockResolvedValue([user]) - authUtils.hashPassword.mockResolvedValue('new-hash') authUtils.writeUsers.mockResolvedValue(true) await resetPasswordHandler(event) @@ -341,11 +354,10 @@ describe('Auth API Endpoints', () => { expect(passwordResetLog.normalizeResetEmail).toHaveBeenCalledWith(' User@Example.com ') }) - it('ändert das Passwort nicht, wenn SMTP nicht konfiguriert ist', async () => { + it('ändert nichts, wenn SMTP nicht konfiguriert ist', async () => { const event = createEvent() mockSuccessReadBody({ email: 'user@example.com' }) authUtils.readUsers.mockResolvedValue([{ id: '1', email: 'user@example.com', name: 'User', password: 'hash' }]) - authUtils.hashPassword.mockResolvedValue('new-hash') delete process.env.SMTP_USER delete process.env.SMTP_PASS @@ -360,11 +372,12 @@ describe('Auth API Endpoints', () => { })) }) - it('protokolliert einen Mailfehler ohne das Passwort zu aktivieren', async () => { + it('protokolliert einen Mailfehler ohne das Passwort zu ersetzen', async () => { const event = createEvent() + const user = { id: '1', email: 'user@example.com', name: 'User', password: 'hash' } mockSuccessReadBody({ email: 'user@example.com' }) - authUtils.readUsers.mockResolvedValue([{ id: '1', email: 'user@example.com', name: 'User', password: 'hash' }]) - authUtils.hashPassword.mockResolvedValue('new-hash') + authUtils.readUsers.mockResolvedValue([user]) + authUtils.writeUsers.mockResolvedValue(true) nodemailer.default.createTransport.mockReturnValueOnce({ sendMail: vi.fn().mockRejectedValue(Object.assign(new Error('SMTP fehlgeschlagen'), { code: 'EAUTH' })) }) @@ -372,7 +385,8 @@ describe('Auth API Endpoints', () => { const response = await resetPasswordHandler(event) expect(response.success).toBe(true) - expect(authUtils.writeUsers).not.toHaveBeenCalled() + expect(authUtils.hashPassword).not.toHaveBeenCalled() + expect(authUtils.writeUsers.mock.calls[0][0][0].password).toBe('hash') expect(passwordResetLog.writePasswordResetLog).toHaveBeenCalledWith(expect.objectContaining({ step: 'mail_send', status: 'failed' @@ -380,6 +394,61 @@ describe('Auth API Endpoints', () => { }) }) + describe('POST /api/auth/reset-password/complete', () => { + it('setzt ein neues Passwort mit gültigem Reset-Token', async () => { + const token = 'reset-token' + const tokenHash = crypto.createHash('sha256').update(token, 'utf8').digest('hex') + const event = createEvent() + const user = { + id: '1', + email: 'user@example.com', + password: 'old-hash', + passwordResetRequired: true, + passwordResetTokens: [{ tokenHash, createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60000).toISOString(), usedAt: null }] + } + mockSuccessReadBody({ token, password: 'new-password' }) + authUtils.readUsers.mockResolvedValue([user]) + authUtils.hashPassword.mockResolvedValue('new-hash') + authUtils.writeUsers.mockResolvedValue(true) + + const response = await completePasswordResetHandler(event) + + expect(response.success).toBe(true) + expect(authUtils.hashPassword).toHaveBeenCalledWith('new-password') + expect(authUtils.writeUsers.mock.calls[0][0][0]).toMatchObject({ + password: 'new-hash', + passwordResetRequired: false + }) + expect(authUtils.writeUsers.mock.calls[0][0][0].passwordResetTokens[0].usedAt).toEqual(expect.any(String)) + expect(authUtils.revokeRefreshSessionsForUser).toHaveBeenCalledWith('1', 'password_reset_completed') + }) + + it('weist abgelaufene Reset-Tokens zurück', async () => { + const token = 'reset-token' + const tokenHash = crypto.createHash('sha256').update(token, 'utf8').digest('hex') + const event = createEvent() + mockSuccessReadBody({ token, password: 'new-password' }) + authUtils.readUsers.mockResolvedValue([{ + id: '1', + email: 'user@example.com', + password: 'old-hash', + passwordResetTokens: [{ tokenHash, expiresAt: new Date(Date.now() - 60000).toISOString(), usedAt: null }] + }]) + + await expect(completePasswordResetHandler(event)).rejects.toMatchObject({ statusCode: 400 }) + expect(authUtils.writeUsers).not.toHaveBeenCalled() + expect(authUtils.hashPassword).not.toHaveBeenCalled() + }) + + it('verlangt mindestens acht Zeichen für das neue Passwort', async () => { + const event = createEvent() + mockSuccessReadBody({ token: 'reset-token', password: 'kurz' }) + + await expect(completePasswordResetHandler(event)).rejects.toMatchObject({ statusCode: 400 }) + expect(authUtils.readUsers).not.toHaveBeenCalled() + }) + }) + describe('GET /api/auth/status', () => { it('liefert loggedOut, wenn kein Cookie gesetzt ist', async () => { const event = createEvent()