supersam/supabase/functions/verify-otp/index.ts

190 lines
5.8 KiB
TypeScript

import { createServiceClient } from "../_shared/security.ts";
import {
getClientIp,
getCorsHeaders,
hashText,
jsonResponse,
preflightResponse,
readJsonBody,
requireRateLimit,
} from "../_shared/security.ts";
const MAX_BODY_BYTES = 8 * 1024;
const OTP_EXPIRY_SECONDS = 600; // 10 minutes
const isValidEmail = (value: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
Deno.serve(async (request) => {
if (request.method === "OPTIONS") {
return preflightResponse(request, "public");
}
if (request.method !== "POST") {
return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
}
const corsHeaders = getCorsHeaders(request, "public");
if (!corsHeaders) {
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
}
try {
const { body } = await readJsonBody<{ email?: string; otp?: string }>(request, {
maxBytes: MAX_BODY_BYTES,
});
const email = String(body.email || "").trim().toLowerCase();
const otp = String(body.otp || "").trim();
if (!email || !isValidEmail(email)) {
return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders);
}
if (!otp || otp.length < 4 || otp.length > 12) {
return jsonResponse({ ok: false, error: "Valid OTP is required" }, 400, corsHeaders);
}
const supabase = createServiceClient();
const emailHash = await hashText(email);
const ipHash = await hashText(getClientIp(request));
await requireRateLimit(supabase, {
scope: "otp-verify",
key: `${ipHash}:${emailHash}`,
maxCount: 5,
windowSeconds: 600,
blockSeconds: 1800,
});
// 1. Find the most recent unverified OTP for this email
const { data: otpRecords, error: fetchError } = await supabase
.from("login_otps")
.select("*")
.eq("email", email)
.eq("verified", false)
.order("created_at", { ascending: false })
.limit(1);
if (fetchError || !otpRecords || otpRecords.length === 0) {
return jsonResponse({ ok: false, error: "Неверный или просроченный код" }, 400, corsHeaders);
}
const otpRecord = otpRecords[0];
// 2. Check expiry (10 minutes)
const createdAt = new Date(otpRecord.created_at);
const now = new Date();
const elapsedSeconds = (now.getTime() - createdAt.getTime()) / 1000;
if (elapsedSeconds > OTP_EXPIRY_SECONDS) {
await supabase.from("login_otps").delete().eq("id", otpRecord.id);
return jsonResponse({ ok: false, error: "Код истёк. Запросите новый." }, 400, corsHeaders);
}
// 3. Verify OTP — compare hash (new) with fallback to plaintext (old records)
const submittedOtpHash = await hashText(otp);
let otpMatches = false;
if (otpRecord.otp_code_hash) {
// New flow: compare SHA-256 hashes
otpMatches = otpRecord.otp_code_hash === submittedOtpHash;
} else if (otpRecord.otp_code) {
// Legacy fallback: plaintext comparison for old records
otpMatches = otpRecord.otp_code === otp;
}
if (!otpMatches) {
return jsonResponse({ ok: false, error: "Неверный код" }, 400, corsHeaders);
}
// 4. Mark as verified and clear plaintext if present
await supabase
.from("login_otps")
.update({ verified: true, otp_code: "" })
.eq("id", otpRecord.id);
// Delete all other unverified OTPs for this email
await supabase
.from("login_otps")
.delete()
.eq("email", email)
.eq("verified", false);
// 5. Find user by email to get user_id
const { data: users } = await supabase
.from("users")
.select("id, name, roles(name)")
.eq("email", email)
.limit(1);
if (!users || users.length === 0) {
return jsonResponse({ ok: false, error: "Пользователь не найден" }, 400, corsHeaders);
}
const userId = users[0].id;
const userName = users[0].name || null;
const userRole = users[0].roles?.name || null;
// Update the login_otps record with user info
await supabase
.from("login_otps")
.update({ name: userName, role: userRole })
.eq("id", otpRecord.id);
// 6. Create session using Supabase admin API
const { data: linkData, error: linkError } = await supabase.auth.admin.generateLink({
type: "magiclink",
email,
});
if (linkError || !linkData) {
console.error("generateLink error:", linkError);
return jsonResponse({ ok: false, error: "Ошибка авторизации" }, 500, corsHeaders);
}
const generatedLink = linkData as any;
const tokenHash = generatedLink.properties?.hashed_token || generatedLink.properties?.token_hash;
if (!tokenHash) {
console.error("No token in generateLink response");
return jsonResponse({ ok: false, error: "Ошибка авторизации" }, 500, corsHeaders);
}
const { data: verifyData, error: verifyError } = await supabase.auth.verifyOtp({
type: "magiclink",
token_hash: tokenHash,
});
if (verifyError) {
console.error("verifyOtp error:", verifyError);
return jsonResponse({ ok: false, error: "Ошибка авторизации" }, 500, corsHeaders);
}
const session = verifyData.session;
const user = verifyData.user;
return jsonResponse(
{
ok: true,
session: session || null,
user: user || null,
},
200,
corsHeaders,
);
} catch (error) {
if (error instanceof Error && "status" in error) {
const httpError = error as { status: number; message: string };
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
}
return jsonResponse(
{
ok: false,
error: error instanceof Error ? error.message : "Unexpected error",
},
500,
corsHeaders,
);
}
});