Added TFA timeout env variables

Added profile session management
Added "Remember me" to bypass TFA using device fingerprint
Fixed profile name not being persistent after logout and login
This commit is contained in:
Muhammad Ibrahim
2025-10-06 00:55:23 +01:00
parent 2edc773adf
commit d379473568
12 changed files with 683 additions and 24 deletions

View File

@@ -31,3 +31,8 @@ JWT_SECRET=your-secure-random-secret-key-change-this-in-production
JWT_EXPIRES_IN=1h JWT_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=7d JWT_REFRESH_EXPIRES_IN=7d
SESSION_INACTIVITY_TIMEOUT_MINUTES=30 SESSION_INACTIVITY_TIMEOUT_MINUTES=30
# TFA Configuration
TFA_REMEMBER_ME_EXPIRES_IN=30d
TFA_MAX_REMEMBER_SESSIONS=5
TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=3

View File

@@ -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");

View File

@@ -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");

View File

@@ -207,15 +207,22 @@ model user_sessions {
access_token_hash String? access_token_hash String?
ip_address String? ip_address String?
user_agent String? user_agent String?
device_fingerprint String?
last_activity DateTime @default(now()) last_activity DateTime @default(now())
expires_at DateTime expires_at DateTime
created_at DateTime @default(now()) created_at DateTime @default(now())
is_revoked Boolean @default(false) 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) users users @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@index([user_id]) @@index([user_id])
@@index([refresh_token]) @@index([refresh_token])
@@index([expires_at]) @@index([expires_at])
@@index([tfa_bypass_until])
@@index([device_fingerprint])
} }
model auto_enrollment_tokens { model auto_enrollment_tokens {

View File

@@ -3,6 +3,7 @@ const { PrismaClient } = require("@prisma/client");
const { const {
validate_session, validate_session,
update_session_activity, update_session_activity,
is_tfa_bypassed,
} = require("../utils/session_manager"); } = require("../utils/session_manager");
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@@ -46,6 +47,9 @@ const authenticateToken = async (req, res, next) => {
// Update session activity timestamp // Update session activity timestamp
await update_session_activity(decoded.sessionId); 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) // Update last login (only on successful authentication)
await prisma.users.update({ await prisma.users.update({
where: { id: validation.user.id }, where: { id: validation.user.id },
@@ -57,6 +61,7 @@ const authenticateToken = async (req, res, next) => {
req.user = validation.user; req.user = validation.user;
req.session_id = decoded.sessionId; req.session_id = decoded.sessionId;
req.tfa_bypassed = tfa_bypassed;
next(); next();
} catch (error) { } catch (error) {
if (error.name === "JsonWebTokenError") { 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 = { module.exports = {
authenticateToken, authenticateToken,
requireAdmin, requireAdmin,
optionalAuth, optionalAuth,
requireTfaIfEnabled,
}; };

View File

@@ -17,12 +17,65 @@ const {
refresh_access_token, refresh_access_token,
revoke_session, revoke_session,
revoke_all_user_sessions, revoke_all_user_sessions,
get_user_sessions,
} = require("../utils/session_manager"); } = require("../utils/session_manager");
const router = express.Router(); const router = express.Router();
const prisma = new PrismaClient(); 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) // Check if any admin users exist (for first-time setup)
router.get("/check-admin-users", async (_req, res) => { router.get("/check-admin-users", async (_req, res) => {
try { try {
@@ -765,6 +818,8 @@ router.post(
id: user.id, id: user.id,
username: user.username, username: user.username,
email: user.email, email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role, role: user.role,
is_active: user.is_active, is_active: user.is_active,
last_login: user.last_login, last_login: user.last_login,
@@ -788,6 +843,10 @@ router.post(
.isLength({ min: 6, max: 6 }) .isLength({ min: 6, max: 6 })
.withMessage("Token must be 6 digits"), .withMessage("Token must be 6 digits"),
body("token").isNumeric().withMessage("Token must contain only numbers"), body("token").isNumeric().withMessage("Token must contain only numbers"),
body("remember_me")
.optional()
.isBoolean()
.withMessage("Remember me must be a boolean"),
], ],
async (req, res) => { async (req, res) => {
try { try {
@@ -796,7 +855,7 @@ router.post(
return res.status(400).json({ errors: errors.array() }); return res.status(400).json({ errors: errors.array() });
} }
const { username, token } = req.body; const { username, token, remember_me = false } = req.body;
// Find user // Find user
const user = await prisma.users.findFirst({ const user = await prisma.users.findFirst({
@@ -865,13 +924,20 @@ router.post(
// Create session with access and refresh tokens // Create session with access and refresh tokens
const ip_address = req.ip || req.connection.remoteAddress; const ip_address = req.ip || req.connection.remoteAddress;
const user_agent = req.get("user-agent"); 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({ res.json({
message: "Login successful", message: "Login successful",
token: session.access_token, token: session.access_token,
refresh_token: session.refresh_token, refresh_token: session.refresh_token,
expires_at: session.expires_at, expires_at: session.expires_at,
tfa_bypass_until: session.tfa_bypass_until,
user: { user: {
id: user.id, id: user.id,
username: user.username, username: user.username,
@@ -1109,10 +1175,43 @@ router.post(
// Get user's active sessions // Get user's active sessions
router.get("/sessions", authenticateToken, async (req, res) => { router.get("/sessions", authenticateToken, async (req, res) => {
try { 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({ res.json({
sessions: sessions, sessions: enhanced_sessions,
}); });
} catch (error) { } catch (error) {
console.error("Get sessions error:", 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" }); 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); await revoke_session(session_id);
res.json({ 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; module.exports = router;

View File

@@ -15,6 +15,16 @@ if (!process.env.JWT_SECRET) {
const JWT_SECRET = process.env.JWT_SECRET; const JWT_SECRET = process.env.JWT_SECRET;
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1h"; const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1h";
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || "7d"; 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( const INACTIVITY_TIMEOUT_MINUTES = parseInt(
process.env.SESSION_INACTIVITY_TIMEOUT_MINUTES || "30", process.env.SESSION_INACTIVITY_TIMEOUT_MINUTES || "30",
10, 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 * 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 { try {
const session_id = crypto.randomUUID(); const session_id = crypto.randomUUID();
const refresh_token = generate_refresh_token(); const refresh_token = generate_refresh_token();
const access_token = generate_access_token(user_id, session_id); 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 // Store session in database
await prisma.user_sessions.create({ 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), access_token_hash: hash_token(access_token),
ip_address: ip_address || null, ip_address: ip_address || null,
user_agent: user_agent || null, user_agent: user_agent || null,
device_fingerprint: device_fingerprint,
last_login_ip: ip_address || null,
last_activity: new Date(), last_activity: new Date(),
expires_at: expires_at, 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, access_token,
refresh_token, refresh_token,
expires_at, expires_at,
tfa_bypass_until,
}; };
} catch (error) { } catch (error) {
console.error("Error creating session:", error); console.error("Error creating session:", error);
@@ -299,6 +435,8 @@ async function get_user_sessions(user_id) {
last_activity: true, last_activity: true,
created_at: true, created_at: true,
expires_at: true, expires_at: true,
tfa_remember_me: true,
tfa_bypass_until: true,
}, },
orderBy: { last_activity: "desc" }, 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 = { module.exports = {
create_session, create_session,
validate_session, validate_session,
@@ -317,6 +491,9 @@ module.exports = {
revoke_all_user_sessions, revoke_all_user_sessions,
cleanup_expired_sessions, cleanup_expired_sessions,
get_user_sessions, get_user_sessions,
is_tfa_bypassed,
generate_device_fingerprint,
check_suspicious_activity,
generate_access_token, generate_access_token,
INACTIVITY_TIMEOUT_MINUTES, INACTIVITY_TIMEOUT_MINUTES,
}; };

View File

@@ -25,6 +25,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { FaYoutube } from "react-icons/fa";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import { useUpdateNotification } from "../contexts/UpdateNotificationContext"; import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
@@ -875,13 +876,14 @@ const Layout = ({ children }) => {
</div> </div>
{/* Global Search Bar */} {/* Global Search Bar */}
<div className="hidden md:flex items-center max-w-sm"> <div className="flex items-center max-w-sm">
<GlobalSearch /> <GlobalSearch />
</div> </div>
<div className="flex flex-1 items-center gap-x-4 lg:gap-x-6 justify-end"> <div className="flex flex-1 items-center gap-x-4 lg:gap-x-6 justify-end">
{/* External Links */} {/* External Links */}
<div className="flex items-center gap-2"> <div className="hidden md:flex items-center gap-2">
{/* 1) GitHub */}
<a <a
href="https://github.com/PatchMon/PatchMon" href="https://github.com/PatchMon/PatchMon"
target="_blank" target="_blank"
@@ -896,6 +898,7 @@ const Layout = ({ children }) => {
</div> </div>
)} )}
</a> </a>
{/* 2) Roadmap */}
<a <a
href="https://github.com/orgs/PatchMon/projects/2/views/1" href="https://github.com/orgs/PatchMon/projects/2/views/1"
target="_blank" target="_blank"
@@ -905,15 +908,7 @@ const Layout = ({ children }) => {
> >
<Route className="h-5 w-5" /> <Route className="h-5 w-5" />
</a> </a>
<a {/* 3) Docs */}
href="https://patchmon.net/discord"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
title="Discord"
>
<DiscordIcon className="h-5 w-5" />
</a>
<a <a
href="https://docs.patchmon.net" href="https://docs.patchmon.net"
target="_blank" target="_blank"
@@ -923,6 +918,17 @@ const Layout = ({ children }) => {
> >
<BookOpen className="h-5 w-5" /> <BookOpen className="h-5 w-5" />
</a> </a>
{/* 4) Discord */}
<a
href="https://patchmon.net/discord"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
title="Discord"
>
<DiscordIcon className="h-5 w-5" />
</a>
{/* 5) Email */}
<a <a
href="mailto:support@patchmon.net" href="mailto:support@patchmon.net"
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm" className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
@@ -930,6 +936,17 @@ const Layout = ({ children }) => {
> >
<Mail className="h-5 w-5" /> <Mail className="h-5 w-5" />
</a> </a>
{/* 6) YouTube */}
<a
href="https://youtube.com/@patchmonTV"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
title="YouTube Channel"
>
<FaYoutube className="h-5 w-5" />
</a>
{/* 7) Web */}
<a <a
href="https://patchmon.net" href="https://patchmon.net"
target="_blank" target="_blank"

View File

@@ -54,7 +54,7 @@ const UsersTab = () => {
}); });
// Update user mutation // Update user mutation
const updateUserMutation = useMutation({ const _updateUserMutation = useMutation({
mutationFn: ({ id, data }) => adminUsersAPI.update(id, data), mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries(["users"]); queryClient.invalidateQueries(["users"]);

View File

@@ -22,6 +22,7 @@ const Login = () => {
const emailId = useId(); const emailId = useId();
const passwordId = useId(); const passwordId = useId();
const tokenId = useId(); const tokenId = useId();
const rememberMeId = useId();
const { login, setAuthState } = useAuth(); const { login, setAuthState } = useAuth();
const [isSignupMode, setIsSignupMode] = useState(false); const [isSignupMode, setIsSignupMode] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -33,6 +34,7 @@ const Login = () => {
}); });
const [tfaData, setTfaData] = useState({ const [tfaData, setTfaData] = useState({
token: "", token: "",
remember_me: false,
}); });
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -127,7 +129,11 @@ const Login = () => {
setError(""); setError("");
try { try {
const response = await authAPI.verifyTfa(tfaUsername, tfaData.token); const response = await authAPI.verifyTfa(
tfaUsername,
tfaData.token,
tfaData.remember_me,
);
if (response.data?.token) { if (response.data?.token) {
// Update AuthContext with the new authentication state // Update AuthContext with the new authentication state
@@ -158,9 +164,11 @@ const Login = () => {
}; };
const handleTfaInputChange = (e) => { const handleTfaInputChange = (e) => {
const { name, value, type, checked } = e.target;
setTfaData({ setTfaData({
...tfaData, ...tfaData,
[e.target.name]: e.target.value.replace(/\D/g, "").slice(0, 6), [name]:
type === "checkbox" ? checked : value.replace(/\D/g, "").slice(0, 6),
}); });
// Clear error when user starts typing // Clear error when user starts typing
if (error) { if (error) {
@@ -170,7 +178,7 @@ const Login = () => {
const handleBackToLogin = () => { const handleBackToLogin = () => {
setRequiresTfa(false); setRequiresTfa(false);
setTfaData({ token: "" }); setTfaData({ token: "", remember_me: false });
setError(""); setError("");
}; };
@@ -436,6 +444,23 @@ const Login = () => {
</div> </div>
</div> </div>
<div className="flex items-center">
<input
id={rememberMeId}
name="remember_me"
type="checkbox"
checked={tfaData.remember_me}
onChange={handleTfaInputChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<label
htmlFor={rememberMeId}
className="ml-2 block text-sm text-secondary-700"
>
Remember me on this computer (skip TFA for 30 days)
</label>
</div>
{error && ( {error && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-3"> <div className="bg-danger-50 border border-danger-200 rounded-md p-3">
<div className="flex"> <div className="flex">

View File

@@ -2,12 +2,16 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
AlertCircle, AlertCircle,
CheckCircle, CheckCircle,
Clock,
Copy, Copy,
Download, Download,
Eye, Eye,
EyeOff, EyeOff,
Key, Key,
LogOut,
Mail, Mail,
MapPin,
Monitor,
Moon, Moon,
RefreshCw, RefreshCw,
Save, Save,
@@ -153,6 +157,7 @@ const Profile = () => {
{ id: "profile", name: "Profile Information", icon: User }, { id: "profile", name: "Profile Information", icon: User },
{ id: "password", name: "Change Password", icon: Key }, { id: "password", name: "Change Password", icon: Key },
{ id: "tfa", name: "Multi-Factor Authentication", icon: Smartphone }, { id: "tfa", name: "Multi-Factor Authentication", icon: Smartphone },
{ id: "sessions", name: "Active Sessions", icon: Monitor },
]; ];
return ( return (
@@ -533,6 +538,9 @@ const Profile = () => {
{/* Multi-Factor Authentication Tab */} {/* Multi-Factor Authentication Tab */}
{activeTab === "tfa" && <TfaTab />} {activeTab === "tfa" && <TfaTab />}
{/* Sessions Tab */}
{activeTab === "sessions" && <SessionsTab />}
</div> </div>
</div> </div>
</div> </div>
@@ -1072,4 +1080,256 @@ const TfaTab = () => {
); );
}; };
// Sessions Tab Component
const SessionsTab = () => {
const _queryClient = useQueryClient();
const [_isLoading, _setIsLoading] = useState(false);
const [message, setMessage] = useState({ type: "", text: "" });
// Fetch user sessions
const {
data: sessionsData,
isLoading: sessionsLoading,
refetch,
} = useQuery({
queryKey: ["user-sessions"],
queryFn: async () => {
const response = await fetch("/api/v1/auth/sessions", {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
if (!response.ok) throw new Error("Failed to fetch sessions");
return response.json();
},
});
// Revoke individual session mutation
const revokeSessionMutation = useMutation({
mutationFn: async (sessionId) => {
const response = await fetch(`/api/v1/auth/sessions/${sessionId}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
if (!response.ok) throw new Error("Failed to revoke session");
return response.json();
},
onSuccess: () => {
setMessage({ type: "success", text: "Session revoked successfully" });
refetch();
},
onError: (error) => {
setMessage({ type: "error", text: error.message });
},
});
// Revoke all sessions mutation
const revokeAllSessionsMutation = useMutation({
mutationFn: async () => {
const response = await fetch("/api/v1/auth/sessions", {
method: "DELETE",
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
if (!response.ok) throw new Error("Failed to revoke sessions");
return response.json();
},
onSuccess: () => {
setMessage({
type: "success",
text: "All other sessions revoked successfully",
});
refetch();
},
onError: (error) => {
setMessage({ type: "error", text: error.message });
},
});
const formatDate = (dateString) => {
return new Date(dateString).toLocaleString();
};
const formatRelativeTime = (dateString) => {
const now = new Date();
const date = new Date(dateString);
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (days > 0) return `${days} day${days > 1 ? "s" : ""} ago`;
if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""} ago`;
if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
return "Just now";
};
const handleRevokeSession = (sessionId) => {
if (window.confirm("Are you sure you want to revoke this session?")) {
revokeSessionMutation.mutate(sessionId);
}
};
const handleRevokeAllSessions = () => {
if (
window.confirm(
"Are you sure you want to revoke all other sessions? This will log you out of all other devices.",
)
) {
revokeAllSessionsMutation.mutate();
}
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<h3 className="text-lg font-medium text-secondary-900 dark:text-secondary-100">
Active Sessions
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300">
Manage your active sessions and devices. You can see where you're
logged in and revoke access for any device.
</p>
</div>
{/* Message */}
{message.text && (
<div
className={`rounded-md p-4 ${
message.type === "success"
? "bg-success-50 border border-success-200 text-success-700"
: "bg-danger-50 border border-danger-200 text-danger-700"
}`}
>
<div className="flex">
{message.type === "success" ? (
<CheckCircle className="h-5 w-5" />
) : (
<AlertCircle className="h-5 w-5" />
)}
<div className="ml-3">
<p className="text-sm">{message.text}</p>
</div>
</div>
</div>
)}
{/* Sessions List */}
{sessionsLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
) : sessionsData?.sessions?.length > 0 ? (
<div className="space-y-4">
{/* Revoke All Button */}
{sessionsData.sessions.filter((s) => !s.is_current_session).length >
0 && (
<div className="flex justify-end">
<button
type="button"
onClick={handleRevokeAllSessions}
disabled={revokeAllSessionsMutation.isPending}
className="inline-flex items-center px-4 py-2 border border-danger-300 text-sm font-medium rounded-md text-danger-700 bg-white hover:bg-danger-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-danger-500 disabled:opacity-50"
>
<LogOut className="h-4 w-4 mr-2" />
{revokeAllSessionsMutation.isPending
? "Revoking..."
: "Revoke All Other Sessions"}
</button>
</div>
)}
{/* Sessions */}
{sessionsData.sessions.map((session) => (
<div
key={session.id}
className={`border rounded-lg p-4 ${
session.is_current_session
? "border-primary-200 bg-primary-50 dark:border-primary-800 dark:bg-primary-900/20"
: "border-secondary-200 bg-white dark:border-secondary-700 dark:bg-secondary-800"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3">
<Monitor className="h-5 w-5 text-secondary-500" />
<div>
<div className="flex items-center space-x-2">
<h4 className="text-sm font-medium text-secondary-900 dark:text-secondary-100">
{session.device_info?.browser} on{" "}
{session.device_info?.os}
</h4>
{session.is_current_session && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
Current Session
</span>
)}
{session.tfa_remember_me && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-success-100 text-success-800 dark:bg-success-900 dark:text-success-200">
Remembered
</span>
)}
</div>
<p className="text-sm text-secondary-600 dark:text-secondary-400">
{session.device_info?.device} • {session.ip_address}
</p>
</div>
</div>
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-secondary-600 dark:text-secondary-400">
<div className="flex items-center space-x-2">
<MapPin className="h-4 w-4" />
<span>
{session.location_info?.city},{" "}
{session.location_info?.country}
</span>
</div>
<div className="flex items-center space-x-2">
<Clock className="h-4 w-4" />
<span>
Last active: {formatRelativeTime(session.last_activity)}
</span>
</div>
<div className="flex items-center space-x-2">
<span>Created: {formatDate(session.created_at)}</span>
</div>
<div className="flex items-center space-x-2">
<span>Login count: {session.login_count}</span>
</div>
</div>
</div>
{!session.is_current_session && (
<button
type="button"
onClick={() => handleRevokeSession(session.id)}
disabled={revokeSessionMutation.isPending}
className="ml-4 inline-flex items-center px-3 py-2 border border-danger-300 text-sm font-medium rounded-md text-danger-700 bg-white hover:bg-danger-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-danger-500 disabled:opacity-50"
>
<LogOut className="h-4 w-4" />
</button>
)}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8">
<Monitor className="mx-auto h-12 w-12 text-secondary-400" />
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-secondary-100">
No active sessions
</h3>
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
You don't have any active sessions at the moment.
</p>
</div>
)}
</div>
);
};
export default Profile; export default Profile;

View File

@@ -224,8 +224,8 @@ export const versionAPI = {
export const authAPI = { export const authAPI = {
login: (username, password) => login: (username, password) =>
api.post("/auth/login", { username, password }), api.post("/auth/login", { username, password }),
verifyTfa: (username, token) => verifyTfa: (username, token, remember_me = false) =>
api.post("/auth/verify-tfa", { username, token }), api.post("/auth/verify-tfa", { username, token, remember_me }),
signup: (username, email, password, firstName, lastName) => signup: (username, email, password, firstName, lastName) =>
api.post("/auth/signup", { api.post("/auth/signup", {
username, username,