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')