sanic-security 1.13.2__py3-none-any.whl → 1.13.4__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.
@@ -2,12 +2,11 @@ import functools
2
2
  import re
3
3
  import warnings
4
4
 
5
- from argon2 import PasswordHasher
6
5
  from argon2.exceptions import VerifyMismatchError
7
6
  from sanic import Sanic
8
7
  from sanic.log import logger
9
8
  from sanic.request import Request
10
- from tortoise.exceptions import DoesNotExist
9
+ from tortoise.exceptions import DoesNotExist, ValidationError, IntegrityError
11
10
 
12
11
  from sanic_security.configuration import config as security_config, DEFAULT_CONFIG
13
12
  from sanic_security.exceptions import (
@@ -18,7 +17,7 @@ from sanic_security.exceptions import (
18
17
  AuditWarning,
19
18
  )
20
19
  from sanic_security.models import Account, AuthenticationSession, Role, TwoStepSession
21
- from sanic_security.utils import get_ip
20
+ from sanic_security.utils import get_ip, password_hasher, secure_headers
22
21
 
23
22
  """
24
23
  Copyright (c) 2020-present Nicholas Aidan Stewart
@@ -42,8 +41,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
42
41
  SOFTWARE.
43
42
  """
44
43
 
45
- password_hasher = PasswordHasher()
46
-
47
44
 
48
45
  async def register(
49
46
  request: Request, verified: bool = False, disabled: bool = False
@@ -62,32 +59,31 @@ async def register(
62
59
  Raises:
63
60
  CredentialsError
64
61
  """
65
- email_lower = validate_email(request.form.get("email").lower())
66
- if await Account.filter(email=email_lower).exists():
67
- raise CredentialsError("An account with this email may already exist.", 409)
68
- elif await Account.filter(
69
- username=validate_username(request.form.get("username"))
70
- ).exists():
71
- raise CredentialsError("An account with this username may already exist.", 409)
72
- elif (
73
- request.form.get("phone")
74
- and await Account.filter(
75
- phone=validate_phone(request.form.get("phone"))
76
- ).exists()
77
- ):
62
+ try:
63
+ account = await Account.create(
64
+ email=request.form.get("email").lower(),
65
+ username=request.form.get("username"),
66
+ password=password_hasher.hash(
67
+ validate_password(request.form.get("password"))
68
+ ),
69
+ phone=request.form.get("phone"),
70
+ verified=verified,
71
+ disabled=disabled,
72
+ )
73
+ logger.info(f"Client {get_ip(request)} has registered account {account.id}.")
74
+ return account
75
+ except ValidationError as e:
78
76
  raise CredentialsError(
79
- "An account with this phone number may already exist.", 409
77
+ "Username must be 3-32 characters long and can only include _ or -."
78
+ if "username" in e.args[0]
79
+ else "Invalid email or phone number."
80
+ )
81
+ except IntegrityError as e:
82
+ raise CredentialsError(
83
+ f"An account with this {"username" if "username" in str(e.args[0]) else "email or phone number"} "
84
+ "may already exist.",
85
+ 409,
80
86
  )
81
- account = await Account.create(
82
- email=email_lower,
83
- username=request.form.get("username"),
84
- password=password_hasher.hash(validate_password(request.form.get("password"))),
85
- phone=request.form.get("phone"),
86
- verified=verified,
87
- disabled=disabled,
88
- )
89
- logger.info(f"Client {get_ip(request)} has registered account {account.id}.")
90
- return account
91
87
 
92
88
 
93
89
  async def login(
@@ -194,8 +190,7 @@ async def fulfill_second_factor(request: Request) -> AuthenticationSession:
194
190
  authentication_session.requires_second_factor = False
195
191
  await authentication_session.save(update_fields=["requires_second_factor"])
196
192
  logger.info(
197
- f"Client {get_ip(request)} has fulfilled authentication session {authentication_session.id} "
198
- "second factor."
193
+ f"Client {get_ip(request)} has fulfilled authentication session {authentication_session.id} second factor."
199
194
  )
200
195
  return authentication_session
201
196
 
@@ -266,65 +261,6 @@ def requires_authentication(arg=None):
266
261
  return decorator(arg) if callable(arg) else decorator
267
262
 
268
263
 
269
- def validate_email(email: str) -> str:
270
- """
271
- Validates email format.
272
-
273
- Args:
274
- email (str): Email being validated.
275
-
276
- Returns:
277
- email
278
-
279
- Raises:
280
- CredentialsError
281
- """
282
- if not re.search(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", email):
283
- raise CredentialsError("Please use a valid email address.", 400)
284
- return email
285
-
286
-
287
- def validate_username(username: str) -> str:
288
- """
289
- Validates username format.
290
-
291
- Args:
292
- username (str): Username being validated.
293
-
294
- Returns:
295
- username
296
-
297
- Raises:
298
- CredentialsError
299
- """
300
- if not re.search(r"^[A-Za-z0-9_-]{3,32}$", username):
301
- raise CredentialsError(
302
- "Username must be between 3-32 characters and not contain any special characters other than _ or -.",
303
- 400,
304
- )
305
- return username
306
-
307
-
308
- def validate_phone(phone: str) -> str:
309
- """
310
- Validates phone number format.
311
-
312
- Args:
313
- phone (str): Phone number being validated.
314
-
315
- Returns:
316
- phone
317
-
318
- Raises:
319
- CredentialsError
320
- """
321
- if phone and not re.search(
322
- r"^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$", phone
323
- ):
324
- raise CredentialsError("Please use a valid phone number.", 400)
325
- return phone
326
-
327
-
328
264
  def validate_password(password: str) -> str:
329
265
  """
330
266
  Validates password requirements.
@@ -348,7 +284,7 @@ def validate_password(password: str) -> str:
348
284
 
349
285
  def initialize_security(app: Sanic, create_root=True) -> None:
350
286
  """
351
- Audits configuration, creates root administrator account, and attaches refresh encoder middleware.
287
+ Audits configuration, creates root administrator account, and attaches response handler middleware.
352
288
 
353
289
  Args:
354
290
  app (Sanic): The main Sanic application instance.
@@ -356,7 +292,8 @@ def initialize_security(app: Sanic, create_root=True) -> None:
356
292
  """
357
293
 
358
294
  @app.on_response
359
- async def refresh_encoder_middleware(request, response):
295
+ async def response_handler_middleware(request, response):
296
+ secure_headers.set_headers(response)
360
297
  if hasattr(request.ctx, "authentication_session"):
361
298
  authentication_session = request.ctx.authentication_session
362
299
  if authentication_session.is_refresh:
@@ -370,8 +307,13 @@ def initialize_security(app: Sanic, create_root=True) -> None:
370
307
  warnings.warn("HttpOnly should be enabled.", AuditWarning)
371
308
  if not security_config.SESSION_SECURE:
372
309
  warnings.warn("Secure should be enabled.", AuditWarning)
373
- if security_config.SESSION_SAMESITE.lower() == "none":
374
- warnings.warn("SameSite should not be set to none.", AuditWarning)
310
+ if (
311
+ not security_config.SESSION_SAMESITE
312
+ or security_config.SESSION_SAMESITE.lower() == "none"
313
+ ):
314
+ warnings.warn("SameSite should not be none.", AuditWarning)
315
+ if not security_config.SESSION_DOMAIN:
316
+ warnings.warn("Domain should not be none.", AuditWarning)
375
317
  if (
376
318
  create_root
377
319
  and security_config.INITIAL_ADMIN_EMAIL
@@ -71,7 +71,7 @@ async def check_permissions(
71
71
  if fnmatch(required_permission, role_permission):
72
72
  return authentication_session
73
73
  logger.warning(
74
- f"Client {get_ip(request)} with account {authentication_session.bearer.id} attempted an unauthorized action. "
74
+ f"Client {get_ip(request)} with account {authentication_session.bearer.id} attempted an unauthorized action."
75
75
  )
76
76
  raise AuthorizationError("Insufficient permissions required for this action.")
77
77
 
@@ -123,14 +123,16 @@ async def assign_role(
123
123
  Args:
124
124
  name (str): The name of the role associated with the account.
125
125
  account (Account): The account associated with the created role.
126
- permissions (str): The permissions of the role associated with the account. Permissions must be separated via comma and in wildcard format.
126
+ permissions (str): The permissions of the role associated with the account. Permissions must be separated via ", " and in wildcard format.
127
127
  description (str): The description of the role associated with the account.
128
128
  """
129
129
  try:
130
130
  role = await Role.filter(name=name).get()
131
131
  except DoesNotExist:
132
132
  role = await Role.create(
133
- description=description, permissions=permissions, name=name
133
+ name=name,
134
+ description=description,
135
+ permissions=permissions,
134
136
  )
135
137
  await account.roles.add(role)
136
138
  return role
@@ -36,6 +36,7 @@ DEFAULT_CONFIG = {
36
36
  "MAX_CHALLENGE_ATTEMPTS": 5,
37
37
  "CAPTCHA_SESSION_EXPIRATION": 60,
38
38
  "CAPTCHA_FONT": "captcha-font.ttf",
39
+ "CAPTCHA_VOICE": "captcha-voice/",
39
40
  "TWO_STEP_SESSION_EXPIRATION": 300,
40
41
  "AUTHENTICATION_SESSION_EXPIRATION": 86400,
41
42
  "AUTHENTICATION_REFRESH_EXPIRATION": 604800,
@@ -62,6 +63,7 @@ class Config(dict):
62
63
  MAX_CHALLENGE_ATTEMPTS (str): The maximum amount of session challenge attempts allowed.
63
64
  CAPTCHA_SESSION_EXPIRATION (int): The amount of seconds till captcha session expiration on creation. Setting to 0 will disable expiration.
64
65
  CAPTCHA_FONT (str): The file path to the font being used for captcha generation.
66
+ CAPTCHA_VOICE (str): The directory of the voice library being used for audio captcha generation.
65
67
  TWO_STEP_SESSION_EXPIRATION (int): The amount of seconds till two-step session expiration on creation. Setting to 0 will disable expiration.
66
68
  AUTHENTICATION_SESSION_EXPIRATION (int): The amount of seconds till authentication session expiration on creation. Setting to 0 will disable expiration.
67
69
  AUTHENTICATION_REFRESH_EXPIRATION (int): The amount of seconds till authentication session refresh expiration. Setting to 0 will disable refresh mechanism.
@@ -82,6 +84,7 @@ class Config(dict):
82
84
  MAX_CHALLENGE_ATTEMPTS: int
83
85
  CAPTCHA_SESSION_EXPIRATION: int
84
86
  CAPTCHA_FONT: str
87
+ CAPTCHA_VOICE: str
85
88
  TWO_STEP_SESSION_EXPIRATION: int
86
89
  AUTHENTICATION_SESSION_EXPIRATION: int
87
90
  AUTHENTICATION_REFRESH_EXPIRATION: int
sanic_security/models.py CHANGED
@@ -1,19 +1,25 @@
1
1
  import base64
2
2
  import datetime
3
- from io import BytesIO
3
+ import re
4
4
  from typing import Union
5
5
 
6
6
  import jwt
7
- from captcha.image import ImageCaptcha
8
7
  from jwt import DecodeError
9
8
  from sanic.request import Request
10
9
  from sanic.response import HTTPResponse, raw
11
10
  from tortoise import fields, Model
12
- from tortoise.exceptions import DoesNotExist
11
+ from tortoise.exceptions import DoesNotExist, ValidationError
12
+ from tortoise.validators import RegexValidator
13
13
 
14
14
  from sanic_security.configuration import config as security_config
15
15
  from sanic_security.exceptions import *
16
- from sanic_security.utils import get_ip, get_code, get_expiration_date
16
+ from sanic_security.utils import (
17
+ get_ip,
18
+ get_code,
19
+ get_expiration_date,
20
+ image_generator,
21
+ audio_generator,
22
+ )
17
23
 
18
24
  """
19
25
  Copyright (c) 2020-present Nicholas Aidan Stewart
@@ -66,7 +72,7 @@ class BaseModel(Model):
66
72
  @property
67
73
  def json(self) -> dict:
68
74
  """
69
- A JSON serializable dict to be used in a HTTP request or response.
75
+ A JSON serializable dict to be used in an HTTP request or response.
70
76
 
71
77
  Example:
72
78
  Below is an example of this method returning a dict to be used for JSON serialization.
@@ -103,9 +109,26 @@ class Account(BaseModel):
103
109
  roles (ManyToManyRelation[Role]): Roles associated with this account.
104
110
  """
105
111
 
106
- username: str = fields.CharField(unique=True, max_length=32)
107
- email: str = fields.CharField(unique=True, max_length=255)
108
- phone: str = fields.CharField(unique=True, max_length=14, null=True)
112
+ username: str = fields.CharField(
113
+ unique=security_config.ALLOW_LOGIN_WITH_USERNAME,
114
+ max_length=32,
115
+ validators=[RegexValidator(r"^[A-Za-z0-9_-]*$", re.I)],
116
+ )
117
+ email: str = fields.CharField(
118
+ unique=True,
119
+ max_length=255,
120
+ validators=[
121
+ RegexValidator(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", re.I)
122
+ ],
123
+ )
124
+ phone: str = fields.CharField(
125
+ unique=True,
126
+ max_length=15,
127
+ null=True,
128
+ validators=[
129
+ RegexValidator(r"^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$", re.I)
130
+ ],
131
+ )
109
132
  password: str = fields.CharField(max_length=255)
110
133
  disabled: bool = fields.BooleanField(default=False)
111
134
  verified: bool = fields.BooleanField(default=False)
@@ -170,9 +193,8 @@ class Account(BaseModel):
170
193
  NotFoundError
171
194
  """
172
195
  try:
173
- account = await Account.filter(email=email, deleted=False).get()
174
- return account
175
- except DoesNotExist:
196
+ return await Account.filter(email=email, deleted=False).get()
197
+ except (DoesNotExist, ValidationError):
176
198
  raise NotFoundError("Account with this email does not exist.")
177
199
 
178
200
  @staticmethod
@@ -190,9 +212,8 @@ class Account(BaseModel):
190
212
  NotFoundError
191
213
  """
192
214
  try:
193
- account = await Account.filter(username=username, deleted=False).get()
194
- return account
195
- except DoesNotExist:
215
+ return await Account.filter(username=username, deleted=False).get()
216
+ except (DoesNotExist, ValidationError):
196
217
  raise NotFoundError("Account with this username does not exist.")
197
218
 
198
219
  @staticmethod
@@ -262,9 +283,8 @@ class Account(BaseModel):
262
283
  NotFoundError
263
284
  """
264
285
  try:
265
- account = await Account.filter(phone=phone, deleted=False).get()
266
- return account
267
- except DoesNotExist:
286
+ return await Account.filter(phone=phone, deleted=False).get()
287
+ except (DoesNotExist, ValidationError):
268
288
  raise NotFoundError("Account with this phone number does not exist.")
269
289
 
270
290
 
@@ -348,19 +368,14 @@ class Session(BaseModel):
348
368
  httponly=security_config.SESSION_HTTPONLY,
349
369
  samesite=security_config.SESSION_SAMESITE,
350
370
  secure=security_config.SESSION_SECURE,
351
- )
352
- if self.expiration_date: # Overrides refresh expiration.
353
- response.cookies.get_cookie(cookie).expires = (
371
+ domain=security_config.SESSION_DOMAIN,
372
+ expires=(
354
373
  self.refresh_expiration_date
355
- if (
356
- hasattr(self, "refresh_expiration_date")
357
- and self.refresh_expiration_date
358
- )
374
+ if hasattr(self, "refresh_expiration_date")
375
+ and self.refresh_expiration_date
359
376
  else self.expiration_date
360
- )
361
-
362
- if security_config.SESSION_DOMAIN:
363
- response.cookies.get_cookie(cookie).domain = security_config.SESSION_DOMAIN
377
+ ),
378
+ )
364
379
 
365
380
  @property
366
381
  def json(self) -> dict:
@@ -369,9 +384,7 @@ class Session(BaseModel):
369
384
  "date_created": str(self.date_created),
370
385
  "date_updated": str(self.date_updated),
371
386
  "expiration_date": str(self.expiration_date),
372
- "bearer": (
373
- self.bearer.username if isinstance(self.bearer, Account) else None
374
- ),
387
+ "bearer": self.bearer.id if isinstance(self.bearer, Account) else None,
375
388
  "active": self.active,
376
389
  }
377
390
 
@@ -491,7 +504,7 @@ class VerificationSession(Session):
491
504
  """
492
505
 
493
506
  attempts: int = fields.IntField(default=0)
494
- code: str = fields.CharField(max_length=10, default=get_code, null=True)
507
+ code: str = fields.CharField(max_length=6, default=get_code, null=True)
495
508
 
496
509
  async def check_code(self, code: str) -> None:
497
510
  """
@@ -562,10 +575,21 @@ class CaptchaSession(VerificationSession):
562
575
  Returns:
563
576
  captcha_image
564
577
  """
565
- image = ImageCaptcha(190, 90, fonts=[security_config.CAPTCHA_FONT])
566
- with BytesIO() as output:
567
- image.generate_image(self.code).save(output, format="JPEG")
568
- return raw(output.getvalue(), content_type="image/jpeg")
578
+ return raw(
579
+ image_generator.generate(self.code, "jpeg").getvalue(),
580
+ content_type="image/jpeg",
581
+ )
582
+
583
+ def get_audio(self) -> HTTPResponse:
584
+ """
585
+ Retrieves captcha audio file.
586
+
587
+ Returns:
588
+ captcha_audio
589
+ """
590
+ return raw(
591
+ bytes(audio_generator.generate(self.code)), content_type="audio/mpeg"
592
+ )
569
593
 
570
594
  class Meta:
571
595
  table = "captcha_session"
@@ -195,9 +195,14 @@ async def on_captcha_request(request):
195
195
  async def on_captcha_image(request):
196
196
  """Request captcha image."""
197
197
  captcha_session = await CaptchaSession.decode(request)
198
- response = captcha_session.get_image()
199
- captcha_session.encode(response)
200
- return response
198
+ return captcha_session.get_image()
199
+
200
+
201
+ @app.get("api/test/capt/audio")
202
+ async def on_captcha_audio(request):
203
+ """Request captcha audio."""
204
+ captcha_session = await CaptchaSession.decode(request)
205
+ return captcha_session.get_audio()
201
206
 
202
207
 
203
208
  @app.post("api/test/capt")
sanic_security/utils.py CHANGED
@@ -2,8 +2,14 @@ import datetime
2
2
  import random
3
3
  import string
4
4
 
5
+ from argon2 import PasswordHasher
6
+ from captcha.audio import AudioCaptcha
7
+ from captcha.image import ImageCaptcha
5
8
  from sanic.request import Request
6
9
  from sanic.response import json as sanic_json, HTTPResponse
10
+ from secure import Secure
11
+
12
+ from sanic_security.configuration import config
7
13
 
8
14
  """
9
15
  Copyright (c) 2020-Present Nicholas Aidan Stewart
@@ -27,6 +33,13 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
33
  SOFTWARE.
28
34
  """
29
35
 
36
+ image_generator = ImageCaptcha(
37
+ 190, 90, fonts=config.CAPTCHA_FONT.replace(" ", "").split(",")
38
+ )
39
+ audio_generator = AudioCaptcha(voicedir=config.CAPTCHA_VOICE)
40
+ password_hasher = PasswordHasher()
41
+ secure_headers = Secure.with_default_headers()
42
+
30
43
 
31
44
  def get_ip(request: Request) -> str:
32
45
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: sanic-security
3
- Version: 1.13.2
3
+ Version: 1.13.4
4
4
  Summary: An async security library for the Sanic framework.
5
5
  Author-email: Aidan Stewart <me@na-stewart.com>
6
6
  Project-URL: Documentation, https://security.na-stewart.com/
@@ -20,6 +20,7 @@ Requires-Dist: captcha>=0.4
20
20
  Requires-Dist: pillow>=9.5.0
21
21
  Requires-Dist: argon2-cffi>=20.1.0
22
22
  Requires-Dist: sanic>=21.3.0
23
+ Requires-Dist: secure>=1.0.1
23
24
  Provides-Extra: crypto
24
25
  Requires-Dist: cryptography>=3.3.1; extra == "crypto"
25
26
  Provides-Extra: dev
@@ -63,7 +64,7 @@ Requires-Dist: cryptography; extra == "dev"
63
64
  * [Configuration](#configuration)
64
65
  * [Usage](#usage)
65
66
  * [Authentication](#authentication)
66
- * [Captcha](#captcha)
67
+ * [CAPTCHA](#captcha)
67
68
  * [Two-step Verification](#two-step-verification)
68
69
  * [Authorization](#authorization)
69
70
  * [Testing](#testing)
@@ -76,21 +77,22 @@ Requires-Dist: cryptography; extra == "dev"
76
77
  <!-- ABOUT THE PROJECT -->
77
78
  ## About The Project
78
79
 
79
- Sanic Security is an authentication, authorization, and verification library designed for use with [Sanic](https://github.com/huge-success/sanic).
80
+ Sanic Security is an authentication, authorization, and verification library designed for use with the
81
+ [Sanic](https://github.com/huge-success/sanic) framework.
80
82
 
81
83
  * Login, registration, and authentication with refresh mechanisms
82
84
  * Role based authorization with wildcard permissions
85
+ * Image & audio CAPTCHA
83
86
  * Two-factor authentication
84
87
  * Two-step verification
85
- * Captcha
86
- * Logging
88
+ * Logging & auditing
87
89
 
88
90
  Visit [security.na-stewart.com](https://security.na-stewart.com) for documentation.
89
91
 
90
92
  <!-- GETTING STARTED -->
91
93
  ## Getting Started
92
94
 
93
- In order to get started, please install [Pip](https://pypi.org/).
95
+ In order to get started, please install [PyPI](https://pypi.org/).
94
96
 
95
97
  ### Installation
96
98
 
@@ -109,14 +111,9 @@ as an extra requirement.
109
111
  pip3 install sanic-security[crypto]
110
112
  ````
111
113
 
112
- * For developers, fork Sanic Security and install development dependencies.
113
- ```shell
114
- pip3 install -e ".[dev]"
115
- ````
116
-
117
114
  * Update sanic-security if already installed.
118
115
  ```shell
119
- pip3 install --upgrade sanic-security
116
+ pip3 install sanic-security --upgrade
120
117
  ```
121
118
 
122
119
  ### Configuration
@@ -154,11 +151,12 @@ You can load environment variables with a different prefix via `config.load_envi
154
151
  | **SESSION_PREFIX** | tkn | Prefix attached to the beginning of session cookies. |
155
152
  | **MAX_CHALLENGE_ATTEMPTS** | 5 | The maximum amount of session challenge attempts allowed. |
156
153
  | **CAPTCHA_SESSION_EXPIRATION** | 60 | The amount of seconds till captcha session expiration on creation. Setting to 0 will disable expiration. |
157
- | **CAPTCHA_FONT** | captcha-font.ttf | The file path to the font being used for captcha generation. |
154
+ | **CAPTCHA_FONT** | captcha-font.ttf | The file path to the font being used for captcha generation. Several fonts can be used by separating them via comma. |
155
+ | **CAPTCHA_VOICE** | captcha-voice/ | The directory of the voice library being used for audio captcha generation. |
158
156
  | **TWO_STEP_SESSION_EXPIRATION** | 200 | The amount of seconds till two-step session expiration on creation. Setting to 0 will disable expiration. |
159
157
  | **AUTHENTICATION_SESSION_EXPIRATION** | 86400 | The amount of seconds till authentication session expiration on creation. Setting to 0 will disable expiration. |
160
158
  | **AUTHENTICATION_REFRESH_EXPIRATION** | 604800 | The amount of seconds till authentication refresh expiration. Setting to 0 will disable refresh mechanism. |
161
- | **ALLOW_LOGIN_WITH_USERNAME** | False | Allows login via username and email. |
159
+ | **ALLOW_LOGIN_WITH_USERNAME** | False | Allows login via username; unique constraint is disabled when set to false. |
162
160
  | **INITIAL_ADMIN_EMAIL** | admin@example.com | Email used when creating the initial admin account. |
163
161
  | **INITIAL_ADMIN_PASSWORD** | admin123 | Password used when creating the initial admin account. |
164
162
 
@@ -319,16 +317,40 @@ async def on_authenticate(request):
319
317
  return response
320
318
  ```
321
319
 
322
- ## Captcha
320
+ ## CAPTCHA
321
+
322
+ Protects against spam and malicious activities by ensuring that only real humans can complete certain actions, like
323
+ submitting a form or creating an account.
323
324
 
324
- A pre-existing font for captcha challenges is included in the Sanic Security repository. You may set your own font by
325
- downloading a .ttf font and defining the file's path in the configuration.
325
+ * Fonts
326
+
327
+ A font for captcha challenges is included in the repository. You can set a custom font by downloading a .ttf file and
328
+ specifying its path in the configuration.
326
329
 
327
330
  [1001 Free Fonts](https://www.1001fonts.com/)
328
331
 
329
- [Recommended Font](https://www.1001fonts.com/source-sans-pro-font.html)
332
+ * Voice Library
333
+
334
+ A voice library for captcha challenges is included in the repository. You can generate your own using `espeak` and
335
+ `ffmpeg`, then specify the library's directory in the configuration.
330
336
 
331
- * Request Captcha
337
+ ```bash
338
+ # Set the language code
339
+ export ESLANG=en
340
+
341
+ # Create a directory for the specified language code
342
+ mkdir "$ESLANG"
343
+
344
+ # Loop through each character (A-Z, 0-9) and create a directory for each
345
+ for i in {A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z,0,1,2,3,4,5,6,7,8,9}; do
346
+ mkdir "$ESLANG/$i"
347
+ espeak -a 150 -s 100 -p 15 -v "$ESLANG" "$i" -w "$ESLANG/$i/orig_default.wav"
348
+ ffmpeg -i "$ESLANG/$i/orig_default.wav" -ar 8000 -ac 1 -acodec pcm_u8 "$ESLANG/$i/default.wav"
349
+ rm "$ESLANG/$i/orig_default.wav"
350
+ done
351
+ ```
352
+
353
+ * Request CAPTCHA
332
354
 
333
355
  ```python
334
356
  @app.get("api/security/captcha")
@@ -339,7 +361,16 @@ async def on_captcha_img_request(request):
339
361
  return response
340
362
  ```
341
363
 
342
- * Captcha
364
+ * Request CAPTCHA Audio
365
+
366
+ ```python
367
+ @app.get("api/security/captcha/audio")
368
+ async def on_captcha_audio_request(request):
369
+ captcha_session = await CaptchaSession.decode(request)
370
+ return captcha_session.get_audio() # Captcha: LJ0F3U
371
+ ```
372
+
373
+ * Attempt CAPTCHA
343
374
 
344
375
  | Key | Value |
345
376
  |-------------|--------|
@@ -352,7 +383,7 @@ async def on_captcha(request):
352
383
  return json("Captcha attempt successful!", captcha_session.json)
353
384
  ```
354
385
 
355
- * Requires Captcha (This method is not called directly and instead used as a decorator)
386
+ * Requires CAPTCHA (This method is not called directly and instead used as a decorator)
356
387
 
357
388
  | Key | Value |
358
389
  |-------------|--------|
@@ -367,7 +398,7 @@ async def on_captcha(request):
367
398
 
368
399
  ## Two-step Verification
369
400
 
370
- Two-step verification should be integrated with other custom functionality. For example, account verification during registration.
401
+ Two-step verification should be integrated with other custom functionalities, such as account verification during registration.
371
402
 
372
403
  * Request Two-step Verification
373
404
 
@@ -399,7 +430,7 @@ async def on_two_step_resend(request):
399
430
  return json("Verification code resend successful!", two_step_session.json)
400
431
  ```
401
432
 
402
- * Two-step Verification
433
+ * Attempt Two-step Verification
403
434
 
404
435
  | Key | Value |
405
436
  |----------|--------|
@@ -442,13 +473,14 @@ user's account; this simplifies common operations, such as adding a user, or cha
442
473
 
443
474
  Wildcard permissions support the concept of multiple levels or parts. For example, you could grant a user the permission
444
475
  `printer:query`, `printer:query,delete`, or `printer:*`.
476
+
445
477
  * Assign Role
446
478
 
447
479
  ```python
448
480
  await assign_role(
449
481
  "Chat Room Moderator",
450
482
  account,
451
- "channels:view,delete, account:suspend,mute, voice:*",
483
+ "channels:view,delete, voice:*, account:suspend,mute",
452
484
  "Can read and delete messages in all chat rooms, suspend and mute accounts, and control voice chat.",
453
485
  )
454
486
  ```
@@ -497,7 +529,7 @@ async def on_check_roles(request):
497
529
 
498
530
  * Make sure the test Sanic instance (`test/server.py`) is running on your machine.
499
531
 
500
- * Run the unit test client (`test/tests.py`) for results.
532
+ * Run the test client (`test/tests.py`) for results.
501
533
 
502
534
  ## Tortoise
503
535
 
@@ -0,0 +1,16 @@
1
+ sanic_security/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ sanic_security/authentication.py,sha256=6pOiZJmnej0no-at7irkrh8YCupAdJVQOJEgfT2cc5k,13214
3
+ sanic_security/authorization.py,sha256=ddJWqGJbFIqII5pUW5SxI7h4EyVB-EhrbGM7jsQutOI,7559
4
+ sanic_security/configuration.py,sha256=h-Kh4PalJpjbDcZvVHCzxX5l-GnldP3Fr8OlgGCZNHY,5680
5
+ sanic_security/exceptions.py,sha256=JiCaBR2kjE1Cj0fc_08y-32fqJJXa_1UCw205T4_RTY,5493
6
+ sanic_security/models.py,sha256=bK5daR6Iq7V7aqNSzksH6DGrCXMj2e4feNRhlxlFQMg,22722
7
+ sanic_security/utils.py,sha256=tgewsCAkNl_NkobHaDlZNIgVopQPiD8SWb6UC6tBYNs,3151
8
+ sanic_security/verification.py,sha256=js2PkqJU6o46atslJ76n-_cYoY5iz5fbyiV0OFwoySo,8668
9
+ sanic_security/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ sanic_security/test/server.py,sha256=Rh_L12HPCfagvAyqkHziBD1C4WHAKZ9ht4mTCpX2Yik,12240
11
+ sanic_security/test/tests.py,sha256=bW5fHJfsCrg8eBmcSqVMLm0R5XRL1ou-XJJRgz09GOE,21993
12
+ sanic_security-1.13.4.dist-info/LICENSE,sha256=sXlJs9_mG-dCkPfWsDnuzydJWagS82E2gYtkVH9enHA,1100
13
+ sanic_security-1.13.4.dist-info/METADATA,sha256=T_LTsAXGsFWZFSntcTzKoEaoPRpz1JIHwLjcPc0Havc,24248
14
+ sanic_security-1.13.4.dist-info/WHEEL,sha256=R06PA3UVYHThwHvxuRWMqaGcr-PuniXahwjmQRFMEkY,91
15
+ sanic_security-1.13.4.dist-info/top_level.txt,sha256=ZybkhHXSjfzhmv8XeqLvnNmLmv21Z0oPX6Ep4DJN8b0,15
16
+ sanic_security-1.13.4.dist-info/RECORD,,
@@ -1,16 +0,0 @@
1
- sanic_security/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- sanic_security/authentication.py,sha256=h0Yq2hWFv1GZoqhPQBmoJnqywGQd6fOYu7zyaqfv6wQ,14432
3
- sanic_security/authorization.py,sha256=1SHx4cU_ibC0o_nEDDYURH_l_K6Q66M0SLzpRQrsIXc,7534
4
- sanic_security/configuration.py,sha256=br2hI3MHsTBh3yfPer5f3bkKSWfQdCeqfLqWmaDNVoM,5510
5
- sanic_security/exceptions.py,sha256=JiCaBR2kjE1Cj0fc_08y-32fqJJXa_1UCw205T4_RTY,5493
6
- sanic_security/models.py,sha256=l3cO5-S1bmE0tqhFXBC9z4IWTCnYNeRvw1VzIFLeaHE,22363
7
- sanic_security/utils.py,sha256=XAUNalcTi53qTz0D8xiDyDyRlq7Z7ffNBzUONJZqe90,2705
8
- sanic_security/verification.py,sha256=js2PkqJU6o46atslJ76n-_cYoY5iz5fbyiV0OFwoySo,8668
9
- sanic_security/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- sanic_security/test/server.py,sha256=79HaNIH1skWTrh2gIbh8WWVNxvYqPA5GlQ8AqRaCsXQ,12094
11
- sanic_security/test/tests.py,sha256=bW5fHJfsCrg8eBmcSqVMLm0R5XRL1ou-XJJRgz09GOE,21993
12
- sanic_security-1.13.2.dist-info/LICENSE,sha256=sXlJs9_mG-dCkPfWsDnuzydJWagS82E2gYtkVH9enHA,1100
13
- sanic_security-1.13.2.dist-info/METADATA,sha256=mcHljpRvZGGTk_hRi2Sf-lqF-QyZIrhn_lNZGkbNjUI,23011
14
- sanic_security-1.13.2.dist-info/WHEEL,sha256=R06PA3UVYHThwHvxuRWMqaGcr-PuniXahwjmQRFMEkY,91
15
- sanic_security-1.13.2.dist-info/top_level.txt,sha256=ZybkhHXSjfzhmv8XeqLvnNmLmv21Z0oPX6Ep4DJN8b0,15
16
- sanic_security-1.13.2.dist-info/RECORD,,