From ac5121caf1fea8cf993643b4060750b6b767920c Mon Sep 17 00:00:00 2001 From: MK-codes365 Date: Thu, 29 Jan 2026 01:12:29 +0530 Subject: [PATCH] feat: implement settings security actions #250 --- client/cyber_lens/src/App.tsx | 6 + client/cyber_lens/src/components/Navbar.tsx | 7 +- client/cyber_lens/src/pages/Settings.tsx | 378 ++++++++++++++++++ client/cyber_lens/src/pages/VerifyAction.tsx | 133 ++++++ server/src/app.ts | 4 + server/src/db/migrate.ts | 61 +++ .../db/migrations/007_create_users_table.sql | 19 + server/src/routes/auth.ts | 150 +++++++ server/src/utils/magicLink.ts | 82 ++++ server/src/utils/resolveOwner.ts | 12 +- 10 files changed, 848 insertions(+), 4 deletions(-) create mode 100644 client/cyber_lens/src/pages/Settings.tsx create mode 100644 client/cyber_lens/src/pages/VerifyAction.tsx create mode 100644 server/src/db/migrate.ts create mode 100644 server/src/db/migrations/007_create_users_table.sql create mode 100644 server/src/routes/auth.ts create mode 100644 server/src/utils/magicLink.ts diff --git a/client/cyber_lens/src/App.tsx b/client/cyber_lens/src/App.tsx index cb42d63..dbe89b1 100644 --- a/client/cyber_lens/src/App.tsx +++ b/client/cyber_lens/src/App.tsx @@ -12,7 +12,10 @@ import News from "./pages/News"; import Login from "../../../contributors/MK-codes365/pages/Login"; import Signup from "../../../contributors/MK-codes365/pages/Signup"; import VerifyEmail from "../../../contributors/MK-codes365/pages/VerifyEmail"; +import VerifyAction from "./pages/VerifyAction"; + import Analytics from "./pages/Analytics"; +import Settings from "./pages/Settings"; const Layout = () => { return ( @@ -37,7 +40,10 @@ function App() { } /> } /> } /> + } /> + } /> + } /> diff --git a/client/cyber_lens/src/components/Navbar.tsx b/client/cyber_lens/src/components/Navbar.tsx index f19574d..07b4c3a 100644 --- a/client/cyber_lens/src/components/Navbar.tsx +++ b/client/cyber_lens/src/components/Navbar.tsx @@ -39,6 +39,9 @@ const Navbar = () => { Analytics + + Settings + {/* Mobile Menu Button */} @@ -78,11 +81,11 @@ const Navbar = () => { News setOpen(false)} className={linkClass} > - Analytics + Settings )} diff --git a/client/cyber_lens/src/pages/Settings.tsx b/client/cyber_lens/src/pages/Settings.tsx new file mode 100644 index 0000000..07eeeb2 --- /dev/null +++ b/client/cyber_lens/src/pages/Settings.tsx @@ -0,0 +1,378 @@ +import React, { useState } from "react"; +import { httpJson } from "../utils/httpClient"; + +const Settings: React.FC = () => { + // State for Change Password + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [pwdLoading, setPwdLoading] = useState(false); + const [pwdMessage, setPwdMessage] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); + + // State for Delete Account + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [deleteLoading, setDeleteLoading] = useState(false); + const [deleteRequested, setDeleteRequested] = useState(false); + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault(); + setPwdMessage(null); + + // Validation + if (newPassword !== confirmPassword) { + setPwdMessage({ type: "error", text: "New passwords do not match" }); + return; + } + + if (newPassword === currentPassword) { + setPwdMessage({ + type: "error", + text: "New password must be different from current password", + }); + return; + } + + if (newPassword.length < 8) { + setPwdMessage({ + type: "error", + text: "New password must be at least 8 characters long", + }); + return; + } + + setPwdLoading(true); + try { + await httpJson("/auth/change-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ currentPassword, newPassword }), + }); + setPwdMessage({ + type: "success", + text: "Password updated successfully", + }); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } catch (err) { + setPwdMessage({ + type: "error", + text: err instanceof Error ? err.message : "Failed to update password", + }); + } finally { + setPwdLoading(false); + } + }; + + const handleRequestDelete = async () => { + setDeleteLoading(true); + try { + await httpJson("/auth/request-delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + setDeleteRequested(true); + setShowDeleteModal(false); + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to request deletion"); + } finally { + setDeleteLoading(false); + } + }; + + return ( +
+
+
+

Account Settings

+

+ Manage your security and preferences +

+
+ + {/* Change Password Section */} +
+
+
+ + + +
+

Change Password

+
+ +
+
+ + setCurrentPassword(e.target.value)} + className="w-full px-4 py-3 bg-neutral-950 border border-neutral-800 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 transition-all" + placeholder="••••••••" + required + /> +
+ +
+
+ + setNewPassword(e.target.value)} + className="w-full px-4 py-3 bg-neutral-950 border border-neutral-800 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 transition-all" + placeholder="Min. 8 characters" + required + /> +
+
+ + setConfirmPassword(e.target.value)} + className="w-full px-4 py-3 bg-neutral-950 border border-neutral-800 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 transition-all" + placeholder="Re-enter new password" + required + /> +
+
+ + {pwdMessage && ( +
+ + {pwdMessage.type === "success" ? ( + + ) : ( + + )} + + {pwdMessage.text} +
+ )} + +
+ +
+
+
+ + {/* Danger Zone */} +
+
+
+ + + +
+

Danger Zone

+
+ +
+
+

+ Delete Account +

+

+ Permanently remove your account and all associated data. This + action is irreversible. All lookup history and settings will be + wiped. +

+ + {deleteRequested ? ( +
+ + + +
+

+ Verification Required +

+

+ A magic link has been sent to your inbox. Please click the + link within 15 minutes to confirm account deletion. +

+
+
+ ) : ( + + )} +
+
+
+
+ + {/* Delete Confirmation Modal */} + {showDeleteModal && ( +
+
+
+ + + +
+ +

+ Confirm Deletion +

+

+ To prevent accidents, we require email verification. Click below + to receive a one-time magic link. Your account stays active until + you confirm via the email. +

+ +
+ + +
+
+
+ )} +
+ ); +}; + +export default Settings; diff --git a/client/cyber_lens/src/pages/VerifyAction.tsx b/client/cyber_lens/src/pages/VerifyAction.tsx new file mode 100644 index 0000000..70788ea --- /dev/null +++ b/client/cyber_lens/src/pages/VerifyAction.tsx @@ -0,0 +1,133 @@ +import React, { useEffect, useState } from "react"; +import { useSearchParams, useNavigate } from "react-router-dom"; +import { httpJson } from "../utils/httpClient"; + +const VerifyAction: React.FC = () => { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const [status, setStatus] = useState<"verifying" | "success" | "error">( + "verifying", + ); + const [message, setMessage] = useState("Verifying your security token..."); + + useEffect(() => { + const token = searchParams.get("token"); + const type = searchParams.get("type"); + + if (!token || !type) { + setStatus("error"); + setMessage("Invalid security link or missing verification data."); + return; + } + + const performVerification = async () => { + try { + if (type === "delete_account") { + await httpJson("/auth/verify-delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + setStatus("success"); + setMessage( + "Your account and all associated data have been permanently removed.", + ); + + // Clear any local session/identity if applicable + // In this architecture, we might want to reset the anonymous-client-id + localStorage.removeItem("anonymous-client-id"); + + setTimeout(() => navigate("/"), 5000); + } else { + setStatus("error"); + setMessage("Unrecognized verification sequence."); + } + } catch (err) { + setStatus("error"); + setMessage( + err instanceof Error ? err.message : "Security verification failed.", + ); + } + }; + + performVerification(); + }, [searchParams, navigate]); + + return ( +
+
+
+ {status === "verifying" && ( +
+
+

+ {message} +

+
+ )} + + {status === "success" && ( +
+
+ + + +
+

+ Action Confirmed +

+

{message}

+

+ Redirecting to home page in 5 seconds... +

+
+ )} + + {status === "error" && ( +
+
+ + + +
+

+ Verification Error +

+

+ {message} +

+ +
+ )} +
+
+
+ ); +}; + +export default VerifyAction; diff --git a/server/src/app.ts b/server/src/app.ts index 6af2fb2..5357009 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -3,6 +3,8 @@ import cors from "cors"; import resolveOwner from "./utils/resolveOwner"; import lookupRouter from "./routes/lookup"; import historyRouter from "./routes/history"; +import authRouter from "./routes/auth"; + const app = express(); @@ -31,5 +33,7 @@ app.get("/", (_req, res) => { app.use("/lookup", lookupRouter); app.use("/history", historyRouter); +app.use("/auth", authRouter); + export default app; diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts new file mode 100644 index 0000000..9b46d67 --- /dev/null +++ b/server/src/db/migrate.ts @@ -0,0 +1,61 @@ +import fs from "fs/promises"; +import path from "path"; +import pool from "./index"; + +export async function runMigrations() { + const client = await pool.connect(); + + try { + console.log("Checking for database migrations..."); + + // Create migrations table if it doesn't exist + await client.query(` + CREATE TABLE IF NOT EXISTS __migrations ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) UNIQUE NOT NULL, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `); + + const migrationsDir = path.join(__dirname, "migrations"); + const files = await fs.readdir(migrationsDir); + const sqlFiles = files + .filter((f) => f.endsWith(".sql")) + .sort(); + + const { rows: appliedRows } = await client.query( + "SELECT name FROM __migrations" + ); + const appliedNames = new Set(appliedRows.map((r) => r.name)); + + // Run pending migrations + for (const file of sqlFiles) { + if (!appliedNames.has(file)) { + console.log(`Applying migration: ${file}`); + const filePath = path.join(migrationsDir, file); + const sql = await fs.readFile(filePath, "utf-8"); + + try { + await client.query("BEGIN"); + await client.query(sql); + await client.query( + "INSERT INTO __migrations (name) VALUES ($1)", + [file] + ); + await client.query("COMMIT"); + console.log(`✓ Applied ${file}`); + } catch (err) { + await client.query("ROLLBACK"); + console.error(`Error applying ${file}:`, err); + throw err; + } + } + } + console.log("All migrations applied successfully."); + } catch (error) { + console.error("Migration failed:", error); + process.exit(1); + } finally { + client.release(); + } +} diff --git a/server/src/db/migrations/007_create_users_table.sql b/server/src/db/migrations/007_create_users_table.sql new file mode 100644 index 0000000..48b0398 --- /dev/null +++ b/server/src/db/migrations/007_create_users_table.sql @@ -0,0 +1,19 @@ +-- Create users table for platform access +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create magic_links table for secure account operations (email verification, deletion, etc.) +CREATE TABLE IF NOT EXISTS magic_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL, + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP, + type VARCHAR(50) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 0000000..bdd0ee4 --- /dev/null +++ b/server/src/routes/auth.ts @@ -0,0 +1,150 @@ +import { Router, Request, Response } from "express"; +import pool from "../db"; +import crypto from "crypto"; +import { + createMagicLink, + sendMagicLinkEmail, + verifyMagicLink, +} from "../utils/magicLink"; + +const router = Router(); + +// TODO: Use bcrypt in production +function hashPassword(password: string): string { + return crypto.createHash("sha256").update(password).digest("hex"); +} + + +// POST /auth/change-password +router.post("/change-password", async (req: Request, res: Response) => { + const { currentPassword, newPassword } = req.body; + const owner = req.owner; + + if (owner.type !== "user") { + res.status(401).json({ error: "Authenticated user account required" }); + return; + } + + try { + const userResult = await pool.query( + "SELECT password_hash FROM users WHERE id = $1", + [owner.id] + ); + + if (userResult.rows.length === 0) { + res.status(404).json({ error: "User profile not found" }); + return; + } + + const { password_hash } = userResult.rows[0]; + + if (hashPassword(currentPassword) !== password_hash) { + res.status(400).json({ error: "The current password you entered is incorrect" }); + return; + } + + await pool.query( + "UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2", + [hashPassword(newPassword), owner.id] + ); + + res.json({ message: "Security credentials updated successfully" }); + } catch (error) { + console.error("Change password failure:", error); + res.status(500).json({ error: "An internal error occurred while updating security settings" }); + } +}); + +// POST /auth/request-delete +router.post("/request-delete", async (req: Request, res: Response) => { + const owner = req.owner; + + if (owner.type !== "user") { + res.status(401).json({ error: "Authenticated user session required" }); + return; + } + + try { + const userResult = await pool.query("SELECT email FROM users WHERE id = $1", [ + owner.id, + ]); + + if (userResult.rows.length === 0) { + res.status(404).json({ error: "User identity not found" }); + return; + } + + const { email } = userResult.rows[0]; + const token = await createMagicLink(owner.id, "delete_account"); + + await sendMagicLinkEmail(email, token, "delete_account"); + + res.json({ message: "Verification link dispatched to your email" }); + } catch (error) { + console.error("Delete request failure:", error); + res.status(500).json({ error: "Failed to initiate account deletion sequence" }); + } +}); + +// POST /auth/verify-delete +router.post("/verify-delete", async (req: Request, res: Response) => { + const { token } = req.body; + + if (!token) { + res.status(400).json({ error: "Verification token is missing" }); + return; + } + + try { + const userId = await verifyMagicLink(token, "delete_account"); + + if (!userId) { + res.status(400).json({ error: "The verification link is invalid or has expired" }); + return; + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // Clear all historical data associated with the user + await client.query( + "DELETE FROM ioc_history WHERE owner_type = 'user' AND owner_id = $1", + [userId] + ); + + // Finalize account termination + await client.query("DELETE FROM users WHERE id = $1", [userId]); + + await client.query("COMMIT"); + res.json({ message: "Account and associated history terminated successfully" }); + } catch (transactionError) { + await client.query("ROLLBACK"); + throw transactionError; + } finally { + client.release(); + } + } catch (error) { + console.error("Delete verification failure:", error); + res.status(500).json({ error: "A system error occurred during account termination" }); + } +}); + +/** + * MOCK SIGNUP (For Testing Purposes) + * Allows creation of a test user to verify Settings functionality + */ +router.post("/signup", async (req: Request, res: Response) => { + const { email, password } = req.body; + try { + const result = await pool.query( + "INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id", + [email, hashPassword(password)] + ); + res.json({ success: true, userId: result.rows[0].id }); + } catch (error) { + res.status(400).json({ error: "User already exists or invalid data" }); + } +}); + +export default router; diff --git a/server/src/utils/magicLink.ts b/server/src/utils/magicLink.ts new file mode 100644 index 0000000..56db327 --- /dev/null +++ b/server/src/utils/magicLink.ts @@ -0,0 +1,82 @@ +import crypto from "crypto"; +import pool from "../db"; + +// Generate secure random token +export function generateSecureToken(): string { + return crypto.randomBytes(32).toString("hex"); +} + + +// Hash token for storage +export function hashToken(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); +} + + +/** + * Creates and stores a magic link in the database + */ +export async function createMagicLink( + userId: string, + type: "delete_account" | "verify_email", + expiresInMinutes = 15 +): Promise { + const token = generateSecureToken(); + const tokenHash = hashToken(token); + const expiresAt = new Date(); + expiresAt.setMinutes(expiresAt.getMinutes() + expiresInMinutes); + + await pool.query( + "INSERT INTO magic_links (user_id, token_hash, expires_at, type) VALUES ($1, $2, $3, $4)", + [userId, tokenHash, expiresAt, type] + ); + + return token; +} + +/** + * Validates a magic link token and returns the associated userId + */ +export async function verifyMagicLink( + token: string, + type: string +): Promise { + const tokenHash = hashToken(token); + + const result = await pool.query( + "SELECT user_id FROM magic_links WHERE token_hash = $1 AND type = $2 AND expires_at > NOW() AND used_at IS NULL", + [tokenHash, type] + ); + + if (result.rows.length === 0) { + return null; + } + + const userId = result.rows[0].user_id; + + // Atomically mark token as used to prevent replay attacks + await pool.query( + "UPDATE magic_links SET used_at = NOW() WHERE token_hash = $1", + [tokenHash] + ); + + return userId; +} + +// Mock email sender - logs to console only +export async function sendMagicLinkEmail( + email: string, + token: string, + type: string +): Promise { + const baseUrl = process.env.CORS_ORIGIN || "http://localhost:5173"; + // The link points to a verification endpoint that will handle the logic + const link = `${baseUrl}/verify-action?token=${token}&type=${type}`; + + console.log("\n--- [INTERNAL MAIL SERVICE] ---"); + console.log(`Target: ${email}`); + console.log(`Action: ${type.toUpperCase()}`); + console.log(`Verification URL: ${link}`); + console.log("Validity: 15 minutes (Single Use Only)"); + console.log("-------------------------------\n"); +} diff --git a/server/src/utils/resolveOwner.ts b/server/src/utils/resolveOwner.ts index d227b65..c750f6d 100644 --- a/server/src/utils/resolveOwner.ts +++ b/server/src/utils/resolveOwner.ts @@ -35,8 +35,16 @@ async function resolveOwner( } try { - await ensureAnonymousClient(clientId); - req.owner = { type: "anonymous", id: clientId }; + // Check if this is a registered user + const userResult = await pool.query("SELECT id FROM users WHERE id = $1", [clientId]); + + if (userResult.rows.length > 0) { + req.owner = { type: "user", id: clientId }; + } else { + // Fallback to anonymous client logic + await ensureAnonymousClient(clientId); + req.owner = { type: "anonymous", id: clientId }; + } next(); } catch (error) { console.error("Failed to resolve owner", error);