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.
@@ -15,111 +15,35 @@ from sanic_security.exceptions import (
15
15
  CredentialsError,
16
16
  DeactivatedError,
17
17
  SecondFactorFulfilledError,
18
+ ExpiredError,
18
19
  )
19
20
  from sanic_security.models import Account, AuthenticationSession, Role, TwoStepSession
20
- from sanic_security.utils import get_ip
21
21
 
22
22
  """
23
- An effective, simple, and async security library for the Sanic framework.
24
- Copyright (C) 2020-present Aidan Stewart
25
-
26
- This program is free software: you can redistribute it and/or modify
27
- it under the terms of the GNU Affero General Public License as published
28
- by the Free Software Foundation, either version 3 of the License, or
29
- (at your option) any later version.
30
-
31
- This program is distributed in the hope that it will be useful,
32
- but WITHOUT ANY WARRANTY; without even the implied warranty of
33
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
34
- GNU Affero General Public License for more details.
35
-
36
- You should have received a copy of the GNU Affero General Public License
37
- along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+ Copyright (c) 2020-present Nicholas Aidan Stewart
24
+
25
+ Permission is hereby granted, free of charge, to any person obtaining a copy
26
+ of this software and associated documentation files (the "Software"), to deal
27
+ in the Software without restriction, including without limitation the rights
28
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
29
+ copies of the Software, and to permit persons to whom the Software is
30
+ furnished to do so, subject to the following conditions:
31
+
32
+ The above copyright notice and this permission notice shall be included in all
33
+ copies or substantial portions of the Software.
34
+
35
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
36
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
37
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
38
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
39
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
40
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
41
+ SOFTWARE.
38
42
  """
39
43
 
40
44
  password_hasher = PasswordHasher()
41
45
 
42
46
 
43
- def validate_email(email: str) -> str:
44
- """
45
- Validates email format.
46
-
47
- Args:
48
- email (str): Email being validated.
49
-
50
- Returns:
51
- email
52
-
53
- Raises:
54
- CredentialsError
55
- """
56
- if not re.search(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", email):
57
- raise CredentialsError("Please use a valid email address.", 400)
58
- return email
59
-
60
-
61
- def validate_username(username: str) -> str:
62
- """
63
- Validates username format.
64
-
65
- Args:
66
- username (str): Username being validated.
67
-
68
- Returns:
69
- username
70
-
71
- Raises:
72
- CredentialsError
73
- """
74
- if not re.search(r"^[A-Za-z0-9_-]{3,32}$", username):
75
- raise CredentialsError(
76
- "Username must be between 3-32 characters and not contain any special characters other than _ or -.",
77
- 400,
78
- )
79
- return username
80
-
81
-
82
- def validate_phone(phone: str) -> str:
83
- """
84
- Validates phone number format.
85
-
86
- Args:
87
- phone (str): Phone number being validated.
88
-
89
- Returns:
90
- phone
91
-
92
- Raises:
93
- CredentialsError
94
- """
95
- if phone and not re.search(
96
- r"^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$", phone
97
- ):
98
- raise CredentialsError("Please use a valid phone number.", 400)
99
- return phone
100
-
101
-
102
- def validate_password(password: str) -> str:
103
- """
104
- Validates password requirements.
105
-
106
- Args:
107
- password (str): Password being validated.
108
-
109
- Returns:
110
- password
111
-
112
- Raises:
113
- CredentialsError
114
- """
115
- if not re.search(r"^(?=.*[A-Z])(?=.*\d)(?=.*[@#$%^&+=!]).*$", password):
116
- raise CredentialsError(
117
- "Password must contain one capital letter, one number, and one special character",
118
- 400,
119
- )
120
- return password
121
-
122
-
123
47
  async def register(
124
48
  request: Request, verified: bool = False, disabled: bool = False
125
49
  ) -> Account:
@@ -139,18 +63,20 @@ async def register(
139
63
  """
140
64
  email_lower = validate_email(request.form.get("email").lower())
141
65
  if await Account.filter(email=email_lower).exists():
142
- raise CredentialsError("An account with this email already exists.", 409)
66
+ raise CredentialsError("An account with this email may already exist.", 409)
143
67
  elif await Account.filter(
144
68
  username=validate_username(request.form.get("username"))
145
69
  ).exists():
146
- raise CredentialsError("An account with this username already exists.", 409)
70
+ raise CredentialsError("An account with this username may already exist.", 409)
147
71
  elif (
148
72
  request.form.get("phone")
149
73
  and await Account.filter(
150
74
  phone=validate_phone(request.form.get("phone"))
151
75
  ).exists()
152
76
  ):
153
- raise CredentialsError("An account with this phone number already exists.", 409)
77
+ raise CredentialsError(
78
+ "An account with this phone number may already exist.", 409
79
+ )
154
80
  validate_password(request.form.get("password"))
155
81
  account = await Account.create(
156
82
  email=email_lower,
@@ -212,9 +138,6 @@ async def login(
212
138
  request, account, requires_second_factor=require_second_factor
213
139
  )
214
140
  except VerifyMismatchError:
215
- logger.warning(
216
- f"Client ({get_ip(request)}) login password attempt is incorrect"
217
- )
218
141
  raise CredentialsError("Incorrect password.", 401)
219
142
 
220
143
 
@@ -241,32 +164,6 @@ async def logout(request: Request) -> AuthenticationSession:
241
164
  return authentication_session
242
165
 
243
166
 
244
- async def authenticate(request: Request) -> AuthenticationSession:
245
- """
246
- Validates client's authentication session and account.
247
-
248
- Args:
249
- request (Request): Sanic request parameter.
250
-
251
- Returns:
252
- authentication_session
253
-
254
- Raises:
255
- NotFoundError
256
- JWTDecodeError
257
- DeletedError
258
- ExpiredError
259
- DeactivatedError
260
- UnverifiedError
261
- DisabledError
262
- SecondFactorRequiredError
263
- """
264
- authentication_session = await AuthenticationSession.decode(request)
265
- authentication_session.validate()
266
- authentication_session.bearer.validate()
267
- return authentication_session
268
-
269
-
270
167
  async def fulfill_second_factor(request: Request) -> AuthenticationSession:
271
168
  """
272
169
  Fulfills client authentication session's second factor requirement via two-step session code.
@@ -288,9 +185,9 @@ async def fulfill_second_factor(request: Request) -> AuthenticationSession:
288
185
  authentication_Session
289
186
  """
290
187
  authentication_session = await AuthenticationSession.decode(request)
291
- two_step_session = await TwoStepSession.decode(request)
292
188
  if not authentication_session.requires_second_factor:
293
189
  raise SecondFactorFulfilledError()
190
+ two_step_session = await TwoStepSession.decode(request)
294
191
  two_step_session.validate()
295
192
  await two_step_session.check_code(request, request.form.get("code"))
296
193
  authentication_session.requires_second_factor = False
@@ -298,9 +195,40 @@ async def fulfill_second_factor(request: Request) -> AuthenticationSession:
298
195
  return authentication_session
299
196
 
300
197
 
198
+ async def authenticate(request: Request) -> tuple[bool, AuthenticationSession]:
199
+ """
200
+ Validates client's authentication session and account. New/Refreshed session automatically returned
201
+ if expired during authentication, requires encoding.
202
+
203
+ Args:
204
+ request (Request): Sanic request parameter.
205
+
206
+ Returns:
207
+ authentication_session
208
+
209
+ Raises:
210
+ NotFoundError
211
+ JWTDecodeError
212
+ DeletedError
213
+ DeactivatedError
214
+ UnverifiedError
215
+ DisabledError
216
+ SecondFactorRequiredError
217
+ """
218
+ authentication_session = await AuthenticationSession.decode(request)
219
+ try:
220
+ authentication_session.validate()
221
+ if not authentication_session.anonymous:
222
+ authentication_session.bearer.validate()
223
+ except ExpiredError:
224
+ authentication_session = await authentication_session.refresh(request)
225
+ return authentication_session
226
+
227
+
301
228
  def requires_authentication(arg=None):
302
229
  """
303
- Validates client's authentication session and account.
230
+ Validates client's authentication session and account. New/Refreshed session automatically returned if expired
231
+ during authentication, requires encoding.
304
232
 
305
233
  Example:
306
234
  This method is not called directly and instead used as a decorator:
@@ -314,7 +242,6 @@ def requires_authentication(arg=None):
314
242
  NotFoundError
315
243
  JWTDecodeError
316
244
  DeletedError
317
- ExpiredError
318
245
  DeactivatedError
319
246
  UnverifiedError
320
247
  DisabledError
@@ -328,10 +255,7 @@ def requires_authentication(arg=None):
328
255
 
329
256
  return wrapper
330
257
 
331
- if callable(arg):
332
- return decorator(arg)
333
- else:
334
- return decorator
258
+ return decorator(arg) if callable(arg) else decorator
335
259
 
336
260
 
337
261
  def create_initial_admin_account(app: Sanic) -> None:
@@ -343,7 +267,7 @@ def create_initial_admin_account(app: Sanic) -> None:
343
267
  """
344
268
 
345
269
  @app.listener("before_server_start")
346
- async def generate(app, loop):
270
+ async def create(app, loop):
347
271
  try:
348
272
  role = await Role.filter(name="Head Admin").get()
349
273
  except DoesNotExist:
@@ -371,3 +295,83 @@ def create_initial_admin_account(app: Sanic) -> None:
371
295
  )
372
296
  await account.roles.add(role)
373
297
  logger.info("Initial admin account created.")
298
+
299
+
300
+ def validate_email(email: str) -> str:
301
+ """
302
+ Validates email format.
303
+
304
+ Args:
305
+ email (str): Email being validated.
306
+
307
+ Returns:
308
+ email
309
+
310
+ Raises:
311
+ CredentialsError
312
+ """
313
+ if not re.search(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", email):
314
+ raise CredentialsError("Please use a valid email address.", 400)
315
+ return email
316
+
317
+
318
+ def validate_username(username: str) -> str:
319
+ """
320
+ Validates username format.
321
+
322
+ Args:
323
+ username (str): Username being validated.
324
+
325
+ Returns:
326
+ username
327
+
328
+ Raises:
329
+ CredentialsError
330
+ """
331
+ if not re.search(r"^[A-Za-z0-9_-]{3,32}$", username):
332
+ raise CredentialsError(
333
+ "Username must be between 3-32 characters and not contain any special characters other than _ or -.",
334
+ 400,
335
+ )
336
+ return username
337
+
338
+
339
+ def validate_phone(phone: str) -> str:
340
+ """
341
+ Validates phone number format.
342
+
343
+ Args:
344
+ phone (str): Phone number being validated.
345
+
346
+ Returns:
347
+ phone
348
+
349
+ Raises:
350
+ CredentialsError
351
+ """
352
+ if phone and not re.search(
353
+ r"^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$", phone
354
+ ):
355
+ raise CredentialsError("Please use a valid phone number.", 400)
356
+ return phone
357
+
358
+
359
+ def validate_password(password: str) -> str:
360
+ """
361
+ Validates password requirements.
362
+
363
+ Args:
364
+ password (str): Password being validated.
365
+
366
+ Returns:
367
+ password
368
+
369
+ Raises:
370
+ CredentialsError
371
+ """
372
+ if not re.search(r"^(?=.*[A-Z])(?=.*\d)(?=.*[@#$%^&+=!]).*$", password):
373
+ raise CredentialsError(
374
+ "Password must contain one capital letter, one number, and one special character",
375
+ 400,
376
+ )
377
+ return password
@@ -1,31 +1,33 @@
1
1
  import functools
2
- import logging
3
2
  from fnmatch import fnmatch
4
3
 
5
4
  from sanic.request import Request
6
5
  from tortoise.exceptions import DoesNotExist
7
6
 
8
7
  from sanic_security.authentication import authenticate
9
- from sanic_security.exceptions import AuthorizationError
8
+ from sanic_security.exceptions import AuthorizationError, AnonymousError
10
9
  from sanic_security.models import Role, Account, AuthenticationSession
11
- from sanic_security.utils import get_ip
12
10
 
13
11
  """
14
- An effective, simple, and async security library for the Sanic framework.
15
- Copyright (C) 2020-present Aidan Stewart
16
-
17
- This program is free software: you can redistribute it and/or modify
18
- it under the terms of the GNU Affero General Public License as published
19
- by the Free Software Foundation, either version 3 of the License, or
20
- (at your option) any later version.
21
-
22
- This program is distributed in the hope that it will be useful,
23
- but WITHOUT ANY WARRANTY; without even the implied warranty of
24
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
25
- GNU Affero General Public License for more details.
26
-
27
- You should have received a copy of the GNU Affero General Public License
28
- along with this program. If not, see <https://www.gnu.org/licenses/>.
12
+ Copyright (c) 2020-present Nicholas Aidan Stewart
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
29
31
  """
30
32
 
31
33
 
@@ -51,8 +53,11 @@ async def check_permissions(
51
53
  UnverifiedError
52
54
  DisabledError
53
55
  AuthorizationError
56
+ AnonymousError
54
57
  """
55
58
  authentication_session = await authenticate(request)
59
+ if authentication_session.anonymous:
60
+ raise AnonymousError()
56
61
  roles = await authentication_session.bearer.roles.filter(deleted=False).all()
57
62
  for role in roles:
58
63
  for required_permission, role_permission in zip(
@@ -60,7 +65,6 @@ async def check_permissions(
60
65
  ):
61
66
  if fnmatch(required_permission, role_permission):
62
67
  return authentication_session
63
- logging.warning(f"Client ({get_ip(request)}) has insufficient permissions.")
64
68
  raise AuthorizationError("Insufficient permissions required for this action.")
65
69
 
66
70
 
@@ -84,16 +88,40 @@ async def check_roles(request: Request, *required_roles: str) -> AuthenticationS
84
88
  UnverifiedError
85
89
  DisabledError
86
90
  AuthorizationError
91
+ AnonymousError
87
92
  """
88
93
  authentication_session = await authenticate(request)
94
+ if authentication_session.anonymous:
95
+ raise AnonymousError()
89
96
  roles = await authentication_session.bearer.roles.filter(deleted=False).all()
90
97
  for role in roles:
91
98
  if role.name in required_roles:
92
99
  return authentication_session
93
- logging.warning(f"Client ({get_ip(request)}) has insufficient roles.")
94
100
  raise AuthorizationError("Insufficient roles required for this action.")
95
101
 
96
102
 
103
+ async def assign_role(
104
+ name: str, account: Account, permissions: str = None, description: str = None
105
+ ) -> Role:
106
+ """
107
+ Easy account role assignment. Role being assigned to an account will be created if it doesn't exist.
108
+
109
+ Args:
110
+ name (str): The name of the role associated with the account.
111
+ account (Account): The account associated with the created role.
112
+ permissions (str): The permissions of the role associated with the account. Permissions must be separated via comma and in wildcard format.
113
+ description (str): The description of the role associated with the account.
114
+ """
115
+ try:
116
+ role = await Role.filter(name=name).get()
117
+ except DoesNotExist:
118
+ role = await Role.create(
119
+ description=description, permissions=permissions, name=name
120
+ )
121
+ await account.roles.add(role)
122
+ return role
123
+
124
+
97
125
  def require_permissions(*required_permissions: str):
98
126
  """
99
127
  Authenticates client and determines if the account has sufficient permissions for an action.
@@ -118,6 +146,7 @@ def require_permissions(*required_permissions: str):
118
146
  UnverifiedError
119
147
  DisabledError
120
148
  AuthorizationError
149
+ AnonymousError
121
150
  """
122
151
 
123
152
  def decorator(func):
@@ -157,6 +186,7 @@ def require_roles(*required_roles: str):
157
186
  UnverifiedError
158
187
  DisabledError
159
188
  AuthorizationError
189
+ AnonymousError
160
190
  """
161
191
 
162
192
  def decorator(func):
@@ -170,25 +200,3 @@ def require_roles(*required_roles: str):
170
200
  return wrapper
171
201
 
172
202
  return decorator
173
-
174
-
175
- async def assign_role(
176
- name: str, account: Account, permissions: str = None, description: str = None
177
- ) -> Role:
178
- """
179
- Easy account role assignment. Role being assigned to an account will be created if it doesn't exist.
180
-
181
- Args:
182
- name (str): The name of the role associated with the account.
183
- account (Account): The account associated with the created role.
184
- permissions (str): The permissions of the role associated with the account. Permissions must be separated via comma and in wildcard format.
185
- description (str): The description of the role associated with the account.
186
- """
187
- try:
188
- role = await Role.filter(name=name).get()
189
- except DoesNotExist:
190
- role = await Role.create(
191
- description=description, permissions=permissions, name=name
192
- )
193
- await account.roles.add(role)
194
- return role
@@ -2,23 +2,26 @@ from os import environ
2
2
 
3
3
  from sanic.utils import str_to_bool
4
4
 
5
-
6
5
  """
7
- An effective, simple, and async security library for the Sanic framework.
8
- Copyright (C) 2020-present Aidan Stewart
9
-
10
- This program is free software: you can redistribute it and/or modify
11
- it under the terms of the GNU Affero General Public License as published
12
- by the Free Software Foundation, either version 3 of the License, or
13
- (at your option) any later version.
14
-
15
- This program is distributed in the hope that it will be useful,
16
- but WITHOUT ANY WARRANTY; without even the implied warranty of
17
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18
- GNU Affero General Public License for more details.
19
-
20
- You should have received a copy of the GNU Affero General Public License
21
- 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.
22
25
  """
23
26
 
24
27
 
@@ -34,8 +37,9 @@ DEFAULT_CONFIG = {
34
37
  "MAX_CHALLENGE_ATTEMPTS": 5,
35
38
  "CAPTCHA_SESSION_EXPIRATION": 60,
36
39
  "CAPTCHA_FONT": "captcha-font.ttf",
37
- "TWO_STEP_SESSION_EXPIRATION": 200,
38
- "AUTHENTICATION_SESSION_EXPIRATION": 2592000,
40
+ "TWO_STEP_SESSION_EXPIRATION": 300,
41
+ "AUTHENTICATION_SESSION_EXPIRATION": 86400,
42
+ "AUTHENTICATION_REFRESH_EXPIRATION": 2592000,
39
43
  "ALLOW_LOGIN_WITH_USERNAME": False,
40
44
  "INITIAL_ADMIN_EMAIL": "admin@example.com",
41
45
  "INITIAL_ADMIN_PASSWORD": "admin123",
@@ -59,8 +63,9 @@ class Config(dict):
59
63
  MAX_CHALLENGE_ATTEMPTS (str): The maximum amount of session challenge attempts allowed.
60
64
  CAPTCHA_SESSION_EXPIRATION (int): The amount of seconds till captcha session expiration on creation. Setting to 0 will disable expiration.
61
65
  CAPTCHA_FONT (str): The file path to the font being used for captcha generation.
62
- TWO_STEP_SESSION_EXPIRATION (int): The amount of seconds till two step session expiration on creation. Setting to 0 will disable expiration.
63
- AUTHENTICATION_SESSION_EXPIRATION (bool): The amount of seconds till authentication session expiration on creation. Setting to 0 will disable expiration.
66
+ TWO_STEP_SESSION_EXPIRATION (int): The amount of seconds till two-step session expiration on creation. Setting to 0 will disable expiration.
67
+ AUTHENTICATION_SESSION_EXPIRATION (int): The amount of seconds till authentication session expiration on creation. Setting to 0 will disable expiration.
68
+ AUTHENTICATION_REFRESH_EXPIRATION (int): The amount of seconds till authentication session refresh expiration.
64
69
  ALLOW_LOGIN_WITH_USERNAME (bool): Allows login via username and email.
65
70
  INITIAL_ADMIN_EMAIL (str): Email used when creating the initial admin account.
66
71
  INITIAL_ADMIN_PASSWORD (str): Password used when creating the initial admin account.
@@ -80,6 +85,7 @@ class Config(dict):
80
85
  CAPTCHA_FONT: str
81
86
  TWO_STEP_SESSION_EXPIRATION: int
82
87
  AUTHENTICATION_SESSION_EXPIRATION: int
88
+ AUTHENTICATION_REFRESH_EXPIRATION: int
83
89
  ALLOW_LOGIN_WITH_USERNAME: bool
84
90
  INITIAL_ADMIN_EMAIL: str
85
91
  INITIAL_ADMIN_PASSWORD: str