From dc8533d707d060e963f43a177f8058d8df57cef5 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 30 Jul 2026 02:17:29 -0700 Subject: [PATCH] feat(supabase): adopt two-factor-auth, notifications-manager, email-verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web app calls these three from TwoFactorSetup, TwoFactorVerify, NotificationBell, AdminPanel and EmailVerification, but this repo never defined them and they are not deployed to the self-hosted host the app points at. They were left behind on the cloud project when everything else moved, so every one of those calls has been hitting a function that is not there — two-factor auth, notifications and email verification have all been silently dead. Recovered from the deployed bundles on the cloud project and added here so the source is tracked and CI deploys them alongside the rest. The tables they need (two_factor_auth, notifications, activity_logs, profiles, email_verification_tokens) already exist on the self-hosted database, so this is a code move only. Registry on the host now lists all three under this project. NOTE: two-factor-auth and notifications-manager take userId straight from the request body while running with the service role, and neither checks the caller. That is how they run on the cloud project today, so this change does not make it worse, but it does mean anyone can disable another account's 2FA or read and delete their notifications. Fixing it properly needs an admin-role check alongside the JWT, because AdminPanel legitimately creates notifications for other users. Tracked separately rather than folded into a move. --- .../functions/email-verification/index.ts | 100 ++++++ .../functions/notifications-manager/index.ts | 126 ++++++++ .../functions/two-factor-auth/index.ts | 305 ++++++++++++++++++ 3 files changed, 531 insertions(+) create mode 100644 web/supabase/functions/email-verification/index.ts create mode 100644 web/supabase/functions/notifications-manager/index.ts create mode 100644 web/supabase/functions/two-factor-auth/index.ts diff --git a/web/supabase/functions/email-verification/index.ts b/web/supabase/functions/email-verification/index.ts new file mode 100644 index 00000000..e2bd0b7c --- /dev/null +++ b/web/supabase/functions/email-verification/index.ts @@ -0,0 +1,100 @@ +export const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type", +}; + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + try { + const { action, token, email } = await req.json(); + + if (action === "send") { + // Generate verification token + const verificationToken = crypto.randomUUID(); + + // Store token in database (expires in 24 hours) + const { createClient } = + await import("https://esm.sh/@supabase/supabase-js@2"); + const supabaseUrl = Deno.env.get("SUPABASE_URL")!; + const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + const supabase = createClient(supabaseUrl, supabaseKey); + + const { data: user } = await supabase.auth.admin.getUserByEmail(email); + + if (!user?.user) { + throw new Error("User not found"); + } + + await supabase.from("email_verification_tokens").insert({ + user_id: user.user.id, + token: verificationToken, + email: email, + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }); + + // In production, send email via email service + // For now, return the token + return new Response( + JSON.stringify({ + success: true, + message: "Verification email sent", + token: verificationToken, // Remove in production + }), + { headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } + + if (action === "verify") { + const { createClient } = + await import("https://esm.sh/@supabase/supabase-js@2"); + const supabaseUrl = Deno.env.get("SUPABASE_URL")!; + const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + const supabase = createClient(supabaseUrl, supabaseKey); + + // Check token validity + const { data: tokenData } = await supabase + .from("email_verification_tokens") + .select("*") + .eq("token", token) + .single(); + + if (!tokenData || new Date(tokenData.expires_at) < new Date()) { + throw new Error("Invalid or expired token"); + } + + // Update user profile + await supabase + .from("profiles") + .update({ + email_verified: true, + email_verified_at: new Date().toISOString(), + }) + .eq("id", tokenData.user_id); + + // Delete used token + await supabase + .from("email_verification_tokens") + .delete() + .eq("token", token); + + return new Response( + JSON.stringify({ + success: true, + message: "Email verified successfully", + }), + { headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } + + throw new Error("Invalid action"); + } catch (error) { + return new Response(JSON.stringify({ error: error.message }), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 400, + }); + } +}); diff --git a/web/supabase/functions/notifications-manager/index.ts b/web/supabase/functions/notifications-manager/index.ts new file mode 100644 index 00000000..32f02262 --- /dev/null +++ b/web/supabase/functions/notifications-manager/index.ts @@ -0,0 +1,126 @@ +export const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type", +}; + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + try { + const { + action, + userId, + notificationId, + title, + message, + type, + actionUrl, + notificationIds, + } = await req.json(); + + // Import Supabase client + const { createClient } = + await import("https://esm.sh/@supabase/supabase-js@2"); + const supabaseUrl = Deno.env.get("SUPABASE_URL")!; + const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + const supabase = createClient(supabaseUrl, supabaseServiceKey); + + let result; + + switch (action) { + case "create": + // Create notification + const { data: notification, error: createError } = await supabase + .from("notifications") + .insert({ + user_id: userId, + title, + message, + type: type || "info", + action_url: actionUrl, + }) + .select() + .single(); + + if (createError) throw createError; + result = { success: true, notification }; + break; + + case "markRead": + // Mark notification as read + const { error: readError } = await supabase + .from("notifications") + .update({ read: true, read_at: new Date().toISOString() }) + .eq("id", notificationId) + .eq("user_id", userId); + + if (readError) throw readError; + result = { success: true }; + break; + + case "markAllRead": + // Mark all notifications as read + const { error: allReadError } = await supabase + .from("notifications") + .update({ read: true, read_at: new Date().toISOString() }) + .eq("user_id", userId) + .eq("read", false); + + if (allReadError) throw allReadError; + result = { success: true }; + break; + + case "delete": + // Delete notification + const { error: deleteError } = await supabase + .from("notifications") + .delete() + .eq("id", notificationId) + .eq("user_id", userId); + + if (deleteError) throw deleteError; + result = { success: true }; + break; + + case "deleteMultiple": + // Delete multiple notifications + const { error: deleteMultipleError } = await supabase + .from("notifications") + .delete() + .in("id", notificationIds) + .eq("user_id", userId); + + if (deleteMultipleError) throw deleteMultipleError; + result = { success: true }; + break; + + case "getUnreadCount": + // Get unread notification count + const { count, error: countError } = await supabase + .from("notifications") + .select("*", { count: "exact", head: true }) + .eq("user_id", userId) + .eq("read", false); + + if (countError) throw countError; + result = { success: true, count }; + break; + + default: + throw new Error("Invalid action"); + } + + return new Response(JSON.stringify(result), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 200, + }); + } catch (error) { + return new Response(JSON.stringify({ error: error.message }), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 400, + }); + } +}); diff --git a/web/supabase/functions/two-factor-auth/index.ts b/web/supabase/functions/two-factor-auth/index.ts new file mode 100644 index 00000000..2a98b435 --- /dev/null +++ b/web/supabase/functions/two-factor-auth/index.ts @@ -0,0 +1,305 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3"; + +export const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type", +}; + +function generateSecret(): string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let secret = ""; + for (let i = 0; i < 32; i++) { + secret += chars[Math.floor(Math.random() * chars.length)]; + } + return secret; +} + +function generateBackupCodes(): string[] { + const codes: string[] = []; + for (let i = 0; i < 10; i++) { + let code = ""; + for (let j = 0; j < 8; j++) { + code += Math.floor(Math.random() * 10).toString(); + } + codes.push(code); + } + return codes; +} + +function generateTOTP(secret: string, window: number = 0): string { + // Simple TOTP implementation + const time = Math.floor(Date.now() / 30000) + window; + const encoder = new TextEncoder(); + const data = encoder.encode(secret + time.toString()); + + // Simple hash-based OTP + let hash = 0; + for (let i = 0; i < data.length; i++) { + hash = (hash << 5) - hash + data[i]; + hash = hash & hash; + } + + const otp = Math.abs(hash) % 1000000; + return otp.toString().padStart(6, "0"); +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + try { + const supabaseUrl = Deno.env.get("SUPABASE_URL")!; + const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + const supabase = createClient(supabaseUrl, supabaseServiceKey); + + const { action, userId, code, backupCode } = await req.json(); + + switch (action) { + case "setup": { + // Generate new 2FA setup + const secret = generateSecret(); + const backupCodes = generateBackupCodes(); + + // Store in database + const { error } = await supabase.from("two_factor_auth").upsert({ + user_id: userId, + secret, + backup_codes: backupCodes, + enabled: false, + updated_at: new Date().toISOString(), + }); + + if (error) throw error; + + // Generate QR code URL for authenticator apps + const otpUrl = `otpauth://totp/PezKuwiChain:user?secret=${secret}&issuer=PezKuwiChain`; + + return new Response( + JSON.stringify({ + success: true, + secret, + qrCode: otpUrl, + backupCodes, + }), + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + } + + case "enable": { + // Verify code and enable 2FA + const { data: twoFA } = await supabase + .from("two_factor_auth") + .select("*") + .eq("user_id", userId) + .single(); + + if (!twoFA) { + throw new Error("2FA not set up"); + } + + // Check code with time window + let isValid = false; + for (let window = -1; window <= 1; window++) { + if (code === generateTOTP(twoFA.secret, window)) { + isValid = true; + break; + } + } + + if (!isValid) { + return new Response( + JSON.stringify({ + success: false, + error: "Invalid verification code", + }), + { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + } + + // Enable 2FA + await supabase + .from("two_factor_auth") + .update({ enabled: true, updated_at: new Date().toISOString() }) + .eq("user_id", userId); + + return new Response( + JSON.stringify({ + success: true, + message: "2FA enabled successfully", + }), + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + } + + case "verify": { + // Verify 2FA code during login + const { data: twoFA } = await supabase + .from("two_factor_auth") + .select("*") + .eq("user_id", userId) + .eq("enabled", true) + .single(); + + if (!twoFA) { + return new Response( + JSON.stringify({ + success: true, + required: false, + }), + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + } + + // Check if it's a backup code + if (backupCode) { + const codes = twoFA.backup_codes || []; + const codeIndex = codes.indexOf(backupCode); + + if (codeIndex === -1) { + return new Response( + JSON.stringify({ + success: false, + error: "Invalid backup code", + }), + { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + } + + // Remove used backup code + codes.splice(codeIndex, 1); + await supabase + .from("two_factor_auth") + .update({ backup_codes: codes }) + .eq("user_id", userId); + + return new Response( + JSON.stringify({ + success: true, + message: "Backup code verified", + }), + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + } + + // Verify TOTP code with time window + let isValid = false; + for (let window = -1; window <= 1; window++) { + if (code === generateTOTP(twoFA.secret, window)) { + isValid = true; + break; + } + } + + if (!isValid) { + return new Response( + JSON.stringify({ + success: false, + error: "Invalid verification code", + }), + { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + } + + return new Response( + JSON.stringify({ + success: true, + message: "Code verified successfully", + }), + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + } + + case "disable": { + // Disable 2FA + await supabase.from("two_factor_auth").delete().eq("user_id", userId); + + return new Response( + JSON.stringify({ + success: true, + message: "2FA disabled successfully", + }), + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + } + + case "check": { + // Check if 2FA is enabled + const { data: twoFA } = await supabase + .from("two_factor_auth") + .select("enabled") + .eq("user_id", userId) + .single(); + + return new Response( + JSON.stringify({ + success: true, + enabled: twoFA?.enabled || false, + }), + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + } + + case "regenerate-backup": { + // Regenerate backup codes + const newBackupCodes = generateBackupCodes(); + + await supabase + .from("two_factor_auth") + .update({ + backup_codes: newBackupCodes, + updated_at: new Date().toISOString(), + }) + .eq("user_id", userId); + + return new Response( + JSON.stringify({ + success: true, + backupCodes: newBackupCodes, + }), + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + } + + default: + throw new Error("Invalid action"); + } + } catch (error) { + return new Response( + JSON.stringify({ + success: false, + error: error.message, + }), + { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + } +});