190 lines
5.8 KiB
TypeScript
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,
|
|
);
|
|
}
|
|
}); |