Scope push logout cleanup to device groups

This commit is contained in:
lizerk 2026-09-04 08:21:25 +03:00
parent f4795922fe
commit 4ea807ee4b
6 changed files with 193 additions and 8 deletions

View File

@ -174,6 +174,7 @@ class SubscribePushRequestSerializer(serializers.Serializer):
class UnsubscribePushRequestSerializer(serializers.Serializer):
endpoint = serializers.CharField(required=False)
device_group_id = serializers.UUIDField(required=False)
class InstallationHeartbeatRequestSerializer(serializers.Serializer):

View File

@ -924,7 +924,10 @@ def subscribe_push(request):
'user': request.user,
'p256dh': keys.get('p256dh', ''),
'auth': keys.get('auth', ''),
'browser': device_name,
# django-push-notifications keeps a legacy browser code in a
# varchar(10). The human-readable device name belongs to
# NotificationInstallation.device_name and may be much longer.
'browser': _web_push_browser(request),
'active': True,
}
)
@ -1181,7 +1184,10 @@ def unsubscribe_push(request):
"""Отписка от Web Push уведомлений"""
from push_notifications.models import WebPushDevice
endpoint = request.data.get('endpoint')
serializer = UnsubscribePushRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
endpoint = serializer.validated_data.get('endpoint')
device_group_id = serializer.validated_data.get('device_group_id')
if endpoint:
NotificationInstallation.objects.filter(
@ -1196,6 +1202,29 @@ def unsubscribe_push(request):
user=request.user,
registration_id=endpoint
).delete()
elif device_group_id:
# Logout is scoped to the current physical device. A user may still
# have active subscriptions on a phone, another browser, or a PWA.
installations = NotificationInstallation.objects.filter(
user=request.user,
device_group_id=device_group_id,
)
endpoints = list(
installations.exclude(endpoint__isnull=True).values_list(
'endpoint',
flat=True,
)
)
deleted_count, _ = WebPushDevice.objects.filter(
user=request.user,
registration_id__in=endpoints,
).delete()
installations.update(
endpoint=None,
push_device_id=None,
active=False,
last_seen_at=timezone.now(),
)
else:
# Удалить все устройства пользователя
deleted_count, _ = WebPushDevice.objects.filter(
@ -1219,3 +1248,17 @@ def unsubscribe_push(request):
'message': f'Deleted {deleted_count} push subscription(s)',
'deleted_count': deleted_count
})
def _web_push_browser(request):
"""Return a value accepted by WebPushDevice.browser (max length 10)."""
user_agent = request.META.get('HTTP_USER_AGENT', '').lower()
if 'firefox' in user_agent:
return 'FIREFOX'
if 'edg/' in user_agent or 'edge/' in user_agent:
return 'EDGE'
if 'opr/' in user_agent or 'opera' in user_agent:
return 'OPERA'
# The library has no Safari choice. For modern HTTPS endpoints this
# legacy field is not used to construct the delivery URL.
return 'CHROME'

View File

@ -628,9 +628,92 @@ class TestNotificationPreferencesApi:
assert response.status_code == 200
installation = NotificationInstallation.objects.get(user=user)
device = WebPushDevice.objects.get(pk=installation.push_device_id)
assert installation.app_kind == "messenger"
assert installation.device_name == "Safari iPhone"
assert installation.installed is True
assert installation.push_ready is True
assert device.browser == "CHROME"
def test_subscribe_push_normalizes_browser_from_user_agent(
self, api_client, user_factory
):
user = user_factory()
api_client.force_authenticate(user=user)
response = api_client.post(
"/api/v1/notifications/push/subscribe/",
{
"endpoint": "https://push.example.test/long-device-name",
"keys": {"p256dh": "test-p256dh", "auth": "test-auth"},
"device_name": "Linux x86_64 · Desktop",
},
format="json",
HTTP_USER_AGENT="Mozilla/5.0 Firefox/142.0",
)
assert response.status_code == 200
device = WebPushDevice.objects.get(user=user)
installation = NotificationInstallation.objects.get(user=user)
assert device.browser == "FIREFOX"
assert installation.device_name == "Linux x86_64 · Desktop"
def test_unsubscribe_device_group_does_not_remove_other_device(
self, api_client, user_factory
):
user = user_factory()
api_client.force_authenticate(user=user)
current_group = "084127c8-ac7a-47c8-92c5-d7d41713b150"
other_group = "73d2d87f-2cd7-44ca-a658-d7d41713a898"
current_device = WebPushDevice.objects.create(
user=user,
registration_id="https://push.example.test/current-device",
p256dh="test-p256dh",
auth="test-auth",
browser="CHROME",
active=True,
)
other_device = WebPushDevice.objects.create(
user=user,
registration_id="https://push.example.test/other-device",
p256dh="test-p256dh",
auth="test-auth",
browser="CHROME",
active=True,
)
current_installation = NotificationInstallation.objects.create(
user=user,
device_group_id=current_group,
app_kind="main",
endpoint=current_device.registration_id,
push_device_id=current_device.id,
installed=True,
)
other_installation = NotificationInstallation.objects.create(
user=user,
device_group_id=other_group,
app_kind="main",
endpoint=other_device.registration_id,
push_device_id=other_device.id,
installed=True,
)
response = api_client.delete(
"/api/v1/notifications/push/unsubscribe/",
{"device_group_id": current_group},
format="json",
)
assert response.status_code == 200
assert response.data["deleted_count"] == 1
current_installation.refresh_from_db()
other_installation.refresh_from_db()
assert current_installation.active is False
assert current_installation.endpoint is None
assert current_installation.push_device_id is None
assert other_installation.active is True
assert other_installation.endpoint == other_device.registration_id
assert WebPushDevice.objects.filter(pk=other_device.pk).exists()
def test_delivery_routing_requires_ready_messenger(
self, api_client, user_factory

View File

@ -11,6 +11,8 @@ import {
} from "@/lib/api/client";
import type { User } from "@/types/api";
import { useRouter } from "next/navigation";
import { unsubscribeAllLocalPushSubscriptions } from "@/lib/push";
import { getDeviceGroupId } from "@/lib/notifications/installations";
export type AuthStatus = 'loading' | 'authenticated' | 'recovering' | 'unauthenticated';
@ -21,7 +23,7 @@ interface UserContextType {
error: string | null;
refreshUser: () => Promise<void>;
retryAuthentication: () => Promise<void>;
logout: () => void;
logout: () => Promise<void>;
}
const UserContext = createContext<UserContextType | undefined>(undefined);
@ -111,12 +113,26 @@ export function UserProvider({ children }: { children: ReactNode }) {
await loadUser();
}, [loadUser]);
const logout = useCallback(() => {
const logout = useCallback(async () => {
clearRetryTimer();
apiClient.clearToken('logout');
setUser(null);
setStatus('unauthenticated');
router.replace('/login');
try {
const cleanupResults = await Promise.allSettled([
apiClient.unsubscribePushForDeviceGroup(getDeviceGroupId()),
unsubscribeAllLocalPushSubscriptions(),
]);
cleanupResults.forEach((result) => {
if (result.status === 'rejected') {
console.warn('[logout] Не удалось полностью очистить push-подписку:', result.reason);
}
});
} catch (caught) {
console.warn('[logout] Не удалось запустить очистку push-подписки:', caught);
} finally {
apiClient.clearToken('logout');
setUser(null);
setStatus('unauthenticated');
router.replace('/login');
}
}, [clearRetryTimer, router]);
useEffect(() => {
@ -134,6 +150,9 @@ export function UserProvider({ children }: { children: ReactNode }) {
const handleAuthEvent = (event: Event) => {
const detail = (event as CustomEvent<AuthSessionEventDetail>).detail;
if (detail?.type === 'session-cleared') {
void unsubscribeAllLocalPushSubscriptions().catch((caught) => {
console.warn('[auth] Не удалось удалить локальную push-подписку:', caught);
});
clearRetryTimer();
setUser(null);
setStatus('unauthenticated');

View File

@ -69,6 +69,12 @@ export function createNotificationsApi(request: RequestFn) {
request('/api/v1/notifications/push/subscribe/', { method: 'POST', body: JSON.stringify(data) }),
unsubscribePush: (endpoint?: string): Promise<{ status: string; message: string }> =>
request('/api/v1/notifications/push/unsubscribe/', { method: 'DELETE', body: JSON.stringify({ endpoint }) }),
unsubscribePushForDeviceGroup: (deviceGroupId: string): Promise<{ status: string; message: string }> =>
request('/api/v1/notifications/push/unsubscribe/', {
method: 'DELETE',
body: JSON.stringify({ device_group_id: deviceGroupId }),
timeoutMs: 5_000,
}),
heartbeatNotificationInstallation: (data: {
installation_id: string;
device_group_id: string;

View File

@ -123,6 +123,39 @@ export async function unsubscribeFromPush(
return success;
}
/**
* Remove every Web Push subscription owned by this origin in the browser.
* This is intentionally local-only so it can also run after an auth session
* expires and the API can no longer authenticate the user.
*/
export async function unsubscribeAllLocalPushSubscriptions(): Promise<number> {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
return 0;
}
const registrations = await navigator.serviceWorker.getRegistrations();
const shownNotifications = await Promise.all(
registrations.map((registration) =>
registration.getNotifications().catch(() => []),
),
);
shownNotifications.flat().forEach((notification) => notification.close());
const subscriptions = await Promise.all(
registrations.map((registration) => registration.pushManager.getSubscription()),
);
const activeSubscriptions = subscriptions.filter(
(subscription): subscription is PushSubscription => subscription !== null,
);
const results = await Promise.allSettled(
activeSubscriptions.map((subscription) => subscription.unsubscribe()),
);
return results.filter(
(result) => result.status === 'fulfilled' && result.value,
).length;
}
/**
* Получает текущую push подписку
*/