diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..26d33521a --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/hng_boilerplate_python_fastapi_web.iml b/.idea/hng_boilerplate_python_fastapi_web.iml new file mode 100644 index 000000000..7a6134d11 --- /dev/null +++ b/.idea/hng_boilerplate_python_fastapi_web.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..33c6cd1ee --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,118 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 000000000..105ce2da2 --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 000000000..3f0fd9bf8 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 000000000..6c4a82298 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..35eb1ddfb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/api/utils/auth.py b/api/utils/auth.py index 1885292db..4a77211a7 100644 --- a/api/utils/auth.py +++ b/api/utils/auth.py @@ -12,6 +12,8 @@ from api.v1.schemas.token import TokenData from api.db.database import get_db from .config import SECRET_KEY, ALGORITHM +from urllib.parse import urlencode +import uuid # Initialize OAuth2PasswordBearer oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") @@ -27,6 +29,7 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: def get_user(db, username: str): return db.query(User).filter(User.username == username).first() + def authenticate_user(db, username: str, password: str): user = get_user(db, username) if not user: @@ -43,4 +46,21 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): expire = datetime.utcnow() + timedelta(minutes=15) to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) - return encoded_jwt \ No newline at end of file + return encoded_jwt + + +def get_user_by_email(db: Session, email: str): + return db.query(User).filter(User.email == email).first() + + +def generate_password_reset_token(user_id: uuid.UUID) -> str: + expires_delta = timedelta(minutes=30) + data = {"sub": user_id} + return create_access_token(data=data, expires_delta=expires_delta) + + +def generate_reset_password_url(user_id: uuid.UUID, token: str) -> str: + base_url = f"http://localhost:7001" + path = "/reset-password" + query_params = urlencode({"token": token, "user_id": user_id}) + return f"{base_url}{path}?{query_params}" diff --git a/api/utils/settings.py b/api/utils/settings.py index afc23dc65..93201437c 100644 --- a/api/utils/settings.py +++ b/api/utils/settings.py @@ -28,6 +28,8 @@ class Settings(BaseSettings): MAIL_FROM: str = config('MAIL_FROM') MAIL_PORT: int = config('MAIL_PORT') MAIL_SERVER: str = config('MAIL_SERVER') + MAIL_STARTTLS: bool = config('MAIL_TLS') + MAIL_SSL_TLS: bool = config('MAIL_SSL') settings = Settings() \ No newline at end of file diff --git a/api/v1/models/user.py b/api/v1/models/user.py index 916f1ed07..4727f8126 100644 --- a/api/v1/models/user.py +++ b/api/v1/models/user.py @@ -21,11 +21,13 @@ from api.v1.models.base import Base, user_organization_association, user_role_association from api.v1.models.base_model import BaseModel from sqlalchemy.dialects.postgresql import UUID +import uuid class User(BaseModel, Base): __tablename__ = 'users' + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) username = Column(String(50), unique=True, nullable=False) email = Column(String(100), unique=True, nullable=False) password = Column(String(255), nullable=False) diff --git a/api/v1/routes/auth.py b/api/v1/routes/auth.py index 3a385c6a7..9276abc06 100644 --- a/api/v1/routes/auth.py +++ b/api/v1/routes/auth.py @@ -18,7 +18,14 @@ from api.v1.schemas.token import Token, LoginRequest from api.v1.schemas.auth import UserBase, SuccessResponse, SuccessResponseData, UserCreate from api.db.database import get_db -from api.utils.auth import authenticate_user, create_access_token,hash_password,get_user +from api.utils.auth import ( + authenticate_user, + create_access_token, + hash_password,get_user, + generate_password_reset_token, + generate_reset_password_url, + get_user_by_email +) from api.utils.dependencies import get_current_admin, get_current_user @@ -26,12 +33,20 @@ from api.v1.models.product import Product +from fastapi import BackgroundTasks +from fastapi_mail import FastMail, MessageSchema + +from fastapi import BackgroundTasks +from fastapi_mail import ConnectionConfig +from pydantic import BaseModel, EmailStr +from api.utils.settings import settings, BASE_DIR db = next(get_db()) auth = APIRouter(prefix="/auth", tags=["auth"]) +# This is in the .evn file already though ACCESS_TOKEN_EXPIRE_MINUTES = 30 @@ -111,3 +126,51 @@ def register_user(user: UserCreate, db: Session = Depends(get_db)): def read_admin_data(current_admin: Annotated[User, Depends(get_current_admin)]): return {"message": "Hello, admin!"} + +conf = ConnectionConfig( + MAIL_USERNAME=settings.MAIL_USERNAME, + MAIL_PASSWORD=settings.MAIL_PASSWORD, + MAIL_FROM=settings.MAIL_FROM, + MAIL_PORT=settings.MAIL_PORT, + MAIL_SERVER=settings.MAIL_SERVER, + MAIL_STARTTLS=settings.MAIL_STARTTLS, + MAIL_SSL_TLS=settings.MAIL_SSL_TLS +) + + +class EmailRequest(BaseModel): + email: EmailStr + + +@auth.post("/password-reset-email/", status_code=status.HTTP_200_OK) +async def send_email(background_tasks: BackgroundTasks, email: EmailRequest, db: Session = Depends(get_db)): + """ + Getting the user from the database, the email in the db since the email schema in the db is unique, it picks the 1st + """ + + user = get_user_by_email(db, email.email) + if not user: + raise HTTPException(status_code=404, detail="We don't have user with the provided email in our database.") + # Generate password reset token + password_reset_token = generate_password_reset_token(user_id=str(user.id)) + reset_password_url = generate_reset_password_url(user_id=str(user.id), token=password_reset_token) + + email_body = (f"Dear {user.username}!\nYou requested for email reset on our site.\n" + f"To reset your password, click the following link: {reset_password_url}") + + message: MessageSchema = MessageSchema( + subject="Reset Password", + recipients=[email.email], + body=email_body, + subtype="plain" + ) + fm = FastMail(conf) + + try: + background_tasks.add_task(fm.send_message, message) + return { + "message": "Password reset email sent successfully.", + "reset_link": reset_password_url + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error sending email: {e}") diff --git a/test.db b/test.db new file mode 100644 index 000000000..13823a3d1 Binary files /dev/null and b/test.db differ diff --git a/tests/v1/reset_password_test.py b/tests/v1/reset_password_test.py new file mode 100644 index 000000000..0ceb5d763 --- /dev/null +++ b/tests/v1/reset_password_test.py @@ -0,0 +1,111 @@ +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from main import app +from api.db.database import get_db +from api.db.database import Base +from unittest.mock import patch, AsyncMock +from fastapi_mail import FastMail +from api.v1.models import User +from api.utils.password_auth import hash_password +import uuid + +SQLALCHEMY_DATABASE_URL = "postgresql://postgres:4253@localhost:5432/hng_fast_api" +engine = create_engine(SQLALCHEMY_DATABASE_URL) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base.metadata.create_all(bind=engine) + + +@pytest.fixture(scope="function") +def db_session(): + """Fixture for setting up and tearing down database sessions for tests.""" + db = TestingSessionLocal() + try: + yield db + finally: + db.rollback() + db.close() + +@pytest.fixture +def override_get_db(db_session): + """Override dependency to use the test database session.""" + app.dependency_overrides[get_db] = lambda: db_session + yield + app.dependency_overrides.pop(get_db, None) + + +client = TestClient(app) + + +@pytest.fixture +def mock_fast_mail(): + with patch.object(FastMail, "send_message", new_callable=AsyncMock) as mock: + yield mock + + +def generate_short_uuid(length=8): + # Generate a UUID and get its hex representation (excluding hyphens) + short_uuid = uuid.uuid4().hex[:length] + return short_uuid + + +def test_send_email_success(mock_fast_mail, override_get_db): + unique_email = f"testuser_success_{generate_short_uuid()}" + "@example.com" + unique_username = f"testuser_success_{generate_short_uuid()}" + user_data = {"email": unique_email, "username": unique_username} + db = next(get_db()) + user = User(**user_data, password=hash_password("testpassword")) + db.add(user) + db.commit() + db.refresh(user) + + response = client.post("/auth/password-reset-email/", json={ + "email": unique_email, + "subject": "Reset Password", + "body": "Please reset your password", + "body_type": "plain" + }) + + assert response.status_code == 200 + + actual_reset_link = mock_fast_mail.call_args[0][0].body.split(' ')[-1] + assert response.json() == { + "message": "Password reset email sent successfully.", + "reset_link": actual_reset_link + } + mock_fast_mail.assert_called_once() + + +def test_email_sending_failure(mock_fast_mail, override_get_db): + unique_email = f"testuser_success_{generate_short_uuid()}" + "@example.com" + unique_username = f"testuser_failure_{generate_short_uuid()}" + user_data = {"email": unique_email, "username": unique_username} + db = next(get_db()) + user = User(**user_data, password=hash_password("testpassword")) + db.add(user) + db.commit() + db.refresh(user) + + # Simulate an email sending failure + mock_fast_mail.side_effect = Exception("Email sending failed") + + # Act + try: + response = client.post("/auth/password-reset-email/", json={ + "email": unique_email, + "subject": "Reset Password", + "body": "Please reset your password", + "body_type": "plain" + }) + # Assert + assert response.status_code == 500 + assert response.json() == {"detail": "Error sending email: Email sending failed"} + # expected_response = {"detail": "Error sending email: Email sending failed"} + # assert response.json() == expected_response + except Exception as e: + print(f"Exception during request: {e}") + +if __name__ == '__main__': + pytest.main()