add forgot endpoint
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
import os
|
||||
|
||||
ISSUER: str = os.getenv('ISSUER') # type: ignore
|
||||
USER_TABLE: str = os.getenv('USER_TABLE') # type: ignore
|
||||
|
||||
EMAIL_SENDER = ('EDUSEG®', 'noreply@eduseg.com.br')
|
||||
|
||||
OAUTH2_TABLE: str = os.getenv('OAUTH2_TABLE') # type: ignore
|
||||
OAUTH2_REFRESH_TOKEN_EXPIRES_IN = 86_400 * 7 # 7 days
|
||||
OAUTH2_SCOPES_SUPPORTED: list[str] = [
|
||||
'openid',
|
||||
|
||||
@@ -4,17 +4,68 @@ from aws_lambda_powertools.utilities.data_classes import (
|
||||
event_source,
|
||||
)
|
||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
from layercake.email_ import Message
|
||||
from layercake.strutils import first_word
|
||||
|
||||
from boto3clients import sesv2_client
|
||||
from config import EMAIL_SENDER
|
||||
|
||||
SUBJECT = '{first_name}, redefina sua senha na EDUSEG®'
|
||||
MESSAGE = """
|
||||
Oi {first_name}, tudo bem?<br/><br/>
|
||||
|
||||
Recebemos sua solicitação para redefinir sua senha na EDUSEG®.<br/>
|
||||
Para continuar, é só clicar no link abaixo:<br/><br/>
|
||||
|
||||
<a href="https://id.saladeaula.digital/reset/{code}">
|
||||
👉 Clique aqui para redefinir sua senha
|
||||
</a>
|
||||
<br/><br/>
|
||||
|
||||
Se você não usar este link em até 3 horas, ele expirará.
|
||||
<a href="https://id.saladeaula.digital/forgot">
|
||||
Obter um novo link de redefinição de senha.
|
||||
</a>
|
||||
<br/><br/>
|
||||
|
||||
Se você não pediu essa redefinição, pode ignorar esta mensagem.
|
||||
"""
|
||||
|
||||
logger = Logger(__name__)
|
||||
|
||||
|
||||
@event_source(data_class=EventBridgeEvent)
|
||||
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
||||
new_image = event.detail['new_image']
|
||||
# Key pattern `PASSWORD_RESET#{code}`
|
||||
first_name = first_word(new_image['name'])
|
||||
# Key pattern `CODE#{code}`
|
||||
*_, code = new_image['sk'].split('#')
|
||||
|
||||
emailmsg = Message(
|
||||
from_=EMAIL_SENDER,
|
||||
to=(new_image['name'], new_image['email']),
|
||||
subject=SUBJECT.format(
|
||||
first_name=first_name,
|
||||
),
|
||||
)
|
||||
emailmsg.add_alternative(
|
||||
MESSAGE.format(
|
||||
first_name=first_name,
|
||||
code=code,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
sesv2_client.send_email(
|
||||
Content={
|
||||
'Raw': {
|
||||
'Data': emailmsg.as_bytes(),
|
||||
},
|
||||
}
|
||||
)
|
||||
logger.info('Email sent')
|
||||
except Exception as exc:
|
||||
logger.exception(exc)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
@@ -21,7 +21,7 @@ from layercake.dynamodb import (
|
||||
from layercake.funcs import omit, pick
|
||||
|
||||
from boto3clients import dynamodb_client
|
||||
from config import ISSUER, OAUTH2_DEFAULT_SCOPES, OAUTH2_SCOPES_SUPPORTED, OAUTH2_TABLE
|
||||
from config import ISSUER, OAUTH2_DEFAULT_SCOPES, OAUTH2_SCOPES_SUPPORTED, USER_TABLE
|
||||
from integrations.apigateway_oauth2.authorization_server import (
|
||||
AuthorizationServer,
|
||||
)
|
||||
@@ -34,7 +34,7 @@ from integrations.apigateway_oauth2.tokens import (
|
||||
from util import read_file_path
|
||||
|
||||
logger = Logger(__name__)
|
||||
dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client)
|
||||
dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
|
||||
private_key = read_file_path('private.pem')
|
||||
private_jwk = JsonWebKey.import_key(private_key)
|
||||
|
||||
|
||||
@@ -17,13 +17,10 @@ from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey
|
||||
from passlib.hash import pbkdf2_sha256
|
||||
|
||||
from boto3clients import dynamodb_client, idp_client
|
||||
from config import (
|
||||
OAUTH2_TABLE,
|
||||
SESSION_EXPIRES_IN,
|
||||
)
|
||||
from config import SESSION_EXPIRES_IN, USER_TABLE
|
||||
|
||||
router = Router()
|
||||
dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client)
|
||||
dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
|
||||
|
||||
|
||||
class InvalidCredentialsError(UnauthorizedError): ...
|
||||
|
||||
@@ -12,13 +12,13 @@ from joserfc.errors import JoseError
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey
|
||||
|
||||
from boto3clients import dynamodb_client
|
||||
from config import OAUTH2_DEFAULT_SCOPES, OAUTH2_TABLE
|
||||
from config import OAUTH2_DEFAULT_SCOPES, USER_TABLE
|
||||
from oauth2 import server
|
||||
from util import parse_cookies
|
||||
|
||||
router = Router()
|
||||
logger = Logger(__name__)
|
||||
dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client)
|
||||
dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
|
||||
|
||||
|
||||
class SessionNotFoundError(NotFoundError): ...
|
||||
|
||||
@@ -1,13 +1,116 @@
|
||||
from dataclasses import dataclass
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated
|
||||
from uuid import uuid4
|
||||
|
||||
from aws_lambda_powertools.event_handler.api_gateway import Router
|
||||
from aws_lambda_powertools.event_handler.api_gateway import Response, Router
|
||||
from aws_lambda_powertools.event_handler.exceptions import NotFoundError
|
||||
from aws_lambda_powertools.event_handler.openapi.params import Body
|
||||
from aws_lambda_powertools.utilities.data_masking import DataMasking
|
||||
from layercake.dateutils import now, ttl
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey
|
||||
from layercake.extra_types import CpfStr
|
||||
from layercake.funcs import pick
|
||||
from pydantic import EmailStr
|
||||
|
||||
from boto3clients import dynamodb_client
|
||||
from config import USER_TABLE
|
||||
|
||||
router = Router()
|
||||
dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
|
||||
data_masker = DataMasking()
|
||||
|
||||
masking_rules = {
|
||||
'email': {'regex_pattern': '(.)(.*)(..)(@.*)', 'mask_format': r'\1****\3\4'},
|
||||
}
|
||||
|
||||
|
||||
class UserNotFoundError(NotFoundError): ...
|
||||
|
||||
|
||||
@router.post('/forgot')
|
||||
def forgot(email: Annotated[EmailStr | CpfStr, Body(embed=True)]):
|
||||
return {}
|
||||
def forgot(username: Annotated[EmailStr | CpfStr, Body(embed=True)]):
|
||||
now_ = now()
|
||||
user = _get_user(username)
|
||||
reset_ttl = ttl(start_dt=now_, hours=3)
|
||||
code = uuid4()
|
||||
|
||||
with dyn.transact_writer() as transact:
|
||||
transact.update(
|
||||
key=KeyPair(
|
||||
pk='PASSWORD_RESET',
|
||||
sk=f'USER#{user.id}',
|
||||
),
|
||||
update_expr='SET #name = :name, \
|
||||
email = :email, \
|
||||
#ttl = :ttl, \
|
||||
updated_at = :now \
|
||||
ADD code_attempts :code',
|
||||
expr_attr_names={
|
||||
'#name': 'name',
|
||||
'#ttl': 'ttl',
|
||||
},
|
||||
expr_attr_values={
|
||||
':name': user.name,
|
||||
':email': user.email,
|
||||
':ttl': reset_ttl,
|
||||
':code': {code},
|
||||
':now': now_,
|
||||
},
|
||||
)
|
||||
dyn.put_item(
|
||||
item={
|
||||
'id': 'PASSWORD_RESET',
|
||||
'sk': f'CODE#{code}',
|
||||
'name': user.name,
|
||||
'user_id': user.id,
|
||||
'ttl': reset_ttl,
|
||||
'created_at': now_,
|
||||
}
|
||||
)
|
||||
|
||||
return Response(
|
||||
status_code=HTTPStatus.CREATED,
|
||||
body=data_masker.erase(
|
||||
{
|
||||
'email': user.email,
|
||||
},
|
||||
masking_rules=masking_rules,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class User:
|
||||
id: str
|
||||
name: str
|
||||
email: str
|
||||
|
||||
|
||||
def _get_user(username: str) -> User:
|
||||
user_id = dyn.collection.get_items(
|
||||
KeyPair(
|
||||
pk='email',
|
||||
sk=SortKey(username, path_spec='user_id'), # type: ignore
|
||||
rename_key='user_id',
|
||||
)
|
||||
+ KeyPair(
|
||||
pk='cpf',
|
||||
sk=SortKey(username, path_spec='user_id'), # type: ignore
|
||||
rename_key='user_id',
|
||||
),
|
||||
flatten_top=False,
|
||||
).get('user_id')
|
||||
|
||||
if not user_id:
|
||||
raise UserNotFoundError('User not found')
|
||||
|
||||
user = dyn.get_item(
|
||||
KeyPair(
|
||||
pk=user_id,
|
||||
sk='0',
|
||||
)
|
||||
)
|
||||
return User(
|
||||
**pick(('id', 'name', 'email'), user),
|
||||
)
|
||||
|
||||
@@ -13,10 +13,10 @@ from layercake.funcs import pick
|
||||
from pydantic import EmailStr
|
||||
|
||||
from boto3clients import dynamodb_client
|
||||
from config import OAUTH2_TABLE
|
||||
from config import USER_TABLE
|
||||
|
||||
router = Router()
|
||||
dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client)
|
||||
dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
|
||||
|
||||
|
||||
class UserAlreadyOnboardedError(ServiceError):
|
||||
|
||||
@@ -5,7 +5,7 @@ from uuid import uuid4
|
||||
|
||||
from aws_lambda_powertools.event_handler import content_types
|
||||
from aws_lambda_powertools.event_handler.api_gateway import Response, Router
|
||||
from aws_lambda_powertools.event_handler.exceptions import NotFoundError, ServiceError
|
||||
from aws_lambda_powertools.event_handler.exceptions import ServiceError
|
||||
from aws_lambda_powertools.event_handler.openapi.params import Body
|
||||
from aws_lambda_powertools.shared.cookies import Cookie
|
||||
from layercake.dateutils import now, ttl
|
||||
@@ -16,12 +16,12 @@ from passlib.hash import pbkdf2_sha256
|
||||
from pydantic import UUID4, EmailStr
|
||||
|
||||
from boto3clients import dynamodb_client
|
||||
from config import OAUTH2_TABLE, SESSION_EXPIRES_IN
|
||||
from config import SESSION_EXPIRES_IN, USER_TABLE
|
||||
|
||||
from .authentication import new_session
|
||||
|
||||
router = Router()
|
||||
dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client)
|
||||
dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
|
||||
|
||||
|
||||
class ConflictError(ServiceError):
|
||||
@@ -29,9 +29,6 @@ class ConflictError(ServiceError):
|
||||
super().__init__(HTTPStatus.CONFLICT, msg)
|
||||
|
||||
|
||||
class UserNotFound(NotFoundError): ...
|
||||
|
||||
|
||||
class CPFConflictError(ConflictError): ...
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ AWSTemplateFormatVersion: 2010-09-09
|
||||
Transform: AWS::Serverless-2016-10-31
|
||||
|
||||
Parameters:
|
||||
OAuth2Table:
|
||||
UserTable:
|
||||
Type: String
|
||||
Default: betaeducacao-prod-users_d2o3r5gmm4it7j
|
||||
|
||||
@@ -23,7 +23,7 @@ Globals:
|
||||
POWERTOOLS_LOGGER_LOG_EVENT: true
|
||||
DYNAMODB_PARTITION_KEY: id
|
||||
DYNAMODB_SORT_KEY: sk
|
||||
OAUTH2_TABLE: !Ref OAuth2Table
|
||||
USER_TABLE: !Ref UserTable
|
||||
ISSUER: https://id.saladeaula.digital
|
||||
|
||||
Resources:
|
||||
@@ -32,6 +32,11 @@ Resources:
|
||||
Properties:
|
||||
RetentionInDays: 90
|
||||
|
||||
EventLog:
|
||||
Type: AWS::Logs::LogGroup
|
||||
Properties:
|
||||
RetentionInDays: 90
|
||||
|
||||
HttpApi:
|
||||
Type: AWS::Serverless::HttpApi
|
||||
Properties:
|
||||
@@ -129,6 +134,34 @@ Resources:
|
||||
Method: GET
|
||||
ApiId: !Ref HttpApi
|
||||
|
||||
EventSendForgotEmailFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
Properties:
|
||||
Handler: events.send_forgot_email.lambda_handler
|
||||
LoggingConfig:
|
||||
LogGroup: !Ref EventLog
|
||||
Policies:
|
||||
- Version: 2012-10-17
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- ses:SendRawEmail
|
||||
Resource:
|
||||
- !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:identity/eduseg.com.br
|
||||
- !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:configuration-set/tracking
|
||||
Events:
|
||||
DynamoDBEvent:
|
||||
Type: EventBridgeRule
|
||||
Properties:
|
||||
Pattern:
|
||||
resources: [!Ref UserTable]
|
||||
detail-type: [INSERT]
|
||||
detail:
|
||||
new_image:
|
||||
id: [PASSWORD_RESET]
|
||||
sk:
|
||||
- prefix: CODE#
|
||||
|
||||
Outputs:
|
||||
HttpApiUrl:
|
||||
Description: URL of your API endpoint
|
||||
|
||||
@@ -16,7 +16,7 @@ SK = 'sk'
|
||||
# https://docs.pytest.org/en/7.1.x/reference/reference.html#pytest.hookspec.pytest_configure
|
||||
def pytest_configure():
|
||||
os.environ['TZ'] = 'America/Sao_Paulo'
|
||||
os.environ['OAUTH2_TABLE'] = PYTEST_TABLE_NAME
|
||||
os.environ['USER_TABLE'] = PYTEST_TABLE_NAME
|
||||
os.environ['SESSION_SECRET'] = 'secret'
|
||||
os.environ['DYNAMODB_PARTITION_KEY'] = PK
|
||||
os.environ['DYNAMODB_SORT_KEY'] = SK
|
||||
|
||||
0
id.saladeaula.digital/tests/events/__init__.py
Normal file
0
id.saladeaula.digital/tests/events/__init__.py
Normal file
19
id.saladeaula.digital/tests/events/test_send_forgot_email.py
Normal file
19
id.saladeaula.digital/tests/events/test_send_forgot_email.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext
|
||||
|
||||
import events.send_forgot_email as app
|
||||
|
||||
|
||||
def test_send_forgot_email(monkeypatch, lambda_context: LambdaContext):
|
||||
monkeypatch.setattr(app.sesv2_client, 'send_email', lambda *args, **kwargs: ...)
|
||||
|
||||
event = {
|
||||
'detail': {
|
||||
'new_image': {
|
||||
'id': 'PASSWORD_RESET',
|
||||
'sk': 'CODE#123',
|
||||
'name': 'Sérgio R Siqueira',
|
||||
'email': 'sergio@somosbeta.com.br',
|
||||
}
|
||||
}
|
||||
}
|
||||
assert app.lambda_handler(event, lambda_context) # type: ignore
|
||||
37
id.saladeaula.digital/tests/routes/test_forgot.py
Normal file
37
id.saladeaula.digital/tests/routes/test_forgot.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from http import HTTPMethod
|
||||
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey
|
||||
|
||||
from ..conftest import HttpApiProxy, LambdaContext
|
||||
|
||||
|
||||
def test_forgot(
|
||||
app,
|
||||
seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
http_api_proxy: HttpApiProxy,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
r = app.lambda_handler(
|
||||
http_api_proxy(
|
||||
raw_path='/forgot',
|
||||
method=HTTPMethod.POST,
|
||||
body={'username': '07879819908'},
|
||||
),
|
||||
lambda_context,
|
||||
)
|
||||
assert 's****io@somosbeta.com.br' == r['body']['email']
|
||||
|
||||
app.lambda_handler(
|
||||
http_api_proxy(
|
||||
raw_path='/forgot',
|
||||
method=HTTPMethod.POST,
|
||||
body={'username': '07879819908'},
|
||||
),
|
||||
lambda_context,
|
||||
)
|
||||
|
||||
forgot = dynamodb_persistence_layer.collection.query(
|
||||
PartitionKey('PASSWORD_RESET'),
|
||||
)
|
||||
assert len(forgot['items']) == 3
|
||||
@@ -1,9 +1,10 @@
|
||||
from decimal import Decimal
|
||||
from http import HTTPStatus
|
||||
|
||||
from aws_lambda_powertools import Logger
|
||||
from aws_lambda_powertools.event_handler.exceptions import (
|
||||
BadRequestError,
|
||||
NotFoundError,
|
||||
ServiceError,
|
||||
)
|
||||
from layercake.dateutils import now, ttl
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey, TransactKey
|
||||
@@ -351,6 +352,6 @@ class EnrollmentNotFoundError(NotFoundError):
|
||||
super().__init__('Enrollment not found')
|
||||
|
||||
|
||||
class EnrollmentConflictError(BadRequestError):
|
||||
class EnrollmentConflictError(ServiceError):
|
||||
def __init__(self, *_):
|
||||
super().__init__('Enrollment status conflict')
|
||||
super().__init__(HTTPStatus.CONFLICT, 'Enrollment status conflict')
|
||||
|
||||
Reference in New Issue
Block a user