Device & Session Management — Auth

Device & Session Management adalah fitur yang memungkinkan user melihat dan mengelola semua sesi aktif mereka — dan merevoke akses dari device yang tidak dikena

Device & Session Management adalah fitur yang memungkinkan user melihat dan mengelola semua sesi aktif mereka — dan merevoke akses dari device yang tidak dikenal.

Mengapa ini penting:

Model data session:

// Tabel sessions
{
  id: "sess_abc123",
  userId: "user_42",
  deviceName: "iPhone 16 Pro",
  deviceType: "mobile",      // "desktop", "mobile", "tablet"
  browser: "Safari 18",
  os: "iOS 18",
  ip: "180.252.xxx.xxx",
  location: "Jakarta, ID",   // dari IP geolocation
  createdAt: "2025-01-10T08:30:00Z",
  lastActiveAt: "2025-04-12T14:22:00Z",
  isCurrent: false,          // true untuk sesi yang sedang aktif
  refreshTokenId: "rt_xyz789" // untuk revokasi
}

Implementasi session tracking:

async function createSession(userId, req) {
  const ua = parseUserAgent(req.headers["user-agent"]);
  const geo = await geolocate(req.ip);

  const session = await db.sessions.create({
    userId,
    deviceName: ua.device || "Unknown Device",
    browser: `${ua.browser} ${ua.browserVersion}`,
    os: `${ua.os} ${ua.osVersion}`,
    ip: req.ip,
    location: `${geo.city}, ${geo.country}`,
    refreshTokenId: generateRefreshToken().id,
    lastActiveAt: new Date()
  });

  return session;
}

// Update lastActiveAt di setiap request
async function touchSession(sessionId) {
  await db.sessions.update(
    { lastActiveAt: new Date() },
    { where: { id: sessionId } }
  );
}

// Revoke sesi tertentu
async function revokeSession(userId, sessionId) {
  const session = await db.sessions.findOne({ where: { id: sessionId, userId } });
  if (!session) throw new Error("Session tidak ditemukan");

  // Invalidate refresh token
  await invalidateRefreshToken(session.refreshTokenId);
  await db.sessions.delete({ where: { id: sessionId } });
}

// Revoke semua sesi kecuali saat ini
async function revokeAllOtherSessions(userId, currentSessionId) {
  const sessions = await db.sessions.findAll({
    where: { userId, id: { [Op.ne]: currentSessionId } }
  });
  for (const session of sessions) {
    await revokeSession(userId, session.id);
  }
}

API endpoints untuk session management:

// Lihat semua sesi aktif
GET /api/user/sessions
// Response: array session info (tanpa refresh token!)

// Revoke sesi tertentu
DELETE /api/user/sessions/:sessionId

// Revoke semua sesi lain
DELETE /api/user/sessions/others

// Logout semua device
DELETE /api/user/sessions/all

Notifikasi keamanan:

// Kirim email jika login dari device/lokasi baru
async function checkNewDevice(userId, session) {
  const known = await db.sessions.findOne({
    where: { userId, ip: session.ip }
  });
  if (!known) {
    await sendEmail(user.email,
      `Login baru terdeteksi dari ${session.location} (${session.deviceName}). Bukan kamu? Klik di sini untuk mengamankan akun.`
    );
  }
}

Anomaly Detection — Deteksi Login Mencurigakan

Login baru dari Jakarta vs kota lama "asing" bukan indikator — mungkin user lagi traveling. Indikator yang lebih aktual untuk flagging:

1. Velocity check (impossible travel)

Login dari Jakarta jam 10:00, lalu login dari New York jam 10:05 — jarak fisik tidak memungkinkan. Ini strong signal akun dikompromikan.

import geoip from "geoip-lite";
import { haversineDistance } from "./geo-utils";

async function checkImpossibleTravel(userId, newIp) {
  const newGeo = geoip.lookup(newIp);
  if (!newGeo) return false;

  // Session terakhir dalam 24 jam
  const recent = await db.query(
    "SELECT ip, last_active_at FROM sessions WHERE user_id = $1 AND last_active_at > NOW() - INTERVAL '24 hours' ORDER BY last_active_at DESC LIMIT 1",
    [userId]
  );

  if (!recent.length) return false;

  const lastGeo = geoip.lookup(recent[0].ip);
  if (!lastGeo) return false;

  const distanceKm = haversineDistance(
    [lastGeo.ll[0], lastGeo.ll[1]],
    [newGeo.ll[0], newGeo.ll[1]]
  );

  const timeDiffMin = (Date.now() - new Date(recent[0].last_active_at)) / 1000 / 60;

  // Max speed commercial flight ~900 km/jam = 15 km/menit
  const maxPossibleDistance = timeDiffMin * 15;

  if (distanceKm > maxPossibleDistance && timeDiffMin < 60) {
    return true;  // IMPOSSIBLE TRAVEL
  }
  return false;
}

2. User-Agent change drastis

User biasa pakai "Chrome on macOS", tiba-tiba "Firefox on Linux" di jam 3 pagi — suspicious.

async function checkUserAgentAnomaly(userId, newUA) {
  // Ambil 10 sesi sebelumnya
  const history = await db.query(
    "SELECT browser, os FROM sessions WHERE user_id = $1 ORDER BY created_at DESC LIMIT 10",
    [userId]
  );

  const newParsed = parseUserAgent(newUA);
  const isFamiliarBrowser = history.some(h => h.browser.includes(newParsed.browser));
  const isFamiliarOS = history.some(h => h.os.includes(newParsed.os));

  return !isFamiliarBrowser && !isFamiliarOS;  // kedua-duanya baru = anomali
}

3. Velocity login attempt per akun

10 login attempt per detik = otomatis bot/attacker. Bukan user legitimate.

async function checkLoginVelocity(email) {
  const recentAttempts = await redis.incr(`login_attempts:${email}`);
  if (recentAttempts === 1) {
    await redis.expire(`login_attempts:${email}`, 60);  // 1 menit window
  }

  if (recentAttempts > 10) {
    return "VELOCITY_EXCEEDED";  // kirim ke MFA challenge atau lockout
  }
  return "OK";
}

4. Score-based risk (layered signal)

Gabungkan signal untuk risk score 0-100. Threshold menentukan action:

async function calculateLoginRisk(userId, req) {
  let risk = 0;

  if (await checkImpossibleTravel(userId, req.ip))       risk += 50;
  if (await checkUserAgentAnomaly(userId, req.headers["user-agent"])) risk += 20;
  if (await isKnownVPN(req.ip))                          risk += 15;
  if (isUnusualTimeOfDay(userId, new Date()))            risk += 10;
  if (await isKnownThreatIP(req.ip))                     risk += 30;

  return risk;
}

// Di login endpoint
const risk = await calculateLoginRisk(user.id, req);

if (risk >= 70) {
  // Force MFA re-challenge bahkan untuk user yang trust device
  return { status: "mfa_required_elevated", risk };
}
if (risk >= 40) {
  // Send notification email, but allow login
  await sendSuspiciousLoginAlert(user.email, loginDetails);
}
// risk < 40 = normal login

Service pihak ketiga yang bisa dipakai kalau tidak mau bikin sendiri: Auth0 Adaptive MFA, AWS Cognito Risk-based, Okta WorkflowOne. Mereka expose risk score lewat API.

Tips production: