Skip to content

Commit b81ea1a

Browse files
committed
[chore] Better cookies management, with a HttpCookieAttributes struct
1 parent 8018916 commit b81ea1a

File tree

8 files changed

+97
-47
lines changed

8 files changed

+97
-47
lines changed

src/apps/daily_challenge/admin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ def play_future_daily_challenge_view(
199199
lookup_key,
200200
expires=now() + _FUTURE_DAILY_CHALLENGE_COOKIE_DURATION,
201201
httponly=True,
202+
samesite="Lax",
202203
)
203204

204205
return response

src/apps/daily_challenge/cookie_helpers.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
import datetime as dt
12
import logging
23
from typing import TYPE_CHECKING, NamedTuple
34

45
from django.utils.timezone import now
56
from msgspec import MsgspecError
67

78
from apps.chess.models import UserPrefs
9+
from lib.http_cookies_helpers import (
10+
HttpCookieAttributes,
11+
set_http_cookie_on_django_response,
12+
)
813

914
from .models import PlayerGameState, PlayerSessionContent, PlayerStats
1015

@@ -15,10 +20,13 @@
1520

1621

1722
_PLAYER_CONTENT_SESSION_KEY = "pc"
18-
_USER_PREFS_COOKIE = {
19-
"name": "uprefs",
20-
"max-age": 3600 * 24 * 30 * 6, # approximately 6 months
21-
}
23+
24+
_USER_PREFS_COOKIE_ATTRS = HttpCookieAttributes(
25+
name="uprefs",
26+
max_age=dt.timedelta(days=30 * 6), # approximately 6 months
27+
http_only=True,
28+
same_site="Lax",
29+
)
2230

2331
_logger = logging.getLogger(__name__)
2432

@@ -100,7 +108,7 @@ def get_user_prefs_from_request(request: "HttpRequest") -> UserPrefs:
100108
def new_content():
101109
return UserPrefs()
102110

103-
cookie_content: str | None = request.COOKIES.get(_USER_PREFS_COOKIE["name"])
111+
cookie_content: str | None = request.COOKIES.get(_USER_PREFS_COOKIE_ATTRS.name)
104112
if cookie_content is None or len(cookie_content) < 5:
105113
return new_content()
106114

@@ -126,12 +134,10 @@ def save_daily_challenge_state_in_session(
126134

127135

128136
def save_user_prefs(*, user_prefs: "UserPrefs", response: "HttpResponse") -> None:
129-
response.set_cookie(
130-
_USER_PREFS_COOKIE["name"],
131-
user_prefs.to_cookie_content(),
132-
max_age=_USER_PREFS_COOKIE["max-age"],
133-
httponly=True,
134-
samesite="Lax",
137+
set_http_cookie_on_django_response(
138+
response=response,
139+
attributes=_USER_PREFS_COOKIE_ATTRS,
140+
value=user_prefs.to_cookie_content(),
135141
)
136142

137143

src/apps/daily_challenge/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
PieceRoleBySquare,
1818
PlayerSide,
1919
)
20-
from lib.django_helpers import literal_to_django_choices
20+
from lib.django_choices_helpers import literal_to_django_choices
2121

2222
from .consts import BOT_SIDE, FACTIONS, PLAYER_SIDE
2323

Lines changed: 45 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,42 @@
1+
import datetime as dt
12
import logging
23
from typing import TYPE_CHECKING, cast
34

45
from django.core.exceptions import SuspiciousOperation
56
from msgspec import MsgspecError
67

8+
from lib.http_cookies_helpers import (
9+
HttpCookieAttributes,
10+
set_http_cookie_on_django_response,
11+
)
12+
713
from .authentication import LichessTokenRetrievalProcessContext
8-
from .models import LICHESS_ACCESS_TOKEN_PREFIX
14+
from .lichess_api import is_lichess_api_access_token_valid
915

1016
if TYPE_CHECKING:
1117
from django.http import HttpRequest, HttpResponse
1218

1319
from .authentication import LichessToken
1420
from .models import LichessAccessToken
1521

16-
_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE = {
17-
"name": "lichess.oauth2.ctx",
18-
# One day should be more than enough to let the user grant their authorisation:
19-
"max-age": 3600 * 24,
20-
}
21-
22-
_API_ACCESS_TOKEN_COOKIE = {
23-
"name": "lichess.access_token",
22+
_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_ATTRS = HttpCookieAttributes(
23+
name="lichess.oauth2.ctx",
24+
# This cookie only has to be valid while the user is redirected to Lichess
25+
# and press the "Authorize" button there.
26+
max_age=dt.timedelta(hours=1),
27+
http_only=True,
28+
same_site="Lax",
29+
)
30+
31+
_API_ACCESS_TOKEN_COOKIE_ATTRS = HttpCookieAttributes(
32+
name="lichess.access_token",
2433
# Access tokens delivered by Lichess "are long-lived (expect one year)".
25-
# Let's store them for approximately 6 months, on our end:
26-
"max-age": 3600 * 24 * 30 * 6,
27-
}
34+
# As Lichess gives us the expiry date of the tokens it gives us, we can use that
35+
# for our own cookie - so no "max-age" entry here, but we'll specify one at runtime.
36+
max_age=None,
37+
http_only=True,
38+
same_site="Lax",
39+
)
2840

2941

3042
_logger = logging.getLogger(__name__)
@@ -37,12 +49,10 @@ def store_oauth2_token_retrieval_context_in_response_cookie(
3749
Store OAuth2 token retrieval context into a short-lived response cookie.
3850
"""
3951

40-
response.set_cookie(
41-
_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE["name"],
42-
context.to_cookie_content(),
43-
max_age=_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE["max-age"],
44-
httponly=True,
45-
samesite="Lax",
52+
set_http_cookie_on_django_response(
53+
response=response,
54+
attributes=_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_ATTRS,
55+
value=context.to_cookie_content(),
4656
)
4757

4858

@@ -53,7 +63,7 @@ def get_oauth2_token_retrieval_context_from_request(
5363
Returns a context created from the "CSRF state" and "code verifier" found in the request's cookies.
5464
"""
5565
cookie_content: str | None = request.COOKIES.get(
56-
_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE["name"]
66+
_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_ATTRS.name
5767
)
5868
if not cookie_content:
5969
return None
@@ -73,7 +83,7 @@ def get_oauth2_token_retrieval_context_from_request(
7383
def delete_oauth2_token_retrieval_context_from_cookies(
7484
response: "HttpResponse",
7585
) -> None:
76-
response.delete_cookie(_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE["name"])
86+
response.delete_cookie(_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_ATTRS.name)
7787

7888

7989
def store_lichess_api_access_token_in_response_cookie(
@@ -82,15 +92,17 @@ def store_lichess_api_access_token_in_response_cookie(
8292
"""
8393
Store a Lichess API token into a long-lived response cookie.
8494
"""
95+
# TODO: use a secured cookie here?
96+
97+
# Our cookie will expire when the access token given by Lichess will:
98+
cookie_attributes = _API_ACCESS_TOKEN_COOKIE_ATTRS._replace(
99+
max_age=dt.timedelta(seconds=token.expires_in),
100+
)
85101

86-
response.set_cookie(
87-
_API_ACCESS_TOKEN_COOKIE["name"],
88-
token.access_token,
89-
# TODO: should we use the token's `expires_in` here, rather than our custom
90-
# expiry period? There are pros and cons, let's decide that later :-)
91-
max_age=_API_ACCESS_TOKEN_COOKIE["max-age"],
92-
httponly=True,
93-
samesite="Lax",
102+
set_http_cookie_on_django_response(
103+
response=response,
104+
attributes=cookie_attributes,
105+
value=token.access_token,
94106
)
95107

96108

@@ -100,14 +112,13 @@ def get_lichess_api_access_token_from_request(
100112
"""
101113
Returns a Lichess API token found in the request's cookies.
102114
"""
103-
cookie_content: str | None = request.COOKIES.get(_API_ACCESS_TOKEN_COOKIE["name"])
115+
cookie_content: str | None = request.COOKIES.get(
116+
_API_ACCESS_TOKEN_COOKIE_ATTRS.name
117+
)
104118
if not cookie_content:
105119
return None
106120

107-
if (
108-
not cookie_content.startswith(LICHESS_ACCESS_TOKEN_PREFIX)
109-
or len(cookie_content) < 10
110-
):
121+
if not is_lichess_api_access_token_valid(cookie_content):
111122
raise SuspiciousOperation(
112123
f"Suspicious Lichess API token value '{cookie_content}'"
113124
)
@@ -118,4 +129,4 @@ def get_lichess_api_access_token_from_request(
118129
def delete_lichess_api_access_token_from_cookies(
119130
response: "HttpResponse",
120131
) -> None:
121-
response.delete_cookie(_API_ACCESS_TOKEN_COOKIE["name"])
132+
response.delete_cookie(_API_ACCESS_TOKEN_COOKIE_ATTRS.name)

src/apps/lichess_bridge/lichess_api.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@
22

33
import berserk
44

5+
from .models import LICHESS_ACCESS_TOKEN_PREFIX
6+
57
if TYPE_CHECKING:
68
from .models import LichessAccessToken
79

810

11+
def is_lichess_api_access_token_valid(token: str) -> bool:
12+
return token.startswith(LICHESS_ACCESS_TOKEN_PREFIX) and len(token) > 10
13+
14+
915
def get_lichess_api_client(access_token: "LichessAccessToken") -> berserk.Client:
1016
return _create_lichess_api_client(access_token)
1117

File renamed without changes.

src/lib/http_cookies_helpers.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from typing import TYPE_CHECKING, Literal, NamedTuple
2+
3+
if TYPE_CHECKING:
4+
import datetime as dt
5+
6+
from django.http import HttpResponse
7+
8+
9+
class HttpCookieAttributes(NamedTuple):
10+
name: str
11+
max_age: "dt.timedelta | None"
12+
http_only: bool
13+
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
14+
same_site: Literal["Strict", "Lax", "None", None] = "Lax"
15+
16+
17+
def set_http_cookie_on_django_response(
18+
*, response: "HttpResponse", attributes: HttpCookieAttributes, value: str
19+
) -> None:
20+
response.set_cookie(
21+
attributes.name,
22+
value,
23+
max_age=attributes.max_age,
24+
httponly=attributes.http_only,
25+
samesite=attributes.same_site,
26+
)

src/project/settings/_base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,5 +192,5 @@
192192
# > (no client authentication, choose any unique client id).
193193
# So it's not a kind of API secret we would have created on Lichess' side, but just an
194194
# arbitrary identifier.
195-
LICHESS_CLIENT_ID = env.get("LICHESS_CLIENT_ID", "")
195+
LICHESS_CLIENT_ID = env.get("LICHESS_CLIENT_ID", "zakuchess.com")
196196
LICHESS_HOST = env.get("LICHESS_HOST", "https://lichess.org")

0 commit comments

Comments
 (0)