feat(auth): implement Android refresh token handling and session management
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m7s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

- 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:
Torsten Schulz (local)
2026-05-27 19:34:32 +02:00
parent 755442fb70
commit 58fd7fa5c6
32 changed files with 1477 additions and 180 deletions

View File

@@ -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'
}))
})
})

View File

@@ -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 })
})
})
})

View File

@@ -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')
})
})
})

View 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')
})
})

View 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'
])
})
})