@@ -94,7 +94,40 @@ async function getOTP(email: string, chatId: string): Promise<string | null> {
9494 return record ?. value ?? null
9595}
9696
97- async function updateOTPValue ( email : string , chatId : string , value : string ) : Promise < void > {
97+ /**
98+ * Lua script for atomic OTP attempt increment.
99+ * Returns: "LOCKED" if max attempts reached (key deleted), new encoded value otherwise, nil if key missing.
100+ */
101+ const ATOMIC_INCREMENT_SCRIPT = `
102+ local val = redis.call('GET', KEYS[1])
103+ if not val then return nil end
104+ local colon = val:find(':([^:]*$)')
105+ local otp, attempts
106+ if colon then
107+ otp = val:sub(1, colon - 1)
108+ attempts = tonumber(val:sub(colon + 1)) or 0
109+ else
110+ otp = val
111+ attempts = 0
112+ end
113+ attempts = attempts + 1
114+ if attempts >= tonumber(ARGV[1]) then
115+ redis.call('DEL', KEYS[1])
116+ return 'LOCKED'
117+ end
118+ local newVal = otp .. ':' .. attempts
119+ redis.call('SET', KEYS[1], newVal, 'KEEPTTL')
120+ return newVal
121+ `
122+
123+ /**
124+ * Atomically increments OTP attempts. Returns 'locked' if max reached, 'incremented' otherwise.
125+ */
126+ async function incrementOTPAttempts (
127+ email : string ,
128+ chatId : string ,
129+ currentValue : string
130+ ) : Promise < 'locked' | 'incremented' > {
98131 const identifier = `chat-otp:${ chatId } :${ email } `
99132 const storageMethod = getStorageMethod ( )
100133
@@ -104,13 +137,35 @@ async function updateOTPValue(email: string, chatId: string, value: string): Pro
104137 throw new Error ( 'Redis configured but client unavailable' )
105138 }
106139 const key = `otp:${ email } :${ chatId } `
107- await redis . set ( key , value , 'KEEPTTL' )
108- } else {
109- await db
110- . update ( verification )
111- . set ( { value, updatedAt : new Date ( ) } )
112- . where ( eq ( verification . identifier , identifier ) )
140+ const result = await redis . eval ( ATOMIC_INCREMENT_SCRIPT , 1 , key , MAX_OTP_ATTEMPTS )
141+ return result === 'LOCKED' ? 'locked' : 'incremented'
142+ }
143+
144+ // DB path: optimistic locking — only update if value hasn't changed since we read it
145+ const { otp, attempts } = decodeOTPValue ( currentValue )
146+ const newAttempts = attempts + 1
147+
148+ if ( newAttempts >= MAX_OTP_ATTEMPTS ) {
149+ await db . delete ( verification ) . where ( eq ( verification . identifier , identifier ) )
150+ return 'locked'
113151 }
152+
153+ const newValue = encodeOTPValue ( otp , newAttempts )
154+ const updated = await db
155+ . update ( verification )
156+ . set ( { value : newValue , updatedAt : new Date ( ) } )
157+ . where ( and ( eq ( verification . identifier , identifier ) , eq ( verification . value , currentValue ) ) )
158+ . returning ( { id : verification . id } )
159+
160+ // If no rows updated, another request already incremented — re-read to check state
161+ if ( updated . length === 0 ) {
162+ const fresh = await getOTP ( email , chatId )
163+ if ( ! fresh ) return 'locked'
164+ const { attempts : freshAttempts } = decodeOTPValue ( fresh )
165+ return freshAttempts >= MAX_OTP_ATTEMPTS ? 'locked' : 'incremented'
166+ }
167+
168+ return 'incremented'
114169}
115170
116171async function deleteOTP ( email : string , chatId : string ) : Promise < void > {
@@ -257,18 +312,14 @@ export async function PUT(
257312 const { otp : storedOTP , attempts } = decodeOTPValue ( storedValue )
258313
259314 if ( storedOTP !== otp ) {
260- const newAttempts = attempts + 1
261- if ( newAttempts >= MAX_OTP_ATTEMPTS ) {
262- await deleteOTP ( email , deployment . id )
263- logger . warn (
264- `[${ requestId } ] OTP invalidated after ${ newAttempts } failed attempts for ${ email } `
265- )
315+ const result = await incrementOTPAttempts ( email , deployment . id , storedValue )
316+ if ( result === 'locked' ) {
317+ logger . warn ( `[${ requestId } ] OTP invalidated after max failed attempts for ${ email } ` )
266318 return addCorsHeaders (
267319 createErrorResponse ( 'Too many failed attempts. Please request a new code.' , 429 ) ,
268320 request
269321 )
270322 }
271- await updateOTPValue ( email , deployment . id , encodeOTPValue ( storedOTP , newAttempts ) )
272323 return addCorsHeaders ( createErrorResponse ( 'Invalid verification code' , 400 ) , request )
273324 }
274325
0 commit comments