Fixed inconsistency between IP-based rate limiting and per-account lockout. Previously, users would hit the IP rate limit at 5 attempts (15 min lockout) but the account wouldn't be marked as locked until 10 attempts (30 min). This caused confusion in the admin UI where locked accounts wouldn't show the unlock button until 10 attempts were reached. Changes: - Reduced account lockout threshold from 10 to 5 failed attempts - Reduced account lockout duration from 30 to 15 minutes - Updated error message to reflect 15 minute lockout period - Added detailed logging when account gets locked - Updated README documentation to reflect correct limits Both protection layers now work in harmony: - IP-based rate limiting: 5 attempts = 15 min lockout - Per-account lockout: 5 attempts = 15 min lock This ensures the admin UI accurately shows account lock status and provides the unlock option as soon as users hit the lockout threshold. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
114 lines
4.0 KiB
TypeScript
114 lines
4.0 KiB
TypeScript
import { getUserByUsername, checkRateLimit, resetRateLimit, createSession, isAccountLocked, incrementFailedAttempts, resetFailedAttempts } from '~/server/utils/database'
|
|
import { setAuthCookie, generateSessionToken } from '~/server/utils/auth'
|
|
import { generateCsrfToken, setCsrfCookie } from '~/server/utils/csrf'
|
|
import bcrypt from 'bcrypt'
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
// Get real client IP from proxy headers (prioritize x-real-ip for NPM)
|
|
const xRealIp = getHeader(event, 'x-real-ip')
|
|
const xForwardedFor = getHeader(event, 'x-forwarded-for')
|
|
const cfConnectingIp = getHeader(event, 'cf-connecting-ip')
|
|
|
|
// Use x-real-ip first (set by NPM), then x-forwarded-for, then cf-connecting-ip, then fallback
|
|
const clientIp = xRealIp ||
|
|
(xForwardedFor ? xForwardedFor.split(',')[0].trim() : null) ||
|
|
cfConnectingIp ||
|
|
getRequestIP(event) ||
|
|
'unknown'
|
|
|
|
// Log IP for verification
|
|
console.log(`[LOGIN ATTEMPT] IP: ${clientIp}, Headers:`, {
|
|
'x-forwarded-for': xForwardedFor,
|
|
'x-real-ip': xRealIp,
|
|
'cf-connecting-ip': cfConnectingIp,
|
|
'getRequestIP': getRequestIP(event)
|
|
})
|
|
|
|
const body = await readBody(event)
|
|
const { username, password } = body
|
|
|
|
if (!username || !password) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
message: 'Username and password are required'
|
|
})
|
|
}
|
|
|
|
const user = getUserByUsername(username.toLowerCase())
|
|
|
|
if (!user) {
|
|
// Check rate limit ONLY on failed attempt
|
|
if (!checkRateLimit(clientIp, 'login', 5, 15)) {
|
|
console.log(`[LOGIN BLOCKED] Rate limited - Username not found: ${username.toLowerCase()}, IP: ${clientIp}`)
|
|
throw createError({
|
|
statusCode: 429,
|
|
message: 'Too many login attempts. Please try again in 15 minutes.'
|
|
})
|
|
}
|
|
console.log(`[LOGIN FAILED] Username not found: ${username.toLowerCase()}, IP: ${clientIp}`)
|
|
throw createError({
|
|
statusCode: 401,
|
|
message: 'Invalid credentials'
|
|
})
|
|
}
|
|
|
|
// SECURITY: Check if account is locked due to too many failed attempts
|
|
if (isAccountLocked(username.toLowerCase())) {
|
|
console.log(`[LOGIN BLOCKED] Account locked - User: ${username.toLowerCase()}, IP: ${clientIp}`)
|
|
throw createError({
|
|
statusCode: 403,
|
|
message: 'Account temporarily locked due to too many failed login attempts. Please try again in 15 minutes or contact an administrator.'
|
|
})
|
|
}
|
|
|
|
// Compare the provided password with the hashed password in the database
|
|
const passwordMatch = await bcrypt.compare(password, user.password)
|
|
|
|
if (!passwordMatch) {
|
|
// SECURITY: Increment failed login attempts for this account
|
|
incrementFailedAttempts(username.toLowerCase())
|
|
|
|
// Check rate limit ONLY on failed attempt
|
|
if (!checkRateLimit(clientIp, 'login', 5, 15)) {
|
|
console.log(`[LOGIN BLOCKED] Rate limited - Invalid password for user: ${username.toLowerCase()}, IP: ${clientIp}`)
|
|
throw createError({
|
|
statusCode: 429,
|
|
message: 'Too many login attempts. Please try again in 15 minutes.'
|
|
})
|
|
}
|
|
console.log(`[LOGIN FAILED] Invalid password for user: ${username.toLowerCase()}, IP: ${clientIp}`)
|
|
throw createError({
|
|
statusCode: 401,
|
|
message: 'Invalid credentials'
|
|
})
|
|
}
|
|
|
|
// Generate session token and CSRF token
|
|
const sessionToken = generateSessionToken()
|
|
const csrfToken = generateCsrfToken()
|
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() // 24 hours
|
|
|
|
// Create session with CSRF token
|
|
createSession(sessionToken, user.username, expiresAt, csrfToken)
|
|
|
|
// Set session cookie
|
|
setAuthCookie(event, sessionToken)
|
|
|
|
// Set CSRF cookie
|
|
setCsrfCookie(event, csrfToken)
|
|
|
|
// SECURITY: Reset failed login attempts on successful login
|
|
resetFailedAttempts(username.toLowerCase())
|
|
|
|
// Reset rate limit on successful login
|
|
resetRateLimit(clientIp, 'login')
|
|
|
|
// Log successful login
|
|
console.log(`[LOGIN SUCCESS] User: ${user.username}, IP: ${clientIp}`)
|
|
|
|
return {
|
|
success: true,
|
|
username: user.username
|
|
}
|
|
})
|