From 7a16a6b129f754a5486c441f2602a8d593edb85f Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Fri, 19 Feb 2021 09:01:38 +0300 Subject: Adds Token Refresh Route Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/routes/auth/authorize.py | 81 +++++++++++++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 13 deletions(-) (limited to 'backend/routes/auth/authorize.py') diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index 975936a..2244152 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -2,17 +2,23 @@ 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.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 @@ -21,7 +27,42 @@ class AuthorizeRequest(BaseModel): class AuthorizeResponse(BaseModel): - token: str = Field(description="A JWT token containing the user information") + username: str = Field("Discord display name.") + + +AUTH_FAILURE = JSONResponse({"error": "auth_failure"}, status_code=400) + + +async def process_token(bearer_token: dict) -> 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("BackendToken") + 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 = JSONResponse({"username": user.display_name}) + response.set_cookie( + "BackendToken", f"JWT {token}", + secure=constants.PRODUCTION, httponly=True, samesite="strict", + max_age=bearer_token["expires_in"] + ) + return response class AuthorizeRoute(Route): @@ -40,19 +81,33 @@ class AuthorizeRoute(Route): async def post(self, request: Request) -> 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"]) + bearer_token = await fetch_bearer_token(data["token"], refresh=False) except httpx.HTTPStatusError: - return JSONResponse({ - "error": "auth_failure" - }, status_code=400) + return AUTH_FAILURE + + return await process_token(bearer_token) - user_details["admin"] = await request.state.db.admins.find_one( - {"_id": user_details["id"]} - ) is not None - token = jwt.encode(user_details, SECRET_KEY, algorithm="HS256") +class TokenRefreshRoute(Route): + """ + Use the refresh code from a JWT to get a new token and generate a new JWT token. + """ + + name = "refresh" + path = "/refresh" + + @requires(["authenticated"]) + @api.validate( + resp=Response(HTTP_200=AuthorizeResponse, HTTP_400=ErrorMessage), + tags=["auth"] + ) + async def post(self, request: Request) -> JSONResponse: + """Refresh an authorization token.""" + try: + token = request.user.decoded_token.get("refresh") + bearer_token = await fetch_bearer_token(token, refresh=True) + except httpx.HTTPStatusError: + return AUTH_FAILURE - return JSONResponse({"token": token}) + return await process_token(bearer_token) -- cgit v1.2.3 From f6b09f5366a0921d12707c444a8bd86e05b7df19 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Fri, 19 Feb 2021 10:13:17 +0300 Subject: Adds Expiry To Authorization Routes Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/routes/auth/authorize.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) (limited to 'backend/routes/auth/authorize.py') diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index 2244152..c6cd86c 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -28,6 +28,7 @@ class AuthorizeRequest(BaseModel): class AuthorizeResponse(BaseModel): username: str = Field("Discord display name.") + expiry: str = Field("ISO formatted timestamp of expiry.") AUTH_FAILURE = JSONResponse({"error": "auth_failure"}, status_code=400) @@ -56,7 +57,11 @@ async def process_token(bearer_token: dict) -> Union[AuthorizeResponse, AUTH_FAI token = jwt.encode(data, SECRET_KEY, algorithm="HS256") user = User(token, user_details) - response = JSONResponse({"username": user.display_name}) + response = JSONResponse({ + "username": user.display_name, + "expiry": token_expiry.isoformat() + }) + response.set_cookie( "BackendToken", f"JWT {token}", secure=constants.PRODUCTION, httponly=True, samesite="strict", -- cgit v1.2.3 From f90d0c7fddb81215b907808b8365f63f42344652 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 21 Feb 2021 01:44:01 +0300 Subject: Dynamically Selects OAuth Redirect URI Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/discord.py | 6 +++--- backend/routes/auth/authorize.py | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) (limited to 'backend/routes/auth/authorize.py') diff --git a/backend/discord.py b/backend/discord.py index 9cdd2c4..8cb602c 100644 --- a/backend/discord.py +++ b/backend/discord.py @@ -2,18 +2,18 @@ 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(code: str, *, refresh: bool) -> 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, - "redirect_uri": OAUTH2_REDIRECT_URI + "redirect_uri": f"{redirect}/callback" } if refresh: diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index c6cd86c..65709ab 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -87,7 +87,8 @@ class AuthorizeRoute(Route): """Generate an authorization token.""" data = await request.json() try: - bearer_token = await fetch_bearer_token(data["token"], refresh=False) + url = request.headers.get("origin") + bearer_token = await fetch_bearer_token(data["token"], url, refresh=False) except httpx.HTTPStatusError: return AUTH_FAILURE @@ -111,7 +112,8 @@ class TokenRefreshRoute(Route): """Refresh an authorization token.""" try: token = request.user.decoded_token.get("refresh") - bearer_token = await fetch_bearer_token(token, refresh=True) + url = request.headers.get("origin") + bearer_token = await fetch_bearer_token(token, url, refresh=True) except httpx.HTTPStatusError: return AUTH_FAILURE -- cgit v1.2.3 From 02154294da8b25bf7dae1b79f170aab888f92797 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sat, 6 Mar 2021 22:42:52 +0300 Subject: Renames Token To `token` Changes the name for the token used to authorize with the backend. Co-authored-by: Joe Banks --- backend/authentication/backend.py | 2 +- backend/routes/auth/authorize.py | 4 ++-- backend/routes/forms/submit.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) (limited to 'backend/routes/auth/authorize.py') diff --git a/backend/authentication/backend.py b/backend/authentication/backend.py index 206d1eb..c7590e9 100644 --- a/backend/authentication/backend.py +++ b/backend/authentication/backend.py @@ -33,7 +33,7 @@ class JWTAuthenticationBackend(authentication.AuthenticationBackend): self, request: Request ) -> t.Optional[tuple[authentication.AuthCredentials, authentication.BaseUser]]: """Handles JWT authentication process.""" - cookie = request.cookies.get("BackendToken") + cookie = request.cookies.get("token") if not cookie: return None diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index 65709ab..98f9887 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -41,7 +41,7 @@ async def process_token(bearer_token: dict) -> Union[AuthorizeResponse, AUTH_FAI try: user_details = await fetch_user_details(bearer_token["access_token"]) except httpx.HTTPStatusError: - AUTH_FAILURE.delete_cookie("BackendToken") + AUTH_FAILURE.delete_cookie("token") return AUTH_FAILURE max_age = datetime.timedelta(seconds=int(bearer_token["expires_in"])) @@ -63,7 +63,7 @@ async def process_token(bearer_token: dict) -> Union[AuthorizeResponse, AUTH_FAI }) response.set_cookie( - "BackendToken", f"JWT {token}", + "token", f"JWT {token}", secure=constants.PRODUCTION, httponly=True, samesite="strict", max_age=bearer_token["expires_in"] ) diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index 4224586..8680b2d 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -75,7 +75,7 @@ class SubmitForm(Route): expiry = None response.set_cookie( - "BackendToken", f"JWT {request.user.token}", + "token", f"JWT {request.user.token}", secure=constants.PRODUCTION, httponly=True, samesite="strict", max_age=(expiry - datetime.datetime.now()).seconds ) -- cgit v1.2.3 From ca730082b523e62595687843a914adad8dbbaccf Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sat, 6 Mar 2021 22:48:19 +0300 Subject: Formats Authorize File Cleans up the authorize file, and the __init__ to maintain the project's code style. Co-authored-by: Joe Banks Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/__init__.py | 1 + backend/routes/auth/authorize.py | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'backend/routes/auth/authorize.py') diff --git a/backend/__init__.py b/backend/__init__.py index 5c91a65..220b457 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -15,6 +15,7 @@ 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"(.*)") diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index 98f9887..26d8622 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -21,6 +21,8 @@ from backend.discord import fetch_bearer_token, fetch_user_details from backend.route import Route from backend.validation import ErrorMessage, api +AUTH_FAILURE = JSONResponse({"error": "auth_failure"}, status_code=400) + class AuthorizeRequest(BaseModel): token: str = Field(description="The access token received from Discord.") @@ -31,9 +33,6 @@ class AuthorizeResponse(BaseModel): expiry: str = Field("ISO formatted timestamp of expiry.") -AUTH_FAILURE = JSONResponse({"error": "auth_failure"}, status_code=400) - - async def process_token(bearer_token: dict) -> Union[AuthorizeResponse, AUTH_FAILURE]: """Post a bearer token to Discord, and return a JWT and username.""" interaction_start = datetime.datetime.now() -- cgit v1.2.3