Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pixels/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
23 changes: 20 additions & 3 deletions pixels/endpoints/authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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}

Expand Down Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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,
}
26 changes: 24 additions & 2 deletions pixels/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."

Expand Down Expand Up @@ -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
58 changes: 51 additions & 7 deletions pixels/utils/auth.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand All @@ -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


Expand All @@ -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"
)