Skip to content
Draft
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
1 change: 1 addition & 0 deletions server/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
'aiosqlite',
'aiohttp',
'discord.py',
'pyjwt',
],
extras_require={
'dev': [
Expand Down
123 changes: 123 additions & 0 deletions server/src/QRServer/api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from enum import Enum
import logging

from QRServer.api.auth import decode_token, make_access_token, make_refresh_token
from QRServer.config import Config
from QRServer.db.connector import DbConnector
from QRServer.db.models import DbUser, Tournament, TournamentDuel, TournamentMatch
Expand Down Expand Up @@ -35,6 +36,10 @@ def __init__(self, config: Config, connector: DbConnector, lobby_server: LobbySe
self.lobby_server = lobby_server
self.game_server = game_server
self.runner = web.AppRunner(self.app)
self.origin = self.config.origin.get()
self.api_token_secret = self.config.api_token_secret.get()
self.api_access_token_lifetime_sec = self.config.api_access_token_lifetime_sec.get()
self.api_refresh_token_lifetime_sec = self.config.api_refresh_token_lifetime_sec.get()

def api_socks(self):
if self.site:
Expand Down Expand Up @@ -64,10 +69,17 @@ def _add_routes(self):
web.get('/api/v1/game/stats', self._v1_game_stats),
web.get('/api/v1/health', self._v1_health),
web.get('/api/v1/lobby/stats', self._v1_lobby_stats),

# Tournaments
web.get('/api/v1/tournaments/{id}', self._v1_tournaments),
web.get('/api/v1/tournaments/{id}/duels', self._v1_tournament_duels),
web.get('/api/v1/tournaments/{id}/matches', self._v1_tournament_matches),
web.get('/api/v1/tournaments/{id}/users', self._v1_tournament_users),
web.post('/api/v1/challenges/{id}', self._v1_join_match_challenge),

# OAuth2
web.get('/.well-known/openid-configuration', self._wellknown_openid_config),
web.post('/oauth/token', self._oauth_token),
])

async def _v1_game_stats(self, _request: web.Request) -> web.Response:
Expand Down Expand Up @@ -160,3 +172,114 @@ async def _v1_tournament_matches(self, request) -> web.Response:
})

return web.json_response(data={'tournament_matches': tournament_matches_view}, status=200)

async def _v1_join_match_challenge(self, request) -> web.Response:
"""
Based on invite id and user credentials generates presigned match url that can be used by front-end
to load the game with specific opponent.
Note that the presigned url WILL contain the hash of the user's password
(this is an swf limitation)
"""
raise NotImplementedError

async def _wellknown_openid_config(self, request: web.Request) -> web.Response:
"""Endpoint for oidc client autodiscovery"""
base = f'{self.origin}'
return web.json_response({
'issuer': base,
'token_endpoint': f'{base}/oauth/token',
'userinfo_endpoint': f'{base}/oauth/userinfo',
'response_types_supported': ['token'],
'grant_types_supported': ['password', 'refresh_token'],
'token_endpoint_auth_methods_supported': ['none'],
})

async def _oauth_token(self, request: web.Request) -> web.Response:
content_type = request.content_type

if 'application/json' in content_type:
# json sent when using raw requests
try:
body = await request.json()
except Exception:
return web.json_response({'error': 'invalid_request'}, status=400)
elif 'application/x-www-form-urlencoded':
# form-encoded, which is what oidc-client-ts sends
body = await request.post()
else:
return web.json_response({'error': 'unsupported_media_type'}, status=400)

if not body:
return web.json_response({'error': 'invalid_request'}, status=400)

grant_type = body.get('grant_type')

match grant_type:
case 'password':
username = body.get('username') or ''
password = body.get('password') or ''
return await self._oauth_password_grant(username, password)

case 'refresh_token':
refresh_token = body.get('refresh_token') or ''
return await self._oauth_refresh_token_grant(refresh_token)
case _: return web.json_response({'error': 'unsupported_grant_type'}, status=400)

async def _oauth_password_grant(self, username: str, password: str) -> web.Response:
"""password is expected in swf format, provided by frontend"""
if not username or not password:
return web.json_response({'error': 'invalid_request'}, status=400)

user: DbUser | None = await self.connector.authenticate_user(
username=username,
password=password.encode()
)
if user is None:
return web.json_response({'error': 'invalid_grant'}, status=401)

access = make_access_token(
self.api_token_secret,
user.user_id,
user.username,
self.api_access_token_lifetime_sec,
)
refresh = make_refresh_token(
self.api_token_secret,
user.user_id,
self.api_refresh_token_lifetime_sec
)

return web.json_response({
'access_token': access,
'refresh_token': refresh,
'token_type': 'Bearer',
'expires_in': self.api_access_token_lifetime_sec,
})

async def _oauth_refresh_token_grant(self, refresh_token: str) -> web.Response:
if not refresh_token:
return web.json_response({'error': 'invalid_request'}, status=400)

try:
claims = decode_token(self.api_token_secret, refresh_token)
except Exception:
return web.json_response({'error': 'invalid_grant'}, status=401)

if claims.get('type') != 'refresh':
return web.json_response({'error': 'invalid_grant'}, status=401)

user: DbUser | None = await self.connector.get_user(claims['sub'])
if user is None:
return web.json_response({'error': 'invalid_grant'}, status=401)

access = make_access_token(
self.api_token_secret,
user.user_id,
user.username,
self.api_access_token_lifetime_sec,
)
return web.json_response({
'access_token': access,
'token_type': 'Bearer',
'expires_in': self.api_access_token_lifetime_sec,
})
28 changes: 28 additions & 0 deletions server/src/QRServer/api/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from datetime import datetime, timedelta, timezone
from secrets import token_hex

import jwt


def make_access_token(secret: str, user_id: str, username: str, lifetime_sec: int) -> str:
payload = {
'sub': user_id,
'username': username,
'type': 'access',
'exp': datetime.now(timezone.utc) + timedelta(seconds=lifetime_sec),
}
return jwt.encode(payload, secret, algorithm='HS256')


def make_refresh_token(secret: str, user_id: str, lifetime_sec: int) -> str:
payload = {
'sub': user_id,
'type': 'refresh',
'exp': datetime.now(timezone.utc) + timedelta(seconds=lifetime_sec),
'jti': token_hex(16),
}
return jwt.encode(payload, secret, algorithm='HS256')


def decode_token(secret: str, token: str) -> dict:
return jwt.decode(token, secret, algorithms=['HS256'])
32 changes: 31 additions & 1 deletion server/src/QRServer/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,30 @@ def __init__(self):
cli_args=[],
description='welcome message sent after joining the lobby',
default_value='')

self.origin = ConfigKey(
config=self,
name='origin',
cli_args=[],
description='The origin where server is hosted at. Used for creating invite links and oauth',
default_value='http://localhost')
self.api_token_secret = ConfigKey(
config=self,
name='api.token_secret',
cli_args=[],
description='Secret key used to sign other tokens. Recommended length: 32 bytes',
default_value='CHANGE_ME!')
self.api_access_token_lifetime_sec = ConfigKey(
config=self,
name='api.access_token_lifetime_sec',
cli_args=[],
description='Lifetime (in seconds) of the access token',
default_value=15 * 60) # 15 minutes
self.api_refresh_token_lifetime_sec = ConfigKey(
config=self,
name='api.refresh_token_lifetime_sec',
cli_args=[],
description='Lifetime (in seconds) of the refresh token',
default_value=6 * 30 * 24 * 60 * 60) # 6 months
self.discord_webhook_lobby_joined_url = ConfigKey(
config=self,
name='discord.webhook.lobby_joined.url',
Expand Down Expand Up @@ -205,6 +228,13 @@ def __init__(self):
cli_args=[],
description='Maximum number of aliases per user',
default_value=1)
self.challenge_invite_duration = ConfigKey(
config=self,
name='discord.bot.challenge.invite_duration',
cli_args=[],
description='Direct challenge invite duration in minutes. Likely reliant on the swf\'s capability to wait'
' for the other party',
default_value='1')

def get_key(self, name: str):
by_name = self.keys_by_name()
Expand Down
66 changes: 63 additions & 3 deletions server/src/QRServer/db/connector.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import logging
import os
import random
import uuid
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone

from QRServer.config import Config
from QRServer.db.common import UpdateCollisionError, retry_on_update_collision
Expand All @@ -10,8 +11,8 @@
from QRServer.common.classes import GameResultHistory, RankingEntry
from QRServer.common import utils
from QRServer.db import migrations
from QRServer.db.models import DbUser, DbMatchReport, TournamentDuel, TournamentMatch, TournamentParticipant, \
Tournament, UserRating
from QRServer.db.models import DbUser, DbMatchReport, MatchInvite, TournamentDuel, TournamentMatch, \
TournamentParticipant, Tournament, UserRating
from QRServer.db.password import password_verify, password_hash

log = logging.getLogger('qr.dbconnector')
Expand Down Expand Up @@ -837,6 +838,65 @@ async def add_duel_match(self, tournament_id: str, duel_idx: int, match_id: str)
await self.conn.commit()
return bool(c.rowcount)

async def get_match_invite(self, invite_id: str) -> MatchInvite | None:
c = await self.conn.cursor()
await c.execute(
"select match_invites.id, u1.username, u2.username, challenger_auth, challenged_auth, issued_at_timestamp,"
" active_until_timestamp"
" from match_invites"
" left join users u1"
" on challenger_id = u1.id"
" left join users u2"
" on challenged_id = u2.id"
" where match_invites.id = ?",
(
invite_id,
))
row = await c.fetchone()
if row is None:
return None

return MatchInvite(
invite_id=row[0],
challenger_username=row[1],
challenged_username=row[2],
challenger_auth=row[3],
challenged_auth=row[4],
issued_at=datetime.fromtimestamp(row[5], tz=timezone.utc),
active_until=datetime.fromtimestamp(row[6], tz=timezone.utc),
)

async def create_match_invite(self, challenger_id: str, challenged_id: str) -> bool:
"""
Returns:
bool: True if successfully created the invite
"""
c = await self.conn.cursor()
now = datetime.now()
await c.execute(
"insert or ignore into match_invites ("
" id,"
" challenger_id,"
" challenged_id,"
" challenger_auth,"
" challenged_auth,"
" issued_at_timestamp,"
" active_until_timestamp"
")"
"values (?, ?, ?, ?, ?, ?, ?)",
(
str(uuid.uuid4()),
challenger_id,
challenged_id,
random.randint(65535),
random.randint(65535),
int(now.timestamp()),
int((now + timedelta(minutes=int(self.config.challenge_invite_duration.get()))).timestamp())
)
)
await self.conn.commit()
return bool(c.rowcount)


async def create_connector(config) -> DbConnector:
data_dir = os.path.abspath(config.data_dir.get())
Expand Down
19 changes: 19 additions & 0 deletions server/src/QRServer/db/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ async def execute_migrations(c, config: Config, max_version=None):
_migration_upgrade_to_v6,
_migration_upgrade_to_v7,
_migration_upgrade_to_v8,
_migration_upgrade_to_v9,
]

for i in range(max_version if max_version and max_version <= len(migrations) else len(migrations)):
Expand Down Expand Up @@ -255,3 +256,21 @@ async def _migration_upgrade_to_v8(c, _config):
)

await _set_version(c, 8)


async def _migration_upgrade_to_v9(c, _config):
await c.execute(
"create table match_invites ("
" id varchar primary key,"
" challenger_id varchar,"
" challenged_id varchar,"
" challenger_auth integer,"
" challenged_auth integer,"
" issued_at_timestamp integer,"
" active_until_timestamp integer,"
" foreign key(challenger_id) references users (id),"
" foreign key(challenged_id) references users (id)"
")"
)

await _set_version(c, 9)
11 changes: 11 additions & 0 deletions server/src/QRServer/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,14 @@ class TournamentDuel:
class TournamentMatch:
duel_idx: int
match: DbMatchReport


@dataclass
class MatchInvite:
invite_id: str
challenger_username: str
challenged_username: str
challenger_auth: int
challenged_auth: int
issued_at: datetime
active_until: datetime
Loading
Loading