wip
This commit is contained in:
111
id.saladeaula.digital/app/apigateway_oauth2.py
Normal file
111
id.saladeaula.digital/app/apigateway_oauth2.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import os
|
||||
from collections import defaultdict
|
||||
|
||||
from authlib.oauth2 import AuthorizationServer as _AuthorizationServer
|
||||
from authlib.oauth2.rfc6749 import ClientMixin, TokenMixin
|
||||
from authlib.oauth2.rfc6749.requests import JsonRequest as _JsonRequest
|
||||
from authlib.oauth2.rfc6749.requests import OAuth2Payload as _OAuth2Payload
|
||||
from authlib.oauth2.rfc6749.requests import OAuth2Request as _OAuth2Request
|
||||
from aws_lambda_powertools.event_handler.api_gateway import Response
|
||||
from aws_lambda_powertools.utilities.data_classes.api_gateway_proxy_event import (
|
||||
APIGatewayProxyEventV2,
|
||||
)
|
||||
|
||||
OAUTH2_SCOPES_SUPPORTED = os.getenv('OAUTH2_SCOPES_SUPPORTED')
|
||||
|
||||
|
||||
class OAuth2Payload(_OAuth2Payload):
|
||||
def __init__(self, request: APIGatewayProxyEventV2):
|
||||
self._request = request
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self._request.query_string_parameters
|
||||
|
||||
@property
|
||||
def datalist(self) -> dict[str, list]:
|
||||
values = defaultdict(list)
|
||||
|
||||
for k, v in self.data.items():
|
||||
values[k].extend([v])
|
||||
return values
|
||||
|
||||
|
||||
class JsonRequest(_JsonRequest):
|
||||
def __init__(self, request: APIGatewayProxyEventV2):
|
||||
uri = f'https://{request.request_context.domain_name}'
|
||||
|
||||
super().__init__(
|
||||
request.request_context.http.method,
|
||||
uri,
|
||||
request.headers,
|
||||
)
|
||||
|
||||
|
||||
class OAuth2Request(_OAuth2Request):
|
||||
def __init__(self, request: APIGatewayProxyEventV2):
|
||||
uri = f'https://{request.request_context.domain_name}'
|
||||
|
||||
super().__init__(
|
||||
request.request_context.http.method,
|
||||
uri,
|
||||
request.headers,
|
||||
)
|
||||
self.payload = OAuth2Payload(request)
|
||||
|
||||
|
||||
class OAuth2Client(ClientMixin):
|
||||
def __init__(
|
||||
self,
|
||||
client_id: str,
|
||||
redirect_uris: list,
|
||||
response_types: list,
|
||||
) -> None:
|
||||
self.client_id = client_id
|
||||
self.redirect_uris = redirect_uris
|
||||
self.response_types = response_types
|
||||
|
||||
def get_client_id(self):
|
||||
return self.client_id
|
||||
|
||||
def get_default_redirect_uri(self) -> str: # type: ignore
|
||||
if self.redirect_uris:
|
||||
return self.redirect_uris[0]
|
||||
|
||||
def check_response_type(self, response_type):
|
||||
return response_type in self.response_types
|
||||
|
||||
def check_redirect_uri(self, redirect_uri):
|
||||
return redirect_uri in self.redirect_uris
|
||||
|
||||
|
||||
class OAuth2Token(TokenMixin): ...
|
||||
|
||||
|
||||
class AuthorizationServer(_AuthorizationServer):
|
||||
def __init__(self, query_client, save_token) -> None:
|
||||
super().__init__(
|
||||
scopes_supported=OAUTH2_SCOPES_SUPPORTED,
|
||||
)
|
||||
|
||||
self._query_client = query_client
|
||||
self._save_token = save_token
|
||||
|
||||
def save_token(self, token, request):
|
||||
return self._save_token(token, request)
|
||||
|
||||
def query_client(self, client_id: str):
|
||||
return self._query_client(client_id)
|
||||
|
||||
def create_oauth2_request(self, request: APIGatewayProxyEventV2) -> OAuth2Request:
|
||||
return OAuth2Request(request)
|
||||
|
||||
def create_json_request(self, request: APIGatewayProxyEventV2) -> JsonRequest:
|
||||
return JsonRequest(request)
|
||||
|
||||
def handle_response(self, status, body, headers):
|
||||
return Response(
|
||||
status_code=status,
|
||||
body=body,
|
||||
headers=headers,
|
||||
)
|
||||
31
id.saladeaula.digital/app/app.py
Normal file
31
id.saladeaula.digital/app/app.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from typing import Any
|
||||
|
||||
from aws_lambda_powertools import Logger, Tracer
|
||||
from aws_lambda_powertools.event_handler.api_gateway import (
|
||||
APIGatewayHttpResolver,
|
||||
)
|
||||
from aws_lambda_powertools.logging import correlation_paths
|
||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
|
||||
from routes.authorize import router as authorize
|
||||
from routes.jwks import router as jwks
|
||||
from routes.login import router as login
|
||||
from routes.openid_configuration import router as openid_configuration
|
||||
from routes.token import router as token
|
||||
from routes.userinfo import router as userinfo
|
||||
|
||||
logger = Logger(__name__)
|
||||
tracer = Tracer()
|
||||
app = APIGatewayHttpResolver(enable_validation=True)
|
||||
app.include_router(login)
|
||||
app.include_router(authorize)
|
||||
app.include_router(jwks)
|
||||
app.include_router(token)
|
||||
app.include_router(userinfo)
|
||||
app.include_router(openid_configuration)
|
||||
|
||||
|
||||
@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_HTTP)
|
||||
@tracer.capture_lambda_handler
|
||||
def lambda_handler(event: dict[str, Any], context: LambdaContext) -> dict[str, Any]:
|
||||
return app.resolve(event, context)
|
||||
19
id.saladeaula.digital/app/boto3clients.py
Normal file
19
id.saladeaula.digital/app/boto3clients.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import os
|
||||
|
||||
import boto3
|
||||
|
||||
|
||||
def get_dynamodb_client():
|
||||
running_sam_local = os.getenv('AWS_SAM_LOCAL')
|
||||
|
||||
if os.getenv('AWS_LAMBDA_FUNCTION_NAME') and not running_sam_local:
|
||||
return boto3.client('dynamodb')
|
||||
|
||||
dockerhost = 'host.docker.internal'
|
||||
localhost = '127.0.0.1'
|
||||
host = dockerhost if running_sam_local else localhost
|
||||
|
||||
return boto3.client('dynamodb', endpoint_url=f'http://{host}:8000')
|
||||
|
||||
|
||||
dynamodb_client = get_dynamodb_client()
|
||||
14
id.saladeaula.digital/app/config.py
Normal file
14
id.saladeaula.digital/app/config.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import os
|
||||
|
||||
ISSUER: str = os.getenv('ISSUER') # type: ignore
|
||||
|
||||
OAUTH2_TABLE: str = os.getenv('OAUTH2_TABLE') # type: ignore
|
||||
DYNAMODB_SORT_KEY = os.getenv('DYNAMODB_SORT_KEY')
|
||||
|
||||
OAUTH2_SCOPES_SUPPORTED = os.getenv('OAUTH2_SCOPES_SUPPORTED')
|
||||
|
||||
JWT_SECRET: str = os.environ.get('JWT_SECRET') # type: ignore
|
||||
JWT_ALGORITHM = 'HS256'
|
||||
JWT_EXP_SECONDS = 900 # 15 minutes
|
||||
|
||||
REFRESH_TOKEN_EXP_SECONDS = 7 * 86400 # 7 days
|
||||
47
id.saladeaula.digital/app/jose_.py
Normal file
47
id.saladeaula.digital/app/jose_.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from aws_lambda_powertools.event_handler.exceptions import ForbiddenError
|
||||
from jose import ExpiredSignatureError, JWTError, jwt
|
||||
from layercake.dateutils import now
|
||||
|
||||
from config import (
|
||||
ISSUER,
|
||||
JWT_ALGORITHM,
|
||||
JWT_EXP_SECONDS,
|
||||
JWT_SECRET,
|
||||
REFRESH_TOKEN_EXP_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def generate_jwt(user_id: str, email: str) -> str:
|
||||
now_ = now()
|
||||
payload = {
|
||||
'sub': user_id,
|
||||
'email': email,
|
||||
'iat': int(now_.timestamp()),
|
||||
'exp': int((now_ + timedelta(seconds=JWT_EXP_SECONDS)).timestamp()),
|
||||
'iss': ISSUER,
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def generate_refresh_token(user_id: str) -> str:
|
||||
now_ = now()
|
||||
payload = {
|
||||
'sub': user_id,
|
||||
'iat': int(now_.timestamp()),
|
||||
'exp': int((now_ + timedelta(seconds=REFRESH_TOKEN_EXP_SECONDS)).timestamp()),
|
||||
'iss': ISSUER,
|
||||
'typ': 'refresh',
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def verify_jwt(token: str) -> dict:
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
return payload
|
||||
except ExpiredSignatureError:
|
||||
raise ForbiddenError('Token expired')
|
||||
except JWTError:
|
||||
raise ForbiddenError('Invalid token')
|
||||
131
id.saladeaula.digital/app/oauth2.py
Normal file
131
id.saladeaula.digital/app/oauth2.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from authlib.oauth2.rfc6749.grants import (
|
||||
AuthorizationCodeGrant as _AuthorizationCodeGrant,
|
||||
)
|
||||
from authlib.oidc.core import OpenIDCode as OpenIDCode_
|
||||
from authlib.oidc.core import UserInfo
|
||||
from aws_lambda_powertools.event_handler.exceptions import NotFoundError
|
||||
from layercake.dateutils import now, ttl
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||
|
||||
from apigateway_oauth2 import (
|
||||
AuthorizationServer,
|
||||
OAuth2Client,
|
||||
OAuth2Token,
|
||||
)
|
||||
from boto3clients import dynamodb_client
|
||||
from config import DYNAMODB_SORT_KEY, OAUTH2_TABLE
|
||||
|
||||
oauth2_layer = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client)
|
||||
|
||||
|
||||
DUMMY_JWT_CONFIG = {
|
||||
'key': 'secret-key',
|
||||
'alg': 'HS256',
|
||||
'iss': 'https://authlib.org',
|
||||
'exp': 3600,
|
||||
}
|
||||
|
||||
|
||||
def create_save_token_func(persistence_layer: DynamoDBPersistenceLayer):
|
||||
def save_token(token, request) -> OAuth2Token:
|
||||
return OAuth2Token()
|
||||
|
||||
return save_token
|
||||
|
||||
|
||||
class ClientNotFoundError(NotFoundError):
|
||||
def __init__(self, *_):
|
||||
super().__init__('Client not found')
|
||||
|
||||
|
||||
def create_query_client_func(persistence_layer: DynamoDBPersistenceLayer):
|
||||
def query_client(client_id) -> OAuth2Client:
|
||||
client = persistence_layer.collection.get_item(
|
||||
KeyPair('OAUTH2_CLIENT', f'CLIENT_ID#{client_id}'),
|
||||
exc_cls=ClientNotFoundError,
|
||||
)
|
||||
|
||||
_, client_id = client.get(DYNAMODB_SORT_KEY, '').split('#')
|
||||
|
||||
return OAuth2Client(
|
||||
client_id=client_id,
|
||||
redirect_uris=client['redirect_uris'],
|
||||
response_types=client['response_types'],
|
||||
)
|
||||
|
||||
return query_client
|
||||
|
||||
|
||||
def save_authorization_code(code, request):
|
||||
data: dict = request.payload.data # type: ignore
|
||||
user: dict = request.user # type: ignore
|
||||
nonce: str | None = data.get('nonce')
|
||||
now_ = now()
|
||||
ttl_ = ttl(start_dt=now_, minutes=15)
|
||||
|
||||
with oauth2_layer.transact_writer() as transact:
|
||||
transact.put(
|
||||
item={
|
||||
'id': f'OAUTH2_CODE#CLIENT_ID#{request.payload.client_id}',
|
||||
'sk': f'CODE#{code}',
|
||||
'redirect_uri': request.payload.redirect_uri, # type: ignore
|
||||
'scope': request.payload.scope, # type: ignore
|
||||
'user_id': user['id'],
|
||||
'nonce': nonce,
|
||||
'created_at': now_,
|
||||
'ttl': ttl_,
|
||||
},
|
||||
)
|
||||
|
||||
if nonce:
|
||||
transact.put(
|
||||
item={
|
||||
'id': f'OAUTH2_CODE#CLIENT_ID#{request.payload.client_id}',
|
||||
'sk': f'NONCE#{nonce}',
|
||||
'code': code,
|
||||
'created_at': now_,
|
||||
'ttl': ttl_,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def exists_nonce(nonce, request):
|
||||
nonce_ = oauth2_layer.get_item(
|
||||
KeyPair(
|
||||
f'OAUTH2_CODE#CLIENT_ID#{request.payload.client_id}',
|
||||
f'NONCE#{nonce}',
|
||||
)
|
||||
)
|
||||
return bool(nonce_)
|
||||
|
||||
|
||||
class OpenIDCode(OpenIDCode_):
|
||||
def exists_nonce(self, nonce, request):
|
||||
return exists_nonce(nonce, request)
|
||||
|
||||
def get_jwt_config(self, grant):
|
||||
return DUMMY_JWT_CONFIG
|
||||
|
||||
def generate_user_info(self, user, scope):
|
||||
return UserInfo(
|
||||
sub=user.id,
|
||||
name=user.name,
|
||||
email=user.email,
|
||||
).filter(scope)
|
||||
|
||||
|
||||
class AuthorizationCodeGrant(_AuthorizationCodeGrant):
|
||||
TOKEN_ENDPOINT_AUTH_METHODS = [
|
||||
'client_secret_basic',
|
||||
'client_secret_post',
|
||||
]
|
||||
|
||||
def save_authorization_code(self, code: str, request):
|
||||
return save_authorization_code(code, request)
|
||||
|
||||
|
||||
authorization = AuthorizationServer(
|
||||
query_client=create_query_client_func(oauth2_layer),
|
||||
save_token=create_save_token_func(oauth2_layer),
|
||||
)
|
||||
authorization.register_grant(AuthorizationCodeGrant, [OpenIDCode(require_nonce=False)])
|
||||
0
id.saladeaula.digital/app/routes/___init__.py
Normal file
0
id.saladeaula.digital/app/routes/___init__.py
Normal file
33
id.saladeaula.digital/app/routes/authorize.py
Normal file
33
id.saladeaula.digital/app/routes/authorize.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from authlib.oauth2 import OAuth2Error
|
||||
from authlib.oauth2.rfc6749 import errors
|
||||
from aws_lambda_powertools.event_handler.api_gateway import Router
|
||||
|
||||
from oauth2 import authorization
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.get('/authorize')
|
||||
def authorize():
|
||||
user = {
|
||||
'id': str(uuid4()),
|
||||
'sub': 'sergio@somosbeta.com.br',
|
||||
}
|
||||
try:
|
||||
grant = authorization.get_consent_grant(
|
||||
request=router.current_event,
|
||||
end_user=user,
|
||||
)
|
||||
except OAuth2Error as err:
|
||||
return dict(err.get_body())
|
||||
|
||||
try:
|
||||
return authorization.create_authorization_response(
|
||||
request=router.current_event,
|
||||
grant_user=user,
|
||||
grant=grant,
|
||||
)
|
||||
except errors.OAuth2Error:
|
||||
return {}
|
||||
8
id.saladeaula.digital/app/routes/jwks.py
Normal file
8
id.saladeaula.digital/app/routes/jwks.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from aws_lambda_powertools.event_handler.api_gateway import Router
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.get('/jwks.json')
|
||||
def jwks():
|
||||
return {}
|
||||
46
id.saladeaula.digital/app/routes/login.html
Normal file
46
id.saladeaula.digital/app/routes/login.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
</head>
|
||||
<body
|
||||
class="bg-black text-white flex items-center justify-center min-h-screen"
|
||||
>
|
||||
<form
|
||||
method="POST"
|
||||
action="/login"
|
||||
class="bg-gray-900 p-8 rounded-lg shadow-lg w-full max-w-sm space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label for="username" class="block mb-1">Email ou CPF</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
class="w-full p-2 rounded bg-gray-800 text-white border border-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block mb-1">Senha</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
class="w-full p-2 rounded bg-gray-800 text-white border border-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-blue-600 hover:bg-blue-700 transition-colors p-2 rounded font-semibold"
|
||||
>
|
||||
Entrar
|
||||
</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
82
id.saladeaula.digital/app/routes/login.py
Normal file
82
id.saladeaula.digital/app/routes/login.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from aws_lambda_powertools.event_handler import (
|
||||
Response,
|
||||
)
|
||||
from aws_lambda_powertools.event_handler.api_gateway import Router
|
||||
from aws_lambda_powertools.event_handler.exceptions import ForbiddenError, NotFoundError
|
||||
from aws_lambda_powertools.event_handler.openapi.params import Form
|
||||
from aws_lambda_powertools.shared.cookies import Cookie
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||
from passlib.hash import pbkdf2_sha256
|
||||
|
||||
from boto3clients import dynamodb_client
|
||||
from config import OAUTH2_TABLE
|
||||
from jose_ import generate_jwt
|
||||
|
||||
router = Router()
|
||||
oauth2_layer = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client)
|
||||
|
||||
|
||||
@router.get('/login')
|
||||
def login_form():
|
||||
html = Path(__file__).with_name('login.html').read_text(encoding='utf-8')
|
||||
|
||||
return Response(
|
||||
body=html,
|
||||
status_code=HTTPStatus.OK.value,
|
||||
content_type='text/html',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/login')
|
||||
def login(
|
||||
username: Annotated[str, Form()],
|
||||
password: Annotated[str, Form()],
|
||||
):
|
||||
user_id, password_hash = _get_user(username)
|
||||
|
||||
if not pbkdf2_sha256.verify(password, password_hash):
|
||||
raise ForbiddenError('Invalid credentials')
|
||||
|
||||
jwt_token = generate_jwt(user_id, username)
|
||||
|
||||
return Response(
|
||||
status_code=HTTPStatus.OK,
|
||||
cookies=[
|
||||
Cookie(
|
||||
name='id_token',
|
||||
value=jwt_token,
|
||||
http_only=True,
|
||||
same_site=None,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _get_user(username: str) -> tuple[str, str]:
|
||||
r = oauth2_layer.collection.get_item(
|
||||
# Post-migration: uncomment the following line
|
||||
# KeyPair('EMAIL', username),
|
||||
KeyPair('email', username),
|
||||
exc_cls=EmailNotFoundError,
|
||||
)
|
||||
|
||||
password = oauth2_layer.collection.get_item(
|
||||
KeyPair(r['user_id'], 'PASSWORD'),
|
||||
exc_cls=UserNotFoundError,
|
||||
)
|
||||
|
||||
return r['user_id'], password['hash']
|
||||
|
||||
|
||||
class EmailNotFoundError(NotFoundError):
|
||||
def __init__(self, *_):
|
||||
super().__init__('Email not found')
|
||||
|
||||
|
||||
class UserNotFoundError(NotFoundError):
|
||||
def __init__(self, *_):
|
||||
super().__init__('User not found')
|
||||
25
id.saladeaula.digital/app/routes/openid_configuration.py
Normal file
25
id.saladeaula.digital/app/routes/openid_configuration.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from aws_lambda_powertools.event_handler.api_gateway import Router
|
||||
|
||||
from config import ISSUER, JWT_ALGORITHM
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.get('/.well-known/openid-configuration')
|
||||
def openid_configuration():
|
||||
return {
|
||||
'issuer': ISSUER,
|
||||
'authorization_endpoint': f'{ISSUER}/authorize',
|
||||
'token_endpoint': f'{ISSUER}/token',
|
||||
'userinfo_endpoint': f'{ISSUER}/userinfo',
|
||||
'jwks_uri': f'{ISSUER}/jwks.json',
|
||||
'scopes_supported': ['openid', 'profile', 'email'],
|
||||
'response_types_supported': ['code'],
|
||||
'grant_types_supported': ['authorization_code', 'refresh_token'],
|
||||
'subject_types_supported': ['public'],
|
||||
'id_token_signing_alg_values_supported': [JWT_ALGORITHM],
|
||||
'token_endpoint_auth_methods_supported': [
|
||||
'client_secret_basic',
|
||||
'client_secret_post',
|
||||
],
|
||||
}
|
||||
8
id.saladeaula.digital/app/routes/token.py
Normal file
8
id.saladeaula.digital/app/routes/token.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from aws_lambda_powertools.event_handler.api_gateway import Router
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.get('/token')
|
||||
def token():
|
||||
return {}
|
||||
8
id.saladeaula.digital/app/routes/userinfo.py
Normal file
8
id.saladeaula.digital/app/routes/userinfo.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from aws_lambda_powertools.event_handler.api_gateway import Router
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.get('/userinfo')
|
||||
def userinfo():
|
||||
return {}
|
||||
11
id.saladeaula.digital/app/security.py
Normal file
11
id.saladeaula.digital/app/security.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import secrets
|
||||
|
||||
SALT_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
|
||||
|
||||
def gen_salt(length: int) -> str:
|
||||
"""Generate a random string of SALT_CHARS with specified ``length``."""
|
||||
if length <= 0:
|
||||
raise ValueError('Salt length must be at least 1.')
|
||||
|
||||
return ''.join(secrets.choice(SALT_CHARS) for _ in range(length))
|
||||
Reference in New Issue
Block a user