diff --git a/backend/env.example b/backend/env.example index 6ef6243..700c2c2 100644 --- a/backend/env.example +++ b/backend/env.example @@ -31,3 +31,8 @@ JWT_SECRET=your-secure-random-secret-key-change-this-in-production JWT_EXPIRES_IN=1h JWT_REFRESH_EXPIRES_IN=7d SESSION_INACTIVITY_TIMEOUT_MINUTES=30 + +# TFA Configuration +TFA_REMEMBER_ME_EXPIRES_IN=30d +TFA_MAX_REMEMBER_SESSIONS=5 +TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=3 diff --git a/backend/prisma/migrations/20251006001639_add_tfa_remember_me_fields/migration.sql b/backend/prisma/migrations/20251006001639_add_tfa_remember_me_fields/migration.sql new file mode 100644 index 0000000..d95b197 --- /dev/null +++ b/backend/prisma/migrations/20251006001639_add_tfa_remember_me_fields/migration.sql @@ -0,0 +1,6 @@ +-- Add TFA remember me fields to user_sessions table +ALTER TABLE "user_sessions" ADD COLUMN "tfa_remember_me" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "user_sessions" ADD COLUMN "tfa_bypass_until" TIMESTAMP(3); + +-- Create index for TFA bypass until field for efficient querying +CREATE INDEX "user_sessions_tfa_bypass_until_idx" ON "user_sessions"("tfa_bypass_until"); diff --git a/backend/prisma/migrations/20251006002023_add_security_fields_to_sessions/migration.sql b/backend/prisma/migrations/20251006002023_add_security_fields_to_sessions/migration.sql new file mode 100644 index 0000000..187aecd --- /dev/null +++ b/backend/prisma/migrations/20251006002023_add_security_fields_to_sessions/migration.sql @@ -0,0 +1,7 @@ +-- Add security fields to user_sessions table for production-ready remember me +ALTER TABLE "user_sessions" ADD COLUMN "device_fingerprint" TEXT; +ALTER TABLE "user_sessions" ADD COLUMN "login_count" INTEGER NOT NULL DEFAULT 1; +ALTER TABLE "user_sessions" ADD COLUMN "last_login_ip" TEXT; + +-- Create index for device fingerprint for efficient querying +CREATE INDEX "user_sessions_device_fingerprint_idx" ON "user_sessions"("device_fingerprint"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 60f4732..b0fcd0f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -207,15 +207,22 @@ model user_sessions { access_token_hash String? ip_address String? user_agent String? + device_fingerprint String? last_activity DateTime @default(now()) expires_at DateTime created_at DateTime @default(now()) is_revoked Boolean @default(false) + tfa_remember_me Boolean @default(false) + tfa_bypass_until DateTime? + login_count Int @default(1) + last_login_ip String? users users @relation(fields: [user_id], references: [id], onDelete: Cascade) @@index([user_id]) @@index([refresh_token]) @@index([expires_at]) + @@index([tfa_bypass_until]) + @@index([device_fingerprint]) } model auto_enrollment_tokens { diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index f13606f..3d5dd38 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -3,6 +3,7 @@ const { PrismaClient } = require("@prisma/client"); const { validate_session, update_session_activity, + is_tfa_bypassed, } = require("../utils/session_manager"); const prisma = new PrismaClient(); @@ -46,6 +47,9 @@ const authenticateToken = async (req, res, next) => { // Update session activity timestamp await update_session_activity(decoded.sessionId); + // Check if TFA is bypassed for this session + const tfa_bypassed = await is_tfa_bypassed(decoded.sessionId); + // Update last login (only on successful authentication) await prisma.users.update({ where: { id: validation.user.id }, @@ -57,6 +61,7 @@ const authenticateToken = async (req, res, next) => { req.user = validation.user; req.session_id = decoded.sessionId; + req.tfa_bypassed = tfa_bypassed; next(); } catch (error) { if (error.name === "JsonWebTokenError") { @@ -114,8 +119,33 @@ const optionalAuth = async (req, _res, next) => { } }; +// Middleware to check if TFA is required for sensitive operations +const requireTfaIfEnabled = async (req, res, next) => { + try { + // Check if user has TFA enabled + const user = await prisma.users.findUnique({ + where: { id: req.user.id }, + select: { tfa_enabled: true }, + }); + + // If TFA is enabled and not bypassed, require TFA verification + if (user?.tfa_enabled && !req.tfa_bypassed) { + return res.status(403).json({ + error: "Two-factor authentication required for this operation", + requires_tfa: true, + }); + } + + next(); + } catch (error) { + console.error("TFA requirement check error:", error); + return res.status(500).json({ error: "Authentication check failed" }); + } +}; + module.exports = { authenticateToken, requireAdmin, optionalAuth, + requireTfaIfEnabled, }; diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js index 7d66856..fd1aa99 100644 --- a/backend/src/routes/authRoutes.js +++ b/backend/src/routes/authRoutes.js @@ -17,12 +17,65 @@ const { refresh_access_token, revoke_session, revoke_all_user_sessions, - get_user_sessions, } = require("../utils/session_manager"); const router = express.Router(); const prisma = new PrismaClient(); +/** + * Parse user agent string to extract browser and OS info + */ +function parse_user_agent(user_agent) { + if (!user_agent) + return { browser: "Unknown", os: "Unknown", device: "Unknown" }; + + const ua = user_agent.toLowerCase(); + + // Browser detection + let browser = "Unknown"; + if (ua.includes("chrome") && !ua.includes("edg")) browser = "Chrome"; + else if (ua.includes("firefox")) browser = "Firefox"; + else if (ua.includes("safari") && !ua.includes("chrome")) browser = "Safari"; + else if (ua.includes("edg")) browser = "Edge"; + else if (ua.includes("opera")) browser = "Opera"; + + // OS detection + let os = "Unknown"; + if (ua.includes("windows")) os = "Windows"; + else if (ua.includes("macintosh") || ua.includes("mac os")) os = "macOS"; + else if (ua.includes("linux")) os = "Linux"; + else if (ua.includes("android")) os = "Android"; + else if (ua.includes("iphone") || ua.includes("ipad")) os = "iOS"; + + // Device type + let device = "Desktop"; + if (ua.includes("mobile")) device = "Mobile"; + else if (ua.includes("tablet") || ua.includes("ipad")) device = "Tablet"; + + return { browser, os, device }; +} + +/** + * Get basic location info from IP (simplified - in production you'd use a service) + */ +function get_location_from_ip(ip) { + if (!ip) return { country: "Unknown", city: "Unknown" }; + + // For localhost/private IPs + if ( + ip === "127.0.0.1" || + ip === "::1" || + ip.startsWith("192.168.") || + ip.startsWith("10.") + ) { + return { country: "Local", city: "Local Network" }; + } + + // In a real implementation, you'd use a service like MaxMind GeoIP2 + // For now, return unknown for external IPs + return { country: "Unknown", city: "Unknown" }; +} + // Check if any admin users exist (for first-time setup) router.get("/check-admin-users", async (_req, res) => { try { @@ -765,6 +818,8 @@ router.post( id: user.id, username: user.username, email: user.email, + first_name: user.first_name, + last_name: user.last_name, role: user.role, is_active: user.is_active, last_login: user.last_login, @@ -788,6 +843,10 @@ router.post( .isLength({ min: 6, max: 6 }) .withMessage("Token must be 6 digits"), body("token").isNumeric().withMessage("Token must contain only numbers"), + body("remember_me") + .optional() + .isBoolean() + .withMessage("Remember me must be a boolean"), ], async (req, res) => { try { @@ -796,7 +855,7 @@ router.post( return res.status(400).json({ errors: errors.array() }); } - const { username, token } = req.body; + const { username, token, remember_me = false } = req.body; // Find user const user = await prisma.users.findFirst({ @@ -865,13 +924,20 @@ router.post( // Create session with access and refresh tokens const ip_address = req.ip || req.connection.remoteAddress; const user_agent = req.get("user-agent"); - const session = await create_session(user.id, ip_address, user_agent); + const session = await create_session( + user.id, + ip_address, + user_agent, + remember_me, + req, + ); res.json({ message: "Login successful", token: session.access_token, refresh_token: session.refresh_token, expires_at: session.expires_at, + tfa_bypass_until: session.tfa_bypass_until, user: { id: user.id, username: user.username, @@ -1109,10 +1175,43 @@ router.post( // Get user's active sessions router.get("/sessions", authenticateToken, async (req, res) => { try { - const sessions = await get_user_sessions(req.user.id); + const sessions = await prisma.user_sessions.findMany({ + where: { + user_id: req.user.id, + is_revoked: false, + expires_at: { gt: new Date() }, + }, + select: { + id: true, + ip_address: true, + user_agent: true, + device_fingerprint: true, + last_activity: true, + created_at: true, + expires_at: true, + tfa_remember_me: true, + tfa_bypass_until: true, + login_count: true, + last_login_ip: true, + }, + orderBy: { last_activity: "desc" }, + }); + + // Enhance sessions with device info + const enhanced_sessions = sessions.map((session) => { + const is_current_session = session.id === req.session_id; + const device_info = parse_user_agent(session.user_agent); + + return { + ...session, + is_current_session, + device_info, + location_info: get_location_from_ip(session.ip_address), + }; + }); res.json({ - sessions: sessions, + sessions: enhanced_sessions, }); } catch (error) { console.error("Get sessions error:", error); @@ -1134,6 +1233,11 @@ router.delete("/sessions/:session_id", authenticateToken, async (req, res) => { return res.status(404).json({ error: "Session not found" }); } + // Don't allow revoking the current session + if (session_id === req.session_id) { + return res.status(400).json({ error: "Cannot revoke current session" }); + } + await revoke_session(session_id); res.json({ @@ -1145,4 +1249,25 @@ router.delete("/sessions/:session_id", authenticateToken, async (req, res) => { } }); +// Revoke all sessions except current one +router.delete("/sessions", authenticateToken, async (req, res) => { + try { + // Revoke all sessions except the current one + await prisma.user_sessions.updateMany({ + where: { + user_id: req.user.id, + id: { not: req.session_id }, + }, + data: { is_revoked: true }, + }); + + res.json({ + message: "All other sessions revoked successfully", + }); + } catch (error) { + console.error("Revoke all sessions error:", error); + res.status(500).json({ error: "Failed to revoke sessions" }); + } +}); + module.exports = router; diff --git a/backend/src/utils/session_manager.js b/backend/src/utils/session_manager.js index 83cbf74..5155071 100644 --- a/backend/src/utils/session_manager.js +++ b/backend/src/utils/session_manager.js @@ -15,6 +15,16 @@ if (!process.env.JWT_SECRET) { const JWT_SECRET = process.env.JWT_SECRET; const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1h"; const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || "7d"; +const TFA_REMEMBER_ME_EXPIRES_IN = + process.env.TFA_REMEMBER_ME_EXPIRES_IN || "30d"; +const TFA_MAX_REMEMBER_SESSIONS = parseInt( + process.env.TFA_MAX_REMEMBER_SESSIONS || "5", + 10, +); +const TFA_SUSPICIOUS_ACTIVITY_THRESHOLD = parseInt( + process.env.TFA_SUSPICIOUS_ACTIVITY_THRESHOLD || "3", + 10, +); const INACTIVITY_TIMEOUT_MINUTES = parseInt( process.env.SESSION_INACTIVITY_TIMEOUT_MINUTES || "30", 10, @@ -70,16 +80,136 @@ function parse_expiration(expiration_string) { } } +/** + * Generate device fingerprint from request data + */ +function generate_device_fingerprint(req) { + const components = [ + req.get("user-agent") || "", + req.get("accept-language") || "", + req.get("accept-encoding") || "", + req.ip || "", + ]; + + // Create a simple hash of device characteristics + const fingerprint = crypto + .createHash("sha256") + .update(components.join("|")) + .digest("hex") + .substring(0, 32); // Use first 32 chars for storage efficiency + + return fingerprint; +} + +/** + * Check for suspicious activity patterns + */ +async function check_suspicious_activity( + user_id, + _ip_address, + _device_fingerprint, +) { + try { + // Check for multiple sessions from different IPs in short time + const recent_sessions = await prisma.user_sessions.findMany({ + where: { + user_id: user_id, + created_at: { + gte: new Date(Date.now() - 24 * 60 * 60 * 1000), // Last 24 hours + }, + is_revoked: false, + }, + select: { + ip_address: true, + device_fingerprint: true, + created_at: true, + }, + }); + + // Count unique IPs and devices + const unique_ips = new Set(recent_sessions.map((s) => s.ip_address)); + const unique_devices = new Set( + recent_sessions.map((s) => s.device_fingerprint), + ); + + // Flag as suspicious if more than threshold different IPs or devices in 24h + if ( + unique_ips.size > TFA_SUSPICIOUS_ACTIVITY_THRESHOLD || + unique_devices.size > TFA_SUSPICIOUS_ACTIVITY_THRESHOLD + ) { + console.warn( + `Suspicious activity detected for user ${user_id}: ${unique_ips.size} IPs, ${unique_devices.size} devices`, + ); + return true; + } + + return false; + } catch (error) { + console.error("Error checking suspicious activity:", error); + return false; + } +} + /** * Create a new session for user */ -async function create_session(user_id, ip_address, user_agent) { +async function create_session( + user_id, + ip_address, + user_agent, + remember_me = false, + req = null, +) { try { const session_id = crypto.randomUUID(); const refresh_token = generate_refresh_token(); const access_token = generate_access_token(user_id, session_id); - const expires_at = parse_expiration(JWT_REFRESH_EXPIRES_IN); + // Generate device fingerprint if request is available + const device_fingerprint = req ? generate_device_fingerprint(req) : null; + + // Check for suspicious activity + if (device_fingerprint) { + const is_suspicious = await check_suspicious_activity( + user_id, + ip_address, + device_fingerprint, + ); + if (is_suspicious) { + console.warn( + `Suspicious activity detected for user ${user_id}, session creation may be restricted`, + ); + } + } + + // Check session limits for remember me + if (remember_me) { + const existing_remember_sessions = await prisma.user_sessions.count({ + where: { + user_id: user_id, + tfa_remember_me: true, + is_revoked: false, + expires_at: { gt: new Date() }, + }, + }); + + // Limit remember me sessions per user + if (existing_remember_sessions >= TFA_MAX_REMEMBER_SESSIONS) { + throw new Error( + "Maximum number of remembered devices reached. Please revoke an existing session first.", + ); + } + } + + // Use longer expiration for remember me sessions + const expires_at = remember_me + ? parse_expiration(TFA_REMEMBER_ME_EXPIRES_IN) + : parse_expiration(JWT_REFRESH_EXPIRES_IN); + + // Calculate TFA bypass until date for remember me sessions + const tfa_bypass_until = remember_me + ? parse_expiration(TFA_REMEMBER_ME_EXPIRES_IN) + : null; // Store session in database await prisma.user_sessions.create({ @@ -90,8 +220,13 @@ async function create_session(user_id, ip_address, user_agent) { access_token_hash: hash_token(access_token), ip_address: ip_address || null, user_agent: user_agent || null, + device_fingerprint: device_fingerprint, + last_login_ip: ip_address || null, last_activity: new Date(), expires_at: expires_at, + tfa_remember_me: remember_me, + tfa_bypass_until: tfa_bypass_until, + login_count: 1, }, }); @@ -100,6 +235,7 @@ async function create_session(user_id, ip_address, user_agent) { access_token, refresh_token, expires_at, + tfa_bypass_until, }; } catch (error) { console.error("Error creating session:", error); @@ -299,6 +435,8 @@ async function get_user_sessions(user_id) { last_activity: true, created_at: true, expires_at: true, + tfa_remember_me: true, + tfa_bypass_until: true, }, orderBy: { last_activity: "desc" }, }); @@ -308,6 +446,42 @@ async function get_user_sessions(user_id) { } } +/** + * Check if TFA is bypassed for a session + */ +async function is_tfa_bypassed(session_id) { + try { + const session = await prisma.user_sessions.findUnique({ + where: { id: session_id }, + select: { + tfa_remember_me: true, + tfa_bypass_until: true, + is_revoked: true, + expires_at: true, + }, + }); + + if (!session) { + return false; + } + + // Check if session is still valid + if (session.is_revoked || new Date() > session.expires_at) { + return false; + } + + // Check if TFA is bypassed and still within bypass period + if (session.tfa_remember_me && session.tfa_bypass_until) { + return new Date() < session.tfa_bypass_until; + } + + return false; + } catch (error) { + console.error("Error checking TFA bypass:", error); + return false; + } +} + module.exports = { create_session, validate_session, @@ -317,6 +491,9 @@ module.exports = { revoke_all_user_sessions, cleanup_expired_sessions, get_user_sessions, + is_tfa_bypassed, + generate_device_fingerprint, + check_suspicious_activity, generate_access_token, INACTIVITY_TIMEOUT_MINUTES, }; diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index a2b3435..fdac292 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -25,6 +25,7 @@ import { X, } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; +import { FaYoutube } from "react-icons/fa"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { useAuth } from "../contexts/AuthContext"; import { useUpdateNotification } from "../contexts/UpdateNotificationContext"; @@ -875,13 +876,14 @@ const Layout = ({ children }) => { {/* Global Search Bar */} -
+ Manage your active sessions and devices. You can see where you're + logged in and revoke access for any device. +
+{message.text}
++ {session.device_info?.device} • {session.ip_address} +
++ You don't have any active sessions at the moment. +
+