mirror of
https://github.com/suitenumerique/messages.git
synced 2026-08-17 21:25:41 +02:00
♻️(auth) deliver CSRF token via session instead of cookie
The upcoming Capacitor mobile shell replays the Django session cookie through its native HTTP layer but not the `csrftoken` cookie, so cookie-based CSRF would break every mutation on mobile. Enabling CSRF_USE_SESSIONS moves the secret server-side and removes the need for a JS-readable cookie: the token is now delivered on the authenticated /users/me/ response, cached in memory by the SPA and echoed as X-CSRFToken. On web this is equivalent or safer — the secret is no longer readable by scripts nor overridable via cross-subdomain cookie tossing.
This commit is contained in:
@@ -10823,6 +10823,11 @@
|
||||
"description": "Get custom attributes for the instance.",
|
||||
"readOnly": true
|
||||
},
|
||||
"csrf_token": {
|
||||
"type": "string",
|
||||
"description": "Return the session-bound CSRF token for the SPA to echo as X-CSRFToken.\n\nWith ``CSRF_USE_SESSIONS`` the secret lives in the session and is no\nlonger exposed as a cookie, so the token is delivered here over the\nauthenticated ``/users/me/`` channel.",
|
||||
"readOnly": true
|
||||
},
|
||||
"abilities": {
|
||||
"type": "object",
|
||||
"description": "Instance permissions and capabilities",
|
||||
@@ -10850,6 +10855,7 @@
|
||||
},
|
||||
"required": [
|
||||
"abilities",
|
||||
"csrf_token",
|
||||
"custom_attributes",
|
||||
"email",
|
||||
"full_name",
|
||||
|
||||
@@ -10,6 +10,7 @@ from django.conf import settings
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Max, Q
|
||||
from django.middleware.csrf import get_token
|
||||
|
||||
from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema_field
|
||||
from rest_framework import serializers
|
||||
@@ -318,6 +319,20 @@ class UserWithAbilitiesSerializer(UserSerializer):
|
||||
"""
|
||||
|
||||
exclude_abilities = False
|
||||
csrf_token = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta(UserSerializer.Meta):
|
||||
fields = UserSerializer.Meta.fields + ["csrf_token"]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_csrf_token(self, _instance) -> str:
|
||||
"""Return the session-bound CSRF token for the SPA to echo as X-CSRFToken.
|
||||
|
||||
With ``CSRF_USE_SESSIONS`` the secret lives in the session and is no
|
||||
longer exposed as a cookie, so the token is delivered here over the
|
||||
authenticated ``/users/me/`` channel.
|
||||
"""
|
||||
return get_token(self.context["request"])
|
||||
|
||||
|
||||
class UserWithoutAbilitiesSerializer(UserSerializer):
|
||||
|
||||
@@ -75,6 +75,33 @@ class TestBlobAPI:
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_upload_session_auth_accepts_session_csrf_token(
|
||||
self, api_client, user_mailbox
|
||||
):
|
||||
"""A cookie-session upload succeeds when echoing the session CSRF token.
|
||||
|
||||
With ``CSRF_USE_SESSIONS`` the token is validated against the secret held
|
||||
in the session (no ``csrftoken`` cookie). ``GET /users/me/`` both returns
|
||||
the token and persists the secret in the session (via the CSRF
|
||||
middleware), exactly as the SPA bootstrap does; echoing that token in the
|
||||
``X-CSRFToken`` header is then accepted.
|
||||
"""
|
||||
_, user = api_client
|
||||
csrf_client = APIClient(enforce_csrf_checks=True)
|
||||
csrf_client.force_login(user) # real session → SessionAuthentication path
|
||||
|
||||
token = csrf_client.get(reverse("users-me")).data["csrf_token"]
|
||||
|
||||
url = reverse("blob-upload", kwargs={"mailbox_id": user_mailbox.id})
|
||||
response = csrf_client.post(
|
||||
url,
|
||||
{"file": self._create_test_file()},
|
||||
format="multipart",
|
||||
HTTP_X_CSRFTOKEN=token,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
def test_upload_download_blob(
|
||||
self,
|
||||
api_client,
|
||||
|
||||
@@ -36,6 +36,11 @@ class TestUsersGetMe:
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# The CSRF token (session-bound, masked per request) is delivered here so
|
||||
# the SPA can echo it as X-CSRFToken under CSRF_USE_SESSIONS; its value is
|
||||
# non-deterministic, so assert its presence and drop it before comparing.
|
||||
csrf_token = data.pop("csrf_token")
|
||||
assert isinstance(csrf_token, str) and csrf_token != ""
|
||||
assert data == {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
|
||||
@@ -1000,6 +1000,16 @@ class Base(Configuration):
|
||||
SESSION_CACHE_ALIAS = "default"
|
||||
SESSION_COOKIE_AGE = 60 * 60 * 12
|
||||
|
||||
# Keep the CSRF secret in the server-side session instead of a readable
|
||||
# `csrftoken` cookie. The SPA reads its token from the authenticated
|
||||
# /users/me/ response, and the native shell from the session exchange — both
|
||||
# echo it in the X-CSRFToken header, validated against the session secret.
|
||||
# The native HTTP jar (CapacitorHttp) replays the session cookie reliably
|
||||
# but not the csrf cookie, so this is what makes mobile mutations work; on
|
||||
# web it is equivalent-or-safer (secret no longer JS-readable, immune to
|
||||
# cross-subdomain cookie tossing).
|
||||
CSRF_USE_SESSIONS = True
|
||||
|
||||
# OIDC - Authorization Code Flow
|
||||
OIDC_CREATE_USER = values.BooleanValue(
|
||||
default=False, environ_name="OIDC_CREATE_USER", environ_prefix=None
|
||||
|
||||
@@ -54,6 +54,24 @@ async function navigateToSharedThread(page: Page, browserName: BrowserName) {
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current user through the API session.
|
||||
*
|
||||
* Also the source of the CSRF token expected by DRF on unsafe verbs: with
|
||||
* `CSRF_USE_SESSIONS` the backend no longer exposes a readable `csrftoken`
|
||||
* cookie — the session-bound token is delivered in the authenticated
|
||||
* /users/me/ payload instead, exactly how the SPA obtains it.
|
||||
*/
|
||||
async function fetchCurrentUser(page: Page) {
|
||||
const meResponse = await page.request.get(`${API_URL}/api/v1.0/users/me/`);
|
||||
expect(meResponse.ok()).toBeTruthy();
|
||||
return (await meResponse.json()) as {
|
||||
id: string;
|
||||
full_name: string;
|
||||
csrf_token: string;
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Thread Events (Internal Messages)", () => {
|
||||
test.beforeAll(async () => {
|
||||
await resetDatabase();
|
||||
@@ -421,17 +439,12 @@ test.describe("Thread Events (Internal Messages)", () => {
|
||||
const threadId = threadMatch?.[1];
|
||||
expect(threadId, "thread id should be present in URL").toBeTruthy();
|
||||
|
||||
// Reuse the existing browser session cookies — they carry both the
|
||||
// session id and the CSRF token expected by DRF on unsafe verbs.
|
||||
const cookies = await page.context().cookies();
|
||||
const csrfToken = cookies.find((c) => c.name === "csrftoken")?.value ?? "";
|
||||
|
||||
// Fetch the current user so we can mention ourselves. The UI filters
|
||||
// Fetch the current user so we can mention ourselves (the UI filters
|
||||
// out self-mentions, but the backend allows them — POSTing directly is
|
||||
// the simplest way to create an unread mention visible to the test user.
|
||||
const meResponse = await page.request.get(`${API_URL}/api/v1.0/users/me/`);
|
||||
expect(meResponse.ok()).toBeTruthy();
|
||||
const me = (await meResponse.json()) as { id: string; full_name: string };
|
||||
// the simplest way to create an unread mention visible to the test user)
|
||||
// along with the session-bound CSRF token carried in the same payload.
|
||||
const me = await fetchCurrentUser(page);
|
||||
const csrfToken = me.csrf_token;
|
||||
|
||||
// Create an IM mentioning the current user. sync_mention_user_events
|
||||
// runs in the post_save signal and materialises the UserEvent MENTION
|
||||
@@ -547,8 +560,7 @@ test.describe("Thread Events (Internal Messages)", () => {
|
||||
const eventId = eventDomId?.replace(/^thread-event-/, "");
|
||||
expect(eventId, "event id should be present on aged bubble").toBeTruthy();
|
||||
|
||||
const cookies = await page.context().cookies();
|
||||
const csrfToken = cookies.find((c) => c.name === "csrftoken")?.value ?? "";
|
||||
const { csrf_token: csrfToken } = await fetchCurrentUser(page);
|
||||
const threadMatch = page.url().match(/\/thread\/([0-9a-f-]+)/i);
|
||||
const threadId = threadMatch?.[1];
|
||||
const updateResponse = await page.request.patch(
|
||||
@@ -615,11 +627,8 @@ test.describe("Thread Events (Assignations)", () => {
|
||||
const threadMatch = page.url().match(/\/thread\/([0-9a-f-]+)/i);
|
||||
const threadId = threadMatch?.[1];
|
||||
expect(threadId, "thread id should be present in URL").toBeTruthy();
|
||||
const cookies = await page.context().cookies();
|
||||
const csrfToken = cookies.find((c) => c.name === "csrftoken")?.value ?? "";
|
||||
const meResponse = await page.request.get(`${API_URL}/api/v1.0/users/me/`);
|
||||
expect(meResponse.ok()).toBeTruthy();
|
||||
const me = (await meResponse.json()) as { id: string; full_name: string };
|
||||
const me = await fetchCurrentUser(page);
|
||||
const csrfToken = me.csrf_token;
|
||||
await page.request.post(
|
||||
`${API_URL}/api/v1.0/threads/${threadId}/events/`,
|
||||
{
|
||||
@@ -721,18 +730,14 @@ test.describe("Thread Events (Assignations)", () => {
|
||||
await navigateToSharedThread(page, browserName);
|
||||
|
||||
// Drive the assignment through the API to keep this test independent
|
||||
// of the popover flow exercised above. The cookies set during sign-in
|
||||
// carry both the session id and the CSRF token DRF expects on POST.
|
||||
// of the popover flow exercised above. The sign-in cookies carry the
|
||||
// session id; the CSRF token comes from the /users/me/ payload.
|
||||
const threadMatch = page.url().match(/\/thread\/([0-9a-f-]+)/i);
|
||||
const threadId = threadMatch?.[1];
|
||||
expect(threadId, "thread id should be present in URL").toBeTruthy();
|
||||
|
||||
const cookies = await page.context().cookies();
|
||||
const csrfToken = cookies.find((c) => c.name === "csrftoken")?.value ?? "";
|
||||
|
||||
const meResponse = await page.request.get(`${API_URL}/api/v1.0/users/me/`);
|
||||
expect(meResponse.ok()).toBeTruthy();
|
||||
const me = (await meResponse.json()) as { id: string; full_name: string };
|
||||
const me = await fetchCurrentUser(page);
|
||||
const csrfToken = me.csrf_token;
|
||||
|
||||
const assignResponse = await page.request.post(
|
||||
`${API_URL}/api/v1.0/threads/${threadId}/events/`,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { getWebCsrfToken, setWebCsrfToken } from "./csrf";
|
||||
|
||||
describe("web csrf store", () => {
|
||||
afterEach(() => {
|
||||
setWebCsrfToken(undefined);
|
||||
});
|
||||
|
||||
it("returns undefined before /users/me/ delivered a token", () => {
|
||||
expect(getWebCsrfToken()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the cached token", () => {
|
||||
setWebCsrfToken("token-from-users-me");
|
||||
expect(getWebCsrfToken()).toBe("token-from-users-me");
|
||||
});
|
||||
|
||||
it("stays out of storage — in-memory only by design", () => {
|
||||
setWebCsrfToken("token-from-users-me");
|
||||
expect(localStorage.length).toBe(0);
|
||||
expect(document.cookie).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* In-memory CSRF token for the web app.
|
||||
*
|
||||
* With `CSRF_USE_SESSIONS` the backend keeps the CSRF secret in the server-side
|
||||
* session and no longer exposes a readable `csrftoken` cookie. The token is
|
||||
* delivered over the authenticated `/users/me/` response, cached here, and
|
||||
* injected as the `X-CSRFToken` header by `getCSRFToken()`. Kept in memory (not
|
||||
* a cookie) so it is never auto-attached cross-site and is dropped on reload.
|
||||
*/
|
||||
|
||||
let webCsrfToken: string | undefined;
|
||||
|
||||
export const setWebCsrfToken = (token: string | undefined): void => {
|
||||
webCsrfToken = token;
|
||||
};
|
||||
|
||||
export const getWebCsrfToken = (): string | undefined => webCsrfToken;
|
||||
@@ -21,6 +21,12 @@ export interface UserWithAbilities {
|
||||
readonly full_name: string | null;
|
||||
/** Get custom attributes for the instance. */
|
||||
readonly custom_attributes: UserWithAbilitiesCustomAttributes;
|
||||
/** Return the session-bound CSRF token for the SPA to echo as X-CSRFToken.
|
||||
|
||||
With ``CSRF_USE_SESSIONS`` the secret lives in the session and is no
|
||||
longer exposed as a cookie, so the token is delivered here over the
|
||||
authenticated ``/users/me/`` channel. */
|
||||
readonly csrf_token: string;
|
||||
/** Instance permissions and capabilities */
|
||||
readonly abilities: UserWithAbilitiesAbilities;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getWebCsrfToken } from "./csrf";
|
||||
|
||||
export const errorCauses = async (response: Response, data?: unknown) => {
|
||||
const errorsBody = (await response.json()) as Record<
|
||||
string,
|
||||
@@ -61,14 +63,14 @@ export const getHeaders = (headers: HeadersInit = {}, isMultipartFormData: boole
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the CSRF token from the document's cookies.
|
||||
* Retrieves the CSRF token to echo in the X-CSRFToken header.
|
||||
*
|
||||
* @returns {string|null} The CSRF token if found in the cookies, or null if not present.
|
||||
* With CSRF_USE_SESSIONS the secret lives in the server-side session and there
|
||||
* is no readable `csrftoken` cookie: the token is delivered over the
|
||||
* authenticated `/users/me/` response and cached in memory.
|
||||
*
|
||||
* @returns {string|undefined} The CSRF token if known, or undefined otherwise.
|
||||
*/
|
||||
export function getCSRFToken() {
|
||||
return document.cookie
|
||||
.split(";")
|
||||
.filter((cookie) => cookie.trim().startsWith("csrftoken="))
|
||||
.map((cookie) => cookie.split("=")[1])
|
||||
.pop();
|
||||
return getWebCsrfToken();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { PropsWithChildren, useEffect, useMemo } from "react";
|
||||
|
||||
import { getRequestUrl } from "@/features/api/utils";
|
||||
import { setWebCsrfToken } from "@/features/api/csrf";
|
||||
import { useUsersMeRetrieve } from "@/features/api/gen/users/users";
|
||||
import { Spinner } from "@gouvfr-lasuite/ui-kit";
|
||||
import { UserWithAbilities } from "../api/gen/models/user_with_abilities";
|
||||
@@ -55,6 +56,13 @@ export const Auth = ({
|
||||
[config.FRONTEND_SILENT_LOGIN_ENABLED, user]
|
||||
);
|
||||
|
||||
// Cache the session-bound CSRF token delivered with /users/me/ so mutations
|
||||
// can echo it in the X-CSRFToken header (no `csrftoken` cookie any more under
|
||||
// CSRF_USE_SESSIONS).
|
||||
useEffect(() => {
|
||||
if (user) setWebCsrfToken(user.csrf_token);
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user !== null) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user