Files
pwap/web/supabase/functions/two-factor-auth/index.ts
T
pezkuwichain dc8533d707 feat(supabase): adopt two-factor-auth, notifications-manager, email-verification
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.
2026-07-30 02:17:29 -07:00

306 lines
8.0 KiB
TypeScript

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" },
},
);
}
});