reset endpoint + frontend reset and login pages add

This commit is contained in:
2026-09-26 19:26:57 +03:00
parent 489014667c
commit bcdf2b5154
32 changed files with 294 additions and 38 deletions
+76 -10
View File
@@ -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; `<form noValidate>`
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<HTMLFormElement>`.
`<p>` must not contain `<div>` (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
+1 -1
View File
@@ -25,5 +25,5 @@ SMTP_SERVER="change_me"
EMAIL_LOGIN="change_me"
EMAIL_PASSWORD="change_me"
FRONT_URL="change_me"
PROD_MODE=bool
+1
View File
@@ -0,0 +1 @@
VITE_BACKEND_URL="change_me"
+14
View File
@@ -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;
}
+11
View File
@@ -0,0 +1,11 @@
export async function requestPasswordReset(email: string): Promise<void> {
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 : "Не удалось отправить запрос");
}
}
+17
View File
@@ -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" });
}
@@ -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%;
}
@@ -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<HTMLFormElement>) => {
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 (
<>
<div className={styles.wrapper_1}>
<form>
<form noValidate onSubmit={handleSubmit}>
<h1 className={styles.Title}> Добро Пожаловать <br /> в The DisExcel Project </h1>
<p className={styles.Description}>Войдите в систему, чтобы продолжить работу с вашими процессами</p>
<label className={styles.Fields} htmlFor="email">Email</label>
@@ -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}
/>
<svg className={styles["email-icon"]} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<rect x="3" y="5" width="18" height="14" rx="2.5" stroke="#72879A" strokeWidth="1.8"/>
<path d="m4.5 7 7.5 6 7.5-6" stroke="#72879A" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
{errors.email && <p className={styles.errorText}>{errors.email}</p>}
</div>
<label className={styles.Fields} htmlFor="password">Password</label>
<div className={styles.pass_field}>
@@ -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}
/>
<svg className={styles["pass-icon"]} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<rect x="4" y="10" width="16" height="11" rx="2.5" stroke="#72879A" strokeWidth="1.8"/>
@@ -67,6 +97,7 @@ export function Auth():ReactElement {
</svg>
)}
</button>
{errors.password && <p className={styles.errorText}>{errors.password}</p>}
</div>
<div className={styles.footer}>
<div className={styles["remember-me"]}>
@@ -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;
}
@@ -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<HTMLFormElement>) => {
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 (
<>
<div className={styles.wrapper_1}>
<form>
<form noValidate onSubmit={handleSubmit}>
<h1 className={styles.Title}> Восстановление пароля </h1>
<p className={styles.Description}>Введите Email, и мы отправим на него новый пароль</p>
<label className={styles.Fields} htmlFor="email">Email</label>
@@ -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}
/>
<svg className={styles["email-icon"]} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<rect x="3" y="5" width="18" height="14" rx="2.5" stroke="#72879A" strokeWidth="1.8"/>
<path d="m4.5 7 7.5 6 7.5-6" stroke="#72879A" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
{errors.email && <p className={styles.errorText}>{errors.email}</p>}
</div>
<div className={styles.submitbtn}>
<button className={styles.submit} type="submit">
+1 -9
View File
@@ -4,13 +4,5 @@ import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
// Every backend route lives under one of these two prefixes
// (see src/web/protected_routes/). Proxying keeps the browser
// on one origin in dev, so no CORS setup is needed here.
'/protected': 'http://localhost:8000',
'/user': 'http://localhost:8000',
},
},
envDir:"./configs",
})
+9
View File
@@ -3,6 +3,7 @@ from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from src.cache.redis_client import redis_client
from src.database.users.crud import Seed
@@ -10,6 +11,7 @@ from src.logging.http_logger import LoggingMiddleware, ProcessingTimeMiddleware
from src.logging.logger import LogWriter
from src.messaging.rabbitmq_client import rabbitmq_client
from src.messaging.topology_setup import apply_topology
from src.models.configs_read.env import env_settings
from src.models.rabbitmq_models.email import email_topology
from src.web.protected_routes.auth_routes import router as protected_router
from src.web.protected_routes.protected_user_action_routes import (
@@ -35,6 +37,13 @@ app.add_middleware(LoggingMiddleware)
app.add_middleware(ProcessingTimeMiddleware)
app.include_router(router=protected_router)
app.include_router(router=protected_user_action_routes)
app.add_middleware(
CORSMiddleware,
allow_origins=[env_settings.FRONT_URL],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("")
+28
View File
@@ -13,6 +13,7 @@ A FastAPI project combining Excel and digital data ("The Great Excel project tha
- Redis — caching, rate limiting, token revocation
- RabbitMQ (aio-pika) — background email workers
- Jinja2 — HTML email templates
- Frontend: React 19, TypeScript, Vite, react-router-dom, CSS Modules (yarn)
- Docker, Ansible — deployment
## Architecture
@@ -30,6 +31,8 @@ src/
├── reports/ # reports
├── service/ # business logic (auth, users_crud, email sending)
└── web/ # routes (protected_routes)
frontend/ # React app (login / password-reset pages), see "Frontend"
```
Layers are connected top to bottom: `web → service → database → models`.
@@ -60,6 +63,31 @@ python daemon_run.py --all # all daemons enabled in configs/daemons.js
- Refresh token is stored in the DB as a SHA256 hash, with rotation and revocation support
- Passwords are hashed with bcrypt
- RBAC: direct user permissions + permissions via groups, checked through `require_permissions()`
- Login: `POST /protected/token` (OAuth2 form-data: `username` = email, `password`); returns the access token and sets the refresh token as an httpOnly cookie. Failed logins are rate-limited (5 per minute per IP)
- Password reset: `POST /protected/reset?email=...` generates a temporary password, saves it and queues an email with it (3 requests per 10 minutes per IP). The response is the same whether or not the email exists
- CORS: the allowed frontend origin is taken from the `FRONT_URL` variable in `configs/.env` (required, e.g. `http://localhost:5173`)
## Frontend
React + Vite app in `frontend/` (login and password-reset pages).
```bash
cd frontend
yarn install
yarn dev # http://localhost:5173
```
Environment variables for the frontend live in `frontend/configs/.env`
(Vite is configured to read them from there). Only variables prefixed with
`VITE_` are exposed to the browser code:
```text
VITE_BACKEND_URL=http://localhost:8000
```
The frontend calls the backend directly (no dev proxy), so `FRONT_URL` in the
backend's `configs/.env` must match the address the frontend runs on.
Restart `yarn dev` after changing either file.
## Testing
+7 -7
View File
@@ -6,19 +6,19 @@ class RateLimit:
def __init__(self) -> None:
self.errors=Errors()
async def rate_limit(self, ip:str)->None:
async def rate_limit(self, ip:str, action:str, limit:int=5, window:int=60)->None:
key=f"action attempt {ip}"
key=f"rate_limit:{action}:{ip}"
attempts = await redis_client.incrby(key)
if attempts == 1:
await redis_client.expire(key, 60)
await redis_client.expire(key, window, nx=True)
if attempts>5:
raise self.errors.rate_limit_error(detail="too many attempts", retry_after=60)
if attempts>limit:
raise self.errors.rate_limit_error(detail="too many attempts", retry_after=window)
async def check_rate_limit(self, client_ip:str) -> None:
await self.rate_limit(client_ip)
async def check_rate_limit(self, client_ip:str, action:str) -> None:
await self.rate_limit(client_ip, action)
rate_limiter=RateLimit()
View File
View File
View File
View File
+2 -1
View File
@@ -73,9 +73,10 @@ class ResetEmailConsumer:
message_id_ctx.set(str(uuid4()))
async with message.process(ignore_processed=True):
data = json.loads(message.body)
daemon_logger.info(f"Обрабатываю: {data}, метка: {message.routing_key}")
daemon_logger.info(f"Обрабатываю reset для {data.get('email')}:, метка: {message.routing_key}")
try:
await self.daemon.send_email(data.get("email"), data.get("temp_password"))
except (
smtplib.SMTPServerDisconnected,
smtplib.SMTPConnectError,
View File
View File
+1
View File
@@ -31,6 +31,7 @@ class Env(Base):
EMAIL_LOGIN:str
EMAIL_PASSWORD:str
FRONT_URL:str
PROD_MODE:bool
model_config=SettingsConfigDict(env_file="configs/.env", extra=None)
View File
-2
View File
@@ -205,8 +205,6 @@ class CurrentUserService:
else:
raise self.error.not_found_error(detail="Refresh Token Not Found")
async def login(self, form_data_email:str, form_data_password:str, request:Request)->tuple[str, str]:
'''revoke all the old refresh tokens'''
user = await self._check(form_data_email, form_data_password)
+12
View File
@@ -1,4 +1,6 @@
import hashlib
import secrets
import string
from datetime import UTC, datetime, timedelta
from uuid import uuid4
@@ -23,6 +25,16 @@ class HashService:
def token_to_hash(self, token:str)->str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
async def generate_temp_password(self, length:int=16)->str:
SPECIAL = "$@#%!^&*()-_+=[]{}"
groups = [string.ascii_uppercase, string.ascii_lowercase, string.digits, SPECIAL]
chars = [secrets.choice(g) for g in groups] # по одному из каждой группы
pool = "".join(groups)
chars += [secrets.choice(pool) for _ in range(length - len(chars))]
secrets.SystemRandom().shuffle(chars) # чтобы спецсимвол не всегда стоял первым
return "".join(chars)
'''jwt'''
class JwtService:
View File
View File
+6
View File
@@ -76,5 +76,11 @@ class CrudService:
raise self.errors.not_found_error(detail="User not found")
return UserOut.model_validate(user_entity)
async def reset_password(self, email:str)->str:
new_password=await self.hash_service.generate_temp_password()
await self.update_user(email, UserUpdate(plain_password=new_password)) # type: ignore[call-arg]
return new_password
async def crud_service()->CrudService:
return CrudService()
+19 -1
View File
@@ -2,9 +2,11 @@ from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from src.cache.rate_limit import rate_limiter
from src.messaging.producers.producers import email_producer
from src.models.configs_read.env import env_settings
from src.models.pydantic_models.model import UserOut
from src.service.auth.auth import CurrentUserService, auth_service
from src.service.users_crud.users_crud import CrudService, crud_service
router=APIRouter(prefix="/protected")
oauth2_schema=OAuth2PasswordBearer(tokenUrl="/protected/token", refreshUrl="/protected/refresh")
@@ -31,7 +33,7 @@ async def get_access_token(request: Request,
try:
access_token, refresh_token=await auth.login(form_data_email=form_data.username, form_data_password=form_data.password, request=request)
except HTTPException:
await rate_limiter.rate_limit(client_ip)
await rate_limiter.rate_limit(client_ip, "login", 5, 60)
raise
response.set_cookie(
@@ -74,6 +76,22 @@ async def logout(response:Response,
response.delete_cookie("refresh_token")
return await auth.logout(refresh_token, access_token)
@router.post("/reset")
async def reset(request: Request,
email:str,
crud:CrudService=Depends(crud_service)
):
client_ip = request.headers.get('x-forwarded-for', '').split(',')[0].strip() or (request.client.host if request.client else 'unknown')
await rate_limiter.rate_limit(client_ip, "reset", 3, 600)
try:
temp_password=await crud.reset_password(email)
await email_producer.send_reset_email(email, temp_password)
except HTTPException as exc:
if exc.status_code!=404:
raise
return {"detail":"New password was sent"}
@router.get("")
async def protected(current_user:UserOut=Depends(require_permissions()))->dict: