reset endpoint + frontend reset and login pages add
This commit is contained in:
Vendored
+7
-7
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
import string
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -22,6 +24,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'''
|
||||
|
||||
@@ -75,6 +75,12 @@ class CrudService:
|
||||
if not user_entity:
|
||||
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()
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user