diff options
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | backend/__init__.py | 19 | ||||
| -rw-r--r-- | backend/authentication/backend.py | 37 | ||||
| -rw-r--r-- | backend/authentication/user.py | 26 | ||||
| -rw-r--r-- | backend/constants.py | 8 | ||||
| -rw-r--r-- | backend/discord.py | 15 | ||||
| -rw-r--r-- | backend/routes/auth/authorize.py | 121 | ||||
| -rw-r--r-- | backend/routes/forms/form.py | 2 | ||||
| -rw-r--r-- | backend/routes/forms/submit.py | 48 | ||||
| -rw-r--r-- | backend/validation.py | 11 | ||||
| -rw-r--r-- | docker-compose.yml | 1 | ||||
| -rw-r--r-- | poetry.lock | 66 | ||||
| -rw-r--r-- | pyproject.toml | 6 | 
13 files changed, 266 insertions, 95 deletions
@@ -18,6 +18,7 @@ Create a `.env` file in the root with the following values inside it (each varia  - `OAUTH2_CLIENT_ID`: Client ID of Discord OAuth2 Application (see prerequisites).  - `OAUTH2_CLIENT_SECRET`: Client Secret of Discord OAuth2 Application (see prerequisites).  - `ALLOWED_URL`: Allowed origin for CORS middleware. +- `PRODUCTION`: Set to False if running on localhost. Defaults to true.  #### Running  To start using the application, simply run `docker-compose up` in the repository root. You'll be able to access the application by visiting http://localhost:8000/ diff --git a/backend/__init__.py b/backend/__init__.py index e629e82..dcbdcdf 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -11,6 +11,17 @@ from backend.middleware import DatabaseMiddleware, ProtectedDocsMiddleware  from backend.route_manager import create_route_map  from backend.validation import api +ORIGINS = [ +    r"(https://[^.?#]*--pydis-forms\.netlify\.app)",  # Netlify Previews +    r"(https?://[^.?#]*.forms-frontend.pages.dev)",  # Cloudflare Previews +] + +if not constants.PRODUCTION: +    # Allow all hosts on non-production deployments +    ORIGINS.append(r"(.*)") + +ALLOW_ORIGIN_REGEX = "|".join(ORIGINS) +  SENTRY_RELEASE = f"forms-backend@{constants.GIT_SHA}"  sentry_sdk.init(      dsn=constants.FORMS_BACKEND_DSN, @@ -22,13 +33,13 @@ sentry_sdk.init(  middleware = [      Middleware(          CORSMiddleware, -        # TODO: Convert this into a RegEx that works for prod, netlify & previews -        allow_origins=["*"], +        allow_origins=["https://forms.pythondiscord.com"], +        allow_origin_regex=ALLOW_ORIGIN_REGEX,          allow_headers=[ -            "Authorization",              "Content-Type"          ], -        allow_methods=["*"] +        allow_methods=["*"], +        allow_credentials=True      ),      Middleware(DatabaseMiddleware),      Middleware(AuthenticationMiddleware, backend=JWTAuthenticationBackend()), diff --git a/backend/authentication/backend.py b/backend/authentication/backend.py index f1d2ece..c7590e9 100644 --- a/backend/authentication/backend.py +++ b/backend/authentication/backend.py @@ -1,6 +1,6 @@ -import jwt  import typing as t +import jwt  from starlette import authentication  from starlette.requests import Request @@ -13,18 +13,18 @@ class JWTAuthenticationBackend(authentication.AuthenticationBackend):      """Custom Starlette authentication backend for JWT."""      @staticmethod -    def get_token_from_header(header: str) -> str: -        """Parse JWT token from header value.""" +    def get_token_from_cookie(cookie: str) -> str: +        """Parse JWT token from cookie."""          try: -            prefix, token = header.split() +            prefix, token = cookie.split()          except ValueError:              raise authentication.AuthenticationError( -                "Unable to split prefix and token from Authorization header." +                "Unable to split prefix and token from authorization cookie."              )          if prefix.upper() != "JWT":              raise authentication.AuthenticationError( -                f"Invalid Authorization header prefix '{prefix}'." +                f"Invalid authorization cookie prefix '{prefix}'."              )          return token @@ -33,11 +33,11 @@ class JWTAuthenticationBackend(authentication.AuthenticationBackend):          self, request: Request      ) -> t.Optional[tuple[authentication.AuthCredentials, authentication.BaseUser]]:          """Handles JWT authentication process.""" -        if "Authorization" not in request.headers: +        cookie = request.cookies.get("token") +        if not cookie:              return None -        auth = request.headers["Authorization"] -        token = self.get_token_from_header(auth) +        token = self.get_token_from_cookie(cookie)          try:              payload = jwt.decode(token, constants.SECRET_KEY, algorithms=["HS256"]) @@ -46,7 +46,22 @@ class JWTAuthenticationBackend(authentication.AuthenticationBackend):          scopes = ["authenticated"] -        if payload.get("admin") is True: +        if not payload.get("token"): +            raise authentication.AuthenticationError("Token is missing from JWT.") +        if not payload.get("refresh"): +            raise authentication.AuthenticationError( +                "Refresh token is missing from JWT." +            ) + +        try: +            user_details = payload.get("user_details") +            if not user_details or not user_details.get("id"): +                raise authentication.AuthenticationError("Improper user details.") +        except Exception: +            raise authentication.AuthenticationError("Could not parse user details.") + +        user = User(token, user_details) +        if await user.fetch_admin_status(request):              scopes.append("admin") -        return authentication.AuthCredentials(scopes), User(token, payload) +        return authentication.AuthCredentials(scopes), user diff --git a/backend/authentication/user.py b/backend/authentication/user.py index f40c68c..857c2ed 100644 --- a/backend/authentication/user.py +++ b/backend/authentication/user.py @@ -1,6 +1,11 @@  import typing as t +import jwt  from starlette.authentication import BaseUser +from starlette.requests import Request + +from backend.constants import SECRET_KEY +from backend.discord import fetch_user_details  class User(BaseUser): @@ -9,6 +14,7 @@ class User(BaseUser):      def __init__(self, token: str, payload: dict[str, t.Any]) -> None:          self.token = token          self.payload = payload +        self.admin = False      @property      def is_authenticated(self) -> bool: @@ -23,3 +29,23 @@ class User(BaseUser):      @property      def discord_mention(self) -> str:          return f"<@{self.payload['id']}>" + +    @property +    def decoded_token(self) -> dict[str, any]: +        return jwt.decode(self.token, SECRET_KEY, algorithms=["HS256"]) + +    async def fetch_admin_status(self, request: Request) -> bool: +        self.admin = await request.state.db.admins.find_one( +            {"_id": self.payload["id"]} +        ) is not None + +        return self.admin + +    async def refresh_data(self) -> None: +        """Fetches user data from discord, and updates the instance.""" +        self.payload = await fetch_user_details(self.decoded_token.get("token")) + +        updated_info = self.decoded_token +        updated_info["user_details"] = self.payload + +        self.token = jwt.encode(updated_info, SECRET_KEY, algorithm="HS256") diff --git a/backend/constants.py b/backend/constants.py index 59b56e0..4bb7fd1 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -1,8 +1,9 @@ -from dotenv import load_dotenv -import os  import binascii +import os  from enum import Enum +from dotenv import load_dotenv +  load_dotenv() @@ -11,6 +12,9 @@ DATABASE_URL = os.getenv("DATABASE_URL")  MONGO_DATABASE = os.getenv("MONGO_DATABASE", "pydis_forms")  SNEKBOX_URL = os.getenv("SNEKBOX_URL", "http://snekbox.default.svc.cluster.local/eval") +PRODUCTION = os.getenv("PRODUCTION", "True").lower() != "false" +PRODUCTION_URL = "https://forms.pythondiscord.com/" +  OAUTH2_CLIENT_ID = os.getenv("OAUTH2_CLIENT_ID")  OAUTH2_CLIENT_SECRET = os.getenv("OAUTH2_CLIENT_SECRET")  OAUTH2_REDIRECT_URI = os.getenv( diff --git a/backend/discord.py b/backend/discord.py index d6310b7..8cb602c 100644 --- a/backend/discord.py +++ b/backend/discord.py @@ -2,22 +2,27 @@  import httpx  from backend.constants import ( -    OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, OAUTH2_REDIRECT_URI +    OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET  )  API_BASE_URL = "https://discord.com/api/v8" -async def fetch_bearer_token(access_code: str) -> dict: +async def fetch_bearer_token(code: str, redirect: str, *, refresh: bool) -> dict:      async with httpx.AsyncClient() as client:          data = {              "client_id": OAUTH2_CLIENT_ID,              "client_secret": OAUTH2_CLIENT_SECRET, -            "grant_type": "authorization_code", -            "code": access_code, -            "redirect_uri": OAUTH2_REDIRECT_URI +            "redirect_uri": f"{redirect}/callback"          } +        if refresh: +            data["grant_type"] = "refresh_token" +            data["refresh_token"] = code +        else: +            data["grant_type"] = "authorization_code" +            data["code"] = code +          r = await client.post(f"{API_BASE_URL}/oauth2/token", headers={              "Content-Type": "application/x-www-form-urlencoded"          }, data=data) diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index 975936a..d4587f0 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -2,26 +2,101 @@  Use a token received from the Discord OAuth2 system to fetch user information.  """ +import datetime +from typing import Union +  import httpx  import jwt  from pydantic.fields import Field  from pydantic.main import BaseModel  from spectree.response import Response +from starlette import responses +from starlette.authentication import requires  from starlette.requests import Request -from starlette.responses import JSONResponse +from backend import constants +from backend.authentication.user import User  from backend.constants import SECRET_KEY -from backend.route import Route  from backend.discord import fetch_bearer_token, fetch_user_details +from backend.route import Route  from backend.validation import ErrorMessage, api +AUTH_FAILURE = responses.JSONResponse({"error": "auth_failure"}, status_code=400) +  class AuthorizeRequest(BaseModel):      token: str = Field(description="The access token received from Discord.")  class AuthorizeResponse(BaseModel): -    token: str = Field(description="A JWT token containing the user information") +    username: str = Field("Discord display name.") +    expiry: str = Field("ISO formatted timestamp of expiry.") + + +async def process_token( +        bearer_token: dict, +        request: Request +) -> Union[AuthorizeResponse, AUTH_FAILURE]: +    """Post a bearer token to Discord, and return a JWT and username.""" +    interaction_start = datetime.datetime.now() + +    try: +        user_details = await fetch_user_details(bearer_token["access_token"]) +    except httpx.HTTPStatusError: +        AUTH_FAILURE.delete_cookie("token") +        return AUTH_FAILURE + +    max_age = datetime.timedelta(seconds=int(bearer_token["expires_in"])) +    token_expiry = interaction_start + max_age + +    data = { +        "token": bearer_token["access_token"], +        "refresh": bearer_token["refresh_token"], +        "user_details": user_details, +        "expiry": token_expiry.isoformat() +    } + +    token = jwt.encode(data, SECRET_KEY, algorithm="HS256") +    user = User(token, user_details) + +    response = responses.JSONResponse({ +        "username": user.display_name, +        "expiry": token_expiry.isoformat() +    }) + +    await set_response_token(response, request, token, bearer_token["expires_in"]) +    return response + + +async def set_response_token( +        response: responses.Response, +        request: Request, +        new_token: str, +        expiry: int +) -> None: +    """Helper that handles logic for updating a token in a set-cookie response.""" +    origin_url = request.headers.get("origin") + +    if origin_url == constants.PRODUCTION_URL: +        domain = request.url.netloc +        samesite = "strict" + +    elif not constants.PRODUCTION: +        domain = None +        samesite = "strict" + +    else: +        domain = request.url.netloc +        samesite = "None" + +    response.set_cookie( +        "token", f"JWT {new_token}", +        secure=constants.PRODUCTION, +        httponly=True, +        samesite=samesite, +        domain=domain, +        max_age=expiry +    )  class AuthorizeRoute(Route): @@ -37,22 +112,38 @@ class AuthorizeRoute(Route):          resp=Response(HTTP_200=AuthorizeResponse, HTTP_400=ErrorMessage),          tags=["auth"]      ) -    async def post(self, request: Request) -> JSONResponse: +    async def post(self, request: Request) -> responses.JSONResponse:          """Generate an authorization token."""          data = await request.json() -          try: -            bearer_token = await fetch_bearer_token(data["token"]) -            user_details = await fetch_user_details(bearer_token["access_token"]) +            url = request.headers.get("origin") +            bearer_token = await fetch_bearer_token(data["token"], url, refresh=False)          except httpx.HTTPStatusError: -            return JSONResponse({ -                "error": "auth_failure" -            }, status_code=400) +            return AUTH_FAILURE -        user_details["admin"] = await request.state.db.admins.find_one( -            {"_id": user_details["id"]} -        ) is not None +        return await process_token(bearer_token, request) + + +class TokenRefreshRoute(Route): +    """ +    Use the refresh code from a JWT to get a new token and generate a new JWT token. +    """ -        token = jwt.encode(user_details, SECRET_KEY, algorithm="HS256") +    name = "refresh" +    path = "/refresh" + +    @requires(["authenticated"]) +    @api.validate( +        resp=Response(HTTP_200=AuthorizeResponse, HTTP_400=ErrorMessage), +        tags=["auth"] +    ) +    async def post(self, request: Request) -> responses.JSONResponse: +        """Refresh an authorization token.""" +        try: +            token = request.user.decoded_token.get("refresh") +            url = request.headers.get("origin") +            bearer_token = await fetch_bearer_token(token, url, refresh=True) +        except httpx.HTTPStatusError: +            return AUTH_FAILURE -        return JSONResponse({"token": token}) +        return await process_token(bearer_token, request) diff --git a/backend/routes/forms/form.py b/backend/routes/forms/form.py index dd1c83f..1c6e44a 100644 --- a/backend/routes/forms/form.py +++ b/backend/routes/forms/form.py @@ -27,7 +27,7 @@ class SingleForm(Route):      @api.validate(resp=Response(HTTP_200=Form, HTTP_404=ErrorMessage), tags=["forms"])      async def get(self, request: Request) -> JSONResponse:          """Returns single form information by ID.""" -        admin = request.user.payload["admin"] if request.user.is_authenticated else False +        admin = request.user.admin if request.user.is_authenticated else False          filters = {              "_id": request.path_params["form_id"] diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index b3a6afd..2624c98 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -3,6 +3,7 @@ Submit a form.  """  import binascii +import datetime  import hashlib  import uuid  from typing import Any, Optional @@ -15,11 +16,13 @@ from starlette.background import BackgroundTask  from starlette.requests import Request  from starlette.responses import JSONResponse -from backend.constants import FRONTEND_URL, FormFeatures, HCAPTCHA_API_SECRET +from backend import constants +from backend.authentication.user import User  from backend.models import Form, FormResponse  from backend.route import Route +from backend.routes.auth.authorize import set_response_token  from backend.routes.forms.unittesting import execute_unittest -from backend.validation import AuthorizationHeaders, ErrorMessage, api +from backend.validation import ErrorMessage, api  HCAPTCHA_VERIFY_URL = "https://hcaptcha.com/siteverify"  HCAPTCHA_HEADERS = { @@ -52,13 +55,37 @@ class SubmitForm(Route):              HTTP_404=ErrorMessage,              HTTP_400=ErrorMessage          ), -        headers=AuthorizationHeaders,          tags=["forms", "responses"]      )      async def post(self, request: Request) -> JSONResponse:          """Submit a response to the form.""" -        data = await request.json() +        response = await self.submit(request) + +        # Silently try to update user data +        try: +            if hasattr(request.user, User.refresh_data.__name__): +                old = request.user.token +                await request.user.refresh_data() + +                if old != request.user.token: +                    try: +                        expiry = datetime.datetime.fromisoformat( +                            request.user.decoded_token.get("expiry") +                        ) +                    except ValueError: +                        expiry = None +                    expiry_seconds = (expiry - datetime.datetime.now()).seconds +                    await set_response_token(response, request, request.user.token, expiry_seconds) + +        except httpx.HTTPStatusError: +            pass + +        return response + +    async def submit(self, request: Request) -> JSONResponse: +        """Helper method for handling submission logic.""" +        data = await request.json()          data["timestamp"] = None          if form := await request.state.db.forms.find_one( @@ -69,7 +96,7 @@ class SubmitForm(Route):              response["id"] = str(uuid.uuid4())              response["form_id"] = form.id -            if FormFeatures.DISABLE_ANTISPAM.value not in form.features: +            if constants.FormFeatures.DISABLE_ANTISPAM.value not in form.features:                  ip_hash_ctx = hashlib.md5()                  ip_hash_ctx.update(request.client.host.encode())                  ip_hash = binascii.hexlify(ip_hash_ctx.digest()) @@ -79,7 +106,7 @@ class SubmitForm(Route):                  async with httpx.AsyncClient() as client:                      query_params = { -                        "secret": HCAPTCHA_API_SECRET, +                        "secret": constants.HCAPTCHA_API_SECRET,                          "response": data.get("captcha")                      }                      r = await client.post( @@ -96,12 +123,13 @@ class SubmitForm(Route):                      "captcha_pass": captcha_data["success"]                  } -            if FormFeatures.REQUIRES_LOGIN.value in form.features: +            if constants.FormFeatures.REQUIRES_LOGIN.value in form.features:                  if request.user.is_authenticated:                      response["user"] = request.user.payload +                    response["user"]["admin"] = request.user.admin                      if ( -                            FormFeatures.COLLECT_EMAIL.value in form.features +                            constants.FormFeatures.COLLECT_EMAIL.value in form.features                              and "email" not in response["user"]                      ):                          return JSONResponse({ @@ -153,7 +181,7 @@ class SubmitForm(Route):              )              send_webhook = None -            if FormFeatures.WEBHOOK_ENABLED.value in form.features: +            if constants.FormFeatures.WEBHOOK_ENABLED.value in form.features:                  send_webhook = BackgroundTask(                      self.send_submission_webhook,                      form=form, @@ -193,7 +221,7 @@ class SubmitForm(Route):          embed = {              "title": "New Form Response",              "description": f"{mention} submitted a response to `{form.name}`.", -            "url": f"{FRONTEND_URL}/path_to_view_form/{response.id}",  # TODO: Enter Form View URL +            "url": f"{constants.FRONTEND_URL}/path_to_view_form/{response.id}",  # noqa # TODO: Enter Form View URL              "timestamp": response.timestamp,              "color": 7506394,          } diff --git a/backend/validation.py b/backend/validation.py index e696683..8771924 100644 --- a/backend/validation.py +++ b/backend/validation.py @@ -1,6 +1,5 @@  """Utilities for providing API payload validation.""" -from typing import Optional  from pydantic.fields import Field  from pydantic.main import BaseModel  from spectree import SpecTree @@ -18,13 +17,3 @@ class ErrorMessage(BaseModel):  class OkayResponse(BaseModel):      status: str = "ok" - - -class AuthorizationHeaders(BaseModel): -    authorization: Optional[str] = Field( -        title="Authorization", -        description=( -            "The Authorization JWT token received from the " -            "authorize route in the format `JWT {token}`" -        ) -    ) diff --git a/docker-compose.yml b/docker-compose.yml index 4e58ef7..8ee46be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,3 +37,4 @@ services:        - OAUTH2_CLIENT_SECRET        - ALLOWED_URL        - DEBUG=true +      - PRODUCTION=false diff --git a/poetry.lock b/poetry.lock index 7ad8db8..54f5b14 100644 --- a/poetry.lock +++ b/poetry.lock @@ -45,7 +45,7 @@ pyflakes = ">=2.2.0,<2.3.0"  [[package]]  name = "flake8-annotations" -version = "2.5.0" +version = "2.6.0"  description = "Flake8 Type Annotation Checks"  category = "dev"  optional = false @@ -70,15 +70,15 @@ tornado = ["tornado (>=0.2)"]  [[package]]  name = "h11" -version = "0.12.0" +version = "0.11.0"  description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"  category = "main"  optional = false -python-versions = ">=3.6" +python-versions = "*"  [[package]]  name = "httpcore" -version = "0.12.3" +version = "0.12.2"  description = "A minimal low-level HTTP client."  category = "main"  optional = false @@ -104,7 +104,7 @@ test = ["Cython (==0.29.14)"]  [[package]]  name = "httpx" -version = "0.16.1" +version = "0.17.0"  description = "The next generation HTTP client."  category = "main"  optional = false @@ -165,7 +165,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"  [[package]]  name = "pydantic" -version = "1.8" +version = "1.8.1"  description = "Data validation and settings management using python 3.6 type hinting"  category = "main"  optional = false @@ -390,7 +390,7 @@ python-versions = ">=3.6.1"  [metadata]  lock-version = "1.1"  python-versions = "^3.9" -content-hash = "6b8eeff310eac53dd82204deba49623f8d7a29e3292104f11647eabea218ac02" +content-hash = "cba71873f66411d6632caf6ddfdf63eae694594ab9e991bf8806ccee6fca477a"  [metadata.files]  certifi = [ @@ -414,8 +414,8 @@ flake8 = [      {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"},  ]  flake8-annotations = [ -    {file = "flake8-annotations-2.5.0.tar.gz", hash = "sha256:e17947a48a5b9f632fe0c72682fc797c385e451048e7dfb20139f448a074cb3e"}, -    {file = "flake8_annotations-2.5.0-py3-none-any.whl", hash = "sha256:3a377140556aecf11fa9f3bb18c10db01f5ea56dc79a730e2ec9b4f1f49e2055"}, +    {file = "flake8-annotations-2.6.0.tar.gz", hash = "sha256:bd0505616c0d85ebb45c6052d339c69f320d3f87fa079ab4e91a4f234a863d05"}, +    {file = "flake8_annotations-2.6.0-py3-none-any.whl", hash = "sha256:8968ff12f296433028ad561c680ccc03a7cd62576d100c3f1475e058b3c11b43"},  ]  gunicorn = [      {file = "gunicorn-20.0.4-py2.py3-none-any.whl", hash = "sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c"}, @@ -444,8 +444,8 @@ httptools = [      {file = "httptools-0.1.1.tar.gz", hash = "sha256:41b573cf33f64a8f8f3400d0a7faf48e1888582b6f6e02b82b9bd4f0bf7497ce"},  ]  httpx = [ -    {file = "httpx-0.16.1-py3-none-any.whl", hash = "sha256:9cffb8ba31fac6536f2c8cde30df859013f59e4bcc5b8d43901cb3654a8e0a5b"}, -    {file = "httpx-0.16.1.tar.gz", hash = "sha256:126424c279c842738805974687e0518a94c7ae8d140cd65b9c4f77ac46ffa537"}, +    {file = "httpx-0.17.0-py3-none-any.whl", hash = "sha256:fe19522f7b0861a1f6ac83306360bb5b7fb1ed64633a1a04a33f04102a1bea60"}, +    {file = "httpx-0.17.0.tar.gz", hash = "sha256:4f7ab2fef7f929c5531abd4f413b41ce2c820e3202f2eeee498f2d92b6849f8d"},  ]  idna = [      {file = "idna-3.1-py3-none-any.whl", hash = "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16"}, @@ -467,28 +467,28 @@ pycodestyle = [      {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"},  ]  pydantic = [ -    {file = "pydantic-1.8-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:22fe5756c6c57279234e4c4027a3549507aca29e9ee832d6aa39c367cb43c99f"}, -    {file = "pydantic-1.8-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:c26d380af3e9a8eb9abe3b6337cea28f057b5425330817c918cf74d0a0a2303d"}, -    {file = "pydantic-1.8-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:a0ff36e3f929d76b91d1624c6673dbdc1407358700d117bb7f29d5696c52d288"}, -    {file = "pydantic-1.8-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:d5aeab86837f8799df0d84bec1190e6cc0062d5c5374636b5599234f2b39fe0a"}, -    {file = "pydantic-1.8-cp36-cp36m-win_amd64.whl", hash = "sha256:999cc108933425752e45d1bf2f57d3cf091f2a5e8b9b8afab5b8872d2cc7645f"}, -    {file = "pydantic-1.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a989924324513215ad2b2cfd187426e6372f76f507b17361142c0b792294960c"}, -    {file = "pydantic-1.8-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2bc9e9f5d91a29dec53346efc5c719d82297885d89c8a62b971492fba222c68d"}, -    {file = "pydantic-1.8-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:12ed0b175bba65e29dfc5859cd539d3512f58bb776bf620a3d3338501fd0f389"}, -    {file = "pydantic-1.8-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:26821f61623b01d618bd8b3243f790ac8bd7ae31b388c0e41aa586002cf350eb"}, -    {file = "pydantic-1.8-cp37-cp37m-win_amd64.whl", hash = "sha256:d361d181a3fb53ebfdc2fb1e3ca55a6b2ad717578a5e119c99641afd11b31a47"}, -    {file = "pydantic-1.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:91baec8ed771d4c53d71ef549d8e36b0f92a31c32296062d562d1d7074dd1d6e"}, -    {file = "pydantic-1.8-cp38-cp38-manylinux1_i686.whl", hash = "sha256:b4e03c84f4e96e3880c9d34508cccbd0f0df6e7dc14b17f960ea8c71448823a3"}, -    {file = "pydantic-1.8-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:c8a3600435b83a4f28f5379f3bb574576521180f691828268268e9f172f1b1eb"}, -    {file = "pydantic-1.8-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:ccc2ab0a240d01847f3d5f0f9e1582d450a2fc3389db33a7af8e7447b205a935"}, -    {file = "pydantic-1.8-cp38-cp38-win_amd64.whl", hash = "sha256:ad2fae68e185cfae5b6d83e7915352ff0b6e5fa84d84bc6a94c3e2de58327114"}, -    {file = "pydantic-1.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5759a4b276bda5ac2360f00e9b1e711aaac51fabd155b422d27f3339710f4264"}, -    {file = "pydantic-1.8-cp39-cp39-manylinux1_i686.whl", hash = "sha256:865410a6df71fb60294887770d19c67d499689f7ce64245182653952cdbd4183"}, -    {file = "pydantic-1.8-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:edf37d30ea60179ef067add9772cf42299ea6cd490b3c94335a68f1021944ac4"}, -    {file = "pydantic-1.8-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:4a83d24bcf9ce8e6fa55c379bba1359461eedb85721bfb3151e240871e2b13a8"}, -    {file = "pydantic-1.8-cp39-cp39-win_amd64.whl", hash = "sha256:77e04800d19acc2a8fbb95fe3d47ff397ce137aa5a2b32cc23a87bac70dda343"}, -    {file = "pydantic-1.8-py3-none-any.whl", hash = "sha256:42b8fb1e4e4783c4aa31df44b64714f96aa4deeacbacf3713a8a238ee7df3b2b"}, -    {file = "pydantic-1.8.tar.gz", hash = "sha256:0b71ca069c16470cb00be0acaf0657eb74cbc4ff5f11b42e79647f170956cda3"}, +    {file = "pydantic-1.8.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0c40162796fc8d0aa744875b60e4dc36834db9f2a25dbf9ba9664b1915a23850"}, +    {file = "pydantic-1.8.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:fff29fe54ec419338c522b908154a2efabeee4f483e48990f87e189661f31ce3"}, +    {file = "pydantic-1.8.1-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:fbfb608febde1afd4743c6822c19060a8dbdd3eb30f98e36061ba4973308059e"}, +    {file = "pydantic-1.8.1-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:eb8ccf12295113ce0de38f80b25f736d62f0a8d87c6b88aca645f168f9c78771"}, +    {file = "pydantic-1.8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:20d42f1be7c7acc352b3d09b0cf505a9fab9deb93125061b376fbe1f06a5459f"}, +    {file = "pydantic-1.8.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dde4ca368e82791de97c2ec019681ffb437728090c0ff0c3852708cf923e0c7d"}, +    {file = "pydantic-1.8.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:3bbd023c981cbe26e6e21c8d2ce78485f85c2e77f7bab5ec15b7d2a1f491918f"}, +    {file = "pydantic-1.8.1-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:830ef1a148012b640186bf4d9789a206c56071ff38f2460a32ae67ca21880eb8"}, +    {file = "pydantic-1.8.1-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:fb77f7a7e111db1832ae3f8f44203691e15b1fa7e5a1cb9691d4e2659aee41c4"}, +    {file = "pydantic-1.8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:3bcb9d7e1f9849a6bdbd027aabb3a06414abd6068cb3b21c49427956cce5038a"}, +    {file = "pydantic-1.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2287ebff0018eec3cc69b1d09d4b7cebf277726fa1bd96b45806283c1d808683"}, +    {file = "pydantic-1.8.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:4bbc47cf7925c86a345d03b07086696ed916c7663cb76aa409edaa54546e53e2"}, +    {file = "pydantic-1.8.1-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:6388ef4ef1435364c8cc9a8192238aed030595e873d8462447ccef2e17387125"}, +    {file = "pydantic-1.8.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:dd4888b300769ecec194ca8f2699415f5f7760365ddbe243d4fd6581485fa5f0"}, +    {file = "pydantic-1.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:8fbb677e4e89c8ab3d450df7b1d9caed23f254072e8597c33279460eeae59b99"}, +    {file = "pydantic-1.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2f2736d9a996b976cfdfe52455ad27462308c9d3d0ae21a2aa8b4cd1a78f47b9"}, +    {file = "pydantic-1.8.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:3114d74329873af0a0e8004627f5389f3bb27f956b965ddd3e355fe984a1789c"}, +    {file = "pydantic-1.8.1-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:258576f2d997ee4573469633592e8b99aa13bda182fcc28e875f866016c8e07e"}, +    {file = "pydantic-1.8.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:c17a0b35c854049e67c68b48d55e026c84f35593c66d69b278b8b49e2484346f"}, +    {file = "pydantic-1.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:e8bc082afef97c5fd3903d05c6f7bb3a6af9fc18631b4cc9fedeb4720efb0c58"}, +    {file = "pydantic-1.8.1-py3-none-any.whl", hash = "sha256:e3f8790c47ac42549dc8b045a67b0ca371c7f66e73040d0197ce6172b385e520"}, +    {file = "pydantic-1.8.1.tar.gz", hash = "sha256:26cf3cb2e68ec6c0cfcb6293e69fb3450c5fd1ace87f46b64f678b0d29eac4c3"},  ]  pyflakes = [      {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, diff --git a/pyproject.toml b/pyproject.toml index d58099d..4ea0099 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,16 +13,16 @@ uvicorn = {extras = ["standard"], version = "^0.13.4"}  motor = "^2.3.1"  python-dotenv = "^0.15.0"  pyjwt = "^2.0.1" -httpx = "^0.16.1" +httpx = "^0.17.0"  gunicorn = "^20.0.4" -pydantic = "^1.7.2" +pydantic = "^1.8.1"  spectree = "^0.4.0"  deepmerge = "^0.1.1"  sentry-sdk = "^0.20.3"  [tool.poetry.dev-dependencies]  flake8 = "^3.8.4" -flake8-annotations = "^2.5.0" +flake8-annotations = "^2.6.0"  [build-system]  requires = ["poetry>=0.12"]  |