feat(auth): implement Android refresh token handling and session management
- Added support for generating Android access tokens and managing refresh sessions in the auth endpoints. - Implemented new tests for login, logout, and refresh functionalities specific to Android clients. - Enhanced password reset logging with normalization and masking of email addresses. - Created a new diagnostics endpoint for password reset attempts, including filtering and summarizing logs. - Introduced a new utility for managing password reset logs with retention policies. - Added tests for password reset log utilities to ensure proper functionality and privacy compliance. - Updated WebAuthn configuration tests to validate origin handling for production and allowed origins.
This commit is contained in:
@@ -8,7 +8,13 @@ vi.mock('../server/utils/auth.js', () => {
|
||||
writeUsers: vi.fn(),
|
||||
verifyPassword: vi.fn(),
|
||||
generateToken: vi.fn(),
|
||||
generateAndroidAccessToken: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
createRefreshSession: vi.fn(),
|
||||
rotateRefreshSession: vi.fn(),
|
||||
revokeRefreshSession: vi.fn(),
|
||||
revokeRefreshSessionsForUser: vi.fn(),
|
||||
getUserById: vi.fn(),
|
||||
hashPassword: vi.fn(),
|
||||
verifyToken: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
@@ -53,11 +59,19 @@ vi.mock('nodemailer', () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../server/utils/password-reset-log.js', () => ({
|
||||
normalizeResetEmail: vi.fn(email => String(email || '').trim().toLowerCase()),
|
||||
maskResetEmail: vi.fn(email => `masked:${String(email || '').trim().toLowerCase()}`),
|
||||
writePasswordResetLog: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
const authUtils = await import('../server/utils/auth.js')
|
||||
const nodemailer = await import('nodemailer')
|
||||
const passwordResetLog = await import('../server/utils/password-reset-log.js')
|
||||
|
||||
import loginHandler from '../server/api/auth/login.post.js'
|
||||
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 statusHandler from '../server/api/auth/status.get.js'
|
||||
@@ -110,6 +124,29 @@ describe('Auth API Endpoints', () => {
|
||||
expect(authUtils.createSession).toHaveBeenCalledWith('1', 'jwt-token')
|
||||
expect(authUtils.writeUsers).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('gibt Android-Clients ein Refresh-Token für eine Gerätesitzung zurück', async () => {
|
||||
const event = createEvent()
|
||||
const user = { id: '1', email: 'test@example.com', password: 'hash', roles: ['mitglied'], active: true }
|
||||
mockSuccessReadBody({ email: user.email, password: 'plain', client: 'android', deviceName: 'Pixel' })
|
||||
authUtils.readUsers.mockResolvedValue([user])
|
||||
authUtils.verifyPassword.mockResolvedValue(true)
|
||||
authUtils.createRefreshSession.mockResolvedValue({ session: { id: 'session-1' }, refreshToken: 'refresh-1' })
|
||||
authUtils.generateAndroidAccessToken.mockReturnValue('access-1')
|
||||
authUtils.writeUsers.mockResolvedValue(true)
|
||||
|
||||
const response = await loginHandler(event)
|
||||
|
||||
expect(authUtils.createRefreshSession).toHaveBeenCalledWith('1', 'Pixel')
|
||||
expect(authUtils.generateAndroidAccessToken).toHaveBeenCalledWith(user, 'session-1')
|
||||
expect(response).toMatchObject({
|
||||
token: 'access-1',
|
||||
accessToken: 'access-1',
|
||||
refreshToken: 'refresh-1',
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
expect(authUtils.createSession).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/logout', () => {
|
||||
@@ -129,6 +166,64 @@ describe('Auth API Endpoints', () => {
|
||||
|
||||
await expect(logoutHandler(event)).rejects.toMatchObject({ statusCode: 500 })
|
||||
})
|
||||
|
||||
it('widerruft beim Android-Logout das Refresh-Token', async () => {
|
||||
const event = createEvent({ headers: { authorization: 'Bearer access-token' } })
|
||||
mockSuccessReadBody({ refreshToken: 'refresh-token' })
|
||||
authUtils.deleteSession.mockResolvedValue()
|
||||
authUtils.revokeRefreshSession.mockResolvedValue(true)
|
||||
|
||||
const response = await logoutHandler(event)
|
||||
|
||||
expect(response.success).toBe(true)
|
||||
expect(authUtils.revokeRefreshSession).toHaveBeenCalledWith('refresh-token')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/refresh', () => {
|
||||
it('rotiert eine gültige Android-Sitzung und gibt ein neues Token-Paar zurück', async () => {
|
||||
const event = createEvent()
|
||||
mockSuccessReadBody({ refreshToken: 'old-refresh' })
|
||||
const user = { id: '1', email: 'test@example.com', roles: ['mitglied'], active: true }
|
||||
authUtils.rotateRefreshSession.mockResolvedValue({
|
||||
status: 'rotated',
|
||||
session: { id: 'session-2', userId: '1' },
|
||||
refreshToken: 'new-refresh'
|
||||
})
|
||||
authUtils.getUserById.mockResolvedValue(user)
|
||||
authUtils.generateAndroidAccessToken.mockReturnValue('new-access')
|
||||
|
||||
const response = await refreshHandler(event)
|
||||
|
||||
expect(authUtils.rotateRefreshSession).toHaveBeenCalledWith('old-refresh')
|
||||
expect(response).toMatchObject({
|
||||
accessToken: 'new-access',
|
||||
refreshToken: 'new-refresh',
|
||||
sessionId: 'session-2'
|
||||
})
|
||||
})
|
||||
|
||||
it('weist widerrufene oder erneut verwendete Refresh-Tokens zurück', async () => {
|
||||
const event = createEvent()
|
||||
mockSuccessReadBody({ refreshToken: 'used-refresh' })
|
||||
authUtils.rotateRefreshSession.mockResolvedValue({ status: 'reused' })
|
||||
|
||||
await expect(refreshHandler(event)).rejects.toMatchObject({ statusCode: 401 })
|
||||
})
|
||||
|
||||
it('widerruft eine rotierte Sitzung, wenn der Benutzer nicht mehr aktiv ist', async () => {
|
||||
const event = createEvent()
|
||||
mockSuccessReadBody({ refreshToken: 'old-refresh' })
|
||||
authUtils.rotateRefreshSession.mockResolvedValue({
|
||||
status: 'rotated',
|
||||
session: { id: 'session-2', userId: '1' },
|
||||
refreshToken: 'new-refresh'
|
||||
})
|
||||
authUtils.getUserById.mockResolvedValue({ id: '1', active: false })
|
||||
|
||||
await expect(refreshHandler(event)).rejects.toMatchObject({ statusCode: 401 })
|
||||
expect(authUtils.revokeRefreshSession).toHaveBeenCalledWith('new-refresh', 'inactive_or_missing_user')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/register', () => {
|
||||
@@ -224,6 +319,64 @@ describe('Auth API Endpoints', () => {
|
||||
const response = await resetPasswordHandler(event)
|
||||
expect(response.success).toBe(true)
|
||||
expect(authUtils.writeUsers).toHaveBeenCalled()
|
||||
expect(authUtils.revokeRefreshSessionsForUser).toHaveBeenCalledWith('1', 'password_reset')
|
||||
expect(passwordResetLog.writePasswordResetLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||
email: 'user@example.com',
|
||||
step: 'mail_send',
|
||||
status: 'completed'
|
||||
}))
|
||||
})
|
||||
|
||||
it('normalisiert Leerzeichen bei der Benutzersuche', async () => {
|
||||
const event = createEvent()
|
||||
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)
|
||||
|
||||
expect(authUtils.writeUsers).toHaveBeenCalled()
|
||||
expect(passwordResetLog.normalizeResetEmail).toHaveBeenCalledWith(' User@Example.com ')
|
||||
})
|
||||
|
||||
it('ändert das Passwort nicht, 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
|
||||
|
||||
const response = await resetPasswordHandler(event)
|
||||
|
||||
expect(response.success).toBe(true)
|
||||
expect(authUtils.writeUsers).not.toHaveBeenCalled()
|
||||
expect(passwordResetLog.writePasswordResetLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||
step: 'mail_configuration',
|
||||
status: 'failed',
|
||||
reason: 'smtp_credentials_missing'
|
||||
}))
|
||||
})
|
||||
|
||||
it('protokolliert einen Mailfehler ohne das Passwort zu aktivieren', 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')
|
||||
nodemailer.default.createTransport.mockReturnValueOnce({
|
||||
sendMail: vi.fn().mockRejectedValue(Object.assign(new Error('SMTP fehlgeschlagen'), { code: 'EAUTH' }))
|
||||
})
|
||||
|
||||
const response = await resetPasswordHandler(event)
|
||||
|
||||
expect(response.success).toBe(true)
|
||||
expect(authUtils.writeUsers).not.toHaveBeenCalled()
|
||||
expect(passwordResetLog.writePasswordResetLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||
step: 'mail_send',
|
||||
status: 'failed'
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock('../server/utils/auth.js', () => ({
|
||||
getUserFromToken: vi.fn(),
|
||||
readUsers: vi.fn(),
|
||||
writeUsers: vi.fn(),
|
||||
revokeRefreshSessionsForUser: vi.fn(),
|
||||
hasRole: vi.fn((user, role) => {
|
||||
if (!user) return false
|
||||
const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : [])
|
||||
@@ -37,14 +38,23 @@ vi.mock('nodemailer', () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../server/utils/password-reset-log.js', () => ({
|
||||
fingerprintResetEmail: vi.fn(email => `fingerprint:${String(email || '').trim().toLowerCase()}`),
|
||||
normalizeResetEmail: vi.fn(email => String(email || '').trim().toLowerCase()),
|
||||
PASSWORD_RESET_LOG_RETENTION_HOURS: 72,
|
||||
readPasswordResetLogs: vi.fn()
|
||||
}))
|
||||
|
||||
const authUtils = await import('../server/utils/auth.js')
|
||||
const nodemailer = await import('nodemailer')
|
||||
const passwordResetLog = await import('../server/utils/password-reset-log.js')
|
||||
|
||||
import usersListHandler from '../server/api/cms/users/list.get.js'
|
||||
import usersApproveHandler from '../server/api/cms/users/approve.post.js'
|
||||
import usersDeactivateHandler from '../server/api/cms/users/deactivate.post.js'
|
||||
import usersRejectHandler from '../server/api/cms/users/reject.post.js'
|
||||
import usersUpdateRoleHandler from '../server/api/cms/users/update-role.post.js'
|
||||
import passwordResetDiagnosticsHandler from '../server/api/cms/password-reset-diagnostics.get.js'
|
||||
|
||||
describe('CMS User Management Endpoints', () => {
|
||||
beforeEach(() => {
|
||||
@@ -138,6 +148,7 @@ describe('CMS User Management Endpoints', () => {
|
||||
const response = await usersDeactivateHandler(event)
|
||||
expect(response.success).toBe(true)
|
||||
expect(authUtils.writeUsers).toHaveBeenCalled()
|
||||
expect(authUtils.revokeRefreshSessionsForUser).toHaveBeenCalledWith('1', 'account_deactivated')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -184,4 +195,49 @@ describe('CMS User Management Endpoints', () => {
|
||||
expect(authUtils.writeUsers).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/cms/password-reset-diagnostics', () => {
|
||||
it('verweigert Zugriff für Vorstand ohne Admin-Rolle', async () => {
|
||||
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||
authUtils.getUserFromToken.mockResolvedValue({ id: 'vorstand', roles: ['vorstand'] })
|
||||
authUtils.hasRole.mockReturnValue(false)
|
||||
|
||||
await expect(passwordResetDiagnosticsHandler(event)).rejects.toMatchObject({ statusCode: 403 })
|
||||
})
|
||||
|
||||
it('findet Benutzer und gefilterte fehlgeschlagene Reset-Abläufe', async () => {
|
||||
const event = adminEvent()
|
||||
event.__query = { email: ' User@Example.com ', failedOnly: 'true' }
|
||||
authUtils.hasRole.mockReturnValue(true)
|
||||
authUtils.readUsers.mockResolvedValue([
|
||||
{ id: '1', email: 'user@example.com', name: 'User Beispiel', active: true }
|
||||
])
|
||||
passwordResetLog.readPasswordResetLogs.mockResolvedValue([
|
||||
{
|
||||
requestId: 'r1',
|
||||
ts: '2026-05-27T10:00:01.000Z',
|
||||
emailMasked: 'us***@ex***.com',
|
||||
emailFingerprint: 'fingerprint:user@example.com',
|
||||
ip: '127.0.0.1',
|
||||
step: 'request_completed',
|
||||
status: 'no_account'
|
||||
},
|
||||
{
|
||||
requestId: 'r2',
|
||||
ts: '2026-05-27T10:00:02.000Z',
|
||||
emailMasked: 'ot***@ex***.com',
|
||||
emailFingerprint: 'fingerprint:other@example.com',
|
||||
ip: '127.0.0.1',
|
||||
step: 'request_completed',
|
||||
status: 'failed'
|
||||
}
|
||||
])
|
||||
|
||||
const response = await passwordResetDiagnosticsHandler(event)
|
||||
|
||||
expect(response.matchingUsers).toHaveLength(1)
|
||||
expect(response.attempts).toHaveLength(1)
|
||||
expect(response.attempts[0]).toMatchObject({ requestId: 'r1', failed: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock('../server/utils/auth.js', () => ({
|
||||
writeUsers: vi.fn(),
|
||||
verifyPassword: vi.fn(),
|
||||
hashPassword: vi.fn(),
|
||||
revokeRefreshSessionsForUser: vi.fn(),
|
||||
migrateUserRoles: vi.fn((user) => {
|
||||
if (!user) return user
|
||||
if (Array.isArray(user.roles)) return user
|
||||
@@ -202,6 +203,7 @@ describe('Config & Profil Endpoints', () => {
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.user.name).toBe('Max Neu')
|
||||
expect(authUtils.writeUsers).toHaveBeenCalled()
|
||||
expect(authUtils.revokeRefreshSessionsForUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prüft aktuelles Passwort bei Passwortänderung', async () => {
|
||||
@@ -236,6 +238,7 @@ describe('Config & Profil Endpoints', () => {
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(authUtils.hashPassword).toHaveBeenCalledWith(updatedPassword)
|
||||
expect(authUtils.revokeRefreshSessionsForUser).toHaveBeenCalledWith('1', 'password_changed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
74
tests/password-reset-log.spec.ts
Normal file
74
tests/password-reset-log.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const filesystem = vi.hoisted(() => ({
|
||||
mkdir: vi.fn(),
|
||||
appendFile: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: filesystem
|
||||
}))
|
||||
|
||||
import {
|
||||
cleanupPasswordResetLogs,
|
||||
fingerprintResetEmail,
|
||||
maskResetEmail,
|
||||
normalizeResetEmail,
|
||||
writePasswordResetLog
|
||||
} from '../server/utils/password-reset-log.js'
|
||||
|
||||
describe('Password reset diagnostic log privacy helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('normalisiert E-Mail-Adressen für Lookup und Korrelation', () => {
|
||||
expect(normalizeResetEmail(' User@Example.com ')).toBe('user@example.com')
|
||||
expect(fingerprintResetEmail(' User@Example.com ')).toBe(fingerprintResetEmail('user@example.com'))
|
||||
})
|
||||
|
||||
it('maskiert die E-Mail-Adresse für Diagnoseausgaben', () => {
|
||||
const masked = maskResetEmail('ag2608@googlemail.com')
|
||||
|
||||
expect(masked).toBe('ag***@go***.com')
|
||||
expect(masked).not.toContain('ag2608')
|
||||
expect(masked).not.toContain('googlemail')
|
||||
})
|
||||
|
||||
it('entfernt Diagnoseeinträge nach 72 Stunden', async () => {
|
||||
const now = Date.parse('2026-05-27T12:00:00.000Z')
|
||||
filesystem.readFile.mockResolvedValue([
|
||||
JSON.stringify({ ts: '2026-05-24T11:59:59.000Z', requestId: 'alt' }),
|
||||
JSON.stringify({ ts: '2026-05-24T12:00:00.000Z', requestId: 'neu' }),
|
||||
''
|
||||
].join('\n'))
|
||||
|
||||
const result = await cleanupPasswordResetLogs(now)
|
||||
|
||||
expect(result).toEqual({ retained: 1, removed: 1 })
|
||||
expect(filesystem.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
`${JSON.stringify({ ts: '2026-05-24T12:00:00.000Z', requestId: 'neu' })}\n`,
|
||||
'utf8'
|
||||
)
|
||||
})
|
||||
|
||||
it('schreibt bereinigte Fehlerdetails ohne E-Mail oder Credentials', async () => {
|
||||
await writePasswordResetLog({
|
||||
requestId: 'r1',
|
||||
email: 'ag2608@googlemail.com',
|
||||
step: 'mail_send',
|
||||
status: 'failed',
|
||||
error: Object.assign(new Error('Versand an ag2608@googlemail.com fehlgeschlagen password=geheim'), { code: 'EAUTH' })
|
||||
})
|
||||
|
||||
const payload = filesystem.appendFile.mock.calls[0][1]
|
||||
expect(payload).toContain('"errorCode":"EAUTH"')
|
||||
expect(payload).toContain('ag***@go***.com')
|
||||
expect(payload).toContain('password=[redacted]')
|
||||
expect(payload).not.toContain('ag2608@googlemail.com')
|
||||
expect(payload).not.toContain('geheim')
|
||||
})
|
||||
})
|
||||
54
tests/webauthn-config.spec.ts
Normal file
54
tests/webauthn-config.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getWebAuthnConfig } from '../server/utils/webauthn-config.js'
|
||||
|
||||
const envNames = [
|
||||
'NUXT_PUBLIC_BASE_URL',
|
||||
'WEBAUTHN_RP_ID',
|
||||
'WEBAUTHN_ORIGIN',
|
||||
'WEBAUTHN_ALLOWED_ORIGINS'
|
||||
]
|
||||
|
||||
const originalEnv = Object.fromEntries(envNames.map(name => [name, process.env[name]]))
|
||||
|
||||
afterEach(() => {
|
||||
for (const name of envNames) {
|
||||
const originalValue = originalEnv[name]
|
||||
if (originalValue === undefined) {
|
||||
delete process.env[name]
|
||||
} else {
|
||||
process.env[name] = originalValue
|
||||
}
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('WebAuthn origin configuration', () => {
|
||||
it('accepts both production hosts when the public URL is the apex domain', () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
process.env.NUXT_PUBLIC_BASE_URL = 'https://harheimertc.de'
|
||||
process.env.WEBAUTHN_RP_ID = 'harheimertc.de'
|
||||
delete process.env.WEBAUTHN_ORIGIN
|
||||
delete process.env.WEBAUTHN_ALLOWED_ORIGINS
|
||||
|
||||
const config = getWebAuthnConfig()
|
||||
|
||||
expect(config.origin).toBe('https://harheimertc.de')
|
||||
expect(config.origins).toEqual([
|
||||
'https://harheimertc.de',
|
||||
'https://www.harheimertc.de'
|
||||
])
|
||||
})
|
||||
|
||||
it('adds explicitly allowed origins without widening test installations implicitly', () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
process.env.WEBAUTHN_ORIGIN = 'https://harheimertc.tsschulz.de'
|
||||
process.env.WEBAUTHN_ALLOWED_ORIGINS = ' https://alias.tsschulz.de/ , https://alias.tsschulz.de '
|
||||
|
||||
const config = getWebAuthnConfig()
|
||||
|
||||
expect(config.origins).toEqual([
|
||||
'https://harheimertc.tsschulz.de',
|
||||
'https://alias.tsschulz.de'
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user