Skip to content

feature + security: Face recognition attendance has no liveness detection or anti-spoofing guard β€” a static photograph held in front of the camera registers as a valid attendance event, enabling any student to mark attendance for absent peers triviallyΒ #159

Description

@prince-pokharna

🎭 The Problem

Learnova's face recognition attendance system uses Face API.js (components/FaceRecognizer.js) to validate student identity and record attendance. This is one of the platform's headline features:

"AI-powered face recognition using Face API.js for contactless attendance"
"Reduces manual roll-call time dramatically"

However, Face API.js performs static face detection and landmark matching only. It has zero liveness detection β€” it cannot distinguish between:

  • A live student sitting in front of the camera βœ…
  • A photograph of that student displayed on a phone or printed and held up ❌

This means any student who has a photo of a classmate (e.g., from Instagram, WhatsApp, or the institution's portal) can mark attendance on their behalf, completely defeating the system's integrity guarantee.

Attack Scenario (trivially reproducible)

  1. Student A is absent.
  2. Student B opens a photo of Student A on their phone screen.
  3. Student B holds the phone in front of the Learnova camera during attendance.
  4. Face API.js detects the face, matches descriptors, marks Student A as present.
  5. No error is thrown. The institution has no indication of spoofing.

This is not theoretical β€” it is the default failure mode of any webcam-based face recognition system without liveness checks.

πŸ’‘ Proposed Fix

1. Add blink detection as a lightweight liveness challenge in components/FaceRecognizer.js

Blink detection uses Eye Aspect Ratio (EAR) from Face API.js's 68-point landmark model, which is already loaded in the project:

// components/FaceRecognizer.js β€” add liveness detection

const EAR_THRESHOLD = 0.22;  // below this = eye closed
const BLINK_CONSEC_FRAMES = 2;
const REQUIRED_BLINKS = 2;

function getEAR(eye) {
  // eye: array of 6 {x, y} landmark points
  const A = dist(eye[1], eye[5]);
  const B = dist(eye[2], eye[4]);
  const C = dist(eye[0], eye[3]);
  return (A + B) / (2.0 * C);
}

function dist(p1, p2) {
  return Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2);
}

// In the detection loop:
let blinkCount = 0;
let blinkFrameCounter = 0;
let livenessVerified = false;

async function runLivenessCheck(videoEl) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error('Liveness check timed out')), 15000);

    const interval = setInterval(async () => {
      const detection = await faceapi
        .detectSingleFace(videoEl, new faceapi.TinyFaceDetectorOptions())
        .withFaceLandmarks();

      if (!detection) return;

      const landmarks = detection.landmarks;
      const leftEye = landmarks.getLeftEye();
      const rightEye = landmarks.getRightEye();
      const ear = (getEAR(leftEye) + getEAR(rightEye)) / 2;

      if (ear < EAR_THRESHOLD) {
        blinkFrameCounter++;
      } else {
        if (blinkFrameCounter >= BLINK_CONSEC_FRAMES) {
          blinkCount++;
        }
        blinkFrameCounter = 0;
      }

      if (blinkCount >= REQUIRED_BLINKS) {
        clearInterval(interval);
        clearTimeout(timer);
        resolve(true);
      }
    }, 100);
  });
}

2. Gate attendance recording behind the liveness check

// In the main attendance flow

async function recordAttendance(videoEl) {
  setStatus('Please blink twice to verify you are present...');
  
  try {
    await runLivenessCheck(videoEl);
  } catch (e) {
    setStatus('Liveness check failed. Please try again and ensure your face is clearly visible.');
    return;
  }

  // Only proceed with face matching after liveness is confirmed
  const descriptor = await getFaceDescriptor(videoEl);
  const match = findBestMatch(descriptor);
  
  if (match.distance < MATCH_THRESHOLD) {
    await submitAttendance(match.studentId);
    setStatus(`βœ… Attendance recorded for ${match.label}`);
  }
}

3. Add a UI instruction prompt for the liveness challenge

// In FaceRecognizer.js render

{livenessStatus === 'pending' && (
  <div className="liveness-prompt">
    <span className="animate-pulse">πŸ‘οΈ</span>
    <p>Please blink <strong>twice</strong> naturally to confirm you are present.</p>
    <p className="text-sm text-muted">Blinks detected: {blinkCount} / {REQUIRED_BLINKS}</p>
  </div>
)}

4. Add server-side logging for failed liveness attempts

// In the attendance API route

if (!livenessVerified) {
  console.warn(`[ATTENDANCE] Liveness check failed for session ${sessionId} at ${new Date().toISOString()}`);
  // Optionally flag to teacher dashboard for manual review
  await flagSuspiciousAttempt(sessionId, studentId);
  return res.status(403).json({ error: 'Liveness verification required' });
}

πŸ“ Files to Modify / Create

File Change
components/FaceRecognizer.js Add getEAR(), runLivenessCheck(), blink counter state
components/AttendanceValidation.js Gate recordAttendance() behind liveness check result
app/attendance/page.js Render blink prompt UI with real-time blink counter
services/authService.js or API route Log and flag failed liveness attempts for teacher review
docs/ Add anti-spoofing documentation for institution admins

🎯 Why This Matters

Learnova claims to replace manual roll call for institutions, and targets schools and universities where attendance fraud is an active and well-known problem. If the system can be defeated with a smartphone photo in under 10 seconds, it provides a false sense of security that is arguably worse than manual attendance β€” because teachers stop verifying what the system is recording.

Blink-based liveness detection adds minimal latency (~1-2 seconds), works entirely client-side with Face API.js's existing landmark model, and does not require any new dependencies.

Suggested labels: security, feature, face-recognition, attendance, anti-spoofing

I would like to work on this. Could you please assign it to me?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions