sanic-security 1.11.7__py3-none-any.whl → 1.12.1__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):
@@ -115,7 +128,11 @@ class DeactivatedError(SessionError):
115
128
  Raised when session is deactivated.
116
129
  """
117
130
 
118
- def __init__(self, message: str = "Session is deactivated.", code: int = 401):
131
+ def __init__(
132
+ self,
133
+ message: str = "Session has been deactivated or refreshed.",
134
+ code: int = 401,
135
+ ):
119
136
  super().__init__(message, code)
120
137
 
121
138
 
@@ -125,7 +142,16 @@ class ExpiredError(SessionError):
125
142
  """
126
143
 
127
144
  def __init__(self):
128
- super().__init__("Session has expired")
145
+ super().__init__("Session has expired.")
146
+
147
+
148
+ class NotExpiredError(SessionError):
149
+ """
150
+ Raised when session needs to be expired.
151
+ """
152
+
153
+ def __init__(self):
154
+ super().__init__("Session has not expired yet.", 403)
129
155
 
130
156
 
131
157
  class SecondFactorRequiredError(SessionError):
@@ -173,10 +199,10 @@ class AuthorizationError(SecurityError):
173
199
  super().__init__(message, 403)
174
200
 
175
201
 
176
- class CredentialsError(SecurityError):
202
+ class AnonymousError(AuthorizationError):
177
203
  """
178
- Raised when credentials are invalid.
204
+ Raised when attempting to authorize an anonymous user.
179
205
  """
180
206
 
181
- def __init__(self, message, code=400):
182
- super().__init__(message, code)
207
+ def __init__(self):
208
+ 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
 
@@ -513,10 +524,14 @@ class AuthenticationSession(Session):
513
524
  Used to authenticate and identify a client.
514
525
 
515
526
  Attributes:
527
+ refresh_expiration_date (bool): Date and time the session can no longer be refreshed.
516
528
  requires_second_factor (bool): Determines if session requires a second factor.
529
+ is_refresh (bool): Will only be true once when instantiated during refresh of expired session.
517
530
  """
518
531
 
532
+ refresh_expiration_date: datetime.datetime = fields.DatetimeField(null=True)
519
533
  requires_second_factor: bool = fields.BooleanField(default=False)
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
+ Refreshes session if expired and 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
@@ -16,7 +19,7 @@ from sanic_security.authorization import (
16
19
  check_roles,
17
20
  )
18
21
  from sanic_security.configuration import config as security_config
19
- from sanic_security.exceptions import SecurityError, CredentialsError
22
+ from sanic_security.exceptions import SecurityError
20
23
  from sanic_security.models import Account, CaptchaSession, AuthenticationSession
21
24
  from sanic_security.utils import json
22
25
  from sanic_security.verification import (
@@ -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
  """
@@ -100,7 +110,20 @@ async def on_login(request):
100
110
  )
101
111
  two_step_session.encode(response)
102
112
  else:
103
- response = json("Login successful!", authentication_session.bearer.json)
113
+ response = json("Login successful!", authentication_session.json)
114
+ authentication_session.encode(response)
115
+ return response
116
+
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
+ )
104
127
  authentication_session.encode(response)
105
128
  return response
106
129
 
@@ -125,21 +148,53 @@ async def on_logout(request):
125
148
  Logout of currently logged in account.
126
149
  """
127
150
  authentication_session = await logout(request)
128
- response = json("Logout successful!", authentication_session.bearer.json)
151
+ response = json("Logout successful!", authentication_session.json)
129
152
  return response
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
+ )
140
173
  return response
141
174
 
142
175
 
176
+ @app.on_response
177
+ async def authentication_refresh_encoder(request, response):
178
+ try:
179
+ authentication_session = request.ctx.authentication_session
180
+ if authentication_session.is_refresh:
181
+ authentication_session.encode(response)
182
+ except AttributeError:
183
+ pass
184
+
185
+
186
+ @app.post("api/test/auth/expire")
187
+ @requires_authentication
188
+ async def on_authentication_expire(request):
189
+ """
190
+ Expire client's session.
191
+ """
192
+ authentication_session = request.ctx.authentication_session
193
+ authentication_session.expiration_date = datetime.datetime.now(datetime.UTC)
194
+ await authentication_session.save(update_fields=["expiration_date"])
195
+ return json("Authentication expired!", authentication_session.json)
196
+
197
+
143
198
  @app.post("api/test/auth/associated")
144
199
  @requires_authentication
145
200
  async def on_get_associated_authentication_sessions(request):
@@ -242,16 +297,12 @@ async def on_account_creation(request):
242
297
  """
243
298
  Quick account creation.
244
299
  """
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
300
  account = await Account.create(
250
301
  username=request.form.get("username"),
251
302
  email=request.form.get("email").lower(),
252
303
  password=password_hasher.hash("password"),
253
304
  verified=True,
254
- dbisabled=False,
305
+ disabled=False,
255
306
  )
256
307
  response = json("Account creation successful!", account.json)
257
308
  return response
@@ -262,6 +313,7 @@ async def on_security_error(request, exception):
262
313
  """
263
314
  Handles security errors with correct response.
264
315
  """
316
+ traceback.print_exc()
265
317
  return exception.json
266
318
 
267
319