This commit is contained in:
2024-04-20 17:37:41 +03:00
parent ed0ecf9f51
commit 03da5914fa
11 changed files with 218 additions and 36 deletions

View File

@ -67,3 +67,11 @@ class QueueLog(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
action = Column(String) action = Column(String)
created = Column(DateTime, default=datetime.datetime.utcnow) 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)

View File

@ -2,8 +2,9 @@ from datetime import datetime, timedelta, timezone
from typing import Annotated, Union from typing import Annotated, Union
from sqlalchemy.orm import Session 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 fastapi.security import OAuth2PasswordRequestForm
from io import BytesIO
from pydantic import BaseModel from pydantic import BaseModel
@ -46,21 +47,27 @@ async def register(
user_data: schemas.UserRegister, user_data: schemas.UserRegister,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
) -> schemas.User: ) -> schemas.User:
user = services.get_user_by_username(db, user_data.username) if services.check_captcha(
if user: id=user_data.captcha.id, prompt=user_data.captcha.prompt, db=db
raise HTTPException( ):
status_code=status.HTTP_400_BAD_REQUEST, user = services.get_user_by_username(db, user_data.username)
detail="User with this username already exists", if user:
headers={"WWW-Authenticate": "Bearer"}, raise HTTPException(
) status_code=status.HTTP_400_BAD_REQUEST,
if user_data.password != user_data.password2: detail="User with this username already exists",
raise HTTPException( headers={"WWW-Authenticate": "Bearer"},
status_code=status.HTTP_400_BAD_REQUEST, )
detail="Passwords do not match", if user_data.password != user_data.password2:
headers={"WWW-Authenticate": "Bearer"}, raise HTTPException(
) status_code=status.HTTP_400_BAD_REQUEST,
user = services.create_user(db=db, user_data=user_data) detail="Passwords do not match",
return user 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") @router.get("/me")
@ -75,3 +82,16 @@ async def get_qnon_user(
anon_user: Annotated[schemas.AnonUser, Depends(services.get_anon_user)] anon_user: Annotated[schemas.AnonUser, Depends(services.get_anon_user)]
) -> schemas.AnonUser: ) -> schemas.AnonUser:
return anon_user 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")

View File

@ -15,9 +15,22 @@ class UserInDB(User):
from_attributes = True from_attributes = True
class Captcha(BaseModel):
id: UUID
class Config:
from_attributes = True
class CaptchaCheck(BaseModel):
id: UUID
prompt: str
class UserRegister(User): class UserRegister(User):
password: str password: str
password2: str password2: str
captcha: CaptchaCheck
class Token(BaseModel): class Token(BaseModel):

View File

@ -6,12 +6,17 @@ from typing import Annotated, Union
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from passlib.context import CryptContext from passlib.context import CryptContext
import uuid import uuid
import random
from io import BytesIO
from captcha.image import ImageCaptcha
from ...db import models from ...db import models
from . import schemas from . import schemas
from ...dependencies import get_db from ...dependencies import get_db
from ...config import jwt_config from ...config import jwt_config
CAPTCHA_SYMBOLS = "abcdefghijklmnopqrstuvwxyz0123456789"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
@ -138,6 +143,42 @@ def get_anon_user(
return anon return anon
raise HTTPException( raise HTTPException(
status_code=status.HTTP_418_IM_A_TEAPOT, status_code=status.HTTP_418_IM_A_TEAPOT,
detail={"message": "tf dude? trying to spoof your client id?"},
) )
return create_anon_user(db) 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

View File

@ -4,4 +4,5 @@ pydantic
sqlalchemy sqlalchemy
psycopg2-binary psycopg2-binary
python-jose[cryptography] python-jose[cryptography]
passlib[all] passlib[all]
captcha

View File

@ -16,13 +16,15 @@
"react-device-detect": "^2.2.3", "react-device-detect": "^2.2.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-redux": "^9.1.0", "react-redux": "^9.1.0",
"react-router-dom": "^6.22.3" "react-router-dom": "^6.22.3",
"uuid": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@rsbuild/core": "^0.5.4", "@rsbuild/core": "^0.5.4",
"@rsbuild/plugin-react": "^0.5.4", "@rsbuild/plugin-react": "^0.5.4",
"@types/react": "^18.2.71", "@types/react": "^18.2.71",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.4.0", "@typescript-eslint/eslint-plugin": "^7.4.0",
"@typescript-eslint/parser": "^7.4.0", "@typescript-eslint/parser": "^7.4.0",
"eslint": "^8.57.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", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz",
"integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" "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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.4.0", "version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.4.0.tgz", "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" "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": { "node_modules/watchpack": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz",

View File

@ -16,13 +16,15 @@
"react-device-detect": "^2.2.3", "react-device-detect": "^2.2.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-redux": "^9.1.0", "react-redux": "^9.1.0",
"react-router-dom": "^6.22.3" "react-router-dom": "^6.22.3",
"uuid": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@rsbuild/core": "^0.5.4", "@rsbuild/core": "^0.5.4",
"@rsbuild/plugin-react": "^0.5.4", "@rsbuild/plugin-react": "^0.5.4",
"@types/react": "^18.2.71", "@types/react": "^18.2.71",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.4.0", "@typescript-eslint/eslint-plugin": "^7.4.0",
"@typescript-eslint/parser": "^7.4.0", "@typescript-eslint/parser": "^7.4.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",

View File

@ -1,10 +1,9 @@
import React, { createContext } from "react"; import React, { createContext } from "react";
import { ConfigProvider, message } from "antd"; import { message } from "antd";
import "./App.css"; import "./App.css";
import HeaderComponent from "./components/HeaderComponent"; import HeaderComponent from "./components/HeaderComponent";
import { darkTheme, lightTheme, theme } from "./config/style"; import { Provider } from "react-redux";
import { Provider, useSelector } from "react-redux"; import { store } from "./config/store";
import { StorePrototype, store } from "./config/store";
import { MessageInstance } from "antd/es/message/interface"; import { MessageInstance } from "antd/es/message/interface";
import AppRoutes from "./pages/AppRoutes"; import AppRoutes from "./pages/AppRoutes";
import ThemeProviderWrapper from "./config/ThemeProviderWrapper"; import ThemeProviderWrapper from "./config/ThemeProviderWrapper";

View File

@ -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 { import {
KeyOutlined, KeyOutlined,
LoadingOutlined, LoadingOutlined,
ReloadOutlined,
UserAddOutlined, UserAddOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import React, { useContext, useEffect, useRef, useState } from "react"; 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 { store, updateClient, updateToken, updateUser } from "../config/store";
import tr from "../config/translation"; import tr from "../config/translation";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { v4 as uuidv4 } from "uuid";
import { baseUrl } from "../config/baseUrl";
const AuthModal = (props: { const AuthModal = (props: {
open: boolean; open: boolean;
@ -29,6 +42,19 @@ const AuthModal = (props: {
const [loginForm] = Form.useForm(); const [loginForm] = Form.useForm();
const [registerForm] = 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({}); const { data, refetch, isFetching, isError } = useGetUserQuery({});
useEffect(() => { useEffect(() => {
if (!isFetching && !isError) { if (!isFetching && !isError) {
@ -36,12 +62,18 @@ const AuthModal = (props: {
} }
}, [data, isFetching, useGetUserQuery]); }, [data, isFetching, useGetUserQuery]);
const { data: clientData, isFetching: isFetchingClient } = useGetClientQuery( const {
{} data: clientData,
); isFetching: isFetchingClient,
isError: isErrorClient,
} = useGetClientQuery({});
useEffect(() => { useEffect(() => {
if (!isFetchingClient) { if (!isFetchingClient) {
store.dispatch(updateClient(clientData.id)); if (isErrorClient) {
store.dispatch(updateClient(null));
} else {
store.dispatch(updateClient(clientData.id));
}
} }
}, [clientData, isFetchingClient, useGetClientQuery]); }, [clientData, isFetchingClient, useGetClientQuery]);
@ -70,7 +102,7 @@ const AuthModal = (props: {
.then(() => props.setOpen(false)) .then(() => props.setOpen(false))
.then(() => navigate("/dashboard")) .then(() => navigate("/dashboard"))
.then(() => props.setDrawerOpen(false)) .then(() => props.setDrawerOpen(false))
.catch(() => messageApi.error(tr("Login failed!"))); .catch((e) => messageApi.error(tr(e.data.detail)));
}; };
const submitRegisterForm = (formData: { const submitRegisterForm = (formData: {
@ -78,12 +110,19 @@ const AuthModal = (props: {
name: string | undefined; name: string | undefined;
password: string; password: string;
password2: string; password2: string;
captcha: { id: string; prompt: string };
}) => { }) => {
formData.captcha.id = captchaId;
registerUser(formData) registerUser(formData)
.unwrap() .unwrap()
.then(() => submitLoginForm(formData)) .then(() => submitLoginForm(formData))
.then(() => props.setOpen(false)) .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"] = [ const items: MenuProps["items"] = [
@ -219,9 +258,37 @@ const AuthModal = (props: {
}, },
}), }),
]} ]}
>
<Input type="password" />
</Form.Item>
<Form.Item label={tr("Captcha")}>
{captchaPic ? (
<img
onClick={() => fetchCaptcha()}
src={captchaPic}
alt={tr("Click to refresh")}
/>
) : (
<Button
onClick={() => fetchCaptcha()}
icon={<ReloadOutlined />}
>
{tr("Fetch captcha")}
</Button>
)}
</Form.Item>
<Form.Item
name={["captcha", "prompt"]}
label={tr("Captcha prompt")}
rules={[
{
required: true,
message: tr("Please enter captcha!"),
},
]}
> >
<Input <Input
type="password" disabled={!captchaId}
onPressEnter={() => registerForm.submit()} onPressEnter={() => registerForm.submit()}
/> />
</Form.Item> </Form.Item>

View File

@ -39,7 +39,7 @@ export type StorePrototype = {
export const updateToken = createAction<string>("auth/updateToken"); export const updateToken = createAction<string>("auth/updateToken");
export const getLocalToken = createAction("auth/getLocalToken"); export const getLocalToken = createAction("auth/getLocalToken");
export const updateClient = createAction<string>("auth/updateClient"); export const updateClient = createAction<string | null>("auth/updateClient");
export const getLocalClient = createAction("auth/getLocalClient"); export const getLocalClient = createAction("auth/getLocalClient");
export const updateUser = createAction<User>("auth/updateUser"); export const updateUser = createAction<User>("auth/updateUser");
export const logOut = createAction("auth/logOut"); export const logOut = createAction("auth/logOut");
@ -67,8 +67,12 @@ export const store = configureStore({
} }
}); });
builder.addCase(updateClient, (state, action) => { builder.addCase(updateClient, (state, action) => {
state.clientId = action.payload; if (action.payload) {
localStorage.setItem("clientId", action.payload); state.clientId = action.payload;
localStorage.setItem("clientId", action.payload);
} else {
localStorage.removeItem("clientId");
}
}); });
builder.addCase(getLocalClient, (state) => { builder.addCase(getLocalClient, (state) => {
const clientId: string | null = localStorage.getItem("clientId"); const clientId: string | null = localStorage.getItem("clientId");
@ -95,7 +99,10 @@ export const store = configureStore({
if (language) { if (language) {
state.language = language; state.language = language;
} else { } else {
state.language = "en"; const clientLanguage = navigator.language.startsWith("en-")
? "en"
: "ru";
state.language = clientLanguage;
} }
}); });
builder.addCase(setTheme, (state, action) => { builder.addCase(setTheme, (state, action) => {

View File

@ -22,6 +22,10 @@ export const QueueApi = createApi({
if (token) { if (token) {
headers.set("authorization", `Bearer ${token}`); headers.set("authorization", `Bearer ${token}`);
} }
const clientID = (getState() as RootState).auth.clientId;
if (clientID) {
headers.set("X-Client-Id", clientID);
}
return headers; return headers;
}, },
}), }),