add frontend and change password

This commit is contained in:
2026-01-09 14:24:21 +03:00
parent 8e0131451d
commit 7a906fa824
44 changed files with 6020 additions and 49 deletions
+25 -5
View File
@@ -5,13 +5,13 @@ import uuid
from fastapi import APIRouter, status, Response, Depends, Request, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from app.users.schemas import UserCreate, User
from app.users.schemas import UserCreate, User, ChangePassword
from app.auth.schemas import Token
from app.users.service import UserService
from app.auth.service import AuthService
from app.users.models import UserModel
from app.core.exceptions import InvalidCredentialsException
from app.auth.dependencies import get_current_user
from app.auth.dependencies import get_current_user, get_current_verified_user
from app.core.config import settings
router = APIRouter(prefix="/auth", tags=["Auth"])
@@ -22,12 +22,12 @@ log = logging.getLogger(__name__)
async def register(user: UserCreate) -> User:
return await UserService.register_new_user(user)
@router.get("/verify/{token}")
@router.post("/email/verify/{token}")
async def verify_email(token: uuid.UUID) -> Dict:
await UserService.verify_email(token)
return {"status": True, "message": "User successfully verified email"}
@router.post("/send/verify-email")
@router.post("/email/resend-verification")
async def resend_verify_email(user: UserModel = Depends(get_current_user)) -> Dict:
if user.is_verified:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Email already verified")
@@ -86,4 +86,24 @@ async def logout(request: Request, response: Response, user: UserModel = Depends
@router.post("/abort")
async def abort_all_sessions(user: UserModel = Depends(get_current_user)) -> Dict:
await AuthService.abort_all_sessions(user.id)
return {"status": True, "message": "All sessions was aborted"}
return {"status": True, "message": "All sessions was aborted"}
@router.post("/password/change")
async def change_password(
passwords: ChangePassword,
user: UserModel = Depends(get_current_verified_user)
) -> Dict:
await UserService.change_password(user, passwords)
return {"status": True, "message": "Successfully change password"}
@router.post("/password/reset")
async def send_reset_password_email(
username: str
) -> Dict:
await UserService.send_reset_password_email(username)
return {"status": True, "message": "Successfully send email reset password"}
@router.post("/password/reset/{token}")
async def reset_password(token: uuid.UUID, new_password: str) -> Dict:
await UserService.reset_password(token, new_password)
return {"status": True, "message": "Successfully reset password"}
+2
View File
@@ -14,3 +14,5 @@ class TokenExpiredException(HTTPException):
class InvalidCredentialsException(HTTPException):
def __init__(self):
super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
UserNotFoundException = HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
+28 -7
View File
@@ -1,6 +1,8 @@
import time
from contextlib import asynccontextmanager
import uvicorn
import logging
import uuid
from fastapi import FastAPI, APIRouter, Request, Response
from fastapi.middleware.cors import CORSMiddleware
@@ -46,19 +48,38 @@ app.add_middleware(
@app.middleware("http")
async def log_requests(request: Request, call_next):
response: Response = await call_next(request)
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
start_time = time.perf_counter()
log.info(
"method=%s path=%s status=%s",
request.method,
request.url.path,
response.status_code,
"Started method=%s path=%s",
request.method, request.url.path,
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status": response.status_code
"type": "start"
}
)
return response
try:
response: Response = await call_next(request)
process_time = time.perf_counter() - start_time
log.info(
"Finished method=%s path=%s status=%s duration=%.3fs",
request.method, request.url.path, response.status_code, process_time,
extra={
"request_id": request_id,
"status": response.status_code,
"duration": process_time,
"type": "end"
}
)
return response
except Exception as e:
log.error("Request failed id=%s error=%s", request_id, str(e))
raise
@app.get("/health")
+25
View File
@@ -26,6 +26,31 @@ class EmailService:
company_name=settings.COMPANY_NAME
)
EmailClient.send_email(to=email, subject=subject, html=html, body=body)
log.info("Verify email sent to %s", email, extra={"email": email})
except Exception as e:
log.error("Failed to send email to %s", email, extra={"email": email})
raise e
@classmethod
def send_reset_password_email(cls, email: str, username: str, url: str):
log.debug("Sending email to %s", email, extra={"email": email})
try:
subject = "Подтверждение эл. почты"
html = EmailClient.render(
template_path="reset_password.html",
username=username,
url=url,
company_name=settings.COMPANY_NAME
)
body = EmailClient.render(
template_path="confirm_email.txt",
username=username,
url=url,
company_name=settings.COMPANY_NAME
)
EmailClient.send_email(to=email, subject=subject, html=html, body=body)
log.info("Verify email sent to %s", email, extra={"email": email})
except Exception as e:
+22 -15
View File
@@ -1,10 +1,28 @@
import logging
import uuid
from typing import Optional
from app.core.redis import get_redis
log = logging.getLogger(__name__)
class TokenStorage:
PREFIX: Optional[str] = None
@classmethod
async def save_token(cls, token: uuid.UUID, user_id: int, ttl: int):
redis_client = await get_redis()
await redis_client.setex(f"{cls.PREFIX}:{token}", ttl, user_id)
log.info("Save new %s token from redis", cls.PREFIX, extra={"user_id": user_id, "token": token})
@classmethod
async def getdel_token(cls, token: uuid.UUID) -> int:
redis_client = await get_redis()
log.debug("User_id fetched from %s token", cls.PREFIX, extra={"token": token})
return await redis_client.getdel(f"{cls.PREFIX}:{token}")
class RefreshTokenStorage:
PREFIX: str = "refresh"
@@ -53,20 +71,9 @@ class RefreshTokenStorage:
class EmailTokenStorage:
PREFIX: str = "email"
@classmethod
async def save_token(cls, token: uuid.UUID, user_id: int, ttl: int):
redis_client = await get_redis()
await redis_client.setex(f"{cls.PREFIX}:{token}", ttl, user_id)
log.info("Save new refresh token from redis", extra={"user_id": user_id, "token": token})
class EmailTokenStorage(TokenStorage):
PREFIX = "email"
@classmethod
async def getdel_token(cls, token: uuid.UUID) -> int:
redis_client = await get_redis()
log.debug("User_id fetched from email token", extra={"token": token})
return await redis_client.getdel(f"{cls.PREFIX}:{token}")
class ChangePasswordTokenStorage(TokenStorage):
PREFIX = "changepassword"
+7 -1
View File
@@ -10,4 +10,10 @@ class EmailTasks:
@staticmethod
@celery_app.task
def send_verify_email_task(email: str, username: str, url: str):
EmailService.send_verify_email(email, username, url)
EmailService.send_verify_email(email, username, url)
@staticmethod
@celery_app.task
def send_reset_password_email_task(email: str, username: str, url: str):
EmailService.send_reset_password_email(email, username, url)
+3
View File
@@ -56,3 +56,6 @@ class UserUpdateDB(UserBase):
is_verified: Optional[bool] = None
is_superuser: Optional[bool] = None
class ChangePassword(BaseModel):
old_password: str
new_password: str
+78 -18
View File
@@ -6,14 +6,13 @@ from typing import List
from fastapi import HTTPException, status
from sqlalchemy import or_
from app.utils.hash_password import hash_password
from app.services.redis_service import EmailTokenStorage
from app.core.redis import get_redis
from app.core.exceptions import InvalidTokenException, TokenExpiredException
from app.utils.hash_password import hash_password, verify_password
from app.services.redis_service import EmailTokenStorage, ChangePasswordTokenStorage
from app.core.exceptions import InvalidTokenException, TokenExpiredException, UserNotFoundException
from app.users.models import UserModel
from app.users.dao import UserDAO
from app.core.database import async_session_maker
from app.users.schemas import UserCreate, UserCreateDB, User, UserUpdate, UserUpdateDB
from app.users.schemas import UserCreate, UserCreateDB, User, UserUpdate, UserUpdateDB, ChangePassword
from app.tasks.email_tasks import EmailTasks
from app.core.config import settings
@@ -27,7 +26,7 @@ class UserService:
user_exist = await UserDAO.find_one_or_none(session, id=user_id)
if user_exist is None:
log.warning("User not found", extra={"user_id": user_id})
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
raise UserNotFoundException
log.debug("User fetched", extra={"user_id": user_id})
return user_exist
@@ -64,10 +63,8 @@ class UserService:
@classmethod
async def send_verify_email(cls, user: UserModel):
redis_client = await get_redis()
token = cls._create_email_verification_token()
url = f"{settings.URL}/api/v1/auth/verify/{token}"
token = cls._create_uuid_token()
url = f"{settings.URL}/verify-email/{token}"
email_token_expires = timedelta(minutes=settings.EMAIL_TOKEN_EXPIRE_MINUTES)
await EmailTokenStorage.save_token(
@@ -79,7 +76,7 @@ class UserService:
@classmethod
def _create_email_verification_token(cls) -> uuid.UUID:
def _create_uuid_token(cls) -> uuid.UUID:
return uuid.uuid4()
@@ -99,11 +96,11 @@ class UserService:
await UserDAO.update(
session,
UserModel.id==int(user_id),
UserModel.id==user_exist.id,
obj_in={"is_verified": True}
)
await session.commit()
log.info("Email verified", extra={"email": user_exist.email, "user_id": user_exist.id})
@classmethod
@@ -113,20 +110,20 @@ class UserService:
if users is None:
log.warning("Users not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Users not found")
raise UserNotFoundException
log.debug("Users fetched", extra={"count": len(users), "offset": offset, "limit": limit})
return users
@classmethod
async def update_user(cls, user_id: int, update_user: UserUpdate):
async def update_user(cls, user_id: int, update_user: UserUpdate) -> User:
async with async_session_maker() as session:
user_exist = await UserDAO.find_one_or_none(session, id=user_id)
if user_exist is None:
log.warning("User not found", extra={"user_id": user_id})
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
raise UserNotFoundException
if user_exist.username != update_user.username:
username_exist = await UserDAO.find_one_or_none(session, username=update_user.username)
@@ -153,7 +150,7 @@ class UserService:
if user_exist is None:
log.warning("User not found", extra={"user_id": user_id})
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
raise UserNotFoundException
await UserDAO.update(
session,
@@ -161,4 +158,67 @@ class UserService:
obj_in={"is_active": False}
)
await session.commit()
log.info("User is inactive", extra={"user_id": user_id})
log.info("User is inactive", extra={"user_id": user_id})
@classmethod
async def change_password(cls, user: UserModel, change_password: ChangePassword):
async with async_session_maker() as session:
if not verify_password(change_password.old_password, user.hashed_password):
log.warning("Invalid current password", extra={"user_id": user.id})
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Invalid current password")
await UserDAO.update(
session,
UserModel.id==user.id,
obj_in={"hashed_password": hash_password(change_password.new_password)}
)
await session.commit()
log.info("Successfully changed password", extra={"user_id": user.id})
@classmethod
async def send_reset_password_email(cls, username: str):
async with async_session_maker() as session:
user = await UserDAO.find_one_or_none(session, username=username)
if user is None:
raise UserNotFoundException
token = cls._create_uuid_token()
url = f"{settings.URL}/reset-password/{token}"
token_expires = timedelta(minutes=settings.EMAIL_TOKEN_EXPIRE_MINUTES)
await ChangePasswordTokenStorage.save_token(
token,
user.id,
int(token_expires.total_seconds())
)
EmailTasks.send_reset_password_email_task.delay(
email=user.email,
username=user.username,
url=url
)
@classmethod
async def reset_password(cls, token: uuid.UUID, new_password: str):
async with async_session_maker() as session:
user_id = await ChangePasswordTokenStorage.getdel_token(token)
if user_id is None:
raise TokenExpiredException
user_exist = await UserDAO.find_one_or_none(session, id=int(user_id))
if user_exist is None:
raise InvalidTokenException
await UserDAO.update(
session,
UserModel.id==user_exist.id,
obj_in={"hashed_password": hash_password(new_password)}
)
await session.commit()
log.info("Successfully reset password", extra={"user_id": user_id})
+2 -2
View File
@@ -3,8 +3,8 @@ from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password):
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password, hashed_password):
def verify_password(plain_password: str, hashed_password: str):
return pwd_context.verify(plain_password, hashed_password)