diff --git a/backend/app/db/models.py b/backend/app/db/models.py index af6df7d..7caf2aa 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -67,3 +67,11 @@ class QueueLog(Base): id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) action = Column(String) created = Column(DateTime, default=datetime.datetime.utcnow) + + +class Captcha(Base): + __tablename__ = "captcha" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + prompt = Column(String(length=6)) + used = Column(Boolean, default=False) diff --git a/backend/app/views/auth/api.py b/backend/app/views/auth/api.py index 245ec59..75da842 100644 --- a/backend/app/views/auth/api.py +++ b/backend/app/views/auth/api.py @@ -2,8 +2,9 @@ from datetime import datetime, timedelta, timezone from typing import Annotated, Union from sqlalchemy.orm import Session -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Response from fastapi.security import OAuth2PasswordRequestForm +from io import BytesIO from pydantic import BaseModel @@ -46,21 +47,27 @@ async def register( user_data: schemas.UserRegister, db: Annotated[Session, Depends(get_db)], ) -> schemas.User: - user = services.get_user_by_username(db, user_data.username) - if user: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="User with this username already exists", - headers={"WWW-Authenticate": "Bearer"}, - ) - if user_data.password != user_data.password2: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Passwords do not match", - headers={"WWW-Authenticate": "Bearer"}, - ) - user = services.create_user(db=db, user_data=user_data) - return user + if services.check_captcha( + id=user_data.captcha.id, prompt=user_data.captcha.prompt, db=db + ): + user = services.get_user_by_username(db, user_data.username) + if user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User with this username already exists", + headers={"WWW-Authenticate": "Bearer"}, + ) + if user_data.password != user_data.password2: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Passwords do not match", + headers={"WWW-Authenticate": "Bearer"}, + ) + user = services.create_user(db=db, user_data=user_data) + return user + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid captcha" + ) @router.get("/me") @@ -75,3 +82,16 @@ async def get_qnon_user( anon_user: Annotated[schemas.AnonUser, Depends(services.get_anon_user)] ) -> schemas.AnonUser: return anon_user + + +@router.get( + "/captcha/{captcha_id}", + responses={200: {"content": {"image/png": {}}}}, + response_class=Response, +) +async def generate_captcha( + captcha: Annotated[BytesIO, Depends(services.get_captcha)] +) -> Response: + captcha.seek(0) + captcha_bytes = captcha.read() + return Response(content=captcha_bytes, media_type="image/png") diff --git a/backend/app/views/auth/schemas.py b/backend/app/views/auth/schemas.py index b613003..98d2cae 100644 --- a/backend/app/views/auth/schemas.py +++ b/backend/app/views/auth/schemas.py @@ -15,9 +15,22 @@ class UserInDB(User): from_attributes = True +class Captcha(BaseModel): + id: UUID + + class Config: + from_attributes = True + + +class CaptchaCheck(BaseModel): + id: UUID + prompt: str + + class UserRegister(User): password: str password2: str + captcha: CaptchaCheck class Token(BaseModel): diff --git a/backend/app/views/auth/services.py b/backend/app/views/auth/services.py index dfe93fa..92a81a5 100644 --- a/backend/app/views/auth/services.py +++ b/backend/app/views/auth/services.py @@ -6,12 +6,17 @@ from typing import Annotated, Union from datetime import datetime, timezone, timedelta from passlib.context import CryptContext import uuid +import random +from io import BytesIO +from captcha.image import ImageCaptcha from ...db import models from . import schemas from ...dependencies import get_db from ...config import jwt_config +CAPTCHA_SYMBOLS = "abcdefghijklmnopqrstuvwxyz0123456789" + pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") @@ -138,6 +143,42 @@ def get_anon_user( return anon raise HTTPException( status_code=status.HTTP_418_IM_A_TEAPOT, - detail={"message": "tf dude? trying to spoof your client id?"}, ) return create_anon_user(db) + + +def get_captcha( + captcha_id: uuid.UUID, db: Annotated[Session, Depends(get_db)] +) -> BytesIO: + prompt = "".join(random.choice(CAPTCHA_SYMBOLS) for i in range(6)) + c = models.Captcha(id=captcha_id, prompt=prompt) + try: + db.add(c) + db.commit() + except: + db.rollback() + raise HTTPException( + status_code=status.HTTP_418_IM_A_TEAPOT, + ) + captcha = ImageCaptcha() + data = captcha.generate(prompt) + return data + + +def check_captcha( + id: uuid.UUID, prompt: str, db: Annotated[Session, Depends(get_db)] +) -> bool: + c = ( + db.query(models.Captcha) + .filter( + models.Captcha.id == id, + models.Captcha.prompt == prompt, + models.Captcha.used == False, + ) + .first() + ) + if c: + setattr(c, "used", True) + db.commit() + return True + return False diff --git a/backend/requirements.txt b/backend/requirements.txt index 126e36c..7563861 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,4 +4,5 @@ pydantic sqlalchemy psycopg2-binary python-jose[cryptography] -passlib[all] \ No newline at end of file +passlib[all] +captcha \ No newline at end of file diff --git a/frontend/app/package-lock.json b/frontend/app/package-lock.json index 65ec1a0..5dc3000 100644 --- a/frontend/app/package-lock.json +++ b/frontend/app/package-lock.json @@ -16,13 +16,15 @@ "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", "react-redux": "^9.1.0", - "react-router-dom": "^6.22.3" + "react-router-dom": "^6.22.3", + "uuid": "^9.0.1" }, "devDependencies": { "@rsbuild/core": "^0.5.4", "@rsbuild/plugin-react": "^0.5.4", "@types/react": "^18.2.71", "@types/react-dom": "^18.2.22", + "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^7.4.0", "@typescript-eslint/parser": "^7.4.0", "eslint": "^8.57.0", @@ -711,6 +713,12 @@ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.4.0.tgz", @@ -4743,6 +4751,18 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/watchpack": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", diff --git a/frontend/app/package.json b/frontend/app/package.json index b54c225..95d6707 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -16,13 +16,15 @@ "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", "react-redux": "^9.1.0", - "react-router-dom": "^6.22.3" + "react-router-dom": "^6.22.3", + "uuid": "^9.0.1" }, "devDependencies": { "@rsbuild/core": "^0.5.4", "@rsbuild/plugin-react": "^0.5.4", "@types/react": "^18.2.71", "@types/react-dom": "^18.2.22", + "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^7.4.0", "@typescript-eslint/parser": "^7.4.0", "eslint": "^8.57.0", diff --git a/frontend/app/src/App.tsx b/frontend/app/src/App.tsx index 20e68bd..b53df5c 100644 --- a/frontend/app/src/App.tsx +++ b/frontend/app/src/App.tsx @@ -1,10 +1,9 @@ import React, { createContext } from "react"; -import { ConfigProvider, message } from "antd"; +import { message } from "antd"; import "./App.css"; import HeaderComponent from "./components/HeaderComponent"; -import { darkTheme, lightTheme, theme } from "./config/style"; -import { Provider, useSelector } from "react-redux"; -import { StorePrototype, store } from "./config/store"; +import { Provider } from "react-redux"; +import { store } from "./config/store"; import { MessageInstance } from "antd/es/message/interface"; import AppRoutes from "./pages/AppRoutes"; import ThemeProviderWrapper from "./config/ThemeProviderWrapper"; diff --git a/frontend/app/src/components/AuthModal.tsx b/frontend/app/src/components/AuthModal.tsx index e43d614..720365e 100644 --- a/frontend/app/src/components/AuthModal.tsx +++ b/frontend/app/src/components/AuthModal.tsx @@ -1,7 +1,18 @@ -import { Carousel, Form, Input, Menu, MenuProps, Modal, Spin } from "antd"; +import { + Button, + Carousel, + Form, + Image, + Input, + Menu, + MenuProps, + Modal, + Spin, +} from "antd"; import { KeyOutlined, LoadingOutlined, + ReloadOutlined, UserAddOutlined, } from "@ant-design/icons"; import React, { useContext, useEffect, useRef, useState } from "react"; @@ -16,6 +27,8 @@ import { MessageContext } from "../App"; import { store, updateClient, updateToken, updateUser } from "../config/store"; import tr from "../config/translation"; import { useNavigate } from "react-router-dom"; +import { v4 as uuidv4 } from "uuid"; +import { baseUrl } from "../config/baseUrl"; const AuthModal = (props: { open: boolean; @@ -29,6 +42,19 @@ const AuthModal = (props: { const [loginForm] = Form.useForm(); const [registerForm] = Form.useForm(); + const [captchaId, setCaptchaId] = useState(""); + const [captchaPic, setCaptchaPic] = useState(""); + + const fetchCaptcha = async () => { + registerForm.setFieldValue(["captcha", "prompt"], ""); + const id = uuidv4(); + setCaptchaId(id); + const res = await fetch(`${baseUrl}/auth/captcha/${id}`); + const imageBlob = await res.blob(); + const imageObjectURL = URL.createObjectURL(imageBlob); + setCaptchaPic(imageObjectURL); + }; + const { data, refetch, isFetching, isError } = useGetUserQuery({}); useEffect(() => { if (!isFetching && !isError) { @@ -36,12 +62,18 @@ const AuthModal = (props: { } }, [data, isFetching, useGetUserQuery]); - const { data: clientData, isFetching: isFetchingClient } = useGetClientQuery( - {} - ); + const { + data: clientData, + isFetching: isFetchingClient, + isError: isErrorClient, + } = useGetClientQuery({}); useEffect(() => { if (!isFetchingClient) { - store.dispatch(updateClient(clientData.id)); + if (isErrorClient) { + store.dispatch(updateClient(null)); + } else { + store.dispatch(updateClient(clientData.id)); + } } }, [clientData, isFetchingClient, useGetClientQuery]); @@ -70,7 +102,7 @@ const AuthModal = (props: { .then(() => props.setOpen(false)) .then(() => navigate("/dashboard")) .then(() => props.setDrawerOpen(false)) - .catch(() => messageApi.error(tr("Login failed!"))); + .catch((e) => messageApi.error(tr(e.data.detail))); }; const submitRegisterForm = (formData: { @@ -78,12 +110,19 @@ const AuthModal = (props: { name: string | undefined; password: string; password2: string; + captcha: { id: string; prompt: string }; }) => { + formData.captcha.id = captchaId; registerUser(formData) .unwrap() .then(() => submitLoginForm(formData)) .then(() => props.setOpen(false)) - .catch(() => messageApi.error(tr("Registration failed!"))); + .then(() => setCaptchaPic("")) + .then(() => registerForm.resetFields()) + .catch((e) => { + messageApi.error(tr(e.data.detail)); + fetchCaptcha(); + }); }; const items: MenuProps["items"] = [ @@ -219,9 +258,37 @@ const AuthModal = (props: { }, }), ]} + > + + + + {captchaPic ? ( + fetchCaptcha()} + src={captchaPic} + alt={tr("Click to refresh")} + /> + ) : ( + + )} + + registerForm.submit()} /> diff --git a/frontend/app/src/config/store.ts b/frontend/app/src/config/store.ts index 6651ffc..3af843d 100644 --- a/frontend/app/src/config/store.ts +++ b/frontend/app/src/config/store.ts @@ -39,7 +39,7 @@ export type StorePrototype = { export const updateToken = createAction("auth/updateToken"); export const getLocalToken = createAction("auth/getLocalToken"); -export const updateClient = createAction("auth/updateClient"); +export const updateClient = createAction("auth/updateClient"); export const getLocalClient = createAction("auth/getLocalClient"); export const updateUser = createAction("auth/updateUser"); export const logOut = createAction("auth/logOut"); @@ -67,8 +67,12 @@ export const store = configureStore({ } }); builder.addCase(updateClient, (state, action) => { - state.clientId = action.payload; - localStorage.setItem("clientId", action.payload); + if (action.payload) { + state.clientId = action.payload; + localStorage.setItem("clientId", action.payload); + } else { + localStorage.removeItem("clientId"); + } }); builder.addCase(getLocalClient, (state) => { const clientId: string | null = localStorage.getItem("clientId"); @@ -95,7 +99,10 @@ export const store = configureStore({ if (language) { state.language = language; } else { - state.language = "en"; + const clientLanguage = navigator.language.startsWith("en-") + ? "en" + : "ru"; + state.language = clientLanguage; } }); builder.addCase(setTheme, (state, action) => { diff --git a/frontend/app/src/slice/QueueApi.ts b/frontend/app/src/slice/QueueApi.ts index d78809c..8a9a77e 100644 --- a/frontend/app/src/slice/QueueApi.ts +++ b/frontend/app/src/slice/QueueApi.ts @@ -22,6 +22,10 @@ export const QueueApi = createApi({ if (token) { headers.set("authorization", `Bearer ${token}`); } + const clientID = (getState() as RootState).auth.clientId; + if (clientID) { + headers.set("X-Client-Id", clientID); + } return headers; }, }),