From eba83d5acc1eef177fa8193f17d8ab77a0d0cf4d Mon Sep 17 00:00:00 2001 From: pavlov346346-source Date: Fri, 11 Sep 2026 10:08:22 +0300 Subject: [PATCH] security: add server-managed password change edge function --- .../caterium-change-password/index.ts | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 supabase/functions/caterium-change-password/index.ts diff --git a/supabase/functions/caterium-change-password/index.ts b/supabase/functions/caterium-change-password/index.ts new file mode 100644 index 0000000..91a7aeb --- /dev/null +++ b/supabase/functions/caterium-change-password/index.ts @@ -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 = { + "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; + 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; + 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); + } +});