diff --git a/classquiz/config.py b/classquiz/config.py index 2065e102..b6f76350 100644 --- a/classquiz/config.py +++ b/classquiz/config.py @@ -102,4 +102,6 @@ def settings() -> Settings: ALLOWED_TAGS_FOR_QUIZ = ["b", "strong", "i", "em", "small", "mark", "del", "sub", "sup"] +ALLOWED_MIME_TYPES = ["image/png", "video/mp4", "image/jpeg", "image/gif", "image/webp"] + server_regex = rf"^{re.escape(settings().root_address)}/api/v1/storage/download/.{{36}}--.{{36}}$" diff --git a/classquiz/routers/editor.py b/classquiz/routers/editor.py index 43d7238a..172ea9ad 100644 --- a/classquiz/routers/editor.py +++ b/classquiz/routers/editor.py @@ -72,10 +72,10 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput): if session_data is None: raise HTTPException(status_code=401, detail="Edit ID not found!") session_data = EditSessionData.parse_raw(session_data) - quiz_input.title = html.unescape(bleach.clean(quiz_input.title, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True)) - quiz_input.description = html.unescape(bleach.clean(quiz_input.description, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True)) + quiz_input.title = bleach.clean(quiz_input.title, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True) + quiz_input.description = bleach.clean(quiz_input.description, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True) if quiz_input.background_color is not None: - quiz_input.background_color = html.unescape(bleach.clean(quiz_input.background_color, tags=[], strip=True)) + quiz_input.background_color = bleach.clean(quiz_input.background_color, tags=[], strip=True) for i, question in enumerate(quiz_input.questions): if question.type == QuizQuestionType.ABCD: diff --git a/classquiz/routers/quiztivity/__init__.py b/classquiz/routers/quiztivity/__init__.py index 8f79376f..cd5a5b2d 100644 --- a/classquiz/routers/quiztivity/__init__.py +++ b/classquiz/routers/quiztivity/__init__.py @@ -17,13 +17,13 @@ router.include_router(shares_router, prefix="/shares") -@router.post("/create") +@router.post("/create", response_model_exclude={"user": ...}) async def create_quiztivity(data: QuizTivityInput, user: User = Depends(get_current_user)) -> QuizTivity: quiztivity = QuizTivity.parse_obj({**data.dict(), "user": user, "id": uuid4(), "created_at": datetime.now()}) return await quiztivity.save() -@router.get("/{uuid}") +@router.get("/{uuid}", response_model_exclude={"user": ...}) async def get_quiztivity(uuid: UUID) -> QuizTivity: quiztivity = await QuizTivity.objects.get_or_none(id=uuid) if quiztivity is None: @@ -31,7 +31,7 @@ async def get_quiztivity(uuid: UUID) -> QuizTivity: return quiztivity -@router.put("/{uuid}") +@router.put("/{uuid}", response_model_exclude={"user": ...}) async def put_quiztivity(data: QuizTivityInput, uuid: UUID, user: User = Depends(get_current_user)) -> QuizTivity: quiztivity = await QuizTivity.objects.get_or_none(id=uuid, user=user) if quiztivity is None: diff --git a/classquiz/routers/storage.py b/classquiz/routers/storage.py index cc5e33c5..7c64025d 100644 --- a/classquiz/routers/storage.py +++ b/classquiz/routers/storage.py @@ -10,7 +10,7 @@ from pydantic import BaseModel from classquiz.auth import get_current_user -from classquiz.config import settings, storage, arq +from classquiz.config import settings, storage, arq, ALLOWED_MIME_TYPES from classquiz.db.models import User, StorageItem, PublicStorageItem, UpdateStorageItem, PrivateStorageItem from classquiz.helpers import check_image_string from classquiz.storage.errors import DownloadingFailedError @@ -119,10 +119,11 @@ async def download_file_head(file_name: str) -> Response: @router.post("/") async def upload_file(file: UploadFile = File(), user: User = Depends(get_current_user)) -> PublicStorageItem: + if file.content_type not in ALLOWED_MIME_TYPES: + raise HTTPException(status_code=422, detail="Unsupported") if user.storage_used > settings.free_storage_limit: raise HTTPException(status_code=409, detail="Storage limit reached") file_id = uuid4() - size = 0 file_obj = StorageItem( id=file_id, diff --git a/classquiz/routers/users/__init__.py b/classquiz/routers/users/__init__.py index 78216201..10be5273 100644 --- a/classquiz/routers/users/__init__.py +++ b/classquiz/routers/users/__init__.py @@ -14,7 +14,6 @@ from fastapi.background import BackgroundTasks from fastapi.responses import JSONResponse, RedirectResponse, PlainTextResponse from fastapi.security import OAuth2PasswordRequestForm -import html from jose import jwt, JWTError @@ -78,7 +77,7 @@ async def create_user(user: RouteUser, background_task: BackgroundTasks) -> User raise HTTPException(status_code=409, detail="User already exists") user.password = get_password_hash(user.password) - user.username = html.unescape(bleach.clean(user.username, tags=[], strip=True)) + user.username = bleach.clean(user.username, tags=[], strip=True) if len(user.username) == 32: return JSONResponse({"details": "Username mustn't be 32 characters long"}, 400) await user.save() diff --git a/classquiz/routers/users/twofa.py b/classquiz/routers/users/twofa.py index 528fc0ab..e8ec5954 100644 --- a/classquiz/routers/users/twofa.py +++ b/classquiz/routers/users/twofa.py @@ -7,10 +7,10 @@ import urllib.parse import pyotp -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel -from classquiz.auth import get_current_user +from classquiz.auth import get_current_user, verify_password from classquiz.db.models import User router = APIRouter() @@ -20,10 +20,16 @@ class GetBackupCodeResponse(BaseModel): code: str -@router.get("/backup_code", response_model=GetBackupCodeResponse) -async def get_backup_code(user: User = Depends(get_current_user)): +class RequirePasswordForAction(BaseModel): + password: str + + +@router.post("/backup_code", response_model=GetBackupCodeResponse) +async def get_backup_code(data: RequirePasswordForAction, user: User = Depends(get_current_user)): backup_code = os.urandom(32).hex() user = await User.objects.get(id=user.id) + if not verify_password(data.password, user.password): + raise HTTPException(status_code=401, detail="Invalid") user.backup_code = backup_code await user.update() return GetBackupCodeResponse(code=backup_code) @@ -31,11 +37,14 @@ async def get_backup_code(user: User = Depends(get_current_user)): class SetRequirePassword(BaseModel): require_password: bool + password: str @router.post("/require_password", response_model=SetRequirePassword) async def set_require_password(data: SetRequirePassword, user: User = Depends(get_current_user)): user = await User.objects.get(id=user.id) + if not verify_password(data.password, user.password): + raise HTTPException(status_code=401, detail="Invalid") user.require_password = data.require_password await user.update() return data @@ -47,8 +56,10 @@ class SetTotpUpResponse(BaseModel): @router.post("/totp", response_model=SetTotpUpResponse) -async def set_totp_up(user: User = Depends(get_current_user)): +async def set_totp_up(data: RequirePasswordForAction, user: User = Depends(get_current_user)): user = await User.objects.get(id=user.id) + if not verify_password(data.password, user.password): + raise HTTPException(status_code=401, detail="Invalid") user.totp_secret = pyotp.random_base32() url = pyotp.totp.TOTP(user.totp_secret).provisioning_uri( name=urllib.parse.quote(user.username), issuer_name="ClassQuiz" @@ -71,7 +82,9 @@ async def get_totp_status(user: User = Depends(get_current_user)): @router.delete("/totp") -async def disable_totp(user: User = Depends(get_current_user)): +async def disable_totp(data: RequirePasswordForAction, user: User = Depends(get_current_user)): user = await User.objects.get(id=user.id) + if not verify_password(data.password, user.password): + raise HTTPException(status_code=401, detail="Invalid") user.totp_secret = None await user.update() diff --git a/classquiz/routers/users/webauthn.py b/classquiz/routers/users/webauthn.py index cb82d0a7..8d814680 100644 --- a/classquiz/routers/users/webauthn.py +++ b/classquiz/routers/users/webauthn.py @@ -8,7 +8,7 @@ from pydantic import BaseModel from webauthn.helpers.cose import COSEAlgorithmIdentifier -from classquiz.auth import get_current_user +from classquiz.auth import get_current_user, verify_password from classquiz.db.models import User, FidoCredentials from classquiz.config import redis, settings @@ -28,9 +28,15 @@ router = APIRouter() -@router.get("/add_key", response_model=PublicKeyCredentialCreationOptions) -async def request_add_key_data(user: User = Depends(get_current_user)): +class RequirePasswordForAction(BaseModel): + password: str + + +@router.post("/add_key_init", response_model=PublicKeyCredentialCreationOptions) +async def request_add_key_data(data: RequirePasswordForAction, user: User = Depends(get_current_user)): user = await User.objects.select_related("fidocredentialss").get(id=user.id) + if not verify_password(data.password, user.password): + raise HTTPException(status_code=401, detail="Invalid") options = generate_registration_options( rp_id=urllib.parse.urlparse(settings.root_address).hostname, rp_name="ClassQuiz", @@ -86,7 +92,9 @@ async def list_security_keys(user: User = Depends(get_current_user)): @router.delete("/key/{key_id}") -async def delete_security_key(key_id: int, user: User = Depends(get_current_user)): +async def delete_security_key(data: RequirePasswordForAction, key_id: int, user: User = Depends(get_current_user)): + if not verify_password(data.password, user.password): + raise HTTPException(status_code=401, detail="Invalid") key = await FidoCredentials.objects.get_or_none(pk=key_id, user=user.id) if key is None: raise HTTPException(status_code=404, detail="Key not found") diff --git a/frontend/package.json b/frontend/package.json index f897ae22..cecefc2c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -68,6 +68,7 @@ "felte": "^1.2.7", "fuse.js": "^6.6.2", "highlight.js": "^11.7.0", + "i18next": "^22.4.15", "i18next-browser-languagedetector": "^7.0.1", "js-cookie": "^3.0.1", "jws": "^4.0.0", @@ -104,8 +105,5 @@ "vite-plugin-iso-import": "^1.0.0", "yup": "^1.1.1" }, - "type": "module", - "dependencies": { - "i18next": "^22.4.15" - } + "type": "module" } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 905abfcb..ac43d077 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -4,11 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -dependencies: - i18next: - specifier: ^22.4.15 - version: 22.4.15 - devDependencies: '@beyonk/svelte-mapbox': specifier: ^9.0.5 @@ -169,6 +164,9 @@ devDependencies: highlight.js: specifier: ^11.7.0 version: 11.7.0 + i18next: + specifier: ^22.4.15 + version: 22.4.15 i18next-browser-languagedetector: specifier: ^7.0.1 version: 7.0.1 @@ -284,6 +282,7 @@ packages: engines: { node: '>=6.9.0' } dependencies: regenerator-runtime: 0.13.11 + dev: true /@beyonk/svelte-mapbox@9.0.5(svelte@3.58.0): resolution: @@ -3490,7 +3489,7 @@ packages: } dependencies: '@babel/runtime': 7.21.0 - dev: false + dev: true /ieee754@1.2.1: resolution: @@ -5065,6 +5064,7 @@ packages: { integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== } + dev: true /require-directory@2.1.1: resolution: diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 061930b9..af7e392a 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -223,7 +223,7 @@ "abcd_description": "Nur eine Antwort kann ausgewählt werden", "voting_description": "Antworten geben keine Punkte", "order_description": "Antworten müssen in die richtige Reihenfolge gebracht werden", - "text_description": "Spieler können text eingeben", + "text_description": "Spieler können Text eingeben", "range_description": "Ein Zahlenbereich kann mit einem Schieberegler ausgewählt werden", "check_choice_description": "Alle richtigen Antworten müssen für Punkte ausgewählt werden", "need_more_help": "Benötigst du mehr Hilfe?", diff --git a/frontend/src/routes/account/settings/security/+page.svelte b/frontend/src/routes/account/settings/security/+page.svelte index e561e773..f46cf0bd 100644 --- a/frontend/src/routes/account/settings/security/+page.svelte +++ b/frontend/src/routes/account/settings/security/+page.svelte @@ -36,18 +36,25 @@ SPDX-License-Identifier: MPL-2.0 if (!browser || user_data?.require_password === undefined) { return; } + const pw = require_password() const res = await fetch('/api/v1/users/2fa/require_password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ require_password: user_data?.require_password }) + body: JSON.stringify({ require_password: user_data?.require_password, password: pw }) }); user_data.require_password = (await res.json()).require_password; }; + const require_password = (): string => { + return prompt("Please enter your password to continue") + } + const add_security_key = async () => { - const res1 = await fetch('/api/v1/users/webauthn/add_key'); + const pw = require_password() + const res1 = await fetch('/api/v1/users/webauthn/add_key_init', {method: "POST", body: JSON.stringify({password: pw}), headers: { 'Content-Type': 'application/json' }}); + if (res1.status === 401) {alert("Password probably wrong"); return} if (!res1.ok) { throw Error('Response not ok'); } @@ -74,29 +81,36 @@ SPDX-License-Identifier: MPL-2.0 }; const remove_security_key = async (key_id: number) => { - await fetch(`/api/v1/users/webauthn/key/${key_id}`, { method: 'DELETE' }); + const pw = require_password() + const res = await fetch(`/api/v1/users/webauthn/key/${key_id}`, { method: 'DELETE', body: JSON.stringify({password: pw}), headers: { 'Content-Type': 'application/json' } }); + if (res.status === 401) {alert("Password probably wrong"); return} data = get_data(); }; const disable_totp = async () => { - await fetch(`/api/v1/users/2fa/totp`, { method: 'DELETE' }); + const pw = require_password() + const res = await fetch(`/api/v1/users/2fa/totp`, { method: 'DELETE', body: JSON.stringify({password: pw}), headers: { 'Content-Type': 'application/json' } }); + if (res.status === 401) {alert("Password probably wrong"); return} data = get_data(); }; const enable_totp = async () => { - const res = await fetch('/api/v1/users/2fa/totp', { method: 'POST' }); + const pw = require_password() + const res = await fetch('/api/v1/users/2fa/totp', { method: 'POST', body: JSON.stringify({password: pw}), headers: { 'Content-Type': 'application/json' } }); + if (res.status === 401) {alert("Password probably wrong"); return} data = get_data(); totp_data = await res.json(); }; const get_backup_code = async () => { + const pw = require_password() if (!confirm('If you continue, your old backup-code will be removed.')) { return; } - const res = await fetch('/api/v1/users/2fa/backup_code'); + const res = await fetch('/api/v1/users/2fa/backup_code', { method: 'POST', body: JSON.stringify({password: pw}), headers: { 'Content-Type': 'application/json' } }); + if (res.status === 401) {alert("Password probably wrong"); return} backup_code = (await res.json()).code; }; - $: console.log(user_data?.require_password, 'hello'); {#await data} @@ -116,11 +130,12 @@ SPDX-License-Identifier: MPL-2.0