From 914b76e70c69202e10dc26269a6210e9e7681ddc Mon Sep 17 00:00:00 2001 From: Eduardo Lauer Date: Thu, 28 Dec 2023 18:25:12 -0300 Subject: [PATCH 01/24] add smtp4dev and mail server env vars --- docker-compose.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index d12b360..d8df889 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,6 @@ services: api-pgd: image: ghcr.io/gestaogovbr/api-pgd:latest - pull_policy: always container_name: api-pgd depends_on: db: @@ -40,8 +39,24 @@ services: # to new `SECRET` run openssl rand -hex 32 SECRET: b8a3054ba3457614e95a88cc0807384430c1b338a54e95e4245f41e060da68bc ACCESS_TOKEN_EXPIRE_MINUTES: 30 + MAIL_USERNAME: '' + MAIL_FROM: admin@api-pgd.gov.br + MAIL_PORT: 25 + MAIL_SERVER: smtp4dev + MAIL_FROM_NAME: admin@api-pgd.gov.br + MAIL_PASSWORD: '' healthcheck: test: ["CMD", "curl", "-f", "http://0.0.0.0:5057/docs"] interval: 5s timeout: 5s retries: 20 + + smtp4dev: + image: rnwood/smtp4dev:v3 + restart: always + ports: + - '5000:80' + - '25:25' # Change the number before : to the port the SMTP server should be accessible on + - '143:143' # Change the number before : to the port the IMAP server should be accessible on + environment: + - ServerOptions__HostName=smtp4dev From c162e89ff605ff47cd5cf9eb9b73728229fcba4b Mon Sep 17 00:00:00 2001 From: Eduardo Lauer Date: Thu, 28 Dec 2023 18:25:28 -0300 Subject: [PATCH 02/24] add fastapi-mail --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 5776e03..24137b0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ httpx==0.24.1 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 python-multipart==0.0.6 +fastapi-mail==1.4.1 \ No newline at end of file From d4c9625a39b1eec7e815f1e4357403cd43c4214e Mon Sep 17 00:00:00 2001 From: Eduardo Lauer Date: Thu, 28 Dec 2023 18:26:01 -0300 Subject: [PATCH 03/24] add forgot_password and reset_password routes --- src/api.py | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/src/api.py b/src/api.py index 4751803..b04e4f7 100644 --- a/src/api.py +++ b/src/api.py @@ -11,12 +11,16 @@ from fastapi.security import OAuth2PasswordRequestForm from fastapi.responses import RedirectResponse from sqlalchemy.exc import IntegrityError +from fastapi_mail import FastMail, MessageSchema, MessageType + import schemas import crud +import logging from db_config import DbContextManager, create_db_and_tables import crud_auth from create_admin_user import init_user_admin +import email_config ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES")) @@ -37,7 +41,6 @@ version=os.environ["TAG_NAME"], ) - @app.on_event("startup") async def on_startup(): await create_db_and_tables() @@ -217,6 +220,62 @@ async def delete_user( detail=f"IntegrityError: {str(exception)}", ) from exception +@app.post( + "/user/forgot_password/{email}", + summary="Recuperação de Acesso", + tags=["Auth"], +) +async def forgot_password( + email: str, + db: DbContextManager = Depends(DbContextManager), + ) -> schemas.UsersGetSchema: + + user = await crud_auth.get_user(db, email) + + if user: + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = crud_auth.create_access_token(data={"sub": user.email}, expires_delta=access_token_expires) + + return await email_config.send_reset_password_mail(email, access_token) + + else: + raise HTTPException( + status.HTTP_404_NOT_FOUND, detail=f"Usuário `{email}` não existe" + ) + +@app.post( + "/user/reset_password/{email}", + summary="Criar nova senha", + tags=["Auth"], +) +async def reset_password( + token: str, + password: str, + user: Annotated[ + schemas.UsersSchema, + Depends(crud_auth.get_current_user), + ], + db: DbContextManager = Depends(DbContextManager), + ) -> schemas.UsersGetSchema: + + """ + Resets password for a user. + """ + try: + await crud_auth.user_reset_password(db, user.email, password) + return {"Senha alterada"} + + except ValueError as e: + raise HTTPException( + status_code=400, detail=f"{e}") + except Exception as e: + raise HTTPException( + status_code=500, detail=f"{e}") + + else: + raise HTTPException( + status.HTTP_404_NOT_FOUND, detail=f"Usuário `{email}` não existe" + ) # ## DATA -------------------------------------------------- From 72887ca4c13199c40190f23f48c1a08c675c27c2 Mon Sep 17 00:00:00 2001 From: Eduardo Lauer Date: Thu, 28 Dec 2023 18:26:29 -0300 Subject: [PATCH 04/24] add fastapi-mail configuration class and templates --- src/email_config.py | 46 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/email_config.py diff --git a/src/email_config.py b/src/email_config.py new file mode 100644 index 0000000..721b959 --- /dev/null +++ b/src/email_config.py @@ -0,0 +1,46 @@ +import os +import logging +from fastapi_mail import FastMail, MessageSchema, ConnectionConfig, MessageType +from starlette.responses import JSONResponse +from typing import List + +conf = ConnectionConfig( + MAIL_USERNAME=os.environ["MAIL_USERNAME"], + MAIL_FROM=os.environ["MAIL_FROM"], + MAIL_PORT=os.environ["MAIL_PORT"], + MAIL_SERVER=os.environ["MAIL_SERVER"], + MAIL_FROM_NAME=os.environ["MAIL_FROM_NAME"], + MAIL_STARTTLS=False, + MAIL_SSL_TLS=False, + MAIL_PASSWORD=os.environ["MAIL_PASSWORD"], + USE_CREDENTIALS=False, + VALIDATE_CERTS=False +) + +async def send_reset_password_mail(email: str, + token: str, + ) -> JSONResponse: + body = f""" + + +

Recuperação de acesso

+

Olá, {email}.

+

Você esqueceu sua senha da API PGD. + Segue o token para geração de nova senha:
{token} +

+ + + """ + try: + message = MessageSchema( + subject="Recuperação de acesso", + recipients=[email], + body=body, + subtype=MessageType.html + ) + fm = FastMail(conf) + await fm.send_message(message) + return JSONResponse(status_code=200, content={"message": "Email enviado!"}) + except Exception as e: + logging.error("Erro ao enviar o email %e", e) + From 60e3849b3402d5fb10b54674ce8b38947fafda01 Mon Sep 17 00:00:00 2001 From: Eduardo Lauer Date: Thu, 28 Dec 2023 18:27:15 -0300 Subject: [PATCH 05/24] create crud function for reset_password --- src/crud_auth.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/crud_auth.py b/src/crud_auth.py index fb31ed8..ebd581e 100644 --- a/src/crud_auth.py +++ b/src/crud_auth.py @@ -234,3 +234,17 @@ async def delete_user( await session.commit() return f"Usuário `{email}` deletado" + +async def user_reset_password(db_session: DbContextManager, + user: schemas.UsersSchema, + email: str, + new_password: str): + + user.password = get_password_hash(new_password) + async with db_session as session: + await session.execute( + update(models.Users).filter_by(email=email).values(**user.model_dump()) + ) + await session.commit() + + return schemas.UsersSchema.model_validate(user) \ No newline at end of file From 14a914c004fbab4537ed059bd25ec599ecb544d5 Mon Sep 17 00:00:00 2001 From: Eduardo Lauer Date: Fri, 29 Dec 2023 11:20:34 -0300 Subject: [PATCH 06/24] refactor forgot and reset password --- src/api.py | 31 ++++++++++--------------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/src/api.py b/src/api.py index b04e4f7..304b757 100644 --- a/src/api.py +++ b/src/api.py @@ -228,7 +228,7 @@ async def delete_user( async def forgot_password( email: str, db: DbContextManager = Depends(DbContextManager), - ) -> schemas.UsersGetSchema: + ) -> schemas.UsersInputSchema: user = await crud_auth.get_user(db, email) @@ -243,39 +243,28 @@ async def forgot_password( status.HTTP_404_NOT_FOUND, detail=f"Usuário `{email}` não existe" ) -@app.post( - "/user/reset_password/{email}", - summary="Criar nova senha", +@app.get( + "/user/reset_password/", + summary="Criar nova senha a partir do token de acesso", tags=["Auth"], ) async def reset_password( - token: str, + access_token: str, password: str, - user: Annotated[ - schemas.UsersSchema, - Depends(crud_auth.get_current_user), - ], db: DbContextManager = Depends(DbContextManager), - ) -> schemas.UsersGetSchema: + ): """ - Resets password for a user. + Gera uma nova senha através do token fornecido por email. """ try: - await crud_auth.user_reset_password(db, user.email, password) - return {"Senha alterada"} + return await crud_auth.user_reset_password(db, access_token, password) except ValueError as e: raise HTTPException( status_code=400, detail=f"{e}") - except Exception as e: - raise HTTPException( - status_code=500, detail=f"{e}") - - else: - raise HTTPException( - status.HTTP_404_NOT_FOUND, detail=f"Usuário `{email}` não existe" - ) + + # ## DATA -------------------------------------------------- From 48eac2b6fdc50c63f30379946d303aa43d2f47fc Mon Sep 17 00:00:00 2001 From: Eduardo Lauer Date: Fri, 29 Dec 2023 11:22:17 -0300 Subject: [PATCH 07/24] implement user_reset_password crud --- src/crud_auth.py | 41 +++++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/src/crud_auth.py b/src/crud_auth.py index ebd581e..90fedb2 100644 --- a/src/crud_auth.py +++ b/src/crud_auth.py @@ -93,11 +93,7 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None): return encoded_jwt - -async def get_current_user( - token: Annotated[str, Depends(oauth2_scheme)], - db: DbContextManager = Depends(DbContextManager), -): +async def verify_token(token: str, db: DbContextManager): credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Credenciais não podem ser validadas", @@ -120,6 +116,18 @@ async def get_current_user( return user +async def get_current_user( + token: Annotated[str, Depends(oauth2_scheme)], + db: DbContextManager = Depends(DbContextManager), +): + return await verify_token(token, db) + +async def get_user_by_token( + token: str, + db: DbContextManager = Depends(DbContextManager), +): + return await verify_token(token, db) + async def get_current_active_user( current_user: Annotated[schemas.UsersSchema, Depends(get_current_user)] @@ -236,15 +244,28 @@ async def delete_user( return f"Usuário `{email}` deletado" async def user_reset_password(db_session: DbContextManager, - user: schemas.UsersSchema, - email: str, - new_password: str): + token: str, + new_password: str) -> str: + """Reset password of a user by passing a access token. + + Args: + db_session (DbContextManager): Session with api database + token (str): access token sended by email + new_password (str): the new password for encryption + + Returns: + str: Message about updated password + """ + + user = await get_user_by_token(token, db_session) + user.password = get_password_hash(new_password) + async with db_session as session: await session.execute( - update(models.Users).filter_by(email=email).values(**user.model_dump()) + update(models.Users).filter_by(email=user.email).values(**user.model_dump()) ) await session.commit() - return schemas.UsersSchema.model_validate(user) \ No newline at end of file + return f"Senha do Usuário {user.email} atualizada" \ No newline at end of file From 1711f834d44d96b7ca46ba4620ef89ada5e1b10a Mon Sep 17 00:00:00 2001 From: Eduardo Lauer Date: Fri, 29 Dec 2023 11:22:35 -0300 Subject: [PATCH 08/24] create basic test for forgot password --- tests/user_test.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/user_test.py b/tests/user_test.py index 3c0ddf4..d4b26d5 100644 --- a/tests/user_test.py +++ b/tests/user_test.py @@ -223,4 +223,13 @@ def test_delete_user_not_exists_logged_in_admin( def test_delete_yourself(client: Client, user1_credentials: dict, header_usr_1: dict): response = client.delete( f"/user/{user1_credentials['email']}", headers=header_usr_1) - assert response.status_code == status.HTTP_401_UNAUTHORIZED \ No newline at end of file + assert response.status_code == status.HTTP_401_UNAUTHORIZED + +# forgot/reset password + +def test_forgot_password(client: Client, + user1_credentials: dict, + header_usr_1: dict): + response = client.post( + f"/user/forgot_password/{user1_credentials['email']}", headers=header_usr_1) + assert response.status_code == status.HTTP_200_OK \ No newline at end of file From c1686dbe6b5debdae20f766c59dc3c3b4cd47972 Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Fri, 19 Jan 2024 16:33:43 -0300 Subject: [PATCH 09/24] Format code with black --- src/api.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/api.py b/src/api.py index 304b757..3ed62c6 100644 --- a/src/api.py +++ b/src/api.py @@ -41,6 +41,7 @@ version=os.environ["TAG_NAME"], ) + @app.on_event("startup") async def on_startup(): await create_db_and_tables() @@ -113,7 +114,6 @@ async def get_users( ], db: DbContextManager = Depends(DbContextManager), ) -> list[schemas.UsersGetSchema]: - return await crud_auth.get_all_users(db) @@ -124,8 +124,7 @@ async def get_users( ) async def create_or_update_user( user_logged: Annotated[ - schemas.UsersSchema, - Depends(crud_auth.get_current_admin_user) + schemas.UsersSchema, Depends(crud_auth.get_current_admin_user) ], user: schemas.UsersSchema, email: str, @@ -220,6 +219,7 @@ async def delete_user( detail=f"IntegrityError: {str(exception)}", ) from exception + @app.post( "/user/forgot_password/{email}", summary="Recuperação de Acesso", @@ -228,21 +228,23 @@ async def delete_user( async def forgot_password( email: str, db: DbContextManager = Depends(DbContextManager), - ) -> schemas.UsersInputSchema: - +) -> schemas.UsersInputSchema: user = await crud_auth.get_user(db, email) if user: access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - access_token = crud_auth.create_access_token(data={"sub": user.email}, expires_delta=access_token_expires) - + access_token = crud_auth.create_access_token( + data={"sub": user.email}, expires_delta=access_token_expires + ) + return await email_config.send_reset_password_mail(email, access_token) - + else: raise HTTPException( status.HTTP_404_NOT_FOUND, detail=f"Usuário `{email}` não existe" ) + @app.get( "/user/reset_password/", summary="Criar nova senha a partir do token de acesso", @@ -252,8 +254,7 @@ async def reset_password( access_token: str, password: str, db: DbContextManager = Depends(DbContextManager), - ): - +): """ Gera uma nova senha através do token fornecido por email. """ @@ -261,10 +262,8 @@ async def reset_password( return await crud_auth.user_reset_password(db, access_token, password) except ValueError as e: - raise HTTPException( - status_code=400, detail=f"{e}") - - + raise HTTPException(status_code=400, detail=f"{e}") + # ## DATA -------------------------------------------------- From de8387bc1fe48ee7c5cecb0c2fd8d624f30c9c3c Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Fri, 19 Jan 2024 16:38:04 -0300 Subject: [PATCH 10/24] Solve some pylint warnings --- src/api.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/api.py b/src/api.py index 3ed62c6..0051838 100644 --- a/src/api.py +++ b/src/api.py @@ -11,12 +11,10 @@ from fastapi.security import OAuth2PasswordRequestForm from fastapi.responses import RedirectResponse from sqlalchemy.exc import IntegrityError -from fastapi_mail import FastMail, MessageSchema, MessageType import schemas import crud -import logging from db_config import DbContextManager, create_db_and_tables import crud_auth from create_admin_user import init_user_admin @@ -111,7 +109,7 @@ async def get_users( user_logged: Annotated[ schemas.UsersSchema, Depends(crud_auth.get_current_admin_user), - ], + ], # pylint: disable=unused-argument db: DbContextManager = Depends(DbContextManager), ) -> list[schemas.UsersGetSchema]: return await crud_auth.get_all_users(db) @@ -125,7 +123,7 @@ async def get_users( async def create_or_update_user( user_logged: Annotated[ schemas.UsersSchema, Depends(crud_auth.get_current_admin_user) - ], + ], # pylint: disable=unused-argument user: schemas.UsersSchema, email: str, db: DbContextManager = Depends(DbContextManager), @@ -176,7 +174,7 @@ async def get_user( user_logged: Annotated[ schemas.UsersSchema, Depends(crud_auth.get_current_admin_user), - ], + ], # pylint: disable=unused-argument email: str, db: DbContextManager = Depends(DbContextManager), ) -> schemas.UsersGetSchema: @@ -239,10 +237,9 @@ async def forgot_password( return await email_config.send_reset_password_mail(email, access_token) - else: - raise HTTPException( - status.HTTP_404_NOT_FOUND, detail=f"Usuário `{email}` não existe" - ) + raise HTTPException( + status.HTTP_404_NOT_FOUND, detail=f"Usuário `{email}` não existe" + ) @app.get( @@ -262,7 +259,7 @@ async def reset_password( return await crud_auth.user_reset_password(db, access_token, password) except ValueError as e: - raise HTTPException(status_code=400, detail=f"{e}") + raise HTTPException(status_code=400, detail=f"{e}") from e # ## DATA -------------------------------------------------- From 07f57dcf20fc54b6833b0253f48bee8b1bb87fd8 Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Fri, 19 Jan 2024 16:40:00 -0300 Subject: [PATCH 11/24] Move up pylint comment --- src/api.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/api.py b/src/api.py index 0051838..a3f5f14 100644 --- a/src/api.py +++ b/src/api.py @@ -106,10 +106,10 @@ async def login_for_access_token( tags=["Auth"], ) async def get_users( - user_logged: Annotated[ + user_logged: Annotated[ # pylint: disable=unused-argument schemas.UsersSchema, Depends(crud_auth.get_current_admin_user), - ], # pylint: disable=unused-argument + ], db: DbContextManager = Depends(DbContextManager), ) -> list[schemas.UsersGetSchema]: return await crud_auth.get_all_users(db) @@ -121,9 +121,9 @@ async def get_users( tags=["Auth"], ) async def create_or_update_user( - user_logged: Annotated[ + user_logged: Annotated[ # pylint: disable=unused-argument schemas.UsersSchema, Depends(crud_auth.get_current_admin_user) - ], # pylint: disable=unused-argument + ], user: schemas.UsersSchema, email: str, db: DbContextManager = Depends(DbContextManager), @@ -171,10 +171,10 @@ async def create_or_update_user( tags=["Auth"], ) async def get_user( - user_logged: Annotated[ + user_logged: Annotated[ # pylint: disable=unused-argument schemas.UsersSchema, Depends(crud_auth.get_current_admin_user), - ], # pylint: disable=unused-argument + ], email: str, db: DbContextManager = Depends(DbContextManager), ) -> schemas.UsersGetSchema: From 148313de7e64656264015fd47744a1633ab77229 Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Fri, 19 Jan 2024 16:42:04 -0300 Subject: [PATCH 12/24] Remove old TODO comments --- src/api.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/api.py b/src/api.py index a3f5f14..596244a 100644 --- a/src/api.py +++ b/src/api.py @@ -304,11 +304,6 @@ async def create_or_update_plano_entregas( plano_entregas: schemas.PlanoEntregasSchema, response: Response, db: DbContextManager = Depends(DbContextManager), - # TODO: Obter meios de verificar permissão opcional. O código abaixo - # bloqueia o acesso, mesmo informando que é opcional. - # access_token_info: Optional[FiefAccessTokenInfo] = Depends( - # auth_backend.authenticated(permissions=["all:read"], optional=True) - # ), ): """Cria um novo plano de entregas ou, se existente, substitui um plano de entregas por um novo com os dados informados.""" @@ -432,11 +427,6 @@ async def create_or_update_plano_trabalho( plano_trabalho: schemas.PlanoTrabalhoSchema, response: Response, db: DbContextManager = Depends(DbContextManager), - # TODO: Obter meios de verificar permissão opcional. O código abaixo - # bloqueia o acesso, mesmo informando que é opcional. - # access_token_info: Optional[FiefAccessTokenInfo] = Depends( - # auth_backend.authenticated(permissions=["all:read"], optional=True) - # ), ): """Cria um novo plano de trabalho ou, se existente, substitui um plano de trabalho por um novo com os dados informados.""" From 404654d690071c882eebf4990b35fbe077c52f35 Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Fri, 19 Jan 2024 16:52:43 -0300 Subject: [PATCH 13/24] Solve pylint warnings --- src/email_config.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/email_config.py b/src/email_config.py index 721b959..96e4b7e 100644 --- a/src/email_config.py +++ b/src/email_config.py @@ -1,8 +1,8 @@ import os import logging from fastapi_mail import FastMail, MessageSchema, ConnectionConfig, MessageType +from fastapi_mail.errors import DBProvaiderError, ConnectionErrors, ApiError from starlette.responses import JSONResponse -from typing import List conf = ConnectionConfig( MAIL_USERNAME=os.environ["MAIL_USERNAME"], @@ -41,6 +41,7 @@ async def send_reset_password_mail(email: str, fm = FastMail(conf) await fm.send_message(message) return JSONResponse(status_code=200, content={"message": "Email enviado!"}) - except Exception as e: + except (DBProvaiderError, ConnectionErrors, ApiError) as e: logging.error("Erro ao enviar o email %e", e) - + finally: + raise e From 4ad734ba2eb276374c9307dd234ef4aa98cfb8fe Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Fri, 19 Jan 2024 17:08:05 -0300 Subject: [PATCH 14/24] Add docstring to function --- src/email_config.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/email_config.py b/src/email_config.py index 96e4b7e..4c7debc 100644 --- a/src/email_config.py +++ b/src/email_config.py @@ -20,6 +20,19 @@ async def send_reset_password_mail(email: str, token: str, ) -> JSONResponse: + """Envia o e-mail contendo token para redefinir a senha. + + Args: + email (str): o email do usuário. + token (str): o token para redefinir a senha. + + Raises: + e: exceção gerada pelo FastAPI Mail. + + Returns: + JSONResponse: resposta retornada para o endpoint. + """ + token_expiration_minutes = os.environ["ACCESS_TOKEN_EXPIRE_MINUTES"] body = f""" From cba4eb0eedf10db34c94badab26e364520ba2f60 Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Fri, 19 Jan 2024 17:08:26 -0300 Subject: [PATCH 15/24] Make message more informative --- src/email_config.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/email_config.py b/src/email_config.py index 4c7debc..2dca850 100644 --- a/src/email_config.py +++ b/src/email_config.py @@ -34,16 +34,26 @@ async def send_reset_password_mail(email: str, """ token_expiration_minutes = os.environ["ACCESS_TOKEN_EXPIRE_MINUTES"] body = f""" - - + +

Recuperação de acesso

Olá, {email}.

-

Você esqueceu sua senha da API PGD. - Segue o token para geração de nova senha:
{token} -

- - - """ +

Foi solicitada a recuperação de sua senha da API PGD. + Caso essa solicitação não tenha sido feita por você, por + favor ignore esta mensagem.

+

Foi gerado o seguinte token para geração de uma nova + senha.

+
+
Token
+
{token}
+
Prazo de validade
+
{token_expiration_minutes} minutos
+
+

Utilize o endpoint /user/reset_password com + este token para redefinir a senha.

+ + + """ try: message = MessageSchema( subject="Recuperação de acesso", From b7bd0a81bd4e7be5032925beeb692ffe9824fac8 Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Fri, 19 Jan 2024 17:18:00 -0300 Subject: [PATCH 16/24] Fix error in raising exeption --- src/email_config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/email_config.py b/src/email_config.py index 2dca850..883732e 100644 --- a/src/email_config.py +++ b/src/email_config.py @@ -66,5 +66,4 @@ async def send_reset_password_mail(email: str, return JSONResponse(status_code=200, content={"message": "Email enviado!"}) except (DBProvaiderError, ConnectionErrors, ApiError) as e: logging.error("Erro ao enviar o email %e", e) - finally: raise e From 54ab74e3c6c09142ecaf9f565cab9ddfbef509f7 Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Fri, 19 Jan 2024 17:52:16 -0300 Subject: [PATCH 17/24] Apply black formatting --- tests/user_test.py | 87 ++++++++++++++++++++-------------------------- 1 file changed, 38 insertions(+), 49 deletions(-) diff --git a/tests/user_test.py b/tests/user_test.py index d4b26d5..795f0e8 100644 --- a/tests/user_test.py +++ b/tests/user_test.py @@ -65,6 +65,7 @@ def test_get_all_users_logged_in_admin(client: Client, header_usr_1: dict): response = client.get("/users", headers=header_usr_1) assert response.status_code == status.HTTP_200_OK + # get /user def test_get_user_not_logged_in(client: Client, header_not_logged_in: dict): response = client.get("/user/foo@oi.com", headers=header_not_logged_in) @@ -72,32 +73,26 @@ def test_get_user_not_logged_in(client: Client, header_not_logged_in: dict): def test_get_user_logged_in_not_admin( - client: Client, user2_credentials: dict, header_usr_2: dict # user is_admin=False + client: Client, user2_credentials: dict, header_usr_2: dict # user is_admin=False ): response = client.get(f"/user/{user2_credentials['email']}", headers=header_usr_2) assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_get_user_as_admin( - client: Client, - user1_credentials: dict, - header_usr_1: dict # user is_admin=True + client: Client, user1_credentials: dict, header_usr_1: dict # user is_admin=True ): response = client.get(f"/user/{user1_credentials['email']}", headers=header_usr_1) assert response.status_code == status.HTTP_200_OK -def test_get_user_not_exists( - client: Client, header_usr_1: dict # user is_admin=True -): +def test_get_user_not_exists(client: Client, header_usr_1: dict): # user is_admin=True response = client.get(f"/user/{USERS_TEST[1]['email']}", headers=header_usr_1) assert response.status_code == status.HTTP_404_NOT_FOUND def test_get_user_self_logged_in( - client: Client, - user1_credentials: dict, - header_usr_1: dict # user is_admin=True + client: Client, user1_credentials: dict, header_usr_1: dict # user is_admin=True ): response = client.get(f"/user/{user1_credentials['email']}", headers=header_usr_1) assert response.status_code == status.HTTP_200_OK @@ -116,15 +111,16 @@ def test_get_user_self_logged_in( # create /user def test_create_user_not_logged_in(client: Client, header_not_logged_in: dict): response = client.put( - f"/user/{USERS_TEST[0]['email']}", headers=header_not_logged_in, json=USERS_TEST[0] + f"/user/{USERS_TEST[0]['email']}", + headers=header_not_logged_in, + json=USERS_TEST[0], ) assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_create_user_logged_in_not_admin( - client: Client, - header_usr_2: dict # user is_admin=False - ): + client: Client, header_usr_2: dict # user is_admin=False +): response = client.put( f"/user/{USERS_TEST[0]['email']}", headers=header_usr_2, json=USERS_TEST[0] ) @@ -132,9 +128,8 @@ def test_create_user_logged_in_not_admin( def test_create_user_logged_in_admin( - client: Client, - header_usr_1: dict # user is_admin=True - ): + client: Client, header_usr_1: dict # user is_admin=True +): response = client.put( f"/user/{USERS_TEST[0]['email']}", headers=header_usr_1, json=USERS_TEST[0] ) @@ -142,9 +137,8 @@ def test_create_user_logged_in_admin( def test_create_user_without_required_fields( - client: Client, - header_usr_1: dict # user is_admin=True - ): + client: Client, header_usr_1: dict # user is_admin=True +): response = client.put( f"/user/{USERS_TEST[2]['email']}", headers=header_usr_1, json=USERS_TEST[2] ) @@ -152,8 +146,7 @@ def test_create_user_without_required_fields( def test_create_user_bad_email_format( - client: Client, - header_usr_1: dict # user is_admin=True + client: Client, header_usr_1: dict # user is_admin=True ): response = client.put( f"/user/{USERS_TEST[3]['email']}", headers=header_usr_1, json=USERS_TEST[3] @@ -162,10 +155,7 @@ def test_create_user_bad_email_format( # update /user -def test_update_user( - client: Client, - header_usr_1: dict # user is_admin=True - ): +def test_update_user(client: Client, header_usr_1: dict): # user is_admin=True # get r_1 = client.get(f"/user/{USERS_TEST[0]['email']}", headers=header_usr_1) data_before = r_1.json() @@ -183,53 +173,52 @@ def test_update_user( r_3 = client.get(f"/user/{USERS_TEST[0]['email']}", headers=header_usr_1) data_after = r_3.json() - assert data_before.get("cod_SIAPE_instituidora", None) == data_after.get("cod_SIAPE_instituidora", None) + assert data_before.get("cod_SIAPE_instituidora", None) == data_after.get( + "cod_SIAPE_instituidora", None + ) # delete /user def test_delete_user_not_logged_in(client: Client, header_not_logged_in: dict): response = client.delete( - f"/user/{USERS_TEST[0]['email']}", headers=header_not_logged_in) + f"/user/{USERS_TEST[0]['email']}", headers=header_not_logged_in + ) assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_delete_user_logged_in_not_admin( - client: Client, - header_usr_2: dict # user is_admin=False - ): - response = client.delete( - f"/user/{USERS_TEST[0]['email']}", headers=header_usr_2) + client: Client, header_usr_2: dict # user is_admin=False +): + response = client.delete(f"/user/{USERS_TEST[0]['email']}", headers=header_usr_2) assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_delete_user_logged_in_admin( - client: Client, - header_usr_1: dict # user is_admin=True - ): - response = client.delete( - f"/user/{USERS_TEST[0]['email']}", headers=header_usr_1) + client: Client, header_usr_1: dict # user is_admin=True +): + response = client.delete(f"/user/{USERS_TEST[0]['email']}", headers=header_usr_1) assert response.status_code == status.HTTP_200_OK def test_delete_user_not_exists_logged_in_admin( - client: Client, - header_usr_1: dict # user is_admin=True - ): - response = client.delete( - f"/user/{USERS_TEST[1]['email']}", headers=header_usr_1) + client: Client, header_usr_1: dict # user is_admin=True +): + response = client.delete(f"/user/{USERS_TEST[1]['email']}", headers=header_usr_1) assert response.status_code == status.HTTP_404_NOT_FOUND def test_delete_yourself(client: Client, user1_credentials: dict, header_usr_1: dict): response = client.delete( - f"/user/{user1_credentials['email']}", headers=header_usr_1) + f"/user/{user1_credentials['email']}", headers=header_usr_1 + ) assert response.status_code == status.HTTP_401_UNAUTHORIZED + # forgot/reset password -def test_forgot_password(client: Client, - user1_credentials: dict, - header_usr_1: dict): + +def test_forgot_password(client: Client, user1_credentials: dict, header_usr_1: dict): response = client.post( - f"/user/forgot_password/{user1_credentials['email']}", headers=header_usr_1) - assert response.status_code == status.HTTP_200_OK \ No newline at end of file + f"/user/forgot_password/{user1_credentials['email']}", headers=header_usr_1 + ) + assert response.status_code == status.HTTP_200_OK From c0eb6e7dad4c597254cb06b1a6de60a3bf6b6523 Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Mon, 22 Jan 2024 18:02:04 -0300 Subject: [PATCH 18/24] Get access token in email and redefine password --- tests/user_test.py | 142 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/tests/user_test.py b/tests/user_test.py index 795f0e8..48524a6 100644 --- a/tests/user_test.py +++ b/tests/user_test.py @@ -5,6 +5,11 @@ * header_usr_1: dict is_admin=True * header_usr_2: dict is_admin=True """ +from datetime import datetime +from imaplib import IMAP4 +import email +import re +from typing import Generator from httpx import Client from fastapi import status @@ -43,6 +48,8 @@ }, ] +TOKEN_IN_MESSAGE = re.compile(r"([^<]+)") + # post /token def test_authenticate(header_usr_1: dict): @@ -217,8 +224,143 @@ def test_delete_yourself(client: Client, user1_credentials: dict, header_usr_1: # forgot/reset password +def get_all_mailbox_messages( + host: str = "localhost", + user: str = "smtp4dev", + password: str = "", +) -> Generator[email.message.Message]: + """Get messages from the mailbox. + + Args: + host (str, optional): IMAP connection host. Defaults to "localhost". + user (str, optional): IMAP connection user name. Defaults to "smtp4dev". + password (str, optional): IMAP connection password. Defaults to "". + + Raises: + ValueError: if the mailbox can't be accessed. + + Yields: + Iterator[email.message.Message]: each message found in the mailbox. + """ + with IMAP4(host=host, port=143) as connection: + connection.login(user, password) + connection.enable("UTF8=ACCEPT") + query_status, inbox = connection.select(readonly=True) + if query_status != "OK": + raise ValueError("Access to email inbox failed.") + message_count = int(inbox[0]) + for message_index in range(message_count + 1): + query_status, response = connection.fetch(str(message_index), "(RFC822)") + for item in response: + if isinstance(item, tuple): + yield email.message_from_bytes(item[1]) + + +def get_latest_message_uid(messages: dict[int, email.message.Message]) -> str: + """Get the latest message uid from the mailbox. + + Args: + messages (dict[int,email.message.Message]): a dict containing + the message uids for keys and the message object for values, + representing all messages in the mailbox. + + Returns: + str: the latest message's uid. + """ + return max( + ( + (datetime.strptime(message["date"], "%a, %d %b %Y %H:%M:%S %z"), index) + for index, message in messages.items() + ) + )[1] + + +def get_message_body( + uid: int, + host: str = "localhost", + user: str = "smtp4dev", + password: str = "", +) -> str: + """Given an uid, get the message's body from the IMAP server. + + Args: + uid (int): the uid of the message. + host (str, optional): IMAP connection host. Defaults to "localhost". + user (str, optional): IMAP connection user name. Defaults to "smtp4dev". + password (str, optional): IMAP connection password. Defaults to "". + + Raises: + ValueError: _description_ + + Returns: + str: _description_ + """ + with IMAP4(host=host, port=143) as connection: + connection.login(user, password) + connection.enable("UTF8=ACCEPT") + query_status, _ = connection.select(readonly=True) + if query_status != "OK": + raise ValueError("Access to email inbox failed.") + query_status, response = connection.fetch(str(uid), "(RFC822)") + message = email.message_from_bytes(response[0][1]) + content = [ + part for part in message.walk() if part.get_content_type() == "text/html" + ][0].get_payload(decode=True) + return content.decode("utf-8") + + +def get_token_from_content(content: str) -> str: + """Get the token from the email content. + + Args: + content (str): content of email. + + Raises: + ValueError: if no token is found in the email. + + Returns: + str: the access token. + """ + match = TOKEN_IN_MESSAGE.search(content) + if match: + return match.group(1) + raise ValueError("Message contains no token.") + + +def get_token_from_email( + host: str = "localhost", + user: str = "smtp4dev", + password: str = "", +) -> str: + """Access the mailbox and get the token from the email. + + Args: + host (str, optional): IMAP connection host. Defaults to "localhost". + user (str, optional): IMAP connection user name. Defaults to "smtp4dev". + password (str, optional): IMAP connection password. Defaults to "". + + Returns: + str: the access token. + """ + messages = dict(enumerate(get_all_mailbox_messages(host, user, password), start=1)) + latest_message = get_latest_message_uid(messages) + return get_token_from_content(get_message_body(latest_message)) + + def test_forgot_password(client: Client, user1_credentials: dict, header_usr_1: dict): response = client.post( f"/user/forgot_password/{user1_credentials['email']}", headers=header_usr_1 ) assert response.status_code == status.HTTP_200_OK + + access_token = get_token_from_email() + new_password = "new_password_for_test" + response = client.get( + "/user/reset_password/", + params={"access_token": access_token, "password": new_password}, + ) + assert response.status_code == status.HTTP_200_OK + + # TODO: test if the new credentials work as expected + # TODO: truncate and register user so as not to interfere in other + # tests From 1af24e835ff956a5d2572b7e07ca9bd7e923149c Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Mon, 22 Jan 2024 18:08:11 -0300 Subject: [PATCH 19/24] Fix generator type annotation syntax --- tests/user_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/user_test.py b/tests/user_test.py index 48524a6..f69fb19 100644 --- a/tests/user_test.py +++ b/tests/user_test.py @@ -228,7 +228,7 @@ def get_all_mailbox_messages( host: str = "localhost", user: str = "smtp4dev", password: str = "", -) -> Generator[email.message.Message]: +) -> Generator[email.message.Message, None, None]: """Get messages from the mailbox. Args: From 7a459d4c117959180a1767c201f2a363217e70ef Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Mon, 22 Jan 2024 18:08:41 -0300 Subject: [PATCH 20/24] Fix imap hostname in docker network for test --- tests/user_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/user_test.py b/tests/user_test.py index f69fb19..8c6475c 100644 --- a/tests/user_test.py +++ b/tests/user_test.py @@ -353,7 +353,7 @@ def test_forgot_password(client: Client, user1_credentials: dict, header_usr_1: ) assert response.status_code == status.HTTP_200_OK - access_token = get_token_from_email() + access_token = get_token_from_email(host="smtp4dev") new_password = "new_password_for_test" response = client.get( "/user/reset_password/", From 8e7e99a009585fee1e51c8d6b711e4674da808eb Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Tue, 23 Jan 2024 11:13:43 -0300 Subject: [PATCH 21/24] Fix host in IMAP connection --- tests/user_test.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/user_test.py b/tests/user_test.py index 8c6475c..34abd2e 100644 --- a/tests/user_test.py +++ b/tests/user_test.py @@ -225,7 +225,7 @@ def test_delete_yourself(client: Client, user1_credentials: dict, header_usr_1: def get_all_mailbox_messages( - host: str = "localhost", + host: str = "smtp4dev", user: str = "smtp4dev", password: str = "", ) -> Generator[email.message.Message, None, None]: @@ -277,7 +277,7 @@ def get_latest_message_uid(messages: dict[int, email.message.Message]) -> str: def get_message_body( uid: int, - host: str = "localhost", + host: str = "smtp4dev", user: str = "smtp4dev", password: str = "", ) -> str: @@ -328,7 +328,7 @@ def get_token_from_content(content: str) -> str: def get_token_from_email( - host: str = "localhost", + host: str = "smtp4dev", user: str = "smtp4dev", password: str = "", ) -> str: @@ -344,7 +344,7 @@ def get_token_from_email( """ messages = dict(enumerate(get_all_mailbox_messages(host, user, password), start=1)) latest_message = get_latest_message_uid(messages) - return get_token_from_content(get_message_body(latest_message)) + return get_token_from_content(get_message_body(uid=latest_message, host=host)) def test_forgot_password(client: Client, user1_credentials: dict, header_usr_1: dict): From ed2d282091f91f44e225b01eeaf616b25c1389be Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Tue, 23 Jan 2024 11:46:36 -0300 Subject: [PATCH 22/24] Apply black formatter --- tests/conftest.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index a07f642..9405a36 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -121,6 +121,7 @@ def delete_user(username: str, password: str, del_user_email: str) -> httpx.Resp return response + def create_user(username: str, password: str, new_user: dict) -> httpx.Response: """_summary_ @@ -135,7 +136,9 @@ def create_user(username: str, password: str, new_user: dict) -> httpx.Response: headers = prepare_header(username, password) new_user_pop = {key: value for key, value in new_user.items() if key != "username"} - response = httpx.put(f"{API_BASE_URL}/user/{new_user['email']}", headers=headers, json=new_user_pop) + response = httpx.put( + f"{API_BASE_URL}/user/{new_user['email']}", headers=headers, json=new_user_pop + ) response.raise_for_status() return response @@ -269,7 +272,9 @@ def fixture_truncate_users(admin_credentials: dict): ): if del_user_email != admin_credentials["username"]: response = delete_user( - admin_credentials["username"], admin_credentials["password"], del_user_email + admin_credentials["username"], + admin_credentials["password"], + del_user_email, ) response.raise_for_status() @@ -280,7 +285,9 @@ def fixture_register_user_1( user1_credentials: dict, admin_credentials: dict, ) -> httpx.Response: - response = create_user(admin_credentials["username"], admin_credentials["password"], user1_credentials) + response = create_user( + admin_credentials["username"], admin_credentials["password"], user1_credentials + ) response.raise_for_status() return response @@ -292,11 +299,14 @@ def fixture_register_user_2( user2_credentials: dict, admin_credentials: dict, ) -> httpx.Response: - response = create_user(admin_credentials["username"], admin_credentials["password"], user2_credentials) + response = create_user( + admin_credentials["username"], admin_credentials["password"], user2_credentials + ) response.raise_for_status() return response + @pytest.fixture(scope="module") def header_not_logged_in() -> dict: return prepare_header(username=None, password=None) From f020e7def90dc1733768ecf198dcc0e3f3c0a442 Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Tue, 23 Jan 2024 11:47:28 -0300 Subject: [PATCH 23/24] Test to make sure the password has changed --- tests/user_test.py | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/tests/user_test.py b/tests/user_test.py index 34abd2e..cf854f8 100644 --- a/tests/user_test.py +++ b/tests/user_test.py @@ -347,13 +347,23 @@ def get_token_from_email( return get_token_from_content(get_message_body(uid=latest_message, host=host)) -def test_forgot_password(client: Client, user1_credentials: dict, header_usr_1: dict): +def test_forgot_password( + register_user_1, # pylint: disable=unused-argument + client: Client, + user1_credentials: dict, + header_usr_1: dict, +): + """Tests the forgot and reset password functonality.""" + # use the forgot_password endpoint to send an email response = client.post( f"/user/forgot_password/{user1_credentials['email']}", headers=header_usr_1 ) assert response.status_code == status.HTTP_200_OK + # get the token from the email access_token = get_token_from_email(host="smtp4dev") + + # reset the password to a new password using the received token new_password = "new_password_for_test" response = client.get( "/user/reset_password/", @@ -361,6 +371,22 @@ def test_forgot_password(client: Client, user1_credentials: dict, header_usr_1: ) assert response.status_code == status.HTTP_200_OK - # TODO: test if the new credentials work as expected - # TODO: truncate and register user so as not to interfere in other - # tests + # test if the old credentials no longer work + response = client.post( + "/token", + data={ + "username": user1_credentials["email"], + "password": user1_credentials["password"], + }, + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + # test if the new credentials work + response = client.post( + "/token", + data={ + "username": user1_credentials["email"], + "password": new_password, + }, + ) + assert response.status_code == status.HTTP_200_OK From 21d03db453e7092c190188a9ed97c88b1a353a08 Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Tue, 23 Jan 2024 11:48:23 -0300 Subject: [PATCH 24/24] Fix spelling --- tests/user_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/user_test.py b/tests/user_test.py index cf854f8..50d4b59 100644 --- a/tests/user_test.py +++ b/tests/user_test.py @@ -353,7 +353,7 @@ def test_forgot_password( user1_credentials: dict, header_usr_1: dict, ): - """Tests the forgot and reset password functonality.""" + """Tests the forgot and reset password functionality.""" # use the forgot_password endpoint to send an email response = client.post( f"/user/forgot_password/{user1_credentials['email']}", headers=header_usr_1