From c26eb97288b3ccd092ddcc85a04275ffb96187f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kv=C3=A1=C4=8Dek?= Date: Thu, 8 Feb 2024 16:26:12 +0100 Subject: [PATCH] Reset hesla, doplneni mailu --- Dockerfile | 2 +- ...password_reset_token_table_a410455fb3bb.py | 35 ++++++++ src/background_jobs/weather.py | 4 +- src/database/models.py | 17 +++- src/emails/emails.py | 15 ++-- src/endpoints/contact.py | 10 ++- src/endpoints/forgotten_password.py | 84 +++++++++++++++++- src/endpoints/graphql.py | 49 +++++++++++ src/endpoints/registration.py | 9 +- src/mailer.py | 1 + src/main.py | 88 ++++++------------- 11 files changed, 235 insertions(+), 79 deletions(-) create mode 100644 alembic/versions/20240208-075902_add_password_reset_token_table_a410455fb3bb.py create mode 100644 src/endpoints/graphql.py diff --git a/Dockerfile b/Dockerfile index 05ef630..99567b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ FROM python:3.11-alpine AS app RUN apk update && \ apk add --no-cache tzdata mariadb-client mariadb-dev && \ pip install --upgrade pip - \ + # protoze xfs uzivatel ma id=33 RUN deluser xfs diff --git a/alembic/versions/20240208-075902_add_password_reset_token_table_a410455fb3bb.py b/alembic/versions/20240208-075902_add_password_reset_token_table_a410455fb3bb.py new file mode 100644 index 0000000..2de93dc --- /dev/null +++ b/alembic/versions/20240208-075902_add_password_reset_token_table_a410455fb3bb.py @@ -0,0 +1,35 @@ +"""add password_reset_token table + +Revision ID: a410455fb3bb +Revises: 39ebf5a1ab7e +Create Date: 2024-02-08 07:59:02.066212 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a410455fb3bb' +down_revision = '39ebf5a1ab7e' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('password_reset_token', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('token', sa.String(length=64), nullable=False), + sa.Column('valid_until', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('password_reset_token') + # ### end Alembic commands ### diff --git a/src/background_jobs/weather.py b/src/background_jobs/weather.py index ba1e7a7..22a8dcb 100644 --- a/src/background_jobs/weather.py +++ b/src/background_jobs/weather.py @@ -8,7 +8,7 @@ from logger import log async def download_weather(date_time: datetime, flight_id: int, airport_id: int, type_: Literal['landing', 'takeoff']): async with get_session() as db: - airport = await models.Airport.get_one(db, airport_id) + airport = await models.Airport.get_one(db, id=airport_id) gps = (airport.gps_latitude, airport.gps_longitude) try: @@ -31,7 +31,7 @@ async def download_weather(date_time: datetime, flight_id: int, airport_id: int, } async with get_session() as db: - flight = await models.Flight.get_one(db, flight_id) + flight = await models.Flight.get_one(db, id=flight_id) existing_weather_id = getattr(flight, f'{type_}_weather_info_id') if existing_weather_id: diff --git a/src/database/models.py b/src/database/models.py index 942f1ef..c316610 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -18,8 +18,8 @@ class BaseModel: return {c: getattr(self, c) for c in self._get_column_names() if c not in self.excluded_columns_in_dict} @classmethod - async def get_one(cls, db_session: AsyncSession, id: int): - return (await db_session.scalars(select(cls).filter_by(id=id))).one() + async def get_one(cls, db_session: AsyncSession, **kwargs): + return (await db_session.scalars(select(cls).filter_by(**kwargs))).one() @classmethod async def create(cls, db_session: AsyncSession, data: dict): @@ -35,7 +35,7 @@ class BaseModel: raise ValueError("Provide either obj or id!") if not obj: - obj = await cls.get_one(db_session, id) + obj = await cls.get_one(db_session, id=id) for key, value in data.items(): if key in cls._get_column_names() and getattr(obj, key) != value: setattr(obj, key, value) @@ -371,3 +371,14 @@ class User(BaseModel): licences: Mapped[Set['License']] = relationship() flights: Mapped[Set['Flight']] = relationship() organizations: Mapped[Set['Organization']] = relationship(secondary=user_is_in_organization) + + +class PasswordResetToken(BaseModel): + __tablename__ = "password_reset_token" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id')) + token: Mapped[str] = mapped_column(String(64), nullable=False) + valid_until: Mapped[datetime] = mapped_column(DateTime, nullable=True) + + user: Mapped['User'] = relationship() diff --git a/src/emails/emails.py b/src/emails/emails.py index 5c105b7..845befb 100644 --- a/src/emails/emails.py +++ b/src/emails/emails.py @@ -30,9 +30,9 @@ class ContactFormEmail(Email): class WelcomeEmail(Email): - def __init__(self, to: list[EmailStr | str]): + def __init__(self, to: EmailStr | str): super().__init__( - to=to, + to=[to], subject="Vítej", template="welcome.html", template_variables={} @@ -40,10 +40,13 @@ class WelcomeEmail(Email): class ResetPasswordEmail(Email): - def __init__(self, to: list[EmailStr | str], template_variables: dict): + def __init__(self, to: EmailStr | str, reset_token: str, validity_hours: int): super().__init__( - to=to, - subject="Reset hesla", + to=[to], + subject="Žádost o reset", template="reset_password.html", - template_variables=template_variables + template_variables={ + "token": reset_token, + "validity_hours": validity_hours, + } ) diff --git a/src/endpoints/contact.py b/src/endpoints/contact.py index 0412cd7..51ff5a7 100644 --- a/src/endpoints/contact.py +++ b/src/endpoints/contact.py @@ -11,10 +11,16 @@ class ContactInput(BaseModel): class ContactEndpoint(): - async def on_post(self, input: ContactInput, resp: Response, backgorund_tasks: BackgroundTasks): + async def on_post(self, input: ContactInput, backgorund_tasks: BackgroundTasks): backgorund_tasks.add_task(send_email, ContactFormEmail( email_from=input.email_from, content=input.content )) - # todo vratit nejakou smysluplnou + return { + "status": "success", + "email": { + "from": input.email_from, + "content": input.content + } + } diff --git a/src/endpoints/forgotten_password.py b/src/endpoints/forgotten_password.py index db5891f..d7f6f1b 100644 --- a/src/endpoints/forgotten_password.py +++ b/src/endpoints/forgotten_password.py @@ -1,4 +1,84 @@ +import os +import random +import string + +import binascii +from datetime import datetime, timedelta +from fastapi import BackgroundTasks +from passlib.hash import bcrypt +from pydantic import BaseModel +from background_jobs.send_email import send_email +from database import models +from database.transaction import get_session +from emails.emails import ResetPasswordEmail + + +class ForgottenPasswordRequest(BaseModel): + email: str + + +class ChangeForgottenPassword(BaseModel): + password: str + token: str + class ForgottenPasswordEndpoint(): - async def on_get(self): - pass \ No newline at end of file + + @staticmethod + def generate_random_token(length): + characters = string.ascii_letters + string.digits + return ''.join(random.choice(characters) for i in range(length)) + + @staticmethod + async def token_info(token: str): + async with get_session() as db: + reset = await models.PasswordResetToken.get_one(db, token=token) + user = await models.User.get_one(db, id=reset.user_id) + token_validity = reset.valid_until + email, name = user.email, user.name + + return { + "token": token, + "token_validity": token_validity, + "user_email": email, + "user_name": name + } + + async def request(self, input: ForgottenPasswordRequest, backgorund_tasks: BackgroundTasks): + validity_hours = 8 + validity = datetime.now() + timedelta(hours=validity_hours) + random_str_token = self.generate_random_token(64) + + async with get_session() as db: + user = await models.User.get_one(db, email=input.email) + token = await models.PasswordResetToken.create(db, { + "user_id": user.id, + "token": random_str_token, + "valid_until": validity + }) + + backgorund_tasks.add_task( + send_email, + email=ResetPasswordEmail( + to=input.email, + reset_token=token, + validity_hours=validity_hours + ) + ) + + @staticmethod + async def change_password(input: ChangeForgottenPassword): + async with get_session() as db: + token = await models.PasswordResetToken.get_one(db, token=input.token) + user_id = token.user_id + + await models.User.update( + db, id=token.user_id, + data={"password_hashed": bcrypt.hash(input.password)} + ) + + await db.delete(token) + + return { + "user_id": user_id + } diff --git a/src/endpoints/graphql.py b/src/endpoints/graphql.py new file mode 100644 index 0000000..de04a24 --- /dev/null +++ b/src/endpoints/graphql.py @@ -0,0 +1,49 @@ +from datetime import timedelta +from fastapi import FastAPI, Security, Depends, BackgroundTasks, APIRouter +from fastapi_jwt import JwtAuthorizationCredentials +from fastapi_jwt.jwt import JwtAccessBearerCookie +from sqlalchemy import select +from starlette.responses import RedirectResponse +from strawberry.fastapi import GraphQLRouter +from config import GRAPHIQL, APP_DEBUG +from database import async_session, models +from graphql_schema.schema import GraphQLContext, schema + + +def setup_graphql_endpoint(app: FastAPI, access_security: JwtAccessBearerCookie, api_router: APIRouter): + async def setup_graphql_context(credentials: JwtAuthorizationCredentials = Security(access_security)): + user_id = credentials['id'] if credentials else None + organization_ids = set() + + if user_id: + async with async_session() as db: + organization_ids = set((await db.scalars( + select(models.user_is_in_organization.c.organization_id) + .filter(models.user_is_in_organization.c.user_id == user_id) + )).all()) + + if APP_DEBUG: + @api_router.get("/graphql/autologin") + async def autologin(): + access_token = access_security.create_access_token(subject={"id": 6, "name": "Franta Vomacka"}) + + response = RedirectResponse(url="/graphql") + access_security.set_access_cookie(response, access_token, expires_delta=timedelta(days=14)) + + return response + + return GraphQLContext( + user_id=user_id, + organization_ids=organization_ids, + jwt_auth_credentials=credentials, + jwt=access_security, + background_tasks=Depends(BackgroundTasks) + ) + + graphql_app = GraphQLRouter( + schema, + graphiql=GRAPHIQL, + debug=APP_DEBUG, + context_getter=setup_graphql_context + ) + app.include_router(graphql_app, prefix="/graphql") diff --git a/src/endpoints/registration.py b/src/endpoints/registration.py index c4b6927..5400900 100644 --- a/src/endpoints/registration.py +++ b/src/endpoints/registration.py @@ -1,11 +1,13 @@ import re -from fastapi import HTTPException +from fastapi import HTTPException, BackgroundTasks from sqlalchemy import select from typing import Optional from pydantic import BaseModel, Field, model_validator +from background_jobs.send_email import send_email from database.models import User from database.transaction import get_session from passlib.hash import bcrypt +from emails.emails import WelcomeEmail class RegistrationInput(BaseModel): @@ -24,7 +26,8 @@ class RegistrationInput(BaseModel): class RegistrationEndpoint: - async def on_post(self, user_data: RegistrationInput) -> User: + @staticmethod + async def on_post(user_data: RegistrationInput, backgorund_tasks: BackgroundTasks) -> User: query = select(User).filter_by(email=user_data.email) async with get_session() as db: existing_user = (await db.scalars(query)).first() @@ -39,6 +42,6 @@ class RegistrationEndpoint: "description": "" }) - # TODO: odeslat WelcomeEmail + backgorund_tasks.add_task(send_email, WelcomeEmail(to=user_data.email)) return model.as_dict() diff --git a/src/mailer.py b/src/mailer.py index 9ace2cd..4299eed 100644 --- a/src/mailer.py +++ b/src/mailer.py @@ -8,6 +8,7 @@ class Mailer: self.conf = ConnectionConfig( MAIL_USERNAME=SMTP_USERNAME, MAIL_PASSWORD=SMTP_PASSWORD, + MAIL_FROM="michalkv@gmail.com", MAIL_FROM_NAME="Polétání", MAIL_PORT=SMTP_PORT, MAIL_SERVER=SMTP_HOST, diff --git a/src/main.py b/src/main.py index 3949466..564ac49 100644 --- a/src/main.py +++ b/src/main.py @@ -1,25 +1,21 @@ import sentry_sdk from datetime import timedelta from typing import Optional -from fastapi import FastAPI, APIRouter, Depends, Security, HTTPException +from fastapi import FastAPI, APIRouter, Security, HTTPException from fastapi_jwt import JwtAuthorizationCredentials, JwtAccessBearerCookie, JwtRefreshBearerCookie from graphql import GraphQLError -from sqlalchemy import select +from sqlalchemy.exc import NoResultFound from starlette.background import BackgroundTasks from starlette.middleware.cors import CORSMiddleware -from starlette.responses import RedirectResponse, Response +from starlette.responses import Response, JSONResponse from starlette.staticfiles import StaticFiles -from strawberry.fastapi import GraphQLRouter -from config import APP_SECRET_KEY, GRAPHIQL, APP_DEBUG, ALLOW_CORS_ORIGINS, SENTRY_DSN, REFRESH_TOKEN_VALIDITY_DAYS -from database import models, async_session -from emails.emails import WelcomeEmail +from config import APP_SECRET_KEY, ALLOW_CORS_ORIGINS, SENTRY_DSN, REFRESH_TOKEN_VALIDITY_DAYS from endpoints.contact import ContactEndpoint, ContactInput -from endpoints.forgotten_password import ForgottenPasswordEndpoint +from endpoints.forgotten_password import ForgottenPasswordRequest, ForgottenPasswordEndpoint, ChangeForgottenPassword +from endpoints.graphql import setup_graphql_endpoint from endpoints.login import LoginEndpoint, LoginInput, RefreshEndpoint, LogoutEndpoint from endpoints.photo_editor_preview import PhotoEditorEndpoint from endpoints.registration import RegistrationInput, RegistrationEndpoint -from graphql_schema.schema import schema, GraphQLContext -from mailer import Mailer class App: @@ -53,7 +49,9 @@ class App: @staticmethod def setup_exception_handlers(app: FastAPI): - pass + @app.exception_handler(NoResultFound) + async def validation_exception_handler(request, exc): + return JSONResponse({"message": "Not found"}, status_code=404) @staticmethod def setup_middleware(app: FastAPI): @@ -70,46 +68,8 @@ class App: app.mount("/uploads", StaticFiles(directory="/app/uploads"), name="uploads") app.mount("/static", StaticFiles(directory="/app/static"), name="static") - def setup_graphql_endpoint(self, app: FastAPI): - async def setup_graphql_context(credentials: JwtAuthorizationCredentials = Security(self.access_security)): - user_id = credentials['id'] if credentials else None - organization_ids = set() - - if user_id: - async with async_session() as db: - organization_ids = set((await db.scalars( - select(models.user_is_in_organization.c.organization_id) - .filter(models.user_is_in_organization.c.user_id == user_id) - )).all()) - - return GraphQLContext( - user_id=user_id, - organization_ids=organization_ids, - jwt_auth_credentials=credentials, - jwt=self.access_security, - background_tasks=Depends(BackgroundTasks) - ) - - graphql_app = GraphQLRouter( - schema, - graphiql=GRAPHIQL, - debug=APP_DEBUG, - context_getter=setup_graphql_context - ) - app.include_router(graphql_app, prefix="/graphql") - def setup_routes(self, app: FastAPI): - self.setup_graphql_endpoint(app) - - if APP_DEBUG: - @self.api_router.get("/graphql/autologin") - async def autologin(): - access_token = self.access_security.create_access_token(subject={"id": 6, "name": "Franta Vomacka"}) - - response = RedirectResponse(url="/graphql") - self.access_security.set_access_cookie(response, access_token, expires_delta=timedelta(days=14)) - - return response + setup_graphql_endpoint(app, self.access_security, self.api_router) @self.api_router.post("/refresh") async def refresh( @@ -121,6 +81,10 @@ class App: refresh_token=self.refresh_security ).on_post(resp, credentials) + @self.api_router.post("/registration", status_code=201) + async def registration(user: RegistrationInput, background_tasks: BackgroundTasks): + return await RegistrationEndpoint().on_post(user, background_tasks) + @self.api_router.post("/login") async def login(resp: Response, user: LoginInput): return await LoginEndpoint( @@ -128,13 +92,21 @@ class App: refresh_token=self.refresh_security ).on_post(user, resp) + @self.api_router.get("/forgotten-password/token/{token}") + async def token_info(token: str): + return await ForgottenPasswordEndpoint().token_info(token) + + @self.api_router.post("/forgotten-password/request") + async def password_reset(input: ForgottenPasswordRequest, background_tasks: BackgroundTasks): + return await ForgottenPasswordEndpoint().request(input, background_tasks) + + @self.api_router.post("/forgotten-password/reset") + async def password_reset(input: ChangeForgottenPassword): + return await ForgottenPasswordEndpoint().change_password(input) + @self.api_router.post("/contact") - async def contact(resp: Response, input: ContactInput, background_tasks: BackgroundTasks): - return await ContactEndpoint().on_post( - input, - resp, - background_tasks - ) + async def contact(input: ContactInput, background_tasks: BackgroundTasks): + return await ContactEndpoint().on_post(input, background_tasks) @self.api_router.post("/logout") async def logout(resp: Response): @@ -173,9 +145,5 @@ class App: rotate=rotate, ) - @self.api_router.post("/registration", status_code=201) - async def registration(user: RegistrationInput): - return await RegistrationEndpoint().on_post(user) - # musi byt na konci app.include_router(self.api_router)