Match registration UI to server-side LAN access policy
All checks were successful
Deploy production / deploy (push) Successful in 10m51s

This commit is contained in:
admin i 2026-09-16 13:31:57 +03:00
parent 53cba386e9
commit e72a039744
12 changed files with 273 additions and 9 deletions

View File

@ -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",

View File

@ -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"

View File

@ -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"),

View File

@ -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",

View File

@ -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)

View File

@ -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 — скрытие кнопки её не заменяет.
## Адрес клиента и границы доверия

View File

@ -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<ReturnType<typeof setTimeout> | null>(null);
@ -381,12 +383,14 @@ export default function Login() {
{loading ? "Вход..." : "Войти"}
</button>
<Link
href="/register"
className="app-action-secondary flex w-full items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold"
>
Создать аккаунт
</Link>
{registration.state === "allowed" && (
<Link
href="/register"
className="app-action-secondary flex w-full items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold"
>
Создать аккаунт
</Link>
)}
</form>
) : (
<div className="space-y-4">

View File

@ -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 (
<RegistrationAccessNotice
state={registration.state}
retry={registration.refresh}
/>
);
}
return (
<div className="app-shell min-h-screen text-[var(--foreground)]">
<div className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center px-6 py-12 sm:px-10">

View File

@ -0,0 +1,39 @@
import Link from "next/link";
import type { RegistrationAvailability } from "@/lib/registration-availability";
export function RegistrationAccessNotice({
state,
retry,
}: {
state: Exclude<RegistrationAvailability, "allowed">;
retry: () => void;
}) {
return (
<div className="app-shell min-h-screen text-[var(--foreground)]">
<main className="mx-auto flex min-h-screen max-w-lg flex-col justify-center px-6 py-12">
<div className="app-surface-elevated rounded-[2rem] p-8">
<h1 className="mb-4 text-3xl font-semibold">Регистрация</h1>
<p className="app-text-muted mb-6" role="status">
{state === "loading"
? "Проверяем доступность регистрации…"
: state === "denied"
? "Регистрация доступна только из локальной сети компании. Подключитесь к корпоративной сети и повторите проверку."
: "Не удалось проверить доступность регистрации. Проверьте соединение и попробуйте ещё раз."}
</p>
{state !== "loading" && (
<button
type="button"
onClick={retry}
className="app-action-secondary mb-4 w-full rounded-lg px-4 py-3 text-sm font-semibold"
>
Проверить снова
</button>
)}
<Link href="/login" className="app-link-accent">
Вернуться ко входу
</Link>
</div>
</main>
</div>
);
}

View File

@ -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,
};
}

View File

@ -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),
);
});

View File

@ -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";
}