Skip to content

Commit f72933b

Browse files
authored
feat: Sso backend 34 (#166)
* add google auth backend * fix * cache access token and create user * improve caching * update migrations * fix create user * BaseOAuth2LoginForm * cache token object * create cache folder * add encrypted char field * plural * plural * auto increase max length * new common * split user proxies into separate files * fix * fix linting errors * fix * fix type aliases * fix linting errors
1 parent 92476b2 commit f72933b

32 files changed

+1870
-1143
lines changed

Pipfile

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ pyjwt = "==2.6.0" # TODO: upgrade to latest version.
2828
psutil = "==7.0.0"
2929
importlib-metadata = "==4.13.0" # TODO: remove. needed by old portal
3030
django-formtools = "==2.5.1" # TODO: remove. needed by old portal
31-
django-otp = "==1.6.0" # TODO: remove. needed by old portal
31+
django-otp = "==1.6.1" # TODO: remove. needed by old portal
3232
# https://pypi.org/user/codeforlife/
33-
cfl-common = "==8.7.9" # TODO: remove
34-
codeforlife-portal = "==8.7.9" # TODO: remove
35-
rapid-router = "==7.5.17" # TODO: remove
33+
cfl-common = "==8.8.2" # TODO: remove
34+
codeforlife-portal = "==8.8.2" # TODO: remove
35+
rapid-router = "==7.5.21" # TODO: remove
3636
phonenumbers = "==8.12.12" # TODO: remove
3737

3838
[dev-packages]

Pipfile.lock

Lines changed: 415 additions & 397 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codeforlife/caches.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""
2+
© Ocado Group
3+
Created on 11/08/2025 at 11:07:45(+01:00).
4+
"""
5+
6+
import typing as t
7+
8+
from django.core.cache import cache
9+
10+
K = t.TypeVar("K")
11+
V = t.TypeVar("V")
12+
13+
14+
class BaseValueCache(t.Generic[V]):
15+
"""Base class which helps to get and set cache values."""
16+
17+
@classmethod
18+
def get(
19+
cls,
20+
key: str,
21+
default: t.Optional[V] = None,
22+
version: t.Optional[int] = None,
23+
) -> t.Optional[V]:
24+
"""
25+
Fetch a given key from the cache. If the key does not exist, return
26+
default, which itself defaults to None.
27+
"""
28+
return cache.get(
29+
key=key,
30+
default=default,
31+
version=version,
32+
)
33+
34+
@classmethod
35+
def set(
36+
cls,
37+
key: str,
38+
value: V,
39+
timeout: t.Optional[int] = None,
40+
version: t.Optional[int] = None,
41+
):
42+
"""
43+
Set a value in the cache. If timeout is given, use that timeout for the
44+
key; otherwise use the default cache timeout.
45+
"""
46+
cache.set(
47+
key=key,
48+
value=value,
49+
timeout=timeout,
50+
version=version,
51+
)
52+
53+
54+
class BaseFixedKeyValueCache(BaseValueCache[V], t.Generic[V]):
55+
"""Base class which helps to get and set cache values with a fixed key."""
56+
57+
key: str
58+
59+
# pylint: disable=arguments-differ
60+
61+
@classmethod
62+
def get( # type: ignore[override]
63+
cls,
64+
default: t.Optional[V] = None,
65+
version: t.Optional[int] = None,
66+
) -> t.Optional[V]:
67+
return super().get(
68+
key=cls.key,
69+
default=default,
70+
version=version,
71+
)
72+
73+
@classmethod
74+
def set( # type: ignore[override]
75+
cls,
76+
value: V,
77+
timeout: t.Optional[int] = None,
78+
version: t.Optional[int] = None,
79+
):
80+
super().set(
81+
key=cls.key,
82+
value=value,
83+
timeout=timeout,
84+
version=version,
85+
)
86+
87+
# pylint: enable=arguments-differ
88+
89+
90+
class BaseDynamicKeyValueCache(BaseValueCache[V], t.Generic[K, V]):
91+
"""Base class which helps to get and set cache values with a dynamic key."""
92+
93+
@staticmethod
94+
def make_key(key: K) -> str:
95+
"""Make the cache key from the key's data."""
96+
raise NotImplementedError()
97+
98+
@classmethod
99+
def get( # type: ignore[override]
100+
cls,
101+
key: K,
102+
default: t.Optional[V] = None,
103+
version: t.Optional[int] = None,
104+
) -> t.Optional[V]:
105+
return super().get(
106+
key=cls.make_key(key),
107+
default=default,
108+
version=version,
109+
)
110+
111+
@classmethod
112+
def set( # type: ignore[override]
113+
cls,
114+
key: K,
115+
value: V,
116+
timeout: t.Optional[int] = None,
117+
version: t.Optional[int] = None,
118+
):
119+
super().set(
120+
key=cls.make_key(key),
121+
value=value,
122+
timeout=timeout,
123+
version=version,
124+
)

codeforlife/forms.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,19 @@ def get_invalid_login_error_message(self) -> str:
7979
NotImplementedError: If message is not set.
8080
"""
8181
raise NotImplementedError()
82+
83+
84+
class BaseOAuth2LoginForm(
85+
BaseLoginForm[AnyAbstractBaseUser],
86+
t.Generic[AnyAbstractBaseUser],
87+
):
88+
"""
89+
Base login form that all other login forms using OAuth2.0 must inherit.
90+
"""
91+
92+
code = forms.CharField(min_length=1)
93+
code_verifier = forms.CharField(min_length=43, max_length=128)
94+
redirect_uri = forms.CharField(min_length=1)
95+
96+
def get_invalid_login_error_message(self):
97+
return "Failed to exchange code for access token."

codeforlife/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@
77
from .abstract_base_user import AbstractBaseUser
88
from .base import *
99
from .base_session_store import BaseSessionStore
10+
from .encrypted_char_field import EncryptedCharField
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""
2+
© Ocado Group
3+
Created on 12/08/2025 at 10:28:24(+01:00).
4+
"""
5+
6+
import typing as t
7+
8+
from cryptography.fernet import Fernet
9+
from django.conf import settings
10+
from django.db import models
11+
12+
13+
class EncryptedCharField(models.CharField):
14+
"""
15+
A custom CharField that encrypts data before saving and decrypts it when
16+
retrieved.
17+
"""
18+
19+
_fernet = Fernet(settings.SECRET_KEY)
20+
_prefix = "ENC:"
21+
22+
def __init__(self, *args, **kwargs):
23+
kwargs["max_length"] += len(self._prefix)
24+
super().__init__(*args, **kwargs)
25+
26+
# pylint: disable-next=unused-argument
27+
def from_db_value(self, value: t.Optional[str], expression, connection):
28+
"""
29+
Converts a value as returned by the database to a Python object. It is
30+
the reverse of get_prep_value().
31+
32+
https://docs.djangoproject.com/en/5.1/howto/custom-model-fields/#converting-values-to-python-objects
33+
"""
34+
if isinstance(value, str):
35+
return self.decrypt_value(value)
36+
return value
37+
38+
def to_python(self, value: t.Optional[str]):
39+
"""
40+
Converts the value into the correct Python object. It acts as the
41+
reverse of value_to_string(), and is also called in clean().
42+
43+
https://docs.djangoproject.com/en/5.1/howto/custom-model-fields/#converting-values-to-python-objects
44+
"""
45+
if isinstance(value, str):
46+
return self.decrypt_value(value)
47+
return value
48+
49+
def get_prep_value(self, value: t.Optional[str]):
50+
"""
51+
'value' is the current value of the model's attribute, and the method
52+
should return data in a format that has been prepared for use as a
53+
parameter in a query.
54+
55+
https://docs.djangoproject.com/en/5.1/howto/custom-model-fields/#converting-python-objects-to-query-values
56+
"""
57+
if isinstance(value, str):
58+
return self.encrypt_value(value)
59+
return value
60+
61+
def encrypt_value(self, value: str):
62+
"""Encrypt the value if it's not encrypted."""
63+
if not value.startswith(self._prefix):
64+
return self._prefix + self._fernet.encrypt(value.encode()).decode()
65+
return value
66+
67+
def decrypt_value(self, value: str):
68+
"""Decrpyt the value if it's encrypted.."""
69+
if value.startswith(self._prefix):
70+
value = value[len(self._prefix) :]
71+
return self._fernet.decrypt(value).decode()
72+
return value

codeforlife/settings/custom.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,11 @@ def get_redis_url():
126126

127127
# The URL to connect to the Redis cache.
128128
REDIS_URL = get_redis_url()
129+
130+
# Our Google OAuth 2.0 client credentials
131+
# https://console.cloud.google.com/auth/clients
132+
GOOGLE_CLIENT_ID = os.getenv(
133+
"GOOGLE_CLIENT_ID",
134+
"354656325390-o5n12nbaivhi4do8lalkh29q403uu9u4.apps.googleusercontent.com",
135+
)
136+
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "REPLACE_ME")

codeforlife/settings/django.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ def get_databases():
149149
"codeforlife.user.auth.backends.OtpBypassTokenBackend",
150150
"codeforlife.user.auth.backends.StudentBackend",
151151
"codeforlife.user.auth.backends.StudentAutoBackend",
152+
"codeforlife.user.auth.backends.GoogleBackend",
152153
]
153154

154155
# Sessions

codeforlife/types.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,24 @@ def get_arg(cls: t.Type[t.Any], index: int, orig_base: int = 0):
4747
The type arg from the class.
4848
"""
4949
return t.get_args(cls.__orig_bases__[orig_base])[index]
50+
51+
52+
class OAuth2TokenFromRefreshDict(t.TypedDict):
53+
"""An OAuth 2.0 token given in exchange for a refresh token."""
54+
55+
access_token: str
56+
token_type: str
57+
scope: str
58+
expires_in: int
59+
error: t.NotRequired[dict]
60+
61+
62+
class OAuth2TokenFromCodeDict(t.TypedDict):
63+
"""An OAuth 2.0 token given in exchange for a code."""
64+
65+
access_token: str
66+
token_type: str
67+
scope: str
68+
expires_in: int
69+
refresh_token: str
70+
error: t.NotRequired[dict]

codeforlife/user/auth/backends/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
# TODO: Create a custom auth backend for Django admin permissions
77
from .email import EmailBackend
8+
from .google import GoogleBackend
89
from .otp import OtpBackend
910
from .otp_bypass_token import OtpBypassTokenBackend
1011
from .student import StudentBackend

0 commit comments

Comments
 (0)