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