diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index cc3c457..1b93a16 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -8,14 +8,18 @@ from sqlmodel import Session from app.core import security -from backend.app.core.config import settings -from app.core.db import engine -from app.models import TokenPayload, User +from app.core.config import settings, logger + +from app.models.user_model import TokenPayload, User +from sqlmodel import Session, create_engine, select + reusable_oauth2 = OAuth2PasswordBearer( tokenUrl=f"{settings.API_V1_STR}/login/access-token" ) +engine = create_engine(str(settings.SYNC_DATABASE_URI)) + def get_db() -> Generator[Session, None, None]: with Session(engine) as session: @@ -49,6 +53,7 @@ def get_current_user(session: SessionDep, token: TokenDep) -> User: def get_current_active_superuser(current_user: CurrentUser) -> User: + logger.debug(f"################### current_user: {current_user}") if not current_user.is_superuser: raise HTTPException( status_code=400, detail="The user doesn't have enough privileges" diff --git a/backend/app/api/routes/login.py b/backend/app/api/routes/login.py new file mode 100644 index 0000000..ad78a2d --- /dev/null +++ b/backend/app/api/routes/login.py @@ -0,0 +1,124 @@ +from datetime import timedelta +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import HTMLResponse +from fastapi.security import OAuth2PasswordRequestForm + +from app import crud +from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser +from app.core import security +from app.core.config import settings +from app.core.security import get_password_hash +from app.models import Message, NewPassword, Token, UserOut +from app.utils import ( + generate_password_reset_token, + generate_reset_password_email, + send_email, + verify_password_reset_token, +) + +router = APIRouter() + + +@router.post("/login/access-token") +def login_access_token( + session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] +) -> Token: + """ + OAuth2 compatible token login, get an access token for future requests + """ + user = crud.authenticate( + session=session, email=form_data.username, password=form_data.password + ) + if not user: + raise HTTPException(status_code=400, detail="Incorrect email or password") + elif not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return Token( + access_token=security.create_access_token( + user.id, expires_delta=access_token_expires + ) + ) + + +@router.post("/login/test-token", response_model=UserOut) +def test_token(current_user: CurrentUser) -> Any: + """ + Test access token + """ + return current_user + + +@router.post("/password-recovery/{email}") +def recover_password(email: str, session: SessionDep) -> Message: + """ + Password Recovery + """ + user = crud.get_user_by_email(session=session, email=email) + + if not user: + raise HTTPException( + status_code=404, + detail="The user with this email does not exist in the system.", + ) + password_reset_token = generate_password_reset_token(email=email) + email_data = generate_reset_password_email( + email_to=user.email, email=email, token=password_reset_token + ) + send_email( + email_to=user.email, + subject=email_data.subject, + html_content=email_data.html_content, + ) + return Message(message="Password recovery email sent") + + +@router.post("/reset-password/") +def reset_password(session: SessionDep, body: NewPassword) -> Message: + """ + Reset password + """ + email = verify_password_reset_token(token=body.token) + if not email: + raise HTTPException(status_code=400, detail="Invalid token") + user = crud.get_user_by_email(session=session, email=email) + if not user: + raise HTTPException( + status_code=404, + detail="The user with this email does not exist in the system.", + ) + elif not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + hashed_password = get_password_hash(password=body.new_password) + user.hashed_password = hashed_password + session.add(user) + session.commit() + return Message(message="Password updated successfully") + + +@router.post( + "/password-recovery-html-content/{email}", + dependencies=[Depends(get_current_active_superuser)], + response_class=HTMLResponse, +) +def recover_password_html_content(email: str, session: SessionDep) -> Any: + """ + HTML Content for Password Recovery + """ + user = crud.get_user_by_email(session=session, email=email) + + if not user: + raise HTTPException( + status_code=404, + detail="The user with this username does not exist in the system.", + ) + password_reset_token = generate_password_reset_token(email=email) + email_data = generate_reset_password_email( + email_to=user.email, email=email, token=password_reset_token + ) + + return HTMLResponse( + content=email_data.html_content, headers={"subject:": email_data.subject} + ) diff --git a/backend/app/api/routes/qa.py b/backend/app/api/routes/qa.py index e52afb5..91d3703 100644 --- a/backend/app/api/routes/qa.py +++ b/backend/app/api/routes/qa.py @@ -1,13 +1,12 @@ from app.core.db import SessionLocal import os import yaml -from fastapi import APIRouter - -from backend.app.core.config import logger +from app.core.config import logger, settings from operator import itemgetter +from typing import Annotated from langchain_community.vectorstores import FAISS from langchain_core.output_parsers import StrOutputParser @@ -19,13 +18,15 @@ from langchain_core.runnables import RunnableParallel from langchain_community.vectorstores.pgvector import PGVector from langchain.memory import ConversationBufferMemory -from backend.app.core.config import settings + from langchain.prompts.prompt import PromptTemplate from app.schemas.chat_schema import ChatBody +from fastapi import APIRouter, Depends +from app.api.deps import get_current_active_superuser router = APIRouter() -config_path = os.path.join(os.path.dirname(__file__), "..", "..", "config.yml") +config_path = os.path.join(os.path.dirname(__file__), "..", "..", "config/chat.yml") with open(config_path, "r") as config_file: config = yaml.load(config_file, Loader=yaml.FullLoader) @@ -33,7 +34,11 @@ @router.post("/chat") -async def chat_action(request: ChatBody): +async def chat_action( + request: ChatBody, + jwt: Annotated[dict, Depends(get_current_active_superuser)], +): + logger.info(f"User JWT from request: {jwt}") embeddings = OpenAIEmbeddings() diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py new file mode 100644 index 0000000..c171e24 --- /dev/null +++ b/backend/app/api/routes/users.py @@ -0,0 +1,219 @@ +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import col, delete, func, select + +from app import crud +from app.api.deps import ( + CurrentUser, + SessionDep, + get_current_active_superuser, +) +from app.core.config import settings +from app.core.security import get_password_hash, verify_password +from app.models import ( + Item, + Message, + UpdatePassword, + User, + UserCreate, + UserCreateOpen, + UserOut, + UsersOut, + UserUpdate, + UserUpdateMe, +) +from app.utils import generate_new_account_email, send_email + +router = APIRouter() + + +@router.get( + "/", dependencies=[Depends(get_current_active_superuser)], response_model=UsersOut +) +def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: + """ + Retrieve users. + """ + + count_statement = select(func.count()).select_from(User) + count = session.exec(count_statement).one() + + statement = select(User).offset(skip).limit(limit) + users = session.exec(statement).all() + + return UsersOut(data=users, count=count) + + +@router.post( + "/", dependencies=[Depends(get_current_active_superuser)], response_model=UserOut +) +def create_user(*, session: SessionDep, user_in: UserCreate) -> Any: + """ + Create new user. + """ + user = crud.get_user_by_email(session=session, email=user_in.email) + if user: + raise HTTPException( + status_code=400, + detail="The user with this email already exists in the system.", + ) + + user = crud.create_user(session=session, user_create=user_in) + if settings.emails_enabled and user_in.email: + email_data = generate_new_account_email( + email_to=user_in.email, username=user_in.email, password=user_in.password + ) + send_email( + email_to=user_in.email, + subject=email_data.subject, + html_content=email_data.html_content, + ) + return user + + +@router.patch("/me", response_model=UserOut) +def update_user_me( + *, session: SessionDep, user_in: UserUpdateMe, current_user: CurrentUser +) -> Any: + """ + Update own user. + """ + + if user_in.email: + existing_user = crud.get_user_by_email(session=session, email=user_in.email) + if existing_user and existing_user.id != current_user.id: + raise HTTPException( + status_code=409, detail="User with this email already exists" + ) + user_data = user_in.model_dump(exclude_unset=True) + current_user.sqlmodel_update(user_data) + session.add(current_user) + session.commit() + session.refresh(current_user) + return current_user + + +@router.patch("/me/password", response_model=Message) +def update_password_me( + *, session: SessionDep, body: UpdatePassword, current_user: CurrentUser +) -> Any: + """ + Update own password. + """ + if not verify_password(body.current_password, current_user.hashed_password): + raise HTTPException(status_code=400, detail="Incorrect password") + if body.current_password == body.new_password: + raise HTTPException( + status_code=400, detail="New password cannot be the same as the current one" + ) + hashed_password = get_password_hash(body.new_password) + current_user.hashed_password = hashed_password + session.add(current_user) + session.commit() + return Message(message="Password updated successfully") + + +@router.get("/me", response_model=UserOut) +def read_user_me(session: SessionDep, current_user: CurrentUser) -> Any: + """ + Get current user. + """ + return current_user + + +@router.post("/open", response_model=UserOut) +def create_user_open(session: SessionDep, user_in: UserCreateOpen) -> Any: + """ + Create new user without the need to be logged in. + """ + if not settings.USERS_OPEN_REGISTRATION: + raise HTTPException( + status_code=403, + detail="Open user registration is forbidden on this server", + ) + user = crud.get_user_by_email(session=session, email=user_in.email) + if user: + raise HTTPException( + status_code=400, + detail="The user with this email already exists in the system", + ) + user_create = UserCreate.from_orm(user_in) + user = crud.create_user(session=session, user_create=user_create) + return user + + +@router.get("/{user_id}", response_model=UserOut) +def read_user_by_id( + user_id: int, session: SessionDep, current_user: CurrentUser +) -> Any: + """ + Get a specific user by id. + """ + user = session.get(User, user_id) + if user == current_user: + return user + if not current_user.is_superuser: + raise HTTPException( + status_code=403, + detail="The user doesn't have enough privileges", + ) + return user + + +@router.patch( + "/{user_id}", + dependencies=[Depends(get_current_active_superuser)], + response_model=UserOut, +) +def update_user( + *, + session: SessionDep, + user_id: int, + user_in: UserUpdate, +) -> Any: + """ + Update a user. + """ + + db_user = session.get(User, user_id) + if not db_user: + raise HTTPException( + status_code=404, + detail="The user with this id does not exist in the system", + ) + if user_in.email: + existing_user = crud.get_user_by_email(session=session, email=user_in.email) + if existing_user and existing_user.id != user_id: + raise HTTPException( + status_code=409, detail="User with this email already exists" + ) + + db_user = crud.update_user(session=session, db_user=db_user, user_in=user_in) + return db_user + + +@router.delete("/{user_id}") +def delete_user( + session: SessionDep, current_user: CurrentUser, user_id: int +) -> Message: + """ + Delete a user. + """ + user = session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + elif user != current_user and not current_user.is_superuser: + raise HTTPException( + status_code=403, detail="The user doesn't have enough privileges" + ) + elif user == current_user and current_user.is_superuser: + raise HTTPException( + status_code=403, detail="Super users are not allowed to delete themselves" + ) + + statement = delete(Item).where(col(Item.owner_id) == user_id) + session.exec(statement) # type: ignore + session.delete(user) + session.commit() + return Message(message="User deleted successfully") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f25bc95..18ac660 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -3,6 +3,7 @@ from loguru import logger from typing import Annotated, Any, Literal import sys +import secrets from pydantic import ( AnyUrl, @@ -24,6 +25,8 @@ class Settings(BaseSettings): API_V1_STR: str = f"/api/{API_VERSION}" PROJECT_NAME: str + SECRET_KEY: str = secrets.token_urlsafe(32) + DB_HOST: str DB_PORT: str DB_NAME: str @@ -53,6 +56,10 @@ def ASYNC_DATABASE_URI(self) -> str: def SYNC_DATABASE_URI(self) -> str: return f"postgresql+psycopg2://{self.DB_USER}:{self.DB_PASS}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" + FIRST_SUPERUSER: str + FIRST_SUPERUSER_PASSWORD: str + USERS_OPEN_REGISTRATION: bool = False + class Config: env_file = "../.env" diff --git a/backend/app/core/db.py b/backend/app/core/db.py index 8b6ac00..92edba8 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -1,6 +1,6 @@ from sqlmodel import SQLModel from sqlalchemy.ext.asyncio import create_async_engine -from backend.app.core.config import settings +from app.core.config import settings from loguru import logger from sqlalchemy.orm import sessionmaker from sqlmodel.ext.asyncio.session import AsyncSession diff --git a/backend/app/core/security.py b/backend/app/core/security.py index f201921..39a225e 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -1,10 +1,10 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any from jose import jwt from passlib.context import CryptContext -from backend.app.core.config import settings +from app.core.config import settings pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -13,7 +13,7 @@ def create_access_token(subject: str | Any, expires_delta: timedelta) -> str: - expire = datetime.utcnow() + expires_delta + expire = datetime.now(timezone.utc) + expires_delta to_encode = {"exp": expire, "sub": str(subject)} encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt diff --git a/backend/app/crud/crud.py b/backend/app/crud/user_crud.py similarity index 95% rename from backend/app/crud/crud.py rename to backend/app/crud/user_crud.py index 405482a..d7e814c 100644 --- a/backend/app/crud/crud.py +++ b/backend/app/crud/user_crud.py @@ -3,7 +3,7 @@ from sqlmodel import Session, select from app.core.security import get_password_hash, verify_password -from app.models import Item, ItemCreate, User, UserCreate, UserUpdate +from app.models.user_model import Item, ItemCreate, User, UserCreate, UserUpdate def create_user(*, session: Session, user_create: UserCreate) -> User: diff --git a/backend/app/main.py b/backend/app/main.py index c88475a..824db61 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from contextlib import asynccontextmanager from fastapi.middleware.cors import CORSMiddleware from app.api.main import api_router -from backend.app.core.config import settings +from app.core.config import settings from typing import Dict diff --git a/backend/ingestion/core/db.py b/backend/ingestion/core/db.py index 502ef01..00312a6 100644 --- a/backend/ingestion/core/db.py +++ b/backend/ingestion/core/db.py @@ -1,6 +1,6 @@ from sqlmodel import SQLModel from sqlalchemy.ext.asyncio import create_async_engine -from backend.app.core.config import settings +from app.core.config import settings import asyncpg import psycopg2 from loguru import logger @@ -14,7 +14,7 @@ User, ) -from backend.ingestion.crud import user_crud +from ingestion.crud import user_crud engine = create_async_engine(str(settings.ASYNC_DATABASE_URI), echo=True)