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.
- sanic_security/authentication.py +140 -136
- sanic_security/authorization.py +50 -42
- sanic_security/configuration.py +26 -20
- sanic_security/exceptions.py +43 -21
- sanic_security/models.py +114 -64
- sanic_security/test/server.py +67 -23
- sanic_security/test/tests.py +80 -15
- sanic_security/utils.py +24 -20
- sanic_security/verification.py +20 -19
- sanic_security-1.12.0.dist-info/LICENSE +21 -0
- {sanic_security-1.11.7.dist-info → sanic_security-1.12.0.dist-info}/METADATA +608 -591
- sanic_security-1.12.0.dist-info/RECORD +16 -0
- {sanic_security-1.11.7.dist-info → sanic_security-1.12.0.dist-info}/WHEEL +2 -1
- sanic_security-1.12.0.dist-info/top_level.txt +1 -0
- sanic_security-1.11.7.dist-info/LICENSE +0 -661
- sanic_security-1.11.7.dist-info/RECORD +0 -15
sanic_security/exceptions.py
CHANGED
@@ -3,21 +3,25 @@ from sanic.exceptions import SanicException
|
|
3
3
|
from sanic_security.utils import json
|
4
4
|
|
5
5
|
"""
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
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,
|
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
|
198
|
+
class AnonymousError(AuthorizationError):
|
177
199
|
"""
|
178
|
-
Raised when
|
200
|
+
Raised when attempting to authorize an anonymous user.
|
179
201
|
"""
|
180
202
|
|
181
|
-
def __init__(self
|
182
|
-
super().__init__(
|
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
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
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
|
-
|
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).
|
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(
|
537
|
-
|
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,
|
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)
|
sanic_security/test/server.py
CHANGED
@@ -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
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
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
|
-
|
139
|
-
|
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
|
-
|
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
|
|
sanic_security/test/tests.py
CHANGED
@@ -7,21 +7,25 @@ import httpx
|
|
7
7
|
from sanic_security.configuration import Config
|
8
8
|
|
9
9
|
"""
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
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
|