mirror of
https://github.com/lorsanstand/Aether.git
synced 2026-06-19 12:05:16 +03:00
add frontend and change password
This commit is contained in:
@@ -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"}
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user