fix(csrf): centraliser l'injection des tokens CSRF via un dcc.Store et un callback pattern-matching
Remplace les 7 callbacks CSRF individuels (un par formulaire, avec IDs string
page-spécifiques) par un seul dcc.Store(id="csrf-token") dans le layout principal
et un callback pattern-matching Output({"type": "csrf-input", "index": ALL}).
Évite les erreurs Dash "id non trouvé dans le layout" sans recourir à
suppress_callback_exceptions=True.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+17
-4
@@ -5,11 +5,13 @@ import dash_bootstrap_components as dbc
|
|||||||
import pandas # noqa: F401 # eager import: avoid plotly's lazy-import race across Dash callback threads
|
import pandas # noqa: F401 # eager import: avoid plotly's lazy-import race across Dash callback threads
|
||||||
import tomllib
|
import tomllib
|
||||||
from dash import (
|
from dash import (
|
||||||
|
ALL,
|
||||||
Dash,
|
Dash,
|
||||||
Input,
|
Input,
|
||||||
Output,
|
Output,
|
||||||
State,
|
State,
|
||||||
callback,
|
callback,
|
||||||
|
ctx,
|
||||||
dcc,
|
dcc,
|
||||||
html,
|
html,
|
||||||
page_container,
|
page_container,
|
||||||
@@ -228,6 +230,7 @@ navbar = dbc.Navbar(
|
|||||||
|
|
||||||
app.layout = html.Div(
|
app.layout = html.Div(
|
||||||
[
|
[
|
||||||
|
dcc.Store(id="csrf-token"),
|
||||||
navbar,
|
navbar,
|
||||||
dbc.Container(
|
dbc.Container(
|
||||||
page_container,
|
page_container,
|
||||||
@@ -272,7 +275,7 @@ def _auth_nav(_):
|
|||||||
children=[
|
children=[
|
||||||
dcc.Input(
|
dcc.Input(
|
||||||
type="hidden",
|
type="hidden",
|
||||||
id="csrf-navbar-logout",
|
id={"type": "csrf-input", "index": "navbar-logout"},
|
||||||
name="csrf_token",
|
name="csrf_token",
|
||||||
),
|
),
|
||||||
html.Button(
|
html.Button(
|
||||||
@@ -293,10 +296,20 @@ def _auth_nav(_):
|
|||||||
|
|
||||||
|
|
||||||
@callback(
|
@callback(
|
||||||
Output("csrf-navbar-logout", "value"),
|
Output("csrf-token", "data"),
|
||||||
Input("csrf-navbar-logout", "id"),
|
Input("_pages_location", "pathname"),
|
||||||
|
Input("auth-nav-slot", "children"),
|
||||||
)
|
)
|
||||||
def _csrf_navbar_logout(_):
|
def _generate_csrf_token(*_):
|
||||||
from flask_wtf.csrf import generate_csrf
|
from flask_wtf.csrf import generate_csrf
|
||||||
|
|
||||||
return generate_csrf()
|
return generate_csrf()
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output({"type": "csrf-input", "index": ALL}, "value"),
|
||||||
|
Input("csrf-token", "data"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def _fill_csrf_inputs(token):
|
||||||
|
return [token] * len(ctx.outputs_list)
|
||||||
|
|||||||
+11
-14
@@ -1,7 +1,6 @@
|
|||||||
import dash_bootstrap_components as dbc
|
import dash_bootstrap_components as dbc
|
||||||
from dash import Input, Output, callback, dcc, html, register_page
|
from dash import dcc, html, register_page
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
from flask_wtf.csrf import generate_csrf
|
|
||||||
|
|
||||||
NAME = "Mon compte"
|
NAME = "Mon compte"
|
||||||
|
|
||||||
@@ -42,7 +41,11 @@ def layout(error: str | None = None, password_changed: str | None = None, **_):
|
|||||||
method="POST",
|
method="POST",
|
||||||
action="/auth/change-password",
|
action="/auth/change-password",
|
||||||
children=[
|
children=[
|
||||||
dcc.Input(type="hidden", id="csrf-change", name="csrf_token"),
|
dcc.Input(
|
||||||
|
type="hidden",
|
||||||
|
id={"type": "csrf-input", "index": "change"},
|
||||||
|
name="csrf_token",
|
||||||
|
),
|
||||||
dbc.Label("Mot de passe actuel"),
|
dbc.Label("Mot de passe actuel"),
|
||||||
dbc.Input(
|
dbc.Input(
|
||||||
type="password",
|
type="password",
|
||||||
@@ -74,19 +77,13 @@ def layout(error: str | None = None, password_changed: str | None = None, **_):
|
|||||||
method="POST",
|
method="POST",
|
||||||
action="/auth/logout",
|
action="/auth/logout",
|
||||||
children=[
|
children=[
|
||||||
dcc.Input(type="hidden", id="csrf-logout", name="csrf_token"),
|
dcc.Input(
|
||||||
|
type="hidden",
|
||||||
|
id={"type": "csrf-input", "index": "logout"},
|
||||||
|
name="csrf_token",
|
||||||
|
),
|
||||||
dbc.Button("Déconnexion", type="submit", color="secondary"),
|
dbc.Button("Déconnexion", type="submit", color="secondary"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@callback(Output("csrf-change", "value"), Input("csrf-change", "id"))
|
|
||||||
def _fill_csrf_change(_):
|
|
||||||
return generate_csrf()
|
|
||||||
|
|
||||||
|
|
||||||
@callback(Output("csrf-logout", "value"), Input("csrf-logout", "id"))
|
|
||||||
def _fill_csrf_logout(_):
|
|
||||||
return generate_csrf()
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import dash_bootstrap_components as dbc
|
import dash_bootstrap_components as dbc
|
||||||
from dash import Input, Output, callback, dcc, html, register_page
|
from dash import dcc, html, register_page
|
||||||
from flask_wtf.csrf import generate_csrf
|
|
||||||
|
|
||||||
NAME = "Connexion"
|
NAME = "Connexion"
|
||||||
|
|
||||||
@@ -47,7 +46,11 @@ def layout(error: str | None = None, email: str | None = None, **kwargs):
|
|||||||
method="POST",
|
method="POST",
|
||||||
action="/auth/login",
|
action="/auth/login",
|
||||||
children=[
|
children=[
|
||||||
dcc.Input(type="hidden", id="csrf-login", name="csrf_token"),
|
dcc.Input(
|
||||||
|
type="hidden",
|
||||||
|
id={"type": "csrf-input", "index": "login"},
|
||||||
|
name="csrf_token",
|
||||||
|
),
|
||||||
dcc.Input(type="hidden", name="next", value=next_url),
|
dcc.Input(type="hidden", name="next", value=next_url),
|
||||||
dbc.Label("Adresse email"),
|
dbc.Label("Adresse email"),
|
||||||
dbc.Input(
|
dbc.Input(
|
||||||
@@ -77,8 +80,3 @@ def layout(error: str | None = None, email: str | None = None, **kwargs):
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@callback(Output("csrf-login", "value"), Input("csrf-login", "id"))
|
|
||||||
def _fill_csrf(_):
|
|
||||||
return generate_csrf()
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import dash_bootstrap_components as dbc
|
import dash_bootstrap_components as dbc
|
||||||
from dash import Input, Output, callback, dcc, html, register_page
|
from dash import dcc, html, register_page
|
||||||
from flask_wtf.csrf import generate_csrf
|
|
||||||
|
|
||||||
NAME = "Inscription"
|
NAME = "Inscription"
|
||||||
|
|
||||||
@@ -36,7 +35,11 @@ def layout(error: str | None = None, email: str | None = None, **_):
|
|||||||
method="POST",
|
method="POST",
|
||||||
action="/auth/signup",
|
action="/auth/signup",
|
||||||
children=[
|
children=[
|
||||||
dcc.Input(type="hidden", id="csrf-signup", name="csrf_token"),
|
dcc.Input(
|
||||||
|
type="hidden",
|
||||||
|
id={"type": "csrf-input", "index": "signup"},
|
||||||
|
name="csrf_token",
|
||||||
|
),
|
||||||
dbc.Label("Adresse email"),
|
dbc.Label("Adresse email"),
|
||||||
dbc.Input(
|
dbc.Input(
|
||||||
type="email",
|
type="email",
|
||||||
@@ -68,8 +71,3 @@ def layout(error: str | None = None, email: str | None = None, **_):
|
|||||||
dcc.Link("Déjà un compte ? Se connecter", href="/connexion"),
|
dcc.Link("Déjà un compte ? Se connecter", href="/connexion"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@callback(Output("csrf-signup", "value"), Input("csrf-signup", "id"))
|
|
||||||
def _fill_csrf(_):
|
|
||||||
return generate_csrf()
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import dash_bootstrap_components as dbc
|
import dash_bootstrap_components as dbc
|
||||||
from dash import Input, Output, callback, dcc, html, register_page
|
from dash import dcc, html, register_page
|
||||||
from flask_wtf.csrf import generate_csrf
|
|
||||||
|
|
||||||
NAME = "Mot de passe oublié"
|
NAME = "Mot de passe oublié"
|
||||||
|
|
||||||
@@ -46,7 +45,11 @@ def layout(
|
|||||||
method="POST",
|
method="POST",
|
||||||
action="/auth/request-password-reset",
|
action="/auth/request-password-reset",
|
||||||
children=[
|
children=[
|
||||||
dcc.Input(type="hidden", id="csrf-forgot", name="csrf_token"),
|
dcc.Input(
|
||||||
|
type="hidden",
|
||||||
|
id={"type": "csrf-input", "index": "forgot"},
|
||||||
|
name="csrf_token",
|
||||||
|
),
|
||||||
dbc.Label("Adresse email"),
|
dbc.Label("Adresse email"),
|
||||||
dbc.Input(
|
dbc.Input(
|
||||||
type="email",
|
type="email",
|
||||||
@@ -62,8 +65,3 @@ def layout(
|
|||||||
dcc.Link("Retour à la connexion", href="/connexion"),
|
dcc.Link("Retour à la connexion", href="/connexion"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@callback(Output("csrf-forgot", "value"), Input("csrf-forgot", "id"))
|
|
||||||
def _fill_csrf(_):
|
|
||||||
return generate_csrf()
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import dash_bootstrap_components as dbc
|
import dash_bootstrap_components as dbc
|
||||||
from dash import Input, Output, callback, dcc, html, register_page
|
from dash import dcc, html, register_page
|
||||||
from flask_wtf.csrf import generate_csrf
|
|
||||||
|
|
||||||
from src.auth.tokens import validate_password_reset_token
|
from src.auth.tokens import validate_password_reset_token
|
||||||
|
|
||||||
@@ -47,7 +46,11 @@ def layout(token: str | None = None, error: str | None = None, **_):
|
|||||||
method="POST",
|
method="POST",
|
||||||
action="/auth/reset-password",
|
action="/auth/reset-password",
|
||||||
children=[
|
children=[
|
||||||
dcc.Input(type="hidden", id="csrf-reset", name="csrf_token"),
|
dcc.Input(
|
||||||
|
type="hidden",
|
||||||
|
id={"type": "csrf-input", "index": "reset"},
|
||||||
|
name="csrf_token",
|
||||||
|
),
|
||||||
dcc.Input(type="hidden", name="token", value=token),
|
dcc.Input(type="hidden", name="token", value=token),
|
||||||
dbc.Label("Nouveau mot de passe (8 caractères minimum)"),
|
dbc.Label("Nouveau mot de passe (8 caractères minimum)"),
|
||||||
dbc.Input(
|
dbc.Input(
|
||||||
@@ -70,8 +73,3 @@ def layout(token: str | None = None, error: str | None = None, **_):
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@callback(Output("csrf-reset", "value"), Input("csrf-reset", "id"))
|
|
||||||
def _fill_csrf(_):
|
|
||||||
return generate_csrf()
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
Vérifie que les callbacks CSRF n'utilisent pas d'IDs string page-spécifiques
|
||||||
|
comme outputs, ce qui provoquerait des erreurs Dash "id non trouvé dans le layout".
|
||||||
|
"""
|
||||||
|
|
||||||
|
import dash
|
||||||
|
|
||||||
|
|
||||||
|
def _find_component_id(component, target_id):
|
||||||
|
"""Parcourt récursivement le layout Dash à la recherche d'un composant par id."""
|
||||||
|
if hasattr(component, "id") and component.id == target_id:
|
||||||
|
return True
|
||||||
|
children = getattr(component, "children", None)
|
||||||
|
if isinstance(children, list):
|
||||||
|
return any(_find_component_id(c, target_id) for c in children)
|
||||||
|
if children is not None:
|
||||||
|
return _find_component_id(children, target_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_csrf_token_store_in_main_layout():
|
||||||
|
"""dcc.Store(id='csrf-token') doit être dans le layout principal (toujours présent)."""
|
||||||
|
from src.app import app
|
||||||
|
|
||||||
|
assert _find_component_id(app.layout, "csrf-token"), (
|
||||||
|
"dcc.Store(id='csrf-token') manquant dans le layout principal. "
|
||||||
|
"Sans lui, les callbacks CSRF référencent des composants absents du layout initial."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_page_specific_csrf_callback_outputs():
|
||||||
|
"""Aucun callback CSRF ne doit cibler un ID string page-spécifique en Output."""
|
||||||
|
old_ids = {
|
||||||
|
"csrf-login.value",
|
||||||
|
"csrf-signup.value",
|
||||||
|
"csrf-forgot.value",
|
||||||
|
"csrf-change.value",
|
||||||
|
"csrf-logout.value",
|
||||||
|
"csrf-reset.value",
|
||||||
|
"csrf-navbar-logout.value",
|
||||||
|
}
|
||||||
|
|
||||||
|
found = [k for k in dash._callback.GLOBAL_CALLBACK_MAP if k in old_ids]
|
||||||
|
|
||||||
|
assert not found, (
|
||||||
|
"Callbacks CSRF avec IDs string trouvés — provoquent des erreurs Dash au démarrage.\n"
|
||||||
|
+ "\n".join(found)
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user