sanic-security 1.11.7__py3-none-any.whl → 1.12.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,21 +3,25 @@ from sanic.exceptions import SanicException
3
3
  from sanic_security.utils import json
4
4
 
5
5
  """
6
- An effective, simple, and async security library for the Sanic framework.
7
- Copyright (C) 2020-present Aidan Stewart
8
-
9
- This program is free software: you can redistribute it and/or modify
10
- it under the terms of the GNU Affero General Public License as published
11
- by the Free Software Foundation, either version 3 of the License, or
12
- (at your option) any later version.
13
-
14
- This program is distributed in the hope that it will be useful,
15
- but WITHOUT ANY WARRANTY; without even the implied warranty of
16
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
- GNU Affero General Public License for more details.
18
-
19
- You should have received a copy of the GNU Affero General Public License
20
- along with this program. If not, see <https://www.gnu.org/licenses/>.
6
+ Copyright (c) 2020-present Nicholas Aidan Stewart
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ of this software and associated documentation files (the "Software"), to deal
10
+ in the Software without restriction, including without limitation the rights
11
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is
13
+ furnished to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ SOFTWARE.
21
25
  """
22
26
 
23
27
 
@@ -53,7 +57,16 @@ class DeletedError(SecurityError):
53
57
  """
54
58
 
55
59
  def __init__(self, message):
56
- super().__init__(message, 410)
60
+ super().__init__(message, 404)
61
+
62
+
63
+ class CredentialsError(SecurityError):
64
+ """
65
+ Raised when credentials are invalid.
66
+ """
67
+
68
+ def __init__(self, message, code=400):
69
+ super().__init__(message, code)
57
70
 
58
71
 
59
72
  class AccountError(SecurityError):
@@ -125,7 +138,16 @@ class ExpiredError(SessionError):
125
138
  """
126
139
 
127
140
  def __init__(self):
128
- super().__init__("Session has expired")
141
+ super().__init__("Session has expired.")
142
+
143
+
144
+ class NotExpiredError(SessionError):
145
+ """
146
+ Raised when session needs to be expired.
147
+ """
148
+
149
+ def __init__(self):
150
+ super().__init__("Session has not expired yet.", 403)
129
151
 
130
152
 
131
153
  class SecondFactorRequiredError(SessionError):
@@ -173,10 +195,10 @@ class AuthorizationError(SecurityError):
173
195
  super().__init__(message, 403)
174
196
 
175
197
 
176
- class CredentialsError(SecurityError):
198
+ class AnonymousError(AuthorizationError):
177
199
  """
178
- Raised when credentials are invalid.
200
+ Raised when attempting to authorize an anonymous user.
179
201
  """
180
202
 
181
- def __init__(self, message, code=400):
182
- super().__init__(message, code)
203
+ def __init__(self):
204
+ super().__init__("Session is anonymous.")
sanic_security/models.py CHANGED
@@ -16,21 +16,25 @@ from sanic_security.exceptions import *
16
16
  from sanic_security.utils import get_ip, get_code, get_expiration_date
17
17
 
18
18
  """
19
- An effective, simple, and async security library for the Sanic framework.
20
- Copyright (C) 2020-present Aidan Stewart
21
-
22
- This program is free software: you can redistribute it and/or modify
23
- it under the terms of the GNU Affero General Public License as published
24
- by the Free Software Foundation, either version 3 of the License, or
25
- (at your option) any later version.
26
-
27
- This program is distributed in the hope that it will be useful,
28
- but WITHOUT ANY WARRANTY; without even the implied warranty of
29
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
30
- GNU Affero General Public License for more details.
31
-
32
- You should have received a copy of the GNU Affero General Public License
33
- along with this program. If not, see <https://www.gnu.org/licenses/>.
19
+ Copyright (c) 2020-present Nicholas Aidan Stewart
20
+
21
+ Permission is hereby granted, free of charge, to any person obtaining a copy
22
+ of this software and associated documentation files (the "Software"), to deal
23
+ in the Software without restriction, including without limitation the rights
24
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
25
+ copies of the Software, and to permit persons to whom the Software is
26
+ furnished to do so, subject to the following conditions:
27
+
28
+ The above copyright notice and this permission notice shall be included in all
29
+ copies or substantial portions of the Software.
30
+
31
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
32
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
33
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
34
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
35
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
36
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
37
+ SOFTWARE.
34
38
  """
35
39
 
36
40
 
@@ -109,19 +113,6 @@ class Account(BaseModel):
109
113
  "models.Role", through="account_role"
110
114
  )
111
115
 
112
- @property
113
- def json(self) -> dict:
114
- return {
115
- "id": self.id,
116
- "date_created": str(self.date_created),
117
- "date_updated": str(self.date_updated),
118
- "email": self.email,
119
- "username": self.username,
120
- "phone": self.phone,
121
- "disabled": self.disabled,
122
- "verified": self.verified,
123
- }
124
-
125
116
  def validate(self) -> None:
126
117
  """
127
118
  Raises an error with respect to account state.
@@ -151,6 +142,19 @@ class Account(BaseModel):
151
142
  self.disabled = True
152
143
  await self.save(update_fields=["disabled"])
153
144
 
145
+ @property
146
+ def json(self) -> dict:
147
+ return {
148
+ "id": self.id,
149
+ "date_created": str(self.date_created),
150
+ "date_updated": str(self.date_updated),
151
+ "email": self.email,
152
+ "username": self.username,
153
+ "phone": self.phone,
154
+ "disabled": self.disabled,
155
+ "verified": self.verified,
156
+ }
157
+
154
158
  @staticmethod
155
159
  async def get_via_email(email: str):
156
160
  """
@@ -233,19 +237,6 @@ class Session(BaseModel):
233
237
  def __init__(self, **kwargs):
234
238
  super().__init__(**kwargs)
235
239
 
236
- @property
237
- def json(self) -> dict:
238
- return {
239
- "id": self.id,
240
- "date_created": str(self.date_created),
241
- "date_updated": str(self.date_updated),
242
- "expiration_date": str(self.expiration_date),
243
- "bearer": self.bearer.username
244
- if isinstance(self.bearer, Account)
245
- else None,
246
- "active": self.active,
247
- }
248
-
249
240
  def validate(self) -> None:
250
241
  """
251
242
  Raises an error with respect to session state.
@@ -257,13 +248,13 @@ class Session(BaseModel):
257
248
  """
258
249
  if self.deleted:
259
250
  raise DeletedError("Session has been deleted.")
251
+ elif not self.active:
252
+ raise DeactivatedError()
260
253
  elif (
261
254
  self.expiration_date
262
255
  and datetime.datetime.now(datetime.timezone.utc) >= self.expiration_date
263
256
  ):
264
257
  raise ExpiredError()
265
- elif not self.active:
266
- raise DeactivatedError()
267
258
 
268
259
  async def deactivate(self):
269
260
  """
@@ -285,23 +276,22 @@ class Session(BaseModel):
285
276
  Args:
286
277
  response (HTTPResponse): Sanic response used to store JWT into a cookie on the client.
287
278
  """
288
- payload = {
289
- "id": self.id,
290
- "date_created": str(self.date_created),
291
- "expiration_date": str(self.expiration_date),
292
- "ip": self.ip,
293
- }
294
279
  cookie = (
295
280
  f"{security_config.SESSION_PREFIX}_{self.__class__.__name__.lower()[:7]}"
296
281
  )
297
282
  encoded_session = jwt.encode(
298
- payload, security_config.SECRET, security_config.SESSION_ENCODING_ALGORITHM
283
+ {
284
+ "id": self.id,
285
+ "date_created": str(self.date_created),
286
+ "expiration_date": str(self.expiration_date),
287
+ "ip": self.ip,
288
+ },
289
+ security_config.SECRET,
290
+ security_config.SESSION_ENCODING_ALGORITHM,
299
291
  )
300
- if isinstance(encoded_session, bytes):
301
- encoded_session = encoded_session.decode()
302
292
  response.cookies.add_cookie(
303
293
  cookie,
304
- encoded_session,
294
+ str(encoded_session),
305
295
  httponly=security_config.SESSION_HTTPONLY,
306
296
  samesite=security_config.SESSION_SAMESITE,
307
297
  secure=security_config.SESSION_SECURE,
@@ -311,6 +301,29 @@ class Session(BaseModel):
311
301
  if security_config.SESSION_DOMAIN:
312
302
  response.cookies.get_cookie(cookie).domain = security_config.SESSION_DOMAIN
313
303
 
304
+ @property
305
+ def json(self) -> dict:
306
+ return {
307
+ "id": self.id,
308
+ "date_created": str(self.date_created),
309
+ "date_updated": str(self.date_updated),
310
+ "expiration_date": str(self.expiration_date),
311
+ "bearer": (
312
+ self.bearer.username if isinstance(self.bearer, Account) else None
313
+ ),
314
+ "active": self.active,
315
+ }
316
+
317
+ @property
318
+ def anonymous(self) -> bool:
319
+ """
320
+ Determines if an account is associated with session.
321
+
322
+ Returns:
323
+ anonymous
324
+ """
325
+ return self.bearer is None
326
+
314
327
  @classmethod
315
328
  async def new(
316
329
  cls,
@@ -345,7 +358,7 @@ class Session(BaseModel):
345
358
  Raises:
346
359
  NotFoundError
347
360
  """
348
- sessions = await cls.filter(bearer=account).prefetch_related("bearer").all()
361
+ sessions = await cls.filter(bearer=account, deleted=False).all()
349
362
  if not sessions:
350
363
  raise NotFoundError("No sessions associated to account were found.")
351
364
  return sessions
@@ -373,9 +386,7 @@ class Session(BaseModel):
373
386
  else:
374
387
  return jwt.decode(
375
388
  cookie,
376
- security_config.SECRET
377
- if not security_config.PUBLIC_SECRET
378
- else security_config.PUBLIC_SECRET,
389
+ security_config.PUBLIC_SECRET or security_config.SECRET,
379
390
  security_config.SESSION_ENCODING_ALGORITHM,
380
391
  )
381
392
  except DecodeError as e:
@@ -421,10 +432,6 @@ class VerificationSession(Session):
421
432
  attempts: int = fields.IntField(default=0)
422
433
  code: str = fields.CharField(max_length=10, default=get_code, null=True)
423
434
 
424
- @classmethod
425
- async def new(cls, request: Request, account: Account, **kwargs):
426
- raise NotImplementedError
427
-
428
435
  async def check_code(self, request: Request, code: str) -> None:
429
436
  """
430
437
  Checks if code passed is equivalent to the session code.
@@ -453,6 +460,10 @@ class VerificationSession(Session):
453
460
  self.active = False
454
461
  await self.save(update_fields=["active"])
455
462
 
463
+ @classmethod
464
+ async def new(cls, request: Request, account: Account, **kwargs):
465
+ raise NotImplementedError
466
+
456
467
  class Meta:
457
468
  abstract = True
458
469
 
@@ -514,9 +525,13 @@ class AuthenticationSession(Session):
514
525
 
515
526
  Attributes:
516
527
  requires_second_factor (bool): Determines if session requires a second factor.
528
+ refresh_expiration_date (bool): Date and time the session can no longer be refreshed.
529
+ is_refresh (bool): Will only be true when instantiated during refresh of expired session.
517
530
  """
518
531
 
519
532
  requires_second_factor: bool = fields.BooleanField(default=False)
533
+ refresh_expiration_date: datetime.datetime = fields.DatetimeField(null=True)
534
+ is_refresh: bool = False
520
535
 
521
536
  def validate(self) -> None:
522
537
  """
@@ -532,16 +547,51 @@ class AuthenticationSession(Session):
532
547
  if self.requires_second_factor:
533
548
  raise SecondFactorRequiredError()
534
549
 
550
+ async def refresh(self, request: Request):
551
+ """
552
+ Seamlessly creates new session if within refresh date.
553
+
554
+ Raises:
555
+ DeletedError
556
+ ExpiredError
557
+ DeactivatedError
558
+ SecondFactorRequiredError
559
+ NotExpiredError
560
+
561
+ Returns:
562
+ session
563
+ """
564
+ try:
565
+ self.validate()
566
+ raise NotExpiredError()
567
+ except ExpiredError as e:
568
+ if (
569
+ datetime.datetime.now(datetime.timezone.utc)
570
+ <= self.refresh_expiration_date
571
+ ):
572
+ self.active = False
573
+ await self.save(update_fields=["active"])
574
+ return await self.new(request, self.bearer, True)
575
+ else:
576
+ raise e
577
+
535
578
  @classmethod
536
- async def new(cls, request: Request, account: Account, **kwargs):
537
- return await AuthenticationSession.create(
579
+ async def new(
580
+ cls, request: Request, account: Account = None, is_refresh=False, **kwargs
581
+ ):
582
+ authentication_session = await AuthenticationSession.create(
538
583
  **kwargs,
539
584
  bearer=account,
540
585
  ip=get_ip(request),
541
586
  expiration_date=get_expiration_date(
542
587
  security_config.AUTHENTICATION_SESSION_EXPIRATION
543
588
  ),
589
+ refresh_expiration_date=get_expiration_date(
590
+ security_config.AUTHENTICATION_REFRESH_EXPIRATION
591
+ ),
544
592
  )
593
+ authentication_session.is_refresh = is_refresh
594
+ return authentication_session
545
595
 
546
596
  class Meta:
547
597
  table = "authentication_session"
@@ -554,7 +604,7 @@ class Role(BaseModel):
554
604
  Attributes:
555
605
  name (str): Name of the role.
556
606
  description (str): Description of the role.
557
- permissions (str): Permissions of the role. Must be separated via comma and in wildcard format (printer:query, printer:query,delete).
607
+ permissions (str): Permissions of the role. Must be separated via comma + space and in wildcard format (printer:query, dashboard:info,delete).
558
608
  """
559
609
 
560
610
  name: str = fields.CharField(unique=True, max_length=255)
@@ -1,3 +1,6 @@
1
+ import datetime
2
+ import traceback
3
+
1
4
  from argon2 import PasswordHasher
2
5
  from sanic import Sanic, text
3
6
  from tortoise.contrib.sanic import register_tortoise
@@ -28,27 +31,34 @@ from sanic_security.verification import (
28
31
  )
29
32
 
30
33
  """
31
- An effective, simple, and async security library for the Sanic framework.
32
- Copyright (C) 2020-present Aidan Stewart
33
-
34
- This program is free software: you can redistribute it and/or modify
35
- it under the terms of the GNU Affero General Public License as published
36
- by the Free Software Foundation, either version 3 of the License, or
37
- (at your option) any later version.
38
-
39
- This program is distributed in the hope that it will be useful,
40
- but WITHOUT ANY WARRANTY; without even the implied warranty of
41
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
42
- GNU Affero General Public License for more details.
43
-
44
- You should have received a copy of the GNU Affero General Public License
45
- along with this program. If not, see <https://www.gnu.org/licenses/>.
34
+ Copyright (c) 2020-present Nicholas Aidan Stewart
35
+
36
+ Permission is hereby granted, free of charge, to any person obtaining a copy
37
+ of this software and associated documentation files (the "Software"), to deal
38
+ in the Software without restriction, including without limitation the rights
39
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
40
+ copies of the Software, and to permit persons to whom the Software is
41
+ furnished to do so, subject to the following conditions:
42
+
43
+ The above copyright notice and this permission notice shall be included in all
44
+ copies or substantial portions of the Software.
45
+
46
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
47
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
48
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
49
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
50
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
51
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
52
+ SOFTWARE.
46
53
  """
47
54
 
48
55
  app = Sanic("sanic-security-test")
49
56
  password_hasher = PasswordHasher()
50
57
 
51
58
 
59
+ # TODO: Testing for new functionality.
60
+
61
+
52
62
  @app.post("api/test/auth/register")
53
63
  async def on_register(request):
54
64
  """
@@ -105,6 +115,19 @@ async def on_login(request):
105
115
  return response
106
116
 
107
117
 
118
+ @app.post("api/test/auth/login/anon")
119
+ async def on_login_anonymous(request):
120
+ """
121
+ Login as anonymous user.
122
+ """
123
+ authentication_session = await AuthenticationSession.new(request)
124
+ response = json(
125
+ "Anonymous user now associated with session!", authentication_session.json
126
+ )
127
+ authentication_session.encode(response)
128
+ return response
129
+
130
+
108
131
  @app.post("api/test/auth/validate-2fa")
109
132
  async def on_two_factor_authentication(request):
110
133
  """
@@ -130,16 +153,40 @@ async def on_logout(request):
130
153
 
131
154
 
132
155
  @app.post("api/test/auth")
133
- @requires_authentication()
156
+ @requires_authentication
134
157
  async def on_authenticate(request):
135
158
  """
136
159
  Authenticate client session and account.
137
160
  """
138
- response = json("Authenticated!", request.ctx.authentication_session.bearer.json)
139
- request.ctx.authentication_session.encode(response)
161
+ authentication_session = request.ctx.authentication_session
162
+ response = json(
163
+ "Authenticated!",
164
+ {
165
+ "bearer": (
166
+ authentication_session.bearer.json
167
+ if not authentication_session.anonymous
168
+ else None
169
+ ),
170
+ "refresh": authentication_session.is_refresh,
171
+ },
172
+ )
173
+ if authentication_session.is_refresh:
174
+ request.ctx.authentication_session.encode(response)
140
175
  return response
141
176
 
142
177
 
178
+ @app.post("api/test/auth/expire")
179
+ @requires_authentication
180
+ async def on_authentication_expire(request):
181
+ """
182
+ Expire client's session.
183
+ """
184
+ authentication_session = request.ctx.authentication_session
185
+ authentication_session.expiration_date = datetime.datetime.now(datetime.UTC)
186
+ await authentication_session.save(update_fields=["expiration_date"])
187
+ return json("Authentication expired!", authentication_session.json)
188
+
189
+
143
190
  @app.post("api/test/auth/associated")
144
191
  @requires_authentication
145
192
  async def on_get_associated_authentication_sessions(request):
@@ -242,16 +289,12 @@ async def on_account_creation(request):
242
289
  """
243
290
  Quick account creation.
244
291
  """
245
- if await Account.filter(email=request.form.get("email").lower()).exists():
246
- raise CredentialsError("An account with this email already exists.", 409)
247
- elif await Account.filter(username=request.form.get("username")).exists():
248
- raise CredentialsError("An account with this username already exists.", 409)
249
292
  account = await Account.create(
250
293
  username=request.form.get("username"),
251
294
  email=request.form.get("email").lower(),
252
295
  password=password_hasher.hash("password"),
253
296
  verified=True,
254
- dbisabled=False,
297
+ disabled=False,
255
298
  )
256
299
  response = json("Account creation successful!", account.json)
257
300
  return response
@@ -262,6 +305,7 @@ async def on_security_error(request, exception):
262
305
  """
263
306
  Handles security errors with correct response.
264
307
  """
308
+ traceback.print_exc()
265
309
  return exception.json
266
310
 
267
311
 
@@ -7,21 +7,25 @@ import httpx
7
7
  from sanic_security.configuration import Config
8
8
 
9
9
  """
10
- An effective, simple, and async security library for the Sanic framework.
11
- Copyright (C) 2020-present Aidan Stewart
12
-
13
- This program is free software: you can redistribute it and/or modify
14
- it under the terms of the GNU Affero General Public License as published
15
- by the Free Software Foundation, either version 3 of the License, or
16
- (at your option) any later version.
17
-
18
- This program is distributed in the hope that it will be useful,
19
- but WITHOUT ANY WARRANTY; without even the implied warranty of
20
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21
- GNU Affero General Public License for more details.
22
-
23
- You should have received a copy of the GNU Affero General Public License
24
- along with this program. If not, see <https://www.gnu.org/licenses/>.
10
+ Copyright (c) 2020-present Nicholas Aidan Stewart
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
25
29
  """
26
30
 
27
31
 
@@ -290,6 +294,19 @@ class LoginTest(TestCase):
290
294
  )
291
295
  assert authenticate_response.status_code == 200, authenticate_response.text
292
296
 
297
+ def test_anonymous_login(self):
298
+ """
299
+ Test login of anonymous user.
300
+ """
301
+ anon_login_response = self.client.post(
302
+ "http://127.0.0.1:8000/api/test/auth/login/anon"
303
+ )
304
+ assert anon_login_response.status_code == 200, anon_login_response.text
305
+ authenticate_response = self.client.post(
306
+ "http://127.0.0.1:8000/api/test/auth",
307
+ )
308
+ assert authenticate_response.status_code == 200, authenticate_response.text
309
+
293
310
 
294
311
  class VerificationTest(TestCase):
295
312
  """
@@ -468,6 +485,23 @@ class AuthorizationTest(TestCase):
468
485
  prohibited_authorization_response.status_code == 403
469
486
  ), prohibited_authorization_response.text
470
487
 
488
+ def test_anonymous_authorization(self):
489
+ anon_login_response = self.client.post(
490
+ "http://127.0.0.1:8000/api/test/auth/login/anon"
491
+ )
492
+ assert anon_login_response.status_code == 200, anon_login_response.text
493
+ authenticate_response = self.client.post(
494
+ "http://127.0.0.1:8000/api/test/auth",
495
+ )
496
+ assert authenticate_response.status_code == 200, authenticate_response.text
497
+ prohibited_authorization_response = self.client.post(
498
+ "http://127.0.0.1:8000/api/test/auth/roles",
499
+ data={"role": "AuthTestPerms"},
500
+ )
501
+ assert (
502
+ prohibited_authorization_response.status_code == 403
503
+ ), prohibited_authorization_response.text
504
+
471
505
 
472
506
  class MiscTest(TestCase):
473
507
  """
@@ -511,3 +545,34 @@ class MiscTest(TestCase):
511
545
  assert (
512
546
  retrieve_associated_response.status_code == 200
513
547
  ), retrieve_associated_response.text
548
+
549
+ def test_authentication_refresh(self):
550
+ """
551
+ Test automatic authentication refresh.
552
+ """
553
+ self.client.post(
554
+ "http://127.0.0.1:8000/api/test/account",
555
+ data={
556
+ "email": "refreshed@misc.test",
557
+ "username": "refreshed",
558
+ },
559
+ )
560
+ login_response = self.client.post(
561
+ "http://127.0.0.1:8000/api/test/auth/login",
562
+ auth=("refreshed@misc.test", "password"),
563
+ )
564
+ assert login_response.status_code == 200, login_response.text
565
+ expire_response = self.client.post("http://127.0.0.1:8000/api/test/auth/expire")
566
+ assert expire_response.status_code == 200, expire_response.text
567
+ authenticate_refresh_response = self.client.post(
568
+ "http://127.0.0.1:8000/api/test/auth",
569
+ )
570
+ assert (
571
+ json.loads(authenticate_refresh_response.text)["data"]["refresh"] is True
572
+ ), authenticate_refresh_response.text
573
+ authenticate_response = self.client.post(
574
+ "http://127.0.0.1:8000/api/test/auth",
575
+ ) # Since session refresh handling is complete, it will be returned as a regular session now.
576
+ assert (
577
+ json.loads(authenticate_response.text)["data"]["refresh"] is False
578
+ ), authenticate_response.text