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'
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user