diff --git a/backend/api/auth/endpoints/__init__.py b/backend/api/auth/endpoints/__init__.py index 37f725b1..76d80c9e 100644 --- a/backend/api/auth/endpoints/__init__.py +++ b/backend/api/auth/endpoints/__init__.py @@ -15,7 +15,7 @@ from .qr_login import ( QrLoginRequestDetailAPIView, QrLoginRequestStatusAPIView, ) -from .registration import RegisterAPIView +from .registration import RegisterAPIView, RegistrationAvailabilityAPIView from .sessions import ( LogoutOtherSessionsAPIView, SessionDetailAPIView, @@ -25,6 +25,7 @@ from .tokens import JWTTokenRefreshView, PhoneOrEmailTokenObtainPairView from .websocket import WebSocketTicketAPIView __all__ = [ + "RegistrationAvailabilityAPIView", "BrowserSessionAPIView", "BrowserSessionLogoutAPIView", "ChangePasswordAPIView", diff --git a/backend/api/auth/endpoints/registration.py b/backend/api/auth/endpoints/registration.py index 8e1a48b2..432f7814 100644 --- a/backend/api/auth/endpoints/registration.py +++ b/backend/api/auth/endpoints/registration.py @@ -7,6 +7,8 @@ from django.core.files.base import ContentFile from django.db import transaction from django.utils import timezone from django.utils.crypto import get_random_string +from django.utils.decorators import method_decorator +from django.views.decorators.cache import never_cache from drf_spectacular.utils import ( OpenApiResponse, extend_schema, @@ -31,6 +33,23 @@ from ..base import AnonymousAPIView logger = logging.getLogger(__name__) +@method_decorator(never_cache, name="dispatch") +class RegistrationAvailabilityAPIView(AnonymousAPIView): + @extend_schema( + tags=["Auth"], + summary="Проверить доступность регистрации для текущего посетителя", + responses={ + 200: inline_serializer( + "RegistrationAvailabilityResponse", + {"registration_allowed": serializers.BooleanField()}, + ) + }, + ) + def get(self, request): + allowed = RegistrationFromLAN().has_permission(request, self) + return Response({"registration_allowed": allowed}) + + class RegisterAPIView(LdapUserCreationMixin, AnonymousAPIView): permission_classes = [RegistrationFromLAN] throttle_scope = "registration" diff --git a/backend/api/auth/urls.py b/backend/api/auth/urls.py index 5d7ec316..922e99f6 100644 --- a/backend/api/auth/urls.py +++ b/backend/api/auth/urls.py @@ -18,6 +18,7 @@ from .views import ( QrLoginRequestDetailAPIView, QrLoginRequestStatusAPIView, RegisterAPIView, + RegistrationAvailabilityAPIView, ResendEmailAPIView, SessionDetailAPIView, SessionListAPIView, @@ -83,6 +84,11 @@ urlpatterns = [ ), path("change-password/", ChangePasswordAPIView.as_view(), name="change-password"), path("register/", RegisterAPIView.as_view(), name="register"), + path( + "registration-availability/", + RegistrationAvailabilityAPIView.as_view(), + name="registration-availability", + ), path("resend-email/", ResendEmailAPIView.as_view(), name="resend-email"), path("verify-email/", VerifyEmailAPIView.as_view(), name="verify-email"), path("sessions/", SessionListAPIView.as_view(), name="sessions"), diff --git a/backend/api/auth/views.py b/backend/api/auth/views.py index 32ac79b4..bdce4139 100644 --- a/backend/api/auth/views.py +++ b/backend/api/auth/views.py @@ -21,6 +21,7 @@ from .endpoints import ( QrLoginRequestDetailAPIView, QrLoginRequestStatusAPIView, RegisterAPIView, + RegistrationAvailabilityAPIView, ResendEmailAPIView, SessionDetailAPIView, SessionListAPIView, @@ -29,6 +30,7 @@ from .endpoints import ( ) __all__ = [ + "RegistrationAvailabilityAPIView", "AnonymousAPIView", "BrowserSessionAPIView", "BrowserSessionLogoutAPIView", diff --git a/backend/tests/api/auth/test_registration_network.py b/backend/tests/api/auth/test_registration_network.py index b49c83c6..f0f46350 100644 --- a/backend/tests/api/auth/test_registration_network.py +++ b/backend/tests/api/auth/test_registration_network.py @@ -213,3 +213,36 @@ def test_other_auth_endpoints_remain_available_externally(route): reverse(route), {}, format="json" ) assert response.status_code == 400 # Normal validation, not the LAN restriction. + + +@pytest.mark.parametrize( + "route", ["api:registration-availability", "api:v1:registration-availability"] +) +@pytest.mark.parametrize( + "enabled,forwarded,allowed", + [ + (True, "172.11.1.128, 172.11.1.254", True), + (True, "203.0.113.7, 172.11.1.254", False), + (True, "172.11.0.100, 203.0.113.7, 172.11.1.254", False), + (True, "", False), + (False, "203.0.113.7, 172.11.1.254", True), + (False, "", True), + ], +) +@pytest.mark.django_db +def test_availability_uses_same_policy_without_side_effects( + settings, route, enabled, forwarded, allowed +): + settings.REGISTRATION_LAN_ONLY_ENABLED = enabled + before = Employee.objects.count() + client = APIClient(REMOTE_ADDR="127.0.0.1", HTTP_X_FORWARDED_FOR=forwarded) + response = client.get(reverse(route)) + assert response.status_code == 200 + assert response.json() == {"registration_allowed": allowed} + assert "no-store" in response["Cache-Control"] + assert "private" in response["Cache-Control"] + assert Employee.objects.count() == before + assert not mail.outbox + # Reading availability must not grant access to a subsequent registration. + response = client.post(reverse("api:register"), {}, format="json") + assert response.status_code == (400 if allowed else 403) diff --git a/docs/registration-network-policy.md b/docs/registration-network-policy.md index b1fab664..dcce8e3a 100644 --- a/docs/registration-network-policy.md +++ b/docs/registration-network-policy.md @@ -15,8 +15,16 @@ REGISTRATION_LAN_ONLY_ENABLED=true принимают регистрацию только из `172.11.0.0/23`. Снаружи возвращается HTTP 403 с понятным сообщением до валидации, создания пользователя, отправки письма и LDAP. Вход, OIDC, восстановление пароля и подтверждение уже отправленного письма этим -ограничением не затрагиваются. Страница формы остаётся доступной; защиту -обеспечивает API, а не скрытие кнопки. +ограничением не затрагиваются. + +Frontend запрашивает `/api/auth/registration-availability/` (есть и alias `/api/v1/auth/registration-availability/`). +Ответ `{"registration_allowed": true/false}` использует ту же проверку, что POST регистрации, +не содержит IP или конфигурацию сети и имеет `Cache-Control: private, no-store`. +Ссылка «Создать аккаунт» видна только после разрешающего ответа. +При прямом открытии `/register` извне показывается объяснение вместо формы. +Во время проверки и при ошибке форма скрыта; при ошибке можно повторить запрос. +Проверка повторяется при возврате фокуса, восстановлении страницы и соединения. +Авторитетной защитой остаётся POST API — скрытие кнопки её не заменяет. ## Адрес клиента и границы доверия diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 941f3ca3..00cb5833 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -16,6 +16,7 @@ import { import { AuthLegalNotice } from "@/components/auth/AuthLegalNotice"; import { useUser } from "@/contexts/UserContext"; +import { useRegistrationAvailability } from "@/hooks/useRegistrationAvailability"; import { apiClient } from "@/lib/api"; import type { QrLoginRequestStatus } from "@/types/api"; @@ -32,6 +33,7 @@ type QrLoginViewState = | "error"; export default function Login() { + const registration = useRegistrationAvailability(); const router = useRouter(); const { refreshUser, status } = useUser(); const pollTimerRef = useRef | null>(null); @@ -381,12 +383,14 @@ export default function Login() { {loading ? "Вход..." : "Войти"} - - Создать аккаунт - + {registration.state === "allowed" && ( + + Создать аккаунт + + )} ) : (
diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx index a77fb3a9..11f8f6e5 100644 --- a/frontend/src/app/register/page.tsx +++ b/frontend/src/app/register/page.tsx @@ -7,6 +7,8 @@ import dynamic from "next/dynamic"; import NextImage from "next/image"; import { Eye, EyeOff } from "lucide-react"; import { AuthLegalNotice } from "@/components/auth/AuthLegalNotice"; +import { RegistrationAccessNotice } from "@/components/auth/RegistrationAccessNotice"; +import { useRegistrationAvailability } from "@/hooks/useRegistrationAvailability"; // Динамический импорт компонента кроппера (чтобы избежать SSR проблем) const AvatarCropper = dynamic(() => import("@/components/AvatarCropper"), { @@ -37,6 +39,7 @@ interface RegisterFormData { } export default function Register() { + const registration = useRegistrationAvailability(); const router = useRouter(); const [currentStep, setCurrentStep] = useState(1); const [isLoading, setIsLoading] = useState(false); @@ -213,6 +216,7 @@ export default function Register() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (registration.state !== "allowed") return; if (!validateStep(currentStep)) return; @@ -250,6 +254,7 @@ export default function Register() { }); const data = await response.json(); + if (response.status === 403) registration.refresh(); if (!response.ok) { // Очищаем предыдущие ошибки @@ -302,6 +307,15 @@ export default function Register() { } }; + if (registration.state !== "allowed") { + return ( + + ); + } + return (
diff --git a/frontend/src/components/auth/RegistrationAccessNotice.tsx b/frontend/src/components/auth/RegistrationAccessNotice.tsx new file mode 100644 index 00000000..cdc18225 --- /dev/null +++ b/frontend/src/components/auth/RegistrationAccessNotice.tsx @@ -0,0 +1,39 @@ +import Link from "next/link"; +import type { RegistrationAvailability } from "@/lib/registration-availability"; + +export function RegistrationAccessNotice({ + state, + retry, +}: { + state: Exclude; + retry: () => void; +}) { + return ( +
+
+
+

Регистрация

+

+ {state === "loading" + ? "Проверяем доступность регистрации…" + : state === "denied" + ? "Регистрация доступна только из локальной сети компании. Подключитесь к корпоративной сети и повторите проверку." + : "Не удалось проверить доступность регистрации. Проверьте соединение и попробуйте ещё раз."} +

+ {state !== "loading" && ( + + )} + + Вернуться ко входу + +
+
+
+ ); +} diff --git a/frontend/src/hooks/useRegistrationAvailability.ts b/frontend/src/hooks/useRegistrationAvailability.ts new file mode 100644 index 00000000..2e5f5939 --- /dev/null +++ b/frontend/src/hooks/useRegistrationAvailability.ts @@ -0,0 +1,58 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + fetchRegistrationAvailability, + type RegistrationAvailability, +} from "@/lib/registration-availability"; + +export function useRegistrationAvailability() { + const [generation, setGeneration] = useState(0); + const [result, setResult] = useState<{ + generation: number; + state: RegistrationAvailability; + } | null>(null); + const refresh = useCallback(() => setGeneration((value) => value + 1), []); + + useEffect(() => { + const controller = new AbortController(); + let active = true; + const timeout = setTimeout(() => controller.abort(), 10000); + void fetchRegistrationAvailability(controller.signal) + .then((state) => { + if (active) setResult({ generation, state }); + }) + .catch(() => { + if (active) setResult({ generation, state: "error" }); + }) + .finally(() => clearTimeout(timeout)); + return () => { + active = false; + clearTimeout(timeout); + controller.abort(); + }; + }, [generation]); + + useEffect(() => { + // Recheck after returning to the tab or restoring a page from bfcache. + window.addEventListener("focus", refresh); + window.addEventListener("online", refresh); + const onPageShow = (event: PageTransitionEvent) => { + if (event.persisted) refresh(); + }; + window.addEventListener("pageshow", onPageShow); + return () => { + window.removeEventListener("focus", refresh); + window.removeEventListener("online", refresh); + window.removeEventListener("pageshow", onPageShow); + }; + }, [refresh]); + + return { + state: + result?.generation === generation + ? result.state + : ("loading" as RegistrationAvailability), + refresh, + }; +} diff --git a/frontend/src/lib/registration-availability.test.js b/frontend/src/lib/registration-availability.test.js new file mode 100644 index 00000000..040f242d --- /dev/null +++ b/frontend/src/lib/registration-availability.test.js @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fetchRegistrationAvailability } from "./registration-availability.ts"; + +for (const allowed of [true, false]) { + test(`registration availability: ${allowed}`, async (t) => { + const signal = new AbortController().signal; + t.mock.method(globalThis, "fetch", async (url, options) => { + assert.equal(url, "/api/auth/registration-availability/"); + assert.equal(options.cache, "no-store"); + assert.equal(options.credentials, "same-origin"); + assert.equal(options.redirect, "error"); + assert.equal(options.signal, signal); + return Response.json({ registration_allowed: allowed }); + }); + assert.equal( + await fetchRegistrationAvailability(signal), + allowed ? "allowed" : "denied", + ); + }); +} + +for (const payload of [ + null, + {}, + { registration_allowed: "true" }, + { registration_allowed: 1 }, +]) { + test(`invalid response fails closed: ${JSON.stringify(payload)}`, async (t) => { + t.mock.method(globalThis, "fetch", async () => Response.json(payload)); + await assert.rejects( + fetchRegistrationAvailability(new AbortController().signal), + ); + }); +} + +test("server failure does not expose registration", async (t) => { + t.mock.method(globalThis, "fetch", async () => + Response.json({ registration_allowed: true }, { status: 503 }), + ); + await assert.rejects( + fetchRegistrationAvailability(new AbortController().signal), + ); +}); + +test("network failure does not expose registration", async (t) => { + t.mock.method(globalThis, "fetch", async () => { + throw new TypeError("offline"); + }); + await assert.rejects( + fetchRegistrationAvailability(new AbortController().signal), + ); +}); diff --git a/frontend/src/lib/registration-availability.ts b/frontend/src/lib/registration-availability.ts new file mode 100644 index 00000000..87cb7db7 --- /dev/null +++ b/frontend/src/lib/registration-availability.ts @@ -0,0 +1,27 @@ +export type RegistrationAvailability = + | "loading" + | "allowed" + | "denied" + | "error"; + +export async function fetchRegistrationAvailability( + signal: AbortSignal, +): Promise<"allowed" | "denied"> { + const response = await fetch("/api/auth/registration-availability/", { + cache: "no-store", + credentials: "same-origin", + redirect: "error", + signal, + }); + if (!response.ok) throw new Error("Registration availability request failed"); + const data: unknown = await response.json(); + if ( + !data || + typeof data !== "object" || + !("registration_allowed" in data) || + typeof data.registration_allowed !== "boolean" + ) { + throw new Error("Invalid registration availability response"); + } + return data.registration_allowed ? "allowed" : "denied"; +}