Reset hesla, doplneni mailu

This commit is contained in:
Michal Kváček
2024-02-08 16:26:12 +01:00
parent 03c9f2d189
commit c26eb97288
11 changed files with 235 additions and 79 deletions
+1 -1
View File
@@ -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
@@ -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 ###
+2 -2
View File
@@ -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:
+14 -3
View File
@@ -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()
+9 -6
View File
@@ -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,
}
)
+8 -2
View File
@@ -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
}
}
+82 -2
View File
@@ -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
@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
}
+49
View File
@@ -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")
+6 -3
View File
@@ -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()
+1
View File
@@ -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,
+28 -60
View File
@@ -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)