diff --git a/classquiz/auth.py b/classquiz/auth.py index 4998c74..ac432d3 100644 --- a/classquiz/auth.py +++ b/classquiz/auth.py @@ -1,7 +1,7 @@ # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. - +import uuid from datetime import timedelta from typing import Dict from typing import Optional @@ -18,9 +18,9 @@ from jose import JWTError, jwt from passlib.hash import argon2 from classquiz.cache import get_cache -from classquiz.config import settings +from classquiz.config import settings, redis from datetime import datetime -from classquiz.db.models import User, TokenData +from classquiz.db.models import User, TokenData, ApiKey settings = settings() @@ -148,3 +148,16 @@ async def check_token(token: str = Depends(oauth2_scheme)): except JWTError: raise credentials_exception return token_data.email + + +async def check_api_key(key: str) -> uuid.UUID | None: + redis_res = await redis.get(f"apikey:{key}") + if redis_res is None: + key2 = await ApiKey.objects.get_or_none(key=key) + if key2 is None: + return None + else: + await redis.set(f"apikey:{key}", key2.user.id.hex, ex=3600) + return key2.user.id + else: + return uuid.UUID(redis_res) diff --git a/classquiz/db/models.py b/classquiz/db/models.py index c653a84..93f4338 100644 --- a/classquiz/db/models.py +++ b/classquiz/db/models.py @@ -43,6 +43,16 @@ class User(ormar.Model): use_enum_values = True +class ApiKey(ormar.Model): + key: str = ormar.String(max_length=48, min_length=48, primary_key=True) + user: Optional[User] = ormar.ForeignKey(User) + + class Meta: + tablename = "api_keys" + metadata = metadata + database = database + + class UserSession(ormar.Model): """ The user session model for user-sessions @@ -151,6 +161,7 @@ class TokenData(BaseModel): class PlayGame(BaseModel): quiz_id: uuid.UUID | str description: str + user_id: uuid.UUID title: str questions: list[QuizQuestion] game_id: uuid.UUID diff --git a/classquiz/routers/quiz.py b/classquiz/routers/quiz.py index 307f81b..e4ba27b 100644 --- a/classquiz/routers/quiz.py +++ b/classquiz/routers/quiz.py @@ -7,7 +7,6 @@ import re import uuid from datetime import datetime from random import randint -from typing import Dict import ormar.exceptions @@ -17,7 +16,7 @@ from fastapi.responses import JSONResponse, StreamingResponse from pydantic import ValidationError, BaseModel import bleach -from classquiz.auth import get_current_user +from classquiz.auth import get_current_user, check_api_key from classquiz.config import redis, settings, storage, meilisearch from classquiz.db.models import Quiz, QuizInput, User, PlayGame, GameSession, GameAnswer1, GameAnswer2 from classquiz.kahoot_importer.import_quiz import import_quiz @@ -113,6 +112,7 @@ async def start_quiz( captcha_enabled=captcha_enabled, cover_image=quiz.cover_image, game_mode=game_mode, + user_id=user.id, ) await redis.set(f"game:{str(game.game_pin)}", game.json(), ex=18000) return {**quiz.dict(exclude={"id"}), **game.dict(exclude={"questions"})} @@ -260,12 +260,15 @@ class GetLiveDataResponse(BaseModel): player_count: int -@router.get("/live", response_model=GetLiveDataResponse) -async def get_live_game_data(game_pin: int): +@router.get("/live", response_model=GetLiveDataResponse, tags=["live"]) +async def get_live_game_data(game_pin: int, api_key: str): + user_id = await check_api_key(api_key) redis_res = await redis.get(f"game:{game_pin}") - if redis_res is None: - raise HTTPException(status_code=404, detail="Game not found") + if redis_res is None or user_id is None: + raise HTTPException(status_code=404, detail="Game not found or API key not found") game = PlayGame.parse_raw(redis_res) + if game.user_id != user_id: + raise HTTPException(status_code=404, detail="Game not found or API key not found") data = GameSession.parse_raw(await redis.get(f"game_session:{game_pin}")) for i in range(0, len(game.questions)): res = await redis.get(f"game_session:{game_pin}:{i}") @@ -279,7 +282,7 @@ async def get_live_game_data(game_pin: int): return GetLiveDataResponse(quiz=game, data=data, player_count=player_count) -@router.get("/live/user_count") +@router.get("/live/user_count", tags=["live"]) async def get_game_user_count(game_pin: int) -> dict[str, int]: # if redis_res is None: # raise HTTPException(status_code=404, detail="Game not found") @@ -287,13 +290,16 @@ async def get_game_user_count(game_pin: int) -> dict[str, int]: return {"player_count": player_count} -@router.get("/live/players", response_model=GameSession) -async def get_game_session(game_pin: int): +@router.get("/live/players", response_model=GameSession, tags=["live"]) +async def get_game_session(game_pin: int, api_key: str): + user_id = await check_api_key(api_key) redis_res = await redis.get(f"game_session:{game_pin}") - if redis_res is None: - raise HTTPException(status_code=404, detail="Game not found") + if redis_res is None or user_id is None: + raise HTTPException(status_code=404, detail="Game not found or API key not found") data = GameSession.parse_raw(redis_res) game = PlayGame.parse_raw(await redis.get(f"game:{game_pin}")) + if game.user_id != user_id: + raise HTTPException(status_code=404, detail="Game not found or API key not found") for i in range(0, len(game.questions)): res = await redis.get(f"game_session:{game_pin}:{i}") if res is None: @@ -305,12 +311,15 @@ async def get_game_session(game_pin: int): return data -@router.post("/live/set_question") -async def set_next_question(game_pin: int, question_number: int): +@router.post("/live/set_question", tags=["live"]) +async def set_next_question(game_pin: int, question_number: int, api_key: str): + user_id = await check_api_key(api_key) redis_res = await redis.get(f"game:{game_pin}") - if redis_res is None: - raise HTTPException(status_code=404, detail="Game not found") - game_data = PlayGame.parse_raw(await redis.get(f"game:{game_pin}")) + if redis_res is None or user_id is None: + raise HTTPException(status_code=404, detail="Game not found or API key not found") + game_data = PlayGame.parse_raw(redis_res) + if game_data.user_id != user_id: + raise HTTPException(status_code=404, detail="Game not found or API key not found") game_data.current_question = question_number await redis.set(f"game:{game_pin}", game_data.json()) await sio.emit( diff --git a/classquiz/routers/users.py b/classquiz/routers/users.py index d766ef0..5147589 100644 --- a/classquiz/routers/users.py +++ b/classquiz/routers/users.py @@ -31,7 +31,7 @@ from classquiz.config import redis, settings, meilisearch import uuid import bleach from pydantic import BaseModel -from classquiz.db.models import User, UserSession, UpdatePassword, Token, Quiz +from classquiz.db.models import User, UserSession, UpdatePassword, Token, Quiz, ApiKey from classquiz.emails import send_register_email, send_forgotten_password_email settings = settings() @@ -312,3 +312,25 @@ async def get_email_from_jwt(data: GetEmailFromJWT): except JWTError as e: print(e) raise HTTPException(status_code=401) + + +@router.post("/api_keys", response_model=ApiKey, response_model_include={"key"}) +async def generate_api_key(user: User = Depends(get_current_user)): + key = ApiKey(key=os.urandom(24).hex(), user=user) + await key.save() + return key.dict(include={"key"}) + + +@router.get("/api_keys", response_model=list[ApiKey], response_model_include={"key"}) +async def list_api_keys(user: User = Depends(get_current_user)): + keys = await ApiKey.objects.filter(user=user).all() + return keys + + +@router.delete("/api_keys") +async def delete_api_key(api_key: str, user: User = Depends(get_current_user)): + key = await ApiKey.objects.get_or_none(key=api_key) + if key is None: + raise HTTPException(status_code=404, detail="Key not found") + await redis.delete(f"apikey:{key.key}") + await key.delete() diff --git a/classquiz/socket_server/__init__.py b/classquiz/socket_server/__init__.py index 781ec5a..e7a20f2 100644 --- a/classquiz/socket_server/__init__.py +++ b/classquiz/socket_server/__init__.py @@ -77,7 +77,10 @@ async def join_game(sid: str, data: dict): await sio.save_session(sid, session) await sio.emit( "joined_game", - {**json.loads(game_data.json(exclude={"quiz_id", "questions"})), "question_count": len(game_data.questions)}, + { + **json.loads(game_data.json(exclude={"quiz_id", "questions", "user_id"})), + "question_count": len(game_data.questions), + }, room=sid, ) redis_res = await redis.get(f"game_session:{data.game_pin}") diff --git a/migrations/versions/ec6cf07ff68a_added_apikey.py b/migrations/versions/ec6cf07ff68a_added_apikey.py new file mode 100644 index 0000000..9cd6244 --- /dev/null +++ b/migrations/versions/ec6cf07ff68a_added_apikey.py @@ -0,0 +1,35 @@ +"""added apikey + +Revision ID: ec6cf07ff68a +Revises: cda6903dfc0c +Create Date: 2022-09-19 21:04:37.967466 + +""" +from alembic import op +import sqlalchemy as sa +import ormar + + +# revision identifiers, used by Alembic. +revision = "ec6cf07ff68a" +down_revision = "cda6903dfc0c" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "api_keys", + sa.Column("key", sa.String(length=48), nullable=False), + sa.Column("user", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True), + sa.ForeignKeyConstraint(["user"], ["users.id"], name="fk_api_keys_users_id_user"), + sa.PrimaryKeyConstraint("key"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("api_keys") + # ### end Alembic commands ###