From d29ad3ceb6fa56569c14abcce693c89456940e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Thu, 4 Dec 2025 15:39:44 -0300 Subject: [PATCH] add forgot endpoint --- id.saladeaula.digital/app/config.py | 3 +- .../app/events/send_forgot_email.py | 55 ++++++++- id.saladeaula.digital/app/oauth2.py | 4 +- .../app/routes/authentication.py | 7 +- id.saladeaula.digital/app/routes/authorize.py | 4 +- id.saladeaula.digital/app/routes/forgot.py | 109 +++++++++++++++++- id.saladeaula.digital/app/routes/lookup.py | 4 +- id.saladeaula.digital/app/routes/register.py | 9 +- id.saladeaula.digital/template.yaml | 37 +++++- id.saladeaula.digital/tests/conftest.py | 2 +- .../tests/events/__init__.py | 0 .../tests/events/test_send_forgot_email.py | 19 +++ .../tests/routes/test_forgot.py | 37 ++++++ konviva-events/app/enrollment.py | 7 +- 14 files changed, 267 insertions(+), 30 deletions(-) create mode 100644 id.saladeaula.digital/tests/events/__init__.py create mode 100644 id.saladeaula.digital/tests/events/test_send_forgot_email.py create mode 100644 id.saladeaula.digital/tests/routes/test_forgot.py diff --git a/id.saladeaula.digital/app/config.py b/id.saladeaula.digital/app/config.py index 1f71079..1d88319 100644 --- a/id.saladeaula.digital/app/config.py +++ b/id.saladeaula.digital/app/config.py @@ -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', diff --git a/id.saladeaula.digital/app/events/send_forgot_email.py b/id.saladeaula.digital/app/events/send_forgot_email.py index d3932de..650bf08 100644 --- a/id.saladeaula.digital/app/events/send_forgot_email.py +++ b/id.saladeaula.digital/app/events/send_forgot_email.py @@ -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?

+ +Recebemos sua solicitação para redefinir sua senha na EDUSEG®.
+Para continuar, é só clicar no link abaixo:

+ + + 👉 Clique aqui para redefinir sua senha + +

+ +Se você não usar este link em até 3 horas, ele expirará. + + Obter um novo link de redefinição de senha. + +

+ +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('#') - return True + 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 diff --git a/id.saladeaula.digital/app/oauth2.py b/id.saladeaula.digital/app/oauth2.py index 39f5ad6..f9a2a93 100644 --- a/id.saladeaula.digital/app/oauth2.py +++ b/id.saladeaula.digital/app/oauth2.py @@ -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) diff --git a/id.saladeaula.digital/app/routes/authentication.py b/id.saladeaula.digital/app/routes/authentication.py index 1f41abc..b633d61 100644 --- a/id.saladeaula.digital/app/routes/authentication.py +++ b/id.saladeaula.digital/app/routes/authentication.py @@ -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): ... diff --git a/id.saladeaula.digital/app/routes/authorize.py b/id.saladeaula.digital/app/routes/authorize.py index c11731a..3167d21 100644 --- a/id.saladeaula.digital/app/routes/authorize.py +++ b/id.saladeaula.digital/app/routes/authorize.py @@ -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): ... diff --git a/id.saladeaula.digital/app/routes/forgot.py b/id.saladeaula.digital/app/routes/forgot.py index 3b37052..29f5fc0 100644 --- a/id.saladeaula.digital/app/routes/forgot.py +++ b/id.saladeaula.digital/app/routes/forgot.py @@ -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), + ) diff --git a/id.saladeaula.digital/app/routes/lookup.py b/id.saladeaula.digital/app/routes/lookup.py index 630a1eb..c746ed6 100644 --- a/id.saladeaula.digital/app/routes/lookup.py +++ b/id.saladeaula.digital/app/routes/lookup.py @@ -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): diff --git a/id.saladeaula.digital/app/routes/register.py b/id.saladeaula.digital/app/routes/register.py index f73206a..09adcac 100644 --- a/id.saladeaula.digital/app/routes/register.py +++ b/id.saladeaula.digital/app/routes/register.py @@ -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): ... diff --git a/id.saladeaula.digital/template.yaml b/id.saladeaula.digital/template.yaml index bd0e822..5e363ad 100644 --- a/id.saladeaula.digital/template.yaml +++ b/id.saladeaula.digital/template.yaml @@ -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 diff --git a/id.saladeaula.digital/tests/conftest.py b/id.saladeaula.digital/tests/conftest.py index d0965f8..6603836 100644 --- a/id.saladeaula.digital/tests/conftest.py +++ b/id.saladeaula.digital/tests/conftest.py @@ -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 diff --git a/id.saladeaula.digital/tests/events/__init__.py b/id.saladeaula.digital/tests/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/id.saladeaula.digital/tests/events/test_send_forgot_email.py b/id.saladeaula.digital/tests/events/test_send_forgot_email.py new file mode 100644 index 0000000..4782557 --- /dev/null +++ b/id.saladeaula.digital/tests/events/test_send_forgot_email.py @@ -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 diff --git a/id.saladeaula.digital/tests/routes/test_forgot.py b/id.saladeaula.digital/tests/routes/test_forgot.py new file mode 100644 index 0000000..af9ab15 --- /dev/null +++ b/id.saladeaula.digital/tests/routes/test_forgot.py @@ -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 diff --git a/konviva-events/app/enrollment.py b/konviva-events/app/enrollment.py index 156f8a7..f8f4511 100644 --- a/konviva-events/app/enrollment.py +++ b/konviva-events/app/enrollment.py @@ -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')