Allow browser SSO logout without JWT authentication
This commit is contained in:
parent
17c84eb366
commit
c7ba753087
@ -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"],
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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 при следующем обращении;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user