diff --git a/client/cyber_lens/src/App.tsx b/client/cyber_lens/src/App.tsx index 7195b62..bd3675a 100644 --- a/client/cyber_lens/src/App.tsx +++ b/client/cyber_lens/src/App.tsx @@ -11,6 +11,7 @@ import Footer from "./components/Footer"; import Home from "./pages/Home"; import History from "./pages/History"; import News from "./pages/News"; + import Login from "./pages/Login"; import Signup from "./pages/Signup"; import SentEmail from "./pages/SentEmailConfirmation"; @@ -19,6 +20,8 @@ import RequestPasswordReset from "./pages/RequestPasswordReset"; import ResetPassword from "./pages/ResetPassword"; import Analytics from "./pages/Analytics"; import NewsDetail from "./pages/NewsDetail"; +import Settings from "./pages/Settings"; +import VerifyAction from "./pages/VerifyAction"; const Layout = () => { return ( @@ -43,6 +46,7 @@ function App() { } /> } /> } /> + } /> {/* -------- Auth Pages (NO Navbar / Footer) -------- */} @@ -50,6 +54,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/client/cyber_lens/src/components/Navbar.tsx b/client/cyber_lens/src/components/Navbar.tsx index abf1970..a646a6d 100644 --- a/client/cyber_lens/src/components/Navbar.tsx +++ b/client/cyber_lens/src/components/Navbar.tsx @@ -4,7 +4,6 @@ import { useState, useEffect, useRef } from "react"; import { useAuth } from "../hooks/useAuth"; import Avatar from "./Avatar"; - const Navbar = () => { const [open, setOpen] = useState(false); const [profileDropdownOpen, setProfileDropdownOpen] = useState(false); @@ -14,20 +13,18 @@ const Navbar = () => { const [isSigningOut, setIsSigningOut] = useState(false); const handleLogout = () => { - if (isSigningOut) return; - - setIsSigningOut(true); - - setTimeout(() => { - logout(); - setProfileDropdownOpen(false); - setOpen(false); - navigate("/"); - setIsSigningOut(false); - }, 800); -}; + if (isSigningOut) return; + setIsSigningOut(true); + setTimeout(() => { + logout(); + setProfileDropdownOpen(false); + setOpen(false); + navigate("/"); + setIsSigningOut(false); + }, 800); + }; // Close dropdown when clicking outside useEffect(() => { @@ -133,10 +130,14 @@ const Navbar = () => { Profile - + {/* Logout */} @@ -154,7 +155,6 @@ const Navbar = () => { {isSigningOut ? "Signing out…" : "Logout"} - )} @@ -181,7 +181,11 @@ const Navbar = () => { {/* Mobile Menu */} {open && (
- setOpen(false)} className={linkClass}> + setOpen(false)} + className={linkClass} + > Home @@ -202,11 +206,11 @@ const Navbar = () => { setOpen(false)} className={linkClass} > - Analytics + Settings {isAuthenticated && user ? ( @@ -223,19 +227,19 @@ const Navbar = () => {

- + > + + {isSigningOut ? "Signing out…" : "Logout"} + ) : ( 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 80d0db0..82738e7 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -10,6 +10,7 @@ import resolveRuntimeOwner from "./middleware/resolveRuntimeOwner"; import newsRouter from "./routes/news"; import lookupRouter from "./routes/lookup"; import historyRouter from "./routes/history"; +import authRouter from "./routes/auth"; import analyticsRouter from "./routes/analytics"; import router from "./routes"; import { runNewsScraper } from "./services/newsScraper"; @@ -37,31 +38,16 @@ app.use(resolveOwner); app.use(authenticateUserOptional); app.use(resolveRuntimeOwner); -// TEMPORARY MIDDLEWARE TO PRINT THE OWNER TYPE -// app.use((req, _res, next) => { -// const owner = req.owner; - -// if (owner?.type === "user") { -// console.info(`[owner] user ${owner.id}`); -// } else if (owner?.type === "anonymous") { -// console.info(`[owner] anonymous ${owner.id}`); -// } else { -// console.info("[owner] unresolved"); -// } - -// next(); -// }); - app.get("/", (_req, res) => { res.json({ status: "ok" }); }); -app.use("/", router); -app.use("/lookup", lookupRouter); -app.use("/history", historyRouter); // Protected routes: require a valid JWT app.use("/analytics", authenticateUser, requireVerifiedEmail, analyticsRouter); app.use("/", router); app.use("/news", newsRouter); +app.use("/lookup", lookupRouter); +app.use("/history", historyRouter); +app.use("/auth", authRouter); export default app; 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 8a92f52..71e490b 100644 --- a/server/src/utils/resolveOwner.ts +++ b/server/src/utils/resolveOwner.ts @@ -40,8 +40,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);