Allow browser SSO logout without JWT authentication

This commit is contained in:
admin i 2026-09-04 12:47:14 +03:00
parent 17c84eb366
commit c7ba753087
7 changed files with 156 additions and 9 deletions

View File

@ -8,7 +8,7 @@ from django.contrib.auth import logout as django_logout
from django.urls import reverse
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import serializers, status
from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
@ -70,8 +70,12 @@ class BrowserSessionAPIView(APIView):
class BrowserSessionLogoutAPIView(APIView):
authentication_classes = [SessionAwareJWTAuthentication]
permission_classes = [IsAuthenticated]
# Clearing the server-side SSO session must remain possible after the JWT
# has expired or has already been removed by another tab. Requiring JWT
# authentication here can leave a stale Django session that silently logs
# the browser back into an external OIDC client as the previous user.
authentication_classes = []
permission_classes = [AllowAny]
@extend_schema(
tags=["Auth"],

View File

@ -9,6 +9,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa
from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import check_password
from django.core.management import call_command
from django.test import Client
from django.urls import reverse
from oauth2_provider.models import get_application_model
from oauth2_provider.settings import oauth2_settings
@ -265,6 +266,98 @@ def test_browser_session_bridge_accepts_only_own_authorization_endpoint(employee
assert rejected.status_code == 400
def test_browser_session_logout_clears_stale_sso_session_without_jwt(
employee,
oidc_client,
):
application, _ = oidc_client
api = APIClient()
api.force_authenticate(employee)
established = api.post(
reverse("api:browser-session"),
{"next": ""},
format="json",
)
assert established.status_code == 200
assert api.session["_auth_user_id"] == str(employee.pk)
# Simulate the frontend reaching logout after JWT removal/expiry. The
# endpoint must still flush the independent Django/OIDC browser session.
anonymous_api = APIClient()
anonymous_api.cookies = api.cookies
logged_out = anonymous_api.post(
reverse("api:browser-session-logout"),
format="json",
)
assert logged_out.status_code == 204
assert "_auth_user_id" not in anonymous_api.session
authorization = anonymous_api.get(
reverse("oauth2_provider:authorize"),
{
"response_type": "code",
"client_id": application.client_id,
"redirect_uri": application.redirect_uris,
"scope": "openid profile email",
"state": "after-logout",
},
)
assert authorization.status_code == 302
assert urlparse(authorization["Location"]).path == "/login"
def test_browser_session_bridge_replaces_previous_portal_user(
employee,
oidc_client,
configured_oidc,
):
application, raw_secret = oidc_client
replacement = User.objects.create_user(
email="anna.smirnova@example.test",
phone_number="+79990000092",
password="Str0ngPass!",
username="asmirnova",
first_name="Анна",
last_name="Смирнова",
is_active=True,
send_activation_email=False,
)
replacement.email_verified = True
replacement.save(update_fields=["email_verified"])
api = APIClient()
api.force_authenticate(employee)
established = api.post(
reverse("api:browser-session"),
{"next": ""},
format="json",
)
assert established.status_code == 200
api.force_authenticate(replacement)
replaced = api.post(
reverse("api:browser-session"),
{"next": ""},
format="json",
)
assert replaced.status_code == 200
browser = Client()
browser.cookies = api.cookies
code = _authorize(browser, application)
tokens = _exchange_code(browser, application, raw_secret, code)
claims = jwt.decode(
tokens["id_token"],
configured_oidc,
algorithms=["RS256"],
audience=application.client_id,
issuer="https://corp.example.test/o",
)
assert claims["sub"] == str(replacement.pk)
assert claims["preferred_username"] == "asmirnova"
def test_claim_helpers_have_stable_fallback_username_and_sorted_groups(employee):
employee.username = None
employee.save(update_fields=["username"])

View File

@ -181,6 +181,20 @@ curl -fsS https://corp.example.com/o/.well-known/jwks.json
Discovery должен публиковать один и тот же issuer, HTTPS endpoints и `RS256`; JWKS содержит только публичную часть ключа. После этого пройти вход тестовой активной учёткой, проверить создание/привязку пользователя, имя, email и avatar, затем проверить отказ для деактивированной тестовой учётки.
## Сессия браузера и смена пользователя
Портал хранит отдельную HttpOnly Django-сессию для OIDC. После каждого
успешного входа `/api/auth/browser-session/` должен перепривязать её к текущему
пользователю, включая обычный вход без параметра `next`. При выходе endpoint
`/api/auth/browser-session/logout/` очищает эту сессию независимо от состояния
JWT: access/refresh token к этому моменту уже может истечь или быть удалён в
другой вкладке. Это не позволяет внешнему приложению повторно войти под
предыдущей учётной записью.
Сессии Gitea и портала остаются независимыми: выход только из Gitea не завершает
сеанс портала. Выход из портала очищает его OIDC-сессию, поэтому следующий вход
из Gitea потребует актуальной авторизации в портале.
## Отзыв доступа и ротация
- отключение сотрудника начинает блокировать access/refresh tokens при следующем обращении;

View File

@ -62,15 +62,19 @@ export default function Login() {
const continueAfterLogin = useCallback(async () => {
const nextUrl = new URLSearchParams(window.location.search).get("next");
if (!nextUrl) {
router.push("/");
return;
}
if (oidcRedirectStartedRef.current) return;
oidcRedirectStartedRef.current = true;
try {
const redirectUrl = await apiClient.establishBrowserSession(nextUrl);
// Synchronize the server-side OIDC session after every successful portal
// login, even when this was a regular login without an OIDC return URL.
// Otherwise switching portal accounts can leave the previous user in the
// Django session and the next external login silently reuses that user.
const redirectUrl = await apiClient.establishBrowserSession(nextUrl ?? "");
if (!nextUrl) {
router.push("/");
return;
}
window.location.assign(redirectUrl);
} catch (caught) {
oidcRedirectStartedRef.current = false;

View File

@ -85,6 +85,9 @@ export function UserProvider({ children }: { children: ReactNode }) {
if (terminal) {
clearRetryTimer();
retryAttemptRef.current = 0;
void apiClient.logoutBrowserSession().catch((logoutError) => {
console.warn('[auth] Не удалось очистить браузерную OIDC-сессию:', logoutError);
});
apiClient.clearToken('expired');
setUser(null);
setStatus('unauthenticated');

View File

@ -165,3 +165,28 @@ test('request timeout terminates the request without deleting tokens', async (t)
assert.equal(storage.getItem('access_token'), 'access');
assert.equal(storage.getItem('refresh_token'), 'refresh');
});
test('browser SSO logout does not depend on JWT and includes its cookie', async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => { globalThis.fetch = originalFetch; });
const storage = installBrowserGlobals();
storage.setItem('access_token', 'possibly-expired-access');
storage.setItem('refresh_token', 'possibly-expired-refresh');
const requests = [];
globalThis.fetch = async (url, options = {}) => {
requests.push({ url, options });
return new Response(null, { status: 204 });
};
const client = new ApiClientBase();
await client.logoutBrowserSession();
assert.equal(requests.length, 1);
assert.equal(requests[0].url, '/api/auth/browser-session/logout/');
assert.equal(requests[0].options.method, 'POST');
assert.equal(requests[0].options.credentials, 'include');
assert.equal(requests[0].options.headers?.Authorization, undefined);
assert.equal(storage.getItem('access_token'), 'possibly-expired-access');
assert.equal(storage.getItem('refresh_token'), 'possibly-expired-refresh');
});

View File

@ -323,8 +323,12 @@ export class ApiClientBase {
}
async logoutBrowserSession(): Promise<void> {
await this.request<void>('/api/auth/browser-session/logout/', {
// This endpoint deliberately does not depend on JWT authentication:
// the browser SSO cookie must still be cleared when access/refresh
// tokens have expired or were removed by another tab.
await this.unauthenticatedRequest<void>('/api/auth/browser-session/logout/', {
method: 'POST',
credentials: 'include',
});
}