add register

This commit is contained in:
2025-12-02 22:54:27 -03:00
parent e5ac436c5a
commit d461a507f9
6 changed files with 279 additions and 44 deletions

View File

@@ -1,9 +1,11 @@
from typing import Any from typing import Any
from aws_lambda_powertools import Logger, Tracer 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 ( from aws_lambda_powertools.event_handler.api_gateway import (
APIGatewayHttpResolver, APIGatewayHttpResolver,
) )
from aws_lambda_powertools.event_handler.exceptions import ServiceError
from aws_lambda_powertools.logging import correlation_paths from aws_lambda_powertools.logging import correlation_paths
from aws_lambda_powertools.utilities.typing import LambdaContext from aws_lambda_powertools.utilities.typing import LambdaContext
@@ -40,6 +42,19 @@ def health():
return {'status': 'available'} 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) @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_HTTP)
@tracer.capture_lambda_handler @tracer.capture_lambda_handler
def lambda_handler(event: dict[str, Any], context: LambdaContext) -> dict[str, Any]: def lambda_handler(event: dict[str, Any], context: LambdaContext) -> dict[str, Any]:

View File

@@ -25,6 +25,12 @@ dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client)
idp = boto3.client('cognito-idp') idp = boto3.client('cognito-idp')
class InvalidCredentialsError(ForbiddenError): ...
class UserNotFoundError(NotFoundError): ...
@router.post('/authentication') @router.post('/authentication')
def authentication( def authentication(
username: Annotated[str, Body()], username: Annotated[str, Body()],
@@ -36,7 +42,7 @@ def authentication(
_get_idp_user(user_id, username, password) _get_idp_user(user_id, username, password)
else: else:
if not pbkdf2_sha256.verify(password, password_hash): if not pbkdf2_sha256.verify(password, password_hash):
raise ForbiddenError('Invalid credentials') raise InvalidCredentialsError('Invalid credentials')
return Response( return Response(
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
@@ -61,7 +67,7 @@ def _get_user(username: str) -> tuple[str, str | None]:
) )
if not user: if not user:
raise UserNotFoundError() raise UserNotFoundError('User not found')
password = dyn.collection.get_item( password = dyn.collection.get_item(
KeyPair( KeyPair(
@@ -121,13 +127,13 @@ def _get_idp_user(
} }
) )
except Exception: except Exception:
raise ForbiddenError('Invalid credentials') raise InvalidCredentialsError('Invalid credentials')
return True return True
def new_session(sub: str) -> str: def new_session(user_id: str) -> str:
sid = str(uuid4()) session_id = str(uuid4())
now_ = now() now_ = now()
exp = ttl(start_dt=now_, seconds=SESSION_EXPIRES_IN) exp = ttl(start_dt=now_, seconds=SESSION_EXPIRES_IN)
@@ -135,24 +141,19 @@ def new_session(sub: str) -> str:
transact.put( transact.put(
item={ item={
'id': 'SESSION', 'id': 'SESSION',
'sk': sid, 'sk': session_id,
'user_id': sub, 'user_id': user_id,
'ttl': exp, 'ttl': exp,
'created_at': now_, 'created_at': now_,
} }
) )
transact.put( transact.put(
item={ item={
'id': sub, 'id': user_id,
'sk': f'SESSION#{sid}', 'sk': f'SESSION#{session_id}',
'ttl': exp, 'ttl': exp,
'created_at': now_, 'created_at': now_,
} }
) )
return f'{sid}:{sub}' return f'{session_id}:{user_id}'
class UserNotFoundError(NotFoundError):
def __init__(self, *_):
super().__init__('User not found')

View File

@@ -1,12 +1,17 @@
from dataclasses import asdict, dataclass
from http import HTTPStatus from http import HTTPStatus
from typing import Annotated 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 ServiceError from aws_lambda_powertools.event_handler.exceptions import NotFoundError, ServiceError
from aws_lambda_powertools.event_handler.openapi.params import Body from aws_lambda_powertools.event_handler.openapi.params import Body
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey from layercake.dateutils import now, ttl
from layercake.extra_types import CnpjStr, CpfStr, NameStr from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
from pydantic import BaseModel, EmailStr 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 boto3clients import dynamodb_client
from config import OAUTH2_TABLE from config import OAUTH2_TABLE
@@ -15,24 +20,181 @@ router = Router()
dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client) dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client)
class UserConflictError(ServiceError): class ConflictError(ServiceError):
def __init__(self, msg: str | dict): def __init__(self, msg: str | dict):
super().__init__(HTTPStatus.CONFLICT, msg) super().__init__(HTTPStatus.CONFLICT, msg)
class Org(BaseModel): class UserNotFound(NotFoundError): ...
id: str | None
class CPFConflictError(ConflictError): ...
class EmailConflictError(ConflictError): ...
class NeverLoggedConflictError(ConflictError): ...
@dataclass(frozen=True)
class User:
id: str
name: str name: str
cnpj: CnpjStr email: str
cpf: str
@router.get('/register') @router.post('/register')
def register( def register(
id: Annotated[UUID4, Body(embed=True, alias='id', default_factory=uuid4)],
name: Annotated[NameStr, Body(embed=True)], name: Annotated[NameStr, Body(embed=True)],
email: Annotated[EmailStr, Body(embed=True)], email: Annotated[EmailStr, Body(embed=True)],
password: Annotated[str, Body(min_length=6, embed=True)], password: Annotated[str, Body(min_length=6, embed=True)],
cpf: Annotated[CpfStr, Body(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,
)

View File

@@ -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 from ..conftest import HttpApiProxy, LambdaContext
def test_register( def test_preexisting_user(
app, app,
seeds, seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,
@@ -17,14 +18,65 @@ def test_register(
raw_path='/register', raw_path='/register',
method=HTTPMethod.POST, method=HTTPMethod.POST,
body={ 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, 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 def test_preexisting_update_email(
assert len(session['items']) == 2 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

View File

@@ -1,5 +1,4 @@
import json import json
import pprint
from base64 import b64encode from base64 import b64encode
from http import HTTPMethod, HTTPStatus from http import HTTPMethod, HTTPStatus
from urllib.parse import urlencode from urllib.parse import urlencode
@@ -35,7 +34,10 @@ def test_token(
'redirect_uri': 'https://localhost/callback', 'redirect_uri': 'https://localhost/callback',
'code': 'kyqp3oSuRFTfuBaCmq3XOgGWg67l42Kt3D6xPEj7Yd3MLdi9', 'code': 'kyqp3oSuRFTfuBaCmq3XOgGWg67l42Kt3D6xPEj7Yd3MLdi9',
'client_id': client_id, 'client_id': client_id,
'code_verifier': '9072df2d3709425993e733f38fb27a825b8860e699364ce9abafdf51077c0bdb4e456ddb741147a4bec4eeda782d92cc', 'code_verifier': (
'9072df2d3709425993e733f38fb27a825b8860e699364ce9'
'abafdf51077c0bdb4e456ddb741147a4bec4eeda782d92cc'
),
} }
), ),
), ),
@@ -45,7 +47,7 @@ def test_token(
assert r['statusCode'] == HTTPStatus.OK assert r['statusCode'] == HTTPStatus.OK
r = json.loads(r['body']) r = json.loads(r['body'])
assert r['expires_in'] == 180 assert r['expires_in'] == 3600
tokens = dynamodb_persistence_layer.query( tokens = dynamodb_persistence_layer.query(
key_cond_expr='#pk = :pk', key_cond_expr='#pk = :pk',

View File

@@ -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": "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 // Session
{"id": "SESSION", "sk": "36af142e-9f6d-49d3-bfe9-6a6bd6ab2712", "user_id": "357db1c5-7442-4075-98a3-fbe5c938a419"} {"id": "SESSION", "sk": "36af142e-9f6d-49d3-bfe9-6a6bd6ab2712", "user_id": "357db1c5-7442-4075-98a3-fbe5c938a419"}
// User data // 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": "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": "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": "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": "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": "PASSWORD", "hash": "$pbkdf2-sha256$29000$IuTcm7M2BiAEgPB.b.3dGw$d8xVCbx8zxg7MeQBrOvCOgniiilsIHEMHzoH/OXftLQ"}
{"id": "fd5914ec-fd37-458b-b6b9-8aeab38b666b", "sk": "SCOPE", "scope": ["openid"]} {"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"}