Skip to content

Commit 3bdd91e

Browse files
Unit tests, 100% coverage!
- Refactoring for small methods - Fix some bugs
1 parent b97edb4 commit 3bdd91e

35 files changed

+3596
-654
lines changed

Diff for: .gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ dist/
22
project/
33
__pycache__/
44
reports/
5+
.coverage
56
.venv/
67
.cache/

Diff for: .gitlab-ci.yml

-2
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,6 @@ type_checker:
2222
allow_failure: true
2323
unit_tests:
2424
extends: .unit_tests
25-
# the code is not ready yet
26-
allow_failure: true
2725
.tag_base:
2826
extends: .tag
2927
needs:

Diff for: .gitlab/unit-tests-ci.yml

+10
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,13 @@ include:
99
script:
1010
- 'make install_all_deps'
1111
- 'make tests'
12+
artifacts:
13+
when: always
14+
paths:
15+
- reports/**
16+
reports:
17+
junit: reports/report.xunit
18+
coverage_report:
19+
coverage_format: cobertura
20+
path: reports/coverage.xml
21+
coverage: /^TOTAL .+? ([\d.]+%)$/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Allow to override `MIN_SECONDS` in `RefreshSessionMiddleware`.

Diff for: _CHANGELOGS/Changed/utc_time.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Use UTC time in `RefreshAccessTokenMiddleware`, `RefreshSessionMiddleware`.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Default value for `jwks` in `BearerAuthenticationBackend` should be dict, not a list.

Diff for: _CHANGELOGS/Fixed/blacklist_expiration_bugfix.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Fix blacklist expiration for token where seconds where used as hours
+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Fix `_clear_cache` method in `CacheBaseView`: was not clearing the session correctly.

Diff for: _CHANGELOGS/Fixed/config_reload.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Configuration cannot be updated when using unit tests. This is now fixed. No impact on lib usage.

Diff for: _CHANGELOGS/Fixed/login_required_error_field.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Respect the optional `fail` parameter of `@login_required` decorator.

Diff for: _CHANGELOGS/Fixed/middleware_inheritance.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Middlewares should not inherit depraceted `MiddlewareMixin`.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- If user does not exist on request, should not crash in `Oauth2MiddlewareMixin.is_oidc_enabled`.

Diff for: leaks.toml

+4
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,8 @@ stopwords = [
44
'OIDC_AUTHORIZATION_HEADER_PREFIX',
55
'oidc_authentication',
66
'oidc_op_authorization_url',
7+
'a_client_secret',
8+
'client_secret',
9+
'BadPrefix',
10+
'django_key',
711
]

Diff for: oauth2_authcodeflow/auth.py

+24-34
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1+
from datetime import datetime
12
from inspect import signature
23
from logging import (
34
debug,
45
warning,
56
)
67
from re import search
7-
from time import time
88
from typing import (
99
Dict,
1010
Optional,
11+
Type,
12+
cast,
1113
)
1214

1315
from django.contrib.auth import get_user_model
@@ -23,6 +25,7 @@
2325
JWTError,
2426
jwt,
2527
)
28+
from pytz import utc
2629
from requests import get as request_get
2730
from requests import post as request_post
2831

@@ -35,8 +38,7 @@
3538

3639

3740
class AuthenticationMixin:
38-
def __init__(self, *args, **kwargs):
39-
self.UserModel = get_user_model()
41+
UserModel: Type[AbstractUser] = cast(Type[AbstractUser], get_user_model())
4042

4143
def validate_and_decode_id_token(self, id_token: str, nonce: Optional[str], jwks: Dict) -> Dict:
4244
header = jwt.get_unverified_header(id_token)
@@ -56,14 +58,10 @@ def validate_and_decode_id_token(self, id_token: str, nonce: Optional[str], jwks
5658
key = settings.OIDC_RP_CLIENT_SECRET
5759
else:
5860
raise NotImplementedError(f"Algo {algo} cannot be handled by this authentication backend")
59-
if isinstance(key, dict):
60-
secret = key
61-
else: # RSA public key (bytes)
62-
secret = key
6361
try:
6462
claims = jwt.decode(
6563
id_token,
66-
secret,
64+
key,
6765
algorithms=settings.OIDC_RP_SIGN_ALGOS_ALLOWED,
6866
audience=settings.OIDC_RP_CLIENT_ID,
6967
options={
@@ -80,7 +78,7 @@ def validate_and_decode_id_token(self, id_token: str, nonce: Optional[str], jwks
8078
raise SuspiciousOperation("JWT Nonce verification failed")
8179
return claims
8280

83-
def validate_claims(self, claims):
81+
def validate_claims(self, claims: Dict) -> None:
8482
expected_list = [settings.OIDC_OP_EXPECTED_EMAIL_CLAIM] + list(settings.OIDC_OP_EXPECTED_CLAIMS)
8583
debug(f"Validate claims={claims} against expected {expected_list}")
8684
for expected in expected_list:
@@ -163,17 +161,10 @@ def authenticate_oauth2(self,
163161
return None
164162
try:
165163
if use_pkce:
166-
if any((
167-
not code,
168-
not code_verifier,
169-
)):
164+
if not code or not code_verifier:
170165
raise SuspiciousOperation('code and code_verifier values are required')
171166
else:
172-
if any((
173-
not code,
174-
not state,
175-
not nonce,
176-
)):
167+
if not code or not state or not nonce:
177168
raise SuspiciousOperation('code, state and nonce values are required')
178169
params = {
179170
'grant_type': 'authorization_code',
@@ -190,53 +181,52 @@ def authenticate_oauth2(self,
190181
result = resp.json()
191182
id_token = result['id_token']
192183
access_token = result['access_token']
193-
expires_in = result.get('expires_in') # in secs, could be missing
184+
access_expires_in = result.get('expires_in') # in secs, could be missing
194185
refresh_token = result.get('refresh_token') if 'offline_access' in settings.OIDC_RP_SCOPES else None
195-
id_claims = self.validate_and_decode_id_token(id_token, nonce, request.session[constants.SESSION_OP_JWKS])
186+
id_claims = self.validate_and_decode_id_token(id_token, nonce, request.session.get(constants.SESSION_OP_JWKS, {}))
196187
self.validate_claims(id_claims)
197-
now = time()
198-
if expires_in:
199-
expires_at = now + expires_in
188+
now_ts = int(datetime.now(tz=utc).timestamp())
189+
session_expires_at = now_ts + settings.OIDC_MIDDLEWARE_SESSION_TIMEOUT_SECONDS
190+
if access_expires_in:
191+
access_expires_at = now_ts + access_expires_in
200192
else:
201-
expires_at = id_claims.get('exp', now + settings.OIDC_MIDDLEWARE_SESSION_TIMEOUT_SECONDS)
193+
access_expires_at = id_claims.get('exp', session_expires_at)
202194
request.session[constants.SESSION_ID_TOKEN] = id_token
203195
request.session[constants.SESSION_ACCESS_TOKEN] = access_token
204-
request.session[constants.SESSION_ACCESS_EXPIRES_AT] = expires_at
205-
request.session[constants.SESSION_EXPIRES_AT] = now + settings.OIDC_MIDDLEWARE_SESSION_TIMEOUT_SECONDS
196+
request.session[constants.SESSION_ACCESS_EXPIRES_AT] = access_expires_at
197+
request.session[constants.SESSION_EXPIRES_AT] = session_expires_at
206198
if refresh_token:
207199
request.session[constants.SESSION_REFRESH_TOKEN] = refresh_token
208200
user = self.get_or_create_user(request, id_claims, access_token)
209201
return user
210202
except Exception as e:
211-
warning(e, str(e))
203+
warning(str(e), exc_info=True)
212204
return None
213205
finally:
214206
# be sure the session is in sync
215207
request.session.save()
216208

217209

218210
class BearerAuthenticationBackend(ModelBackend, AuthenticationMixin, OIDCUrlsMixin):
219-
def __init__(self, *args, **kwargs):
211+
def __init__(self, *args, **kwargs) -> None:
220212
super().__init__(*args, **kwargs)
221213
self.authorization_prefix = settings.OIDC_AUTHORIZATION_HEADER_PREFIX
222214
self.oidc_urls = self.get_oidc_urls({})
223215

224216
def authenticate(self, request: HttpRequest, username: Optional[str] = None, password: Optional[str] = None, **kwargs) -> Optional[AbstractBaseUser]:
225217
"""Authenticates users using the Authorization header and previous OIDC Id Token."""
226-
try:
227-
prefix, id_token = request.headers.get('Authorization', ' ').split(' ', 1)
228-
except ValueError:
229-
prefix = id_token = ''
218+
auth_header = request.headers.get('Authorization', '')
219+
prefix, id_token = auth_header.split(' ', 1) if ' ' in auth_header else ('', '')
230220
if not prefix or not id_token:
231221
return None
232222
try:
233223
if prefix != self.authorization_prefix:
234224
raise SuspiciousOperation(f"Authorization should start with a {self.authorization_prefix} prefix")
235225
if BlacklistedToken.is_blacklisted(id_token):
236226
raise SuspiciousOperation(f"token {id_token} is blacklisted")
237-
id_claims = self.validate_and_decode_id_token(id_token, nonce=None, jwks=self.oidc_urls.get(constants.SESSION_OP_JWKS, []))
227+
id_claims = self.validate_and_decode_id_token(id_token, nonce=None, jwks=self.oidc_urls.get(constants.SESSION_OP_JWKS, {}))
238228
user = self.get_or_create_user(request, id_claims, '')
239229
return user
240230
except Exception as e:
241-
warning(e.args[0])
231+
warning(str(e), exc_info=True)
242232
return None

Diff for: oauth2_authcodeflow/conf.py

+39-14
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
from base64 import urlsafe_b64encode
22
from hashlib import sha1
3+
from types import FunctionType
34
from typing import (
45
Any,
56
Callable,
67
Dict,
8+
Optional,
9+
Set,
710
Tuple,
811
)
912

1013
from django.conf import settings as dj_settings
1114
from django.core.exceptions import ImproperlyConfigured
15+
from django.core.signals import setting_changed
1216
from django.utils.module_loading import import_string
1317

1418
from . import constants
1519

16-
assert constants
17-
1820

1921
def import_string_as_func(value: str, attr: str) -> Callable:
2022
try:
@@ -30,8 +32,6 @@ def get_default_django_username(claims: Dict) -> str:
3032
).decode('ascii').rstrip('=')
3133

3234

33-
func = type(lambda: 0)
34-
3535
DEFAULTS: Dict[str, Tuple[Any, Any]] = {
3636
'OIDC_VIEW_AUTHENTICATE': (type, f'{__package__}.views.AuthenticateView'),
3737
'OIDC_VIEW_CALLBACK': (type, f'{__package__}.views.CallbackView'),
@@ -114,18 +114,18 @@ def get_default_django_username(claims: Dict) -> str:
114114
# Function or dotted path to a function that compute the django username based on claims.
115115
# The username should be unique for this app.
116116
# The default is to use a base64 encode of the email hash (sha1).
117-
'OIDC_DJANGO_USERNAME_FUNC': (func, get_default_django_username),
117+
'OIDC_DJANGO_USERNAME_FUNC': (FunctionType, get_default_django_username),
118118
# Default, by value None, is the value of `OIDC_OP_EXPECTED_EMAIL_CLAIM`
119119
# You can also provide a lambda that takes all the claims as argument and return an email
120-
'OIDC_EMAIL_CLAIM': ((str, func), None),
120+
'OIDC_EMAIL_CLAIM': ((str, FunctionType), None),
121121
# You can also provide a lambda that takes all the claims as argument and return a firstname
122-
'OIDC_FIRSTNAME_CLAIM': ((str, func), 'given_name'),
122+
'OIDC_FIRSTNAME_CLAIM': ((str, FunctionType), 'given_name'),
123123
# You can also provide a lambda that takes all the claims as argument and return a lastname
124-
'OIDC_LASTNAME_CLAIM': ((str, func), 'family_name'),
124+
'OIDC_LASTNAME_CLAIM': ((str, FunctionType), 'family_name'),
125125
# Callable (that takes the user, the claims and optionaly the request and access_token as arguments)
126126
# to extend user with other potential additional information available in the claims or from another request
127127
# You can also specify a dotted path to a callable
128-
'OIDC_EXTEND_USER': (func, None),
128+
'OIDC_EXTEND_USER': (FunctionType, None),
129129
# Scramble the password on each SSO connection/renewal. If False, it will only scramble it when creating an account.
130130
'OIDC_UNUSABLE_PASSWORD': (bool, True),
131131
'OIDC_BLACKLIST_TOKEN_TIMEOUT_SECONDS': (int, 7 * 86400), # 7 days
@@ -150,28 +150,53 @@ class Settings:
150150
"""
151151
A settings object, that allows settings to be accessed as properties.
152152
"""
153-
def __init__(self, defaults=None):
153+
defaults: Dict[str, Tuple[Any, Any]]
154+
155+
def __init__(self, defaults: Optional[Dict[str, Tuple[Any, Any]]] = None) -> None:
154156
self.defaults = defaults or DEFAULTS
157+
self._cache: Set[str] = set()
158+
setting_changed.connect(self.reload)
155159

156-
def __getattr__(self, attr):
160+
def __getattr__(self, attr: str) -> Any:
157161
atype, def_val = self.defaults.get(attr, (None, None))
158162
val = getattr(dj_settings, attr, def_val)
159163
if atype is None: # other setting
160164
atype = type(val),
161165
elif not isinstance(atype, tuple):
162166
atype = atype,
163167
val = self._check_type_and_get_value(attr, val, atype)
164-
setattr(self, attr, val) # cache the result
168+
# cache the result
169+
self._cache.add(attr)
170+
setattr(self, attr, val)
165171
return val
166172

167173
def _check_type_and_get_value(self, attr: str, val: Any, atype: tuple) -> Any:
168-
if (func in atype or type in atype) and isinstance(val, str) and '.' in val:
174+
if (FunctionType in atype or type in atype) and isinstance(val, str) and '.' in val:
169175
val = import_string_as_func(val, attr) # dotted strings to function
170176
if val is ImproperlyConfigured:
171177
raise ImproperlyConfigured(f"Setting '{attr}' not found, it should be defined")
172178
if val is not None and not isinstance(val, atype):
173-
raise ImproperlyConfigured(f"Invalid setting: '{attr}' should be of type {atype} and is of type {type(val)}")
179+
raise ImproperlyConfigured(f"Invalid setting: '{attr}' should be of type {', '.join(map(str, atype))} and is of type {type(val)}")
174180
return val
175181

182+
def reload(self, *args, **kwargs) -> None:
183+
attr = str(kwargs.get('setting'))
184+
# hasattr cannot be used because it relies on getattr and __getattr__, so use the _cache
185+
if attr in self._cache:
186+
self._cache.remove(attr)
187+
try:
188+
delattr(self, attr)
189+
except AttributeError:
190+
pass
191+
176192

177193
settings = Settings()
194+
195+
196+
__all__ = [
197+
'Settings',
198+
'constants',
199+
'get_default_django_username',
200+
'import_string_as_func',
201+
'settings',
202+
]

Diff for: oauth2_authcodeflow/management/commands/oidc_urls.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,4 @@ def handle(self, *args, **options):
2626
self.stdout.write(f"redirect_url: {redirect_url}")
2727
self.stdout.write(f"logout_url: {logout_url}")
2828
except NoReverseMatch as e:
29-
raise CommandError(e)
29+
raise CommandError(str(e))

0 commit comments

Comments
 (0)