Skip to content

Commit a909024

Browse files
committed
Fix security event gaps in password reset flow
Log rate limit hits on forgot-password, distinguish expired vs invalid reset tokens with separate event types, and log password reset requests before token generation.
1 parent a5e23b2 commit a909024

3 files changed

Lines changed: 55 additions & 15 deletions

File tree

src/app/api/auth/forgot-password/route.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ export async function POST(request: NextRequest) {
3838
}, 'forgot-password')
3939

4040
if (rateLimitResult) {
41+
await logSecurityEvent({
42+
type: 'ADMIN_PASSWORD_RESET_RATE_LIMIT_HIT',
43+
severity: 'WARNING',
44+
ipAddress: getClientIpAddress(request),
45+
details: {
46+
reason: 'Too many password reset requests',
47+
},
48+
})
4149
return rateLimitResult
4250
}
4351

@@ -91,6 +99,17 @@ export async function POST(request: NextRequest) {
9199
settings?.smtpFromAddress
92100
)
93101

102+
// Log the reset request
103+
await logSecurityEvent({
104+
type: 'ADMIN_PASSWORD_RESET_REQUESTED',
105+
severity: 'INFO',
106+
ipAddress: getClientIpAddress(request),
107+
details: {
108+
userId: user.id,
109+
email: user.email,
110+
},
111+
})
112+
94113
// Generate secure reset token
95114
const token = generatePasswordResetToken({
96115
userId: user.id,

src/app/api/auth/reset-password/route.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NextRequest, NextResponse } from 'next/server'
22
import { prisma } from '@/lib/db'
33
import { rateLimit } from '@/lib/rate-limit'
4-
import { verifyPasswordResetToken } from '@/lib/password-reset'
4+
import { verifyPasswordResetTokenWithReason } from '@/lib/password-reset'
55
import { hashPassword, validatePassword } from '@/lib/encryption'
66
import { invalidateAdminSessions } from '@/lib/session-invalidation'
77
import { logSecurityEvent } from '@/lib/video-access'
@@ -82,22 +82,25 @@ export async function POST(request: NextRequest) {
8282
}
8383

8484
// Verify and decode token
85-
const payload = verifyPasswordResetToken(token)
86-
if (!payload) {
87-
// Log invalid token attempt
85+
const tokenResult = verifyPasswordResetTokenWithReason(token)
86+
if (!tokenResult.valid) {
87+
const eventType = tokenResult.reason === 'expired'
88+
? 'ADMIN_PASSWORD_RESET_TOKEN_EXPIRED'
89+
: 'ADMIN_PASSWORD_RESET_TOKEN_INVALID'
8890
await logSecurityEvent({
89-
type: 'ADMIN_PASSWORD_RESET_TOKEN_INVALID',
91+
type: eventType,
9092
severity: 'WARNING',
9193
ipAddress: getClientIpAddress(request),
9294
details: {
93-
reason: 'Invalid or expired token',
95+
reason: tokenResult.reason === 'expired' ? 'Token expired' : 'Invalid or malformed token',
9496
},
9597
})
9698
return NextResponse.json(
9799
{ error: authMessages.invalidOrExpiredResetToken || 'Invalid or expired reset token' },
98100
{ status: 400 }
99101
)
100102
}
103+
const payload = tokenResult.payload
101104

102105
// Atomically mark token as used (single-use enforcement via SETNX)
103106
const redis = getRedis()

src/lib/password-reset.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,33 +35,51 @@ export function generatePasswordResetToken(input: {
3535

3636
/**
3737
* Verify and decode a password reset token
38-
*
38+
*
3939
* @param token - Encrypted token string
4040
* @returns Decoded payload or null if invalid/expired
4141
*/
4242
export function verifyPasswordResetToken(token: string): {
4343
userId: string
4444
userEmail: string
4545
} | null {
46+
const result = verifyPasswordResetTokenWithReason(token)
47+
return result.valid ? result.payload : null
48+
}
49+
50+
/**
51+
* Verify a password reset token and return the failure reason.
52+
* Used by the reset-password route to distinguish expired vs invalid tokens.
53+
*/
54+
export function verifyPasswordResetTokenWithReason(token: string): {
55+
valid: true
56+
payload: { userId: string; userEmail: string }
57+
} | {
58+
valid: false
59+
reason: 'invalid' | 'expired'
60+
} {
4661
try {
4762
const decoded = decrypt(token)
4863
const payload = JSON.parse(decoded) as Partial<PasswordResetPayloadV1>
4964

5065
// Validate structure
51-
if (payload.v !== 1 || payload.t !== 'password_reset') return null
52-
if (typeof payload.uid !== 'string' || payload.uid.length === 0) return null
53-
if (typeof payload.em !== 'string' || payload.em.length === 0) return null
54-
if (typeof payload.exp !== 'number') return null
66+
if (payload.v !== 1 || payload.t !== 'password_reset') return { valid: false, reason: 'invalid' }
67+
if (typeof payload.uid !== 'string' || payload.uid.length === 0) return { valid: false, reason: 'invalid' }
68+
if (typeof payload.em !== 'string' || payload.em.length === 0) return { valid: false, reason: 'invalid' }
69+
if (typeof payload.exp !== 'number') return { valid: false, reason: 'invalid' }
5570

5671
// Check expiration
57-
if (Date.now() > payload.exp) return null
72+
if (Date.now() > payload.exp) return { valid: false, reason: 'expired' }
5873

5974
return {
60-
userId: payload.uid,
61-
userEmail: payload.em,
75+
valid: true,
76+
payload: {
77+
userId: payload.uid,
78+
userEmail: payload.em,
79+
},
6280
}
6381
} catch {
64-
return null
82+
return { valid: false, reason: 'invalid' }
6583
}
6684
}
6785

0 commit comments

Comments
 (0)