mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-08-12 20:51:37 +00:00
0fe8c85dfe
two-factor-auth and notifications-manager run with the service role, so they
bypass RLS and whatever user id they act on is the entire authorisation
decision. Both read that id from the request body, and neither checked who was
calling. Access-Control-Allow-Origin was '*' on both.
Confirmed against the live endpoint: with only the public anon key and no user
session, from an arbitrary origin,
POST /functions/v1/two-factor-auth {"action":"check","userId":"<any-uuid>"}
-> 200 {"success":true,"enabled":false}
VERIFY_JWT is on and does not stop this, because the anon key is itself a valid
JWT and every browser has it. The same path reaches "disable", so anyone could
turn off another account's 2FA, and read, delete or forge their notifications.
The client already sends the signed-in user's token — functions.invoke puts the
session access token in the Authorization header — so the caller can simply be
read from it. Both functions now do that and ignore the body's userId. An
anon-key call carries no user, so it is rejected with 401.
create is the one action that may target someone else, because AdminPanel
notifies other users; that path now requires admin or super_admin in
admin_roles. CORS drops to the same allowlist the other functions here use.
No frontend change: the body may keep sending userId, it is simply not read.
Note: 2FA is not yet enforced at login — TwoFactorVerify is not referenced
anywhere and Login.tsx never checks it, so enrolling does not currently protect
sign-in. Tracked separately; this change is about the endpoints themselves.
310 lines
8.3 KiB
TypeScript
310 lines
8.3 KiB
TypeScript
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3";
|
|
import { getCaller, getCorsHeaders, unauthorized } from "../_shared/caller-auth.ts";
|
|
|
|
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) => {
|
|
const corsHeaders = getCorsHeaders(req.headers.get("origin"));
|
|
|
|
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);
|
|
|
|
// Every action below touches one account's 2FA state, so the account is
|
|
// taken from the caller's token. The body used to name it, which let an
|
|
// anon-key request read or disable anyone's 2FA.
|
|
const caller = await getCaller(req, supabase);
|
|
if (!caller) return unauthorized(corsHeaders);
|
|
const userId = caller.id;
|
|
|
|
const { action, 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" },
|
|
},
|
|
);
|
|
}
|
|
});
|