3
3
import asyncio
4
4
import contextlib
5
5
from logging import getLogger
6
- from typing import TYPE_CHECKING
6
+ from typing import TYPE_CHECKING , Any
7
7
from uuid import uuid4
8
8
9
9
from django .urls import reverse
10
10
from reactpy import component , hooks , html
11
11
12
12
from reactpy_django .javascript_components import HttpRequest
13
- from reactpy_django .models import SwitchSession
13
+ from reactpy_django .models import SynchronizeSession
14
14
15
15
if TYPE_CHECKING :
16
16
from django .contrib .sessions .backends .base import SessionBase
19
19
20
20
21
21
@component
22
- def session_manager (child ):
22
+ def session_manager (child : Any ):
23
23
"""This component can force the client (browser) to switch HTTP sessions,
24
24
making it match the websocket session.
25
25
26
26
Used to force persistent authentication between Django's websocket and HTTP stack."""
27
27
from reactpy_django import config
28
28
29
- switch_sessions , set_switch_sessions = hooks .use_state (False )
29
+ synchronize_requested , set_synchronize_requested = hooks .use_state (False )
30
30
_ , set_rerender = hooks .use_state (uuid4 )
31
31
uuid_ref = hooks .use_ref (str (uuid4 ()))
32
32
uuid = uuid_ref .current
33
33
scope = hooks .use_connection ().scope
34
34
35
35
@hooks .use_effect (dependencies = [])
36
36
def setup_asgi_scope ():
37
- """Store a trigger function in websocket scope so that ReactPy-Django's hooks can command a session synchronization."""
37
+ """Store trigger functions in the websocket scope so that ReactPy-Django's hooks can command
38
+ any relevant actions."""
38
39
scope .setdefault ("reactpy" , {})
39
40
scope ["reactpy" ]["synchronize_session" ] = synchronize_session
40
41
scope ["reactpy" ]["rerender" ] = rerender
41
42
42
- @hooks .use_effect (dependencies = [switch_sessions ])
43
- async def synchronize_session_timeout ():
44
- """Ensure that the ASGI scope is available to this component.
45
- This effect will automatically be cancelled if the session is successfully
46
- switched (via dependencies=[switch_sessions])."""
47
- if switch_sessions :
43
+ @hooks .use_effect (dependencies = [synchronize_requested ])
44
+ async def synchronize_session_watchdog ():
45
+ """This effect will automatically be cancelled if the session is successfully
46
+ switched (via effect dependencies)."""
47
+ if synchronize_requested :
48
48
await asyncio .sleep (config .REACTPY_AUTH_TIMEOUT + 0.1 )
49
49
await asyncio .to_thread (
50
50
_logger .warning ,
51
51
f"Client did not switch sessions within { config .REACTPY_AUTH_TIMEOUT } (REACTPY_AUTH_TIMEOUT) seconds." ,
52
52
)
53
- set_switch_sessions (False )
53
+ set_synchronize_requested (False )
54
54
55
55
async def synchronize_session ():
56
56
"""Entrypoint where the server will command the client to switch HTTP sessions
@@ -60,37 +60,43 @@ async def synchronize_session():
60
60
if not session or not session .session_key :
61
61
return
62
62
63
- # Delete any sessions currently associated with this UUID
64
- with contextlib .suppress (SwitchSession .DoesNotExist ):
65
- obj = await SwitchSession .objects .aget (uuid = uuid )
63
+ # Delete any sessions currently associated with this UUID, which also resets
64
+ # the SynchronizeSession validity time.
65
+ # This exists to fix scenarios where...
66
+ # 1) The developer manually rotates the session key.
67
+ # 2) A component tree requests multiple logins back-to-back before they finish.
68
+ # 3) A login is requested, but the server failed to respond to the HTTP request.
69
+ with contextlib .suppress (SynchronizeSession .DoesNotExist ):
70
+ obj = await SynchronizeSession .objects .aget (uuid = uuid )
66
71
await obj .adelete ()
67
72
68
73
# Begin the process of synchronizing HTTP and websocket sessions
69
- obj = await SwitchSession .objects .acreate (uuid = uuid , session_key = session .session_key )
74
+ obj = await SynchronizeSession .objects .acreate (uuid = uuid , session_key = session .session_key )
70
75
await obj .asave ()
71
- set_switch_sessions (True )
76
+ set_synchronize_requested (True )
72
77
73
78
async def synchronize_session_callback (status_code : int , response : str ):
74
79
"""This callback acts as a communication bridge, allowing the client to notify the server
75
- of the status of session switch command ."""
76
- set_switch_sessions (False )
80
+ of the status of session switch."""
81
+ set_synchronize_requested (False )
77
82
if status_code >= 300 or status_code < 200 :
78
83
await asyncio .to_thread (
79
84
_logger .warning ,
80
85
f"Client returned unexpected HTTP status code ({ status_code } ) while trying to sychronize sessions." ,
81
86
)
82
87
83
88
async def rerender ():
84
- """Force a rerender of the entire component tree."""
89
+ """Event that can force a rerender of the entire component tree."""
85
90
set_rerender (uuid4 ())
86
91
87
- # Switch sessions using a client side HttpRequest component, if needed
92
+ # If needed, synchronize sessions by configuring all relevant session cookies.
93
+ # This is achieved by commanding the client to perform a HTTP request to our session manager endpoint.
88
94
http_request = None
89
- if switch_sessions :
95
+ if synchronize_requested :
90
96
http_request = HttpRequest (
91
97
{
92
98
"method" : "GET" ,
93
- "url" : reverse ("reactpy:switch_session " , args = [uuid ]),
99
+ "url" : reverse ("reactpy:session_manager " , args = [uuid ]),
94
100
"body" : None ,
95
101
"callback" : synchronize_session_callback ,
96
102
},
0 commit comments