diff --git a/pixels/constants.py b/pixels/constants.py index eacb3e6..4a8b255 100644 --- a/pixels/constants.py +++ b/pixels/constants.py @@ -37,6 +37,12 @@ class Discord: API_BASE = "https://discord.com/api/v8" +class Authorization: + """Configuration regarding the access token authorization.""" + + EXPIRES_IN = 3600 + + class Server: """General config for the pixels server.""" diff --git a/pixels/endpoints/authorization.py b/pixels/endpoints/authorization.py index 082eab6..ac9c7c3 100644 --- a/pixels/endpoints/authorization.py +++ b/pixels/endpoints/authorization.py @@ -5,7 +5,8 @@ from httpx import AsyncClient from starlette.responses import RedirectResponse -from pixels.constants import Discord, Server +from pixels.constants import Authorization, Discord, Server +from pixels.models import AccessToken, RefreshToken from pixels.utils import auth log = logging.getLogger(__name__) @@ -24,7 +25,7 @@ async def authorize() -> Response: @router.get("/show_token") async def show_token(request: Request, token: str = Cookie(None)) -> Response: # noqa: B008 - """Show the token from the URL path to the user.""" + """Show the refresh token from the URL path to the user.""" template_name = "cookie_disabled.html" context = {"request": request} @@ -52,7 +53,7 @@ def build_oauth_token_request(code: str) -> tuple[dict, dict]: @router.get("/callback") async def auth_callback(request: Request) -> Response: """ - Create the user given the authorization code and output the token. + Create the user given the authorization code and output the refresh token. This endpoint is only used as a redirect target from Discord. """ @@ -80,3 +81,19 @@ async def auth_callback(request: Request) -> Response: path='/show_token', ) return redirect + + +@router.post("/authenticate", response_model=AccessToken) +async def authenticate(request: Request, body: RefreshToken) -> dict: + """ + Authenticate and request an access token. + + Users should replace their local refresh token with the one returned. + """ + return { + "access_token": await auth.generate_access_token(request.state.db_conn, body.refresh_token), + "token_type": "Bearer", + "expires_in": Authorization.EXPIRES_IN, + # In the future we may use this so that we can regenerate refresh tokens every once in a while + "refresh_token": body.refresh_token, + } diff --git a/pixels/models.py b/pixels/models.py index 5f36150..cb6ef03 100644 --- a/pixels/models.py +++ b/pixels/models.py @@ -77,8 +77,15 @@ class AuthState(enum.Enum): "There is no token provided, provide one in an Authorization header in the format 'Bearer {your token here}'" "or navigate to /authorize to get one" ) - BAD_HEADER = "The Authorization header does not specify the Bearer scheme." - INVALID_TOKEN = "The token provided is not a valid token or has expired, navigate to /authorize to get a new one." + WRONG_TOKEN = ( + "The token provided is not the access token, make a POST request to /authenticate. " + "Refer to the documentation for more information." + ) + BAD_HEADER = "The Authorization header does not specify the correct scheme." + INVALID_TOKEN = ( + "The token provided is not a valid token or has expired, navigate to /authorize to get a refresh token " + "or make a POST request to /authenticate to get a new access token." + ) BANNED = "You are banned." NEEDS_MODERATOR = "This endpoint is limited to moderators." @@ -107,3 +114,18 @@ class GetSize(BaseModel): width: int height: int + + +class RefreshToken(BaseModel): + """Expected body for requesting a new access token.""" + + refresh_token: str + + +class AccessToken(RefreshToken): + """Response for requesting a new access token.""" + + access_token: str + token_type: str + expires_in: int + refresh_token: str diff --git a/pixels/utils/auth.py b/pixels/utils/auth.py index 2b6be72..e628b59 100644 --- a/pixels/utils/auth.py +++ b/pixels/utils/auth.py @@ -1,12 +1,13 @@ import logging import secrets +from datetime import datetime, timedelta, timezone from asyncpg import Connection from fastapi import HTTPException, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from jose import JWTError, jwt -from pixels.constants import Server +from pixels.constants import Authorization, Server from pixels.models import AuthState log = logging.getLogger(__name__) @@ -31,22 +32,24 @@ async def __call__(self, request: Request): except JWTError: raise HTTPException(status_code=403, detail=AuthState.INVALID_TOKEN.value) - user_id, token_salt = token_data["id"], token_data["salt"] - user_state = await request.state.db_conn.fetchrow( "SELECT is_banned, is_mod, key_salt FROM users WHERE user_id = $1", - int(user_id) + int(token_data["id"]) ) # Handle bad scenarios - if user_state is None or user_state["key_salt"] != token_salt: + + expired = int(token_data["expiration"]) < datetime.now(timezone.utc).timestamp() + if user_state is None or user_state["key_salt"] != token_data["salt"] or expired: raise HTTPException(status_code=403, detail=AuthState.INVALID_TOKEN.value) + elif token_data["grant_type"] != "refresh_token": + raise HTTPException(status_code=403, detail=AuthState.WRONG_TOKEN) elif user_state["is_banned"]: raise HTTPException(status_code=403, detail=AuthState.BANNED.value) elif self.is_mod_endpoint and not user_state["is_mod"]: raise HTTPException(status_code=403, detail=AuthState.NEEDS_MODERATOR.value) - request.state.user_id = int(user_id) + request.state.user_id = int(token_data["id"]) return credentials @@ -72,4 +75,45 @@ async def reset_user_token(conn: Connection, user_id: str) -> str: token_salt, is_mod, ) - return jwt.encode({"id": user_id, "salt": token_salt}, Server.JWT_SECRET, algorithm="HS256") + return jwt.encode( + { + "id": user_id, + "grant_type": "authorization_code", + "salt": token_salt + }, + Server.JWT_SECRET, + algorithm="HS256" + ) + + +async def generate_access_token(conn: Connection, refresh_token: str) -> str: + """ + Generate a new access token for a user from its refresh token. + + This does not invalidate the old access token, rather it only generates a new + access token with a newer `expiration` field. + """ + try: + token_data = jwt.decode(refresh_token, Server.JWT_SECRET) + except JWTError: + raise HTTPException(status_code=403, detail=AuthState.INVALID_TOKEN.value) + + is_banned, key_salt = await conn.fetchrow( + "SELECT is_banned, key_salt FROM users WHERE user_id = $1", int(token_data["id"]) + ) + if is_banned: + raise PermissionError + elif key_salt != token_data["salt"]: + raise HTTPException(status_code=403, detail=AuthState.INVALID_TOKEN.value) + + expiration = datetime.now(timezone.utc) + timedelta(seconds=Authorization.EXPIRES_IN) + return jwt.encode( + { + "id": token_data["id"], + "grant_type": "refresh_token", + "expiration": expiration.timestamp(), + "salt": key_salt + }, + Server.JWT_SECRET, + algorithm="HS256" + )