From bcdf2b5154e23d1b51ee0c2833b6f49aec0edfff Mon Sep 17 00:00:00 2001 From: "MH.Dmitrii" Date: Sat, 26 Sep 2026 19:26:57 +0300 Subject: [PATCH] reset endpoint + frontend reset and login pages add --- CLAUDE.md | 86 ++++++++++++++++--- configs/.env.example | 2 +- frontend/configs/.env.example | 1 + frontend/src/api/LoginAPI/Auth.ts | 14 +++ frontend/src/api/ResetAPI/Reset.ts | 11 +++ frontend/src/api/token.ts | 17 ++++ .../auth/components/AuthComponent.module.css | 13 +++ .../auth/components/AuthComponent.tsx | 39 ++++++++- .../auth/components/ResetComponent.module.css | 12 +++ .../auth/components/ResetComponent.tsx | 32 ++++++- frontend/vite.config.ts | 10 +-- main.py | 9 ++ readme.md | 28 ++++++ src/cache/rate_limit.py | 14 +-- src/database/auth/__init__.py | 0 src/database/users/__init__.py | 0 src/errors/http_errors/__init__.py | 0 src/messaging/consumers/__init__.py | 0 src/messaging/consumers/consumers.py | 3 +- src/messaging/producers/__init__.py | 0 src/models/configs_read/__init__.py | 0 src/models/configs_read/env.py | 1 + src/models/pydantic_models/__init__.py | 0 src/models/rabbitmq_models/__init__.py | 0 src/service/auth/__init__.py | 0 src/service/auth/auth.py | 2 - src/service/auth/jwt.py | 12 +++ src/service/email/__init__.py | 0 src/service/users_crud/__init__.py | 0 src/service/users_crud/users_crud.py | 6 ++ src/web/protected_routes/__init__.py | 0 src/web/protected_routes/auth_routes.py | 20 ++++- 32 files changed, 294 insertions(+), 38 deletions(-) create mode 100644 frontend/configs/.env.example create mode 100644 frontend/src/api/LoginAPI/Auth.ts create mode 100644 frontend/src/api/ResetAPI/Reset.ts create mode 100644 frontend/src/api/token.ts create mode 100644 src/database/auth/__init__.py create mode 100644 src/database/users/__init__.py create mode 100644 src/errors/http_errors/__init__.py create mode 100644 src/messaging/consumers/__init__.py create mode 100644 src/messaging/producers/__init__.py create mode 100644 src/models/configs_read/__init__.py create mode 100644 src/models/pydantic_models/__init__.py create mode 100644 src/models/rabbitmq_models/__init__.py create mode 100644 src/service/auth/__init__.py create mode 100644 src/service/email/__init__.py create mode 100644 src/service/users_crud/__init__.py create mode 100644 src/web/protected_routes/__init__.py diff --git a/CLAUDE.md b/CLAUDE.md index d31f5be..5b80f96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,13 @@ workers. Python >=3.13,<4.0, Poetry for dependency management. - `secure` flag on refresh_token cookie is driven by `env_settings.PROD_MODE` (bool) — `False` locally/tests so cookies work over plain HTTP, `True` in prod. +- **CORS**: `CORSMiddleware` in `main.py` with `allow_origins=[env_settings. + FRONT_URL]` and `allow_credentials=True` (needed for the httpOnly + `refresh_token` cookie — with credentials, `["*"]` is rejected by browsers). + `FRONT_URL` is a required `Env` field with no default, so the backend fails + to start without it in `configs/.env` (e.g. `http://localhost:5173`). It is + added last, after the logging middlewares, so it wraps them and CORS + headers also appear on error responses (401/429). ## Redis (`src/cache/`) @@ -27,12 +34,15 @@ workers. Python >=3.13,<4.0, Poetry for dependency management. `redis.Redis` directly (inherits all commands, no manual wrapping needed). - Three uses: permissions is-cache was considered and rejected (no real DB savings — `get_user_by_id` already eager-loads everything via `selectin` - in one call); rate limiting on login (`RateLimit.rate_limit(ip)` — - `INCR` + `EXPIRE` on first attempt, blocks >5/60s); access-token revoke - blacklist (see above). -- Rate limit is only triggered inside `except HTTPException` on `/protected/token` - — i.e. only on failed logins, not successful ones (otherwise legitimate - repeated logins would trip it). + in one call); rate limiting (`RateLimit.rate_limit(ip, action, limit=5, + window=60)` — `INCR` + `EXPIRE ... NX` on first attempt, key + `rate_limit:{action}:{ip}`, so each action has its own counter); access-token + revoke blacklist (see above). +- Two rate-limited actions: `"login"` (5/60s) and `"reset"` (3/600s). + The login limit is only triggered inside `except HTTPException` on + `/protected/token` — i.e. only on failed logins, not successful ones + (otherwise legitimate repeated logins would trip it). `/protected/reset` + counts every call. ## RabbitMQ (`src/messaging/`) @@ -96,10 +106,19 @@ workers. Python >=3.13,<4.0, Poetry for dependency management. reads both `email` and `temp_password` from the message body and uses the same transient/permanent classification as `WelcomeEmailConsumer`. `EmailProducer.send_reset_email(email, temp_password)` takes the password - as a second argument now. **Still missing**: nothing in the app actually - calls `send_reset_email` yet — there's no password-reset route that - generates a `temp_password` and publishes it. Don't assume the - reset-password feature is reachable end-to-end until that route exists. + as a second argument. +- **Reset route**: `POST /protected/reset?email=...` in `auth_routes.py` — + `email` is a plain `str` param, so FastAPI reads it from the **query + string**, not the JSON body. The route rate-limits (3/600s per IP), calls + `CrudService.reset_password(email)` (generates a 16-char temp password via + `HashService.generate_temp_password()` — which is `async` — hashes and + saves it through `update_user`), then publishes `{email, temp_password}` to + `email.reset`. A 404 (unknown email) is swallowed so the response is always + `{"detail": "New password was sent"}` — no user enumeration. Known rough + edge: the DB password is changed *before* the email is sent, so a message + that ends up in the DLQ leaves the user with a changed password and no + email; and anyone who knows an email can reset that account's password. + A token-link flow would avoid both. ## Logging (`src/logging/`) @@ -218,6 +237,53 @@ workers. Python >=3.13,<4.0, Poetry for dependency management. --force` if it persists across rebuilds; a one-off warning on first build isn't necessarily a problem. +## Frontend (`frontend/`) + +- React 19 + TypeScript + Vite + `react-router-dom` 7, package manager is + **yarn** (`yarn.lock`). Run with `yarn dev` (Vite, default + `http://localhost:5173`). The user writes/edits frontend code themselves — + explain rather than edit unless asked (see memory). +- Layout: `src/features/auth/components/` (`AuthComponent`, `ResetComponent`, + each with its own `.module.css`), `src/pages/` (`LoginPage` at `/`, `ForgotPasswordPage` + at `/forgot-password` — thin wrappers), `src/layouts/AuthLayout` (shared + split-screen shell with the marketing block; takes `children` for the form + on the right), `src/api/` (`token.ts`, `LoginAPI/Auth.ts`, + `ResetAPI/Reset.ts`), `src/types/UserTypes.ts` (mirrors the Pydantic + `UserOut`/`UserCreate`). `index.css` = global reset/font, `App.css` = `#root` + frame. +- **Env**: Vite reads `.env*` from `frontend/configs/` (`envDir: "./configs"` + in `vite.config.ts`), not from `frontend/`. Only `VITE_`-prefixed variables + reach the browser code, via `import.meta.env`. Backend address is + `VITE_BACKEND_URL`, exported as `API_URL` from `src/api/token.ts`. Restart + the dev server after editing `.env`. There is **no** Vite `server.proxy` + (removed on purpose) — the browser calls the backend directly, so backend + CORS (see Auth) must allow the frontend origin. +- **Access token** lives in a module variable in `src/api/token.ts` + (`setAccessToken`/`getAccessToken`), not in `localStorage`; it is lost on + page reload (a `/protected/refresh` call on startup is not implemented + yet). `authFetch(path, init)` adds `Authorization: Bearer` and + `credentials: "include"`. Login (`POST /protected/token`) must be sent as + **form-data** (`URLSearchParams` with `username` = email and `password`), + because the route uses `OAuth2PasswordRequestForm`, and returns + `{access_token, token_type}`; the refresh token arrives as an httpOnly cookie. +- Forms are controlled inputs (`useState`) with hand-written validation: + per-field `errors` state, checked on blur and on submit; `
` + so browser bubbles don't preempt it. Invalid field gets `.inputError`, text + goes in an absolutely positioned `.errorText` under the field. Backend + errors are logged with `console.error` and shown as a short generic message. +- **CSS Modules gotchas**: only *class* selectors are hashed. Bare element + selectors (`form {}`, `input {}`) and `#id` selectors stay global and leak + between components (one module's `form { gap }` restyled the other form). + Scope them under the module's own class (`.wrapper_1 form {}`) — the + hashed ancestor makes the rule local. Classes with hyphens are accessed as + `styles["email-icon"]`. Class names shared between two modules are + independent copies (duplicate the rules, don't rely on sharing). +- React 19 types: `FormEvent` is deprecated; use `SubmitEvent`. + `

` must not contain `

` (hydration warning). +- Login currently redirects to `http://localhost:8000/docs` after success + (placeholder — no post-login page yet); `ResetComponent` copies that + placeholder redirect too. + ## Testing (`tests/unit`, `tests/integrated`, `tests/e2e`) - **Recurring root cause of "different event loop" / `MissingGreenlet`-style diff --git a/configs/.env.example b/configs/.env.example index b4875b3..3e75494 100644 --- a/configs/.env.example +++ b/configs/.env.example @@ -25,5 +25,5 @@ SMTP_SERVER="change_me" EMAIL_LOGIN="change_me" EMAIL_PASSWORD="change_me" - +FRONT_URL="change_me" PROD_MODE=bool \ No newline at end of file diff --git a/frontend/configs/.env.example b/frontend/configs/.env.example new file mode 100644 index 0000000..e8d0ef0 --- /dev/null +++ b/frontend/configs/.env.example @@ -0,0 +1 @@ +VITE_BACKEND_URL="change_me" \ No newline at end of file diff --git a/frontend/src/api/LoginAPI/Auth.ts b/frontend/src/api/LoginAPI/Auth.ts new file mode 100644 index 0000000..97935c9 --- /dev/null +++ b/frontend/src/api/LoginAPI/Auth.ts @@ -0,0 +1,14 @@ +import { API_URL, setAccessToken } from "../token"; + +export async function login(email: string, password: string) { + const body = new URLSearchParams({ username: email, password }); + const res = await fetch(`${API_URL}/protected/token`, { + method: "POST", + body, + credentials: "include", + }); + if (!res.ok) throw new Error("Неверный email или пароль"); + const data = await res.json(); + setAccessToken(data.access_token); + return data; +} diff --git a/frontend/src/api/ResetAPI/Reset.ts b/frontend/src/api/ResetAPI/Reset.ts new file mode 100644 index 0000000..4912fae --- /dev/null +++ b/frontend/src/api/ResetAPI/Reset.ts @@ -0,0 +1,11 @@ + +export async function requestPasswordReset(email: string): Promise { + const res = await fetch(`http://localhost:8000/protected/reset?${new URLSearchParams({ email })}`, { + method: "POST", + credentials: "include", + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(typeof body?.detail === "string" ? body.detail : "Не удалось отправить запрос"); + } +} \ No newline at end of file diff --git a/frontend/src/api/token.ts b/frontend/src/api/token.ts new file mode 100644 index 0000000..14bcfce --- /dev/null +++ b/frontend/src/api/token.ts @@ -0,0 +1,17 @@ +export const API_URL = import.meta.env.VITE_BACKEND_URL; + +let accessToken: string | null = null; + +export function setAccessToken(token: string | null) { + accessToken = token; +} + +export function getAccessToken() { + return accessToken; +} + +export async function authFetch(path: string, init: RequestInit = {}) { + const headers = new Headers(init.headers); + if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`); + return fetch(`${API_URL}${path}`, { ...init, headers, credentials: "include" }); +} diff --git a/frontend/src/features/auth/components/AuthComponent.module.css b/frontend/src/features/auth/components/AuthComponent.module.css index 69f0eb7..9724e21 100644 --- a/frontend/src/features/auth/components/AuthComponent.module.css +++ b/frontend/src/features/auth/components/AuthComponent.module.css @@ -142,3 +142,16 @@ height: 25px; border-radius: 10px; } + +.wrapper_1 .inputError { + border-color: #d93025; + background-color: #fdf2f1; +} +.errorText { + color: #d93025; + font-size: 14px; + padding-top: 6px; + position: absolute; + left: 4px; + top: 100%; +} diff --git a/frontend/src/features/auth/components/AuthComponent.tsx b/frontend/src/features/auth/components/AuthComponent.tsx index b31b76e..cfbd8f7 100644 --- a/frontend/src/features/auth/components/AuthComponent.tsx +++ b/frontend/src/features/auth/components/AuthComponent.tsx @@ -1,17 +1,42 @@ -import { useState, type ReactElement } from "react"; +import { useState, type SubmitEvent, type ReactElement } from "react"; import { Link } from "react-router-dom"; import styles from "./AuthComponent.module.css"; +import { login } from "../../../api/LoginAPI/Auth"; export function Auth():ReactElement { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [rememberMe, setRememberMe] = useState(false); const [showPassword, setShowPassword] = useState(false); + const [errors, setErrors] = useState<{ email?: string; password?: string }>({}); + + const validateEmail = (value: string) => { + if (!value) return "Введите email"; + if (!/^\S+@\S+\.\S+$/.test(value)) return "Некорректный email"; + return undefined; + }; + const validatePassword = (value: string) => + value ? undefined : "Введите пароль"; + + const handleSubmit = async (e: SubmitEvent) => { + e.preventDefault(); + const next = { email: validateEmail(email), password: validatePassword(password) }; + setErrors(next); + if (next.email || next.password) return; + + try { + await login(email, password); + window.location.href = "http://localhost:8000/docs"; + } catch (err) { + console.error(err); + setErrors({ password: "Неверный email или пароль" }); + } + }; return ( <>
- +

Добро Пожаловать
в The DisExcel Project

Войдите в систему, чтобы продолжить работу с вашими процессами

@@ -23,13 +48,16 @@ export function Auth():ReactElement { autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} + onBlur={() => setErrors((p) => ({ ...p, email: validateEmail(email) }))} placeholder="Email" - required + aria-invalid={!!errors.email} + className={errors.email ? styles.inputError : undefined} /> + {errors.email &&

{errors.email}

}
@@ -41,8 +69,10 @@ export function Auth():ReactElement { autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} + onBlur={() => setErrors((p) => ({ ...p, password: validatePassword(password) }))} placeholder="Password" - required + aria-invalid={!!errors.password} + className={errors.password ? styles.inputError : undefined} /> @@ -67,6 +97,7 @@ export function Auth():ReactElement { )} + {errors.password &&

{errors.password}

}
diff --git a/frontend/src/features/auth/components/ResetComponent.module.css b/frontend/src/features/auth/components/ResetComponent.module.css index f758be1..dc3e6d3 100644 --- a/frontend/src/features/auth/components/ResetComponent.module.css +++ b/frontend/src/features/auth/components/ResetComponent.module.css @@ -91,3 +91,15 @@ color: #087A55; font-weight: 600; } + +.wrapper_1 .inputError { + border-color: #d93025; + background-color: #fdf2f1; +} +.errorText { + color: #d93025; + font-size: 14px; + position: absolute; + top: 100%; + left: 4px; +} \ No newline at end of file diff --git a/frontend/src/features/auth/components/ResetComponent.tsx b/frontend/src/features/auth/components/ResetComponent.tsx index d91a6c4..9e831bb 100644 --- a/frontend/src/features/auth/components/ResetComponent.tsx +++ b/frontend/src/features/auth/components/ResetComponent.tsx @@ -1,14 +1,37 @@ import styles from "./ResetComponent.module.css"; -import { useState, type ReactElement } from "react"; +import { useState, type ReactElement, type SubmitEvent } from "react"; import { Link } from "react-router-dom"; +import { requestPasswordReset } from "../../../api/ResetAPI/Reset"; export function Reset():ReactElement { const [email, setEmail] = useState(""); + const [errors, setErrors] = useState<{ email?: string;}>({}); + + const validateEmail = (value: string) => { + if (!value) return "Введите email"; + if (!/^\S+@\S+\.\S+$/.test(value)) return "Некорректный email"; + return undefined; + }; + const handleSubmit = async (e: SubmitEvent) => { + e.preventDefault(); + const next = { + email: validateEmail(email), + }; + setErrors(next); + if (next.email) return; + try { + await requestPasswordReset(email); + window.location.href = "http://localhost:8000/docs"; + } catch (err) { + console.error(err); + setErrors({ email: "Ошибка, попробуйте позже" }); + } + }; return ( <>
- +

Восстановление пароля

Введите Email, и мы отправим на него новый пароль

@@ -20,13 +43,16 @@ export function Reset():ReactElement { autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} + onBlur={() => setErrors((p) => ({ ...p, email: validateEmail(email) }))} placeholder="Email" - required + aria-invalid={!!errors.email} + className={errors.email ? styles.inputError : undefined} /> + {errors.email &&

{errors.email}

}