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)
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)

View File

@ -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")

View File

@ -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):

View File

@ -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

View File

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

View File

@ -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",

View File

@ -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",

View File

@ -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";

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 {
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: {
},
}),
]}
>
<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
type="password"
disabled={!captchaId}
onPressEnter={() => registerForm.submit()}
/>
</Form.Item>

View File

@ -39,7 +39,7 @@ export type StorePrototype = {
export const updateToken = createAction<string>("auth/updateToken");
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 updateUser = createAction<User>("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) => {

View File

@ -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;
},
}),