Match registration UI to server-side LAN access policy
All checks were successful
Deploy production / deploy (push) Successful in 10m51s
All checks were successful
Deploy production / deploy (push) Successful in 10m51s
This commit is contained in:
parent
53cba386e9
commit
e72a039744
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 — скрытие кнопки её не заменяет.
|
||||
|
||||
## Адрес клиента и границы доверия
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
39
frontend/src/components/auth/RegistrationAccessNotice.tsx
Normal file
39
frontend/src/components/auth/RegistrationAccessNotice.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
frontend/src/hooks/useRegistrationAvailability.ts
Normal file
58
frontend/src/hooks/useRegistrationAvailability.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
53
frontend/src/lib/registration-availability.test.js
Normal file
53
frontend/src/lib/registration-availability.test.js
Normal 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),
|
||||
);
|
||||
});
|
||||
27
frontend/src/lib/registration-availability.ts
Normal file
27
frontend/src/lib/registration-availability.ts
Normal 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";
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user