security: add server-managed password change edge function
This commit is contained in:
parent
d5265a1c11
commit
eba83d5acc
110
supabase/functions/caterium-change-password/index.ts
Normal file
110
supabase/functions/caterium-change-password/index.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from "npm:@supabase/supabase-js@2.116.0";
|
||||
|
||||
const PROD_ORIGINS = new Set([
|
||||
"https://app.caterium.ru",
|
||||
"https://caterium.ru",
|
||||
"https://www.caterium.ru",
|
||||
]);
|
||||
const WORKERS_DEV_ORIGIN = /^https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.workers\.dev$/i;
|
||||
const LOCAL_ORIGIN = /^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d{1,5})?$/i;
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
const MAX_PASSWORD_LENGTH = 128;
|
||||
|
||||
function allowedOrigin(origin: string) {
|
||||
if (!origin) return true;
|
||||
return PROD_ORIGINS.has(origin) || WORKERS_DEV_ORIGIN.test(origin) || LOCAL_ORIGIN.test(origin);
|
||||
}
|
||||
|
||||
function corsHeaders(req: Request) {
|
||||
const origin = req.headers.get("Origin") || "";
|
||||
const headers: Record<string, string> = {
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Content-Type": "application/json",
|
||||
"Vary": "Origin",
|
||||
};
|
||||
if (origin && allowedOrigin(origin)) headers["Access-Control-Allow-Origin"] = origin;
|
||||
return headers;
|
||||
}
|
||||
|
||||
const reply = (req: Request, body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: corsHeaders(req) });
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
const origin = req.headers.get("Origin") || "";
|
||||
if (origin && !allowedOrigin(origin)) return reply(req, { error: "Origin is not allowed", code: "origin_not_allowed" }, 403);
|
||||
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders(req) });
|
||||
if (req.method !== "POST") return reply(req, { error: "Method not allowed", code: "method_not_allowed" }, 405);
|
||||
|
||||
try {
|
||||
const auth = req.headers.get("Authorization") || "";
|
||||
if (!/^Bearer\s+\S+/i.test(auth)) return reply(req, { error: "Требуется авторизация.", code: "unauthorized" }, 401);
|
||||
|
||||
const url = Deno.env.get("SUPABASE_URL") || "";
|
||||
const anon = Deno.env.get("SUPABASE_ANON_KEY") || "";
|
||||
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "";
|
||||
if (!url || !anon || !serviceKey) {
|
||||
console.error("[caterium-change-password] incomplete server auth configuration");
|
||||
return reply(req, { error: "Сервис временно недоступен.", code: "server_configuration" }, 503);
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch (_) {
|
||||
return reply(req, { error: "Некорректный формат запроса.", code: "invalid_json" }, 400);
|
||||
}
|
||||
|
||||
const currentPassword = String(body.current_password || "");
|
||||
const newPassword = String(body.new_password || "");
|
||||
if (!currentPassword) return reply(req, { error: "Введите текущий пароль.", code: "current_password_required" }, 400);
|
||||
if (newPassword.length < MIN_PASSWORD_LENGTH) return reply(req, { error: `Новый пароль должен содержать минимум ${MIN_PASSWORD_LENGTH} символов.`, code: "password_too_short" }, 400);
|
||||
if (newPassword.length > MAX_PASSWORD_LENGTH) return reply(req, { error: "Новый пароль слишком длинный.", code: "password_too_long" }, 400);
|
||||
if (newPassword === currentPassword) return reply(req, { error: "Новый пароль должен отличаться от текущего.", code: "password_unchanged" }, 400);
|
||||
|
||||
const caller = createClient(url, anon, {
|
||||
global: { headers: { Authorization: auth } },
|
||||
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
|
||||
});
|
||||
const { data: userResult, error: userError } = await caller.auth.getUser();
|
||||
const user = userResult?.user;
|
||||
if (userError || !user?.id || !user.email) return reply(req, { error: "Сессия пользователя недействительна.", code: "unauthorized" }, 401);
|
||||
|
||||
const verifier = createClient(url, anon, {
|
||||
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
|
||||
});
|
||||
const verified = await verifier.auth.signInWithPassword({ email: user.email, password: currentPassword });
|
||||
if (verified.error || !verified.data?.user || verified.data.user.id !== user.id) {
|
||||
return reply(req, { error: "Текущий пароль введён неверно.", code: "current_password_invalid" }, 400);
|
||||
}
|
||||
|
||||
const service = createClient(url, serviceKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
|
||||
});
|
||||
const appMetadata = {
|
||||
...(user.app_metadata || {}),
|
||||
must_change_password: false,
|
||||
password_policy_version: "v1774",
|
||||
password_changed_at: new Date().toISOString(),
|
||||
};
|
||||
const userMetadata = { ...(user.user_metadata || {}) } as Record<string, unknown>;
|
||||
delete userMetadata.must_change_password;
|
||||
|
||||
const updated = await service.auth.admin.updateUserById(user.id, {
|
||||
password: newPassword,
|
||||
app_metadata: appMetadata,
|
||||
user_metadata: userMetadata,
|
||||
});
|
||||
if (updated.error || !updated.data?.user) throw new Error(updated.error?.message || "Password update failed");
|
||||
|
||||
return reply(req, {
|
||||
status: "changed",
|
||||
must_change_password: false,
|
||||
reauthenticate: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[caterium-change-password]", error);
|
||||
return reply(req, { error: "Не удалось изменить пароль. Повторите попытку.", code: "password_change_failed" }, 500);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user