From d461a507f931b558de47cf73f278c067d3d471a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Tue, 2 Dec 2025 22:54:27 -0300 Subject: [PATCH] add register --- id.saladeaula.digital/app/app.py | 15 ++ .../app/routes/authentication.py | 31 +-- id.saladeaula.digital/app/routes/register.py | 188 ++++++++++++++++-- .../tests/routes/test_register.py | 68 ++++++- .../tests/routes/test_token.py | 8 +- id.saladeaula.digital/tests/seeds.jsonl | 13 +- 6 files changed, 279 insertions(+), 44 deletions(-) diff --git a/id.saladeaula.digital/app/app.py b/id.saladeaula.digital/app/app.py index 56d1161..2d38c58 100644 --- a/id.saladeaula.digital/app/app.py +++ b/id.saladeaula.digital/app/app.py @@ -1,9 +1,11 @@ from typing import Any from aws_lambda_powertools import Logger, Tracer +from aws_lambda_powertools.event_handler import Response, content_types from aws_lambda_powertools.event_handler.api_gateway import ( APIGatewayHttpResolver, ) +from aws_lambda_powertools.event_handler.exceptions import ServiceError from aws_lambda_powertools.logging import correlation_paths from aws_lambda_powertools.utilities.typing import LambdaContext @@ -40,6 +42,19 @@ def health(): return {'status': 'available'} +@app.exception_handler(ServiceError) +def exc_error(exc: ServiceError): + return Response( + body={ + 'type': type(exc).__name__, + 'message': str(exc), + }, + content_type=content_types.APPLICATION_JSON, + status_code=exc.status_code, + compress=True, + ) + + @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]: diff --git a/id.saladeaula.digital/app/routes/authentication.py b/id.saladeaula.digital/app/routes/authentication.py index 1d2359c..04a6962 100644 --- a/id.saladeaula.digital/app/routes/authentication.py +++ b/id.saladeaula.digital/app/routes/authentication.py @@ -25,6 +25,12 @@ dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client) idp = boto3.client('cognito-idp') +class InvalidCredentialsError(ForbiddenError): ... + + +class UserNotFoundError(NotFoundError): ... + + @router.post('/authentication') def authentication( username: Annotated[str, Body()], @@ -36,7 +42,7 @@ def authentication( _get_idp_user(user_id, username, password) else: if not pbkdf2_sha256.verify(password, password_hash): - raise ForbiddenError('Invalid credentials') + raise InvalidCredentialsError('Invalid credentials') return Response( status_code=HTTPStatus.OK, @@ -61,7 +67,7 @@ def _get_user(username: str) -> tuple[str, str | None]: ) if not user: - raise UserNotFoundError() + raise UserNotFoundError('User not found') password = dyn.collection.get_item( KeyPair( @@ -121,13 +127,13 @@ def _get_idp_user( } ) except Exception: - raise ForbiddenError('Invalid credentials') + raise InvalidCredentialsError('Invalid credentials') return True -def new_session(sub: str) -> str: - sid = str(uuid4()) +def new_session(user_id: str) -> str: + session_id = str(uuid4()) now_ = now() exp = ttl(start_dt=now_, seconds=SESSION_EXPIRES_IN) @@ -135,24 +141,19 @@ def new_session(sub: str) -> str: transact.put( item={ 'id': 'SESSION', - 'sk': sid, - 'user_id': sub, + 'sk': session_id, + 'user_id': user_id, 'ttl': exp, 'created_at': now_, } ) transact.put( item={ - 'id': sub, - 'sk': f'SESSION#{sid}', + 'id': user_id, + 'sk': f'SESSION#{session_id}', 'ttl': exp, 'created_at': now_, } ) - return f'{sid}:{sub}' - - -class UserNotFoundError(NotFoundError): - def __init__(self, *_): - super().__init__('User not found') + return f'{session_id}:{user_id}' diff --git a/id.saladeaula.digital/app/routes/register.py b/id.saladeaula.digital/app/routes/register.py index 9b7ba98..de0c568 100644 --- a/id.saladeaula.digital/app/routes/register.py +++ b/id.saladeaula.digital/app/routes/register.py @@ -1,12 +1,17 @@ +from dataclasses import asdict, 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.exceptions import ServiceError +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.openapi.params import Body -from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey -from layercake.extra_types import CnpjStr, CpfStr, NameStr -from pydantic import BaseModel, EmailStr +from layercake.dateutils import now, ttl +from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair +from layercake.extra_types import CpfStr, NameStr +from layercake.funcs import pick +from passlib.hash import pbkdf2_sha256 +from pydantic import UUID4, EmailStr from boto3clients import dynamodb_client from config import OAUTH2_TABLE @@ -15,24 +20,181 @@ router = Router() dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client) -class UserConflictError(ServiceError): +class ConflictError(ServiceError): def __init__(self, msg: str | dict): super().__init__(HTTPStatus.CONFLICT, msg) -class Org(BaseModel): - id: str | None +class UserNotFound(NotFoundError): ... + + +class CPFConflictError(ConflictError): ... + + +class EmailConflictError(ConflictError): ... + + +class NeverLoggedConflictError(ConflictError): ... + + +@dataclass(frozen=True) +class User: + id: str name: str - cnpj: CnpjStr + email: str + cpf: str -@router.get('/register') +@router.post('/register') def register( + id: Annotated[UUID4, Body(embed=True, alias='id', default_factory=uuid4)], name: Annotated[NameStr, Body(embed=True)], email: Annotated[EmailStr, Body(embed=True)], password: Annotated[str, Body(min_length=6, embed=True)], cpf: Annotated[CpfStr, Body(embed=True)], - id: Annotated[str | None, Body(embed=True)] = None, - org: Annotated[Org | None, Body(embed=True)] = None, ): - return {} + new_user = User(id=str(id), name=name, email=email, cpf=cpf) + existing = dyn.collection.get_item( + KeyPair(str(id), '0'), + default=False, + raise_on_error=False, + ) + + if existing: + _update_user( + old_user=User(**pick(('id', 'name', 'email', 'cpf'), existing)), + new_user=new_user, + password=password, + ) + + return Response( + status_code=HTTPStatus.OK, + body=asdict(new_user), + ) + + _create_user(user=new_user, password=password) + + return Response( + status_code=HTTPStatus.CREATED, + body=asdict(new_user), + ) + + +def _create_user(*, user: User, password: str): + now_ = now() + + with dyn.transact_writer() as transact: + transact.put( + item={ + 'sk': '0', + 'email_verified': False, + 'created_at': now_, + } + | asdict(user), + ) + transact.put( + item={ + 'id': user.id, + # Post-migration (users): rename `emails` to `EMAIL` + 'sk': f'emails#{user.email}', + 'email_verified': False, + 'email_primary': True, + 'mx_record_exists': False, + 'created_at': now_, + } + ) + transact.put( + item={ + 'id': user.id, + 'sk': 'PASSWORD', + 'hash': pbkdf2_sha256.hash(password), + 'created_at': now_, + } + ) + transact.put( + item={ + # Post-migration (users): rename `cpf` to `CPF` + 'id': 'cpf', + 'sk': user.cpf, + 'created_at': now_, + }, + cond_expr='attribute_not_exists(sk)', + exc_cls=CPFConflictError, + ) + transact.put( + item={ + # Post-migration (users): rename `email` to `EMAIL` + 'id': 'email', + 'sk': user.email, + 'created_at': now_, + }, + cond_expr='attribute_not_exists(sk)', + exc_cls=EmailConflictError, + ) + + +def _update_user(*, old_user: User, new_user: User, password: str): + now_ = now() + + with dyn.transact_writer() as transact: + transact.update( + key=KeyPair(new_user.id, '0'), + update_expr='SET #name = :name, \ + email = :email, \ + updated_at = :now', + expr_attr_names={ + '#name': 'name', + }, + expr_attr_values={ + ':name': new_user.name, + ':email': new_user.email, + ':now': now_, + }, + cond_expr='attribute_exists(sk)', + ) + transact.put( + item={ + 'id': new_user.id, + 'sk': 'PASSWORD', + 'hash': pbkdf2_sha256.hash(password), + 'created_at': now_, + } + ) + transact.delete( + key=KeyPair(new_user.id, 'NEVER_LOGGED'), + cond_expr='attribute_exists(sk)', + exc_cls=NeverLoggedConflictError, + ) + + if new_user.email != old_user.email: + transact.put( + item={ + 'id': new_user.id, + # Post-migration (users): rename `emails` to `EMAIL` + 'sk': f'emails#{new_user.email}', + 'email_verified': False, + 'email_primary': True, + 'mx_record_exists': False, + 'created_at': now_, + } + ) + transact.put( + item={ + 'id': new_user.id, + 'sk': f'EMAIL_VERIFICATION#{uuid4()}', + 'name': new_user.name, + 'email': new_user.email, + 'ttl': ttl(start_dt=now_, days=30), + 'created_at': now_, + } + ) + transact.put( + item={ + # Post-migration (users): rename `email` to `EMAIL` + 'id': 'email', + 'sk': new_user.email, + 'created_at': now_, + }, + cond_expr='attribute_not_exists(sk)', + exc_cls=EmailConflictError, + ) diff --git a/id.saladeaula.digital/tests/routes/test_register.py b/id.saladeaula.digital/tests/routes/test_register.py index f9be440..46a45d9 100644 --- a/id.saladeaula.digital/tests/routes/test_register.py +++ b/id.saladeaula.digital/tests/routes/test_register.py @@ -1,11 +1,12 @@ -from http import HTTPMethod +import json +from http import HTTPMethod, HTTPStatus -from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey +from layercake.dynamodb import DynamoDBPersistenceLayer from ..conftest import HttpApiProxy, LambdaContext -def test_register( +def test_preexisting_user( app, seeds, dynamodb_persistence_layer: DynamoDBPersistenceLayer, @@ -17,14 +18,65 @@ def test_register( raw_path='/register', method=HTTPMethod.POST, body={ - 'name': '07879819908', + 'id': '357db1c5-7442-4075-98a3-fbe5c938a419', + 'name': 'Sérgio R Siqueira', + 'cpf': '07879819908', + 'password': 'Led@Zepellin', + 'email': 'sergio@somosbeta.com.br', }, ), lambda_context, ) - assert len(r['cookies']) == 1 + assert r['statusCode'] == HTTPStatus.OK - session = dynamodb_persistence_layer.collection.query(PartitionKey('SESSION')) - # One seesion if created from seeds - assert len(session['items']) == 2 + +def test_preexisting_update_email( + app, + seeds, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + http_api_proxy: HttpApiProxy, + lambda_context: LambdaContext, +): + r = app.lambda_handler( + http_api_proxy( + raw_path='/register', + method=HTTPMethod.POST, + body={ + 'id': '357db1c5-7442-4075-98a3-fbe5c938a419', + 'name': 'Sérgio R Siqueira', + 'cpf': '07879819908', + 'password': 'Led@Zepellin', + 'email': 'osergiosiqueira@gmail.com', + }, + ), + lambda_context, + ) + body = json.loads(r['body']) + + assert body['type'] == 'EmailConflictError' + assert r['statusCode'] == HTTPStatus.CONFLICT + + +def test_non_preexisting_user( + app, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + http_api_proxy: HttpApiProxy, + lambda_context: LambdaContext, +): + r = app.lambda_handler( + http_api_proxy( + raw_path='/register', + method=HTTPMethod.POST, + body={ + # 'id': '14ddcef6-483c-4181-bdb2-3e9a31a24732', + 'name': 'David Bowie', + 'cpf': '23355097055', + 'password': 'Ziggy@Stardust', + 'email': 'david@bowie.com', + }, + ), + lambda_context, + ) + + assert r['statusCode'] == HTTPStatus.CREATED diff --git a/id.saladeaula.digital/tests/routes/test_token.py b/id.saladeaula.digital/tests/routes/test_token.py index 9455af6..9b92835 100644 --- a/id.saladeaula.digital/tests/routes/test_token.py +++ b/id.saladeaula.digital/tests/routes/test_token.py @@ -1,5 +1,4 @@ import json -import pprint from base64 import b64encode from http import HTTPMethod, HTTPStatus from urllib.parse import urlencode @@ -35,7 +34,10 @@ def test_token( 'redirect_uri': 'https://localhost/callback', 'code': 'kyqp3oSuRFTfuBaCmq3XOgGWg67l42Kt3D6xPEj7Yd3MLdi9', 'client_id': client_id, - 'code_verifier': '9072df2d3709425993e733f38fb27a825b8860e699364ce9abafdf51077c0bdb4e456ddb741147a4bec4eeda782d92cc', + 'code_verifier': ( + '9072df2d3709425993e733f38fb27a825b8860e699364ce9' + 'abafdf51077c0bdb4e456ddb741147a4bec4eeda782d92cc' + ), } ), ), @@ -45,7 +47,7 @@ def test_token( assert r['statusCode'] == HTTPStatus.OK r = json.loads(r['body']) - assert r['expires_in'] == 180 + assert r['expires_in'] == 3600 tokens = dynamodb_persistence_layer.query( key_cond_expr='#pk = :pk', diff --git a/id.saladeaula.digital/tests/seeds.jsonl b/id.saladeaula.digital/tests/seeds.jsonl index 75cb066..ef18ea3 100644 --- a/id.saladeaula.digital/tests/seeds.jsonl +++ b/id.saladeaula.digital/tests/seeds.jsonl @@ -7,18 +7,21 @@ {"id": "OAUTH2#TOKEN", "sk": "REFRESH_TOKEN#CyF3Ik3b9hMIo3REVv27gZAHd7dvwZq6QrkhWr7qHEen4UVy", "client_id": "d72d4005-1fa7-4430-9754-80d5e2487bb6", "token_type": "Bearer", "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6IlRjT0VuV3JGSUFEYlZJNjJlY1pzU28ydEI1eW5mbkZZNTZ0Uy05b0stNW8ifQ.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0IiwiZXhwIjoxNzU5NTg2NzgzLCJjbGllbnRfaWQiOiJkNzJkNDAwNS0xZmE3LTQ0MzAtOTc1NC04MGQ1ZTI0ODdiYjYiLCJpYXQiOjE3NTg5ODE5ODMsImp0aSI6Ik9uVzRIZm1FdFl2a21CbE4iLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIHJlYWQ6dXNlcnMiLCJzdWIiOiIzNTdkYjFjNS03NDQyLTQwNzUtOThhMy1mYmU1YzkzOGE0MTkiLCJhdWQiOiJkNzJkNDAwNS0xZmE3LTQ0MzAtOTc1NC04MGQ1ZTI0ODdiYjYifQ.i0NVgvPuf5jvl8JcYNsVCzjVUTDLihgQO4LmLeNijx9Ed3p_EgtVtcHFWFvEebe_LwTuDDtIJveH22Piyp4zresNSc_YNumnuvoY1aNd0ic2RIEtXaklRroq0xHwL_IVT-Dt6P9xL5Hyygx47Pvmci4U3wWK32a6Sb1Mm7ZZgXA00xWI1bJ_zwxFLvDkHDp9nrAa_vEWN6zRBcWc7JYNsgiaPMC0DoL8it0k48_g44zfsjGAZLcWFMoPlYt3wIcQQDeCKMsSJI0VPnqKK0pq4OOVs-pjkMyAU5aEMPvVOwdAL3VZY16RXt3eTzsmMH1XoRdCMP6UAx4ZS10RLGUPeA", "scope": "openid profile email read:users", "user": {"id": "357db1c5-7442-4075-98a3-fbe5c938a419", "name": "S\u00e9rgio R Siqueira", "email": "sergio@somosbeta.com.br", "email_verified": false}, "expires_in": 180, "issued_at": 1758981984, "ttl": 1759586784} -{"id": "email", "sk": "sergio@somosbeta.com.br", "user_id": "357db1c5-7442-4075-98a3-fbe5c938a419"} -{"id": "cpf", "sk": "07879819908", "user_id": "357db1c5-7442-4075-98a3-fbe5c938a419"} - // Session {"id": "SESSION", "sk": "36af142e-9f6d-49d3-bfe9-6a6bd6ab2712", "user_id": "357db1c5-7442-4075-98a3-fbe5c938a419"} // User data -{"id": "357db1c5-7442-4075-98a3-fbe5c938a419", "sk": "0", "name": "Sérgio R Siqueira", "email": "sergio@somosbeta.com.br"} +{"id": "357db1c5-7442-4075-98a3-fbe5c938a419", "sk": "0", "name": "Sérgio R Siqueira", "email": "sergio@somosbeta.com.br", "cpf": "07879819908"} {"id": "357db1c5-7442-4075-98a3-fbe5c938a419", "sk": "PASSWORD", "hash": "$pbkdf2-sha256$29000$IuTcm7M2BiAEgPB.b.3dGw$d8xVCbx8zxg7MeQBrOvCOgniiilsIHEMHzoH/OXftLQ"} {"id": "357db1c5-7442-4075-98a3-fbe5c938a419", "sk": "SCOPE", "scope": ["openid", "profile", "email", "offline_access", "apps:admin"]} {"id": "357db1c5-7442-4075-98a3-fbe5c938a419", "sk": "SESSION#36af142e-9f6d-49d3-bfe9-6a6bd6ab2712", "created_at": "2025-09-17T13:44:34.544491-03:00", "ttl": 1760719474} +{"id": "357db1c5-7442-4075-98a3-fbe5c938a419", "sk": "NEVER_LOGGED"} + {"id": "fd5914ec-fd37-458b-b6b9-8aeab38b666b", "sk": "0", "name": "Johnny Cash", "email": "johnny@johnnycash.com"} {"id": "fd5914ec-fd37-458b-b6b9-8aeab38b666b", "sk": "PASSWORD", "hash": "$pbkdf2-sha256$29000$IuTcm7M2BiAEgPB.b.3dGw$d8xVCbx8zxg7MeQBrOvCOgniiilsIHEMHzoH/OXftLQ"} -{"id": "fd5914ec-fd37-458b-b6b9-8aeab38b666b", "sk": "SCOPE", "scope": ["openid"]} \ No newline at end of file +{"id": "fd5914ec-fd37-458b-b6b9-8aeab38b666b", "sk": "SCOPE", "scope": ["openid"]} + +{"id": "email", "sk": "sergio@somosbeta.com.br", "user_id": "357db1c5-7442-4075-98a3-fbe5c938a419"} +{"id": "email", "sk": "osergiosiqueira@gmail.com", "user_id": "357db1c5-7442-4075-98a3-fbe5c938a419"} +{"id": "cpf", "sk": "07879819908", "user_id": "357db1c5-7442-4075-98a3-fbe5c938a419"}