const crypto = require("crypto"); const redis = require("../../config/redisClient"); const otpHash = (challengeId, otp) => crypto.createHmac("sha256", process.env.JWT_SECRET).update(`${challengeId}:${otp}`).digest("hex"); const generateOtp = () => crypto.randomInt(0, 1000000).toString().padStart(6, "0"); const createLoginChallenge = async ({ userId, rememberMe, context }) => { const challengeId = crypto.randomUUID(); const otp = generateOtp(); await redis.set(`login:${challengeId}`, JSON.stringify({ userId, otpHash: otpHash(challengeId, otp), attempts: 0, rememberMe, context }), "EX", Number(process.env.LOGIN_OTP_TTL_SECONDS || 900)); return { challengeId, otp }; }; const VERIFY_SCRIPT = ` local raw=redis.call('GET',KEYS[1]); if not raw then return {-3} end local value=cjson.decode(raw) if value.otpHash==ARGV[1] then redis.call('DEL',KEYS[1]); return {1,value.userId,cjson.encode(value)} end value.attempts=(value.attempts or 0)+1 if value.attempts>=tonumber(ARGV[2]) then redis.call('DEL',KEYS[1]); return {-2} end redis.call('SET',KEYS[1],cjson.encode(value),'KEEPTTL'); return {-1} `; const verifyLoginChallenge = async (challengeId, otp) => { const result = await redis.eval(VERIFY_SCRIPT, 1, `login:${challengeId}`, otpHash(challengeId, otp), Number(process.env.LOGIN_OTP_MAX_ATTEMPTS || 5)); if (Number(result[0]) !== 1) throw Object.assign(new Error("Invalid or expired challenge"), { code: "INVALID_OTP", status: 401 }); const stored = JSON.parse(result[2]); return { userId: result[1], rememberMe: Boolean(stored.rememberMe), context: stored.context || {} }; }; module.exports = { generateOtp, otpHash, createLoginChallenge, verifyLoginChallenge };