diff --git a/requirements.txt b/requirements.txt index 7ca401c..8eaecc6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,10 @@ aiohttp jinja2 aiohttp_session bcrypt +python-dotenv +aiosmtplib +aiojobs pytest pytest-aiohttp aiohttp-test-utils +pytest-mock diff --git a/retoors/main.py b/retoors/main.py index 65d2928..3f4c9ce 100644 --- a/retoors/main.py +++ b/retoors/main.py @@ -5,11 +5,13 @@ from pathlib import Path from aiohttp_session import setup as setup_session from aiohttp_session.cookie_storage import EncryptedCookieStorage import os +import aiojobs # Import aiojobs from .routes import setup_routes from .services.user_service import UserService from .services.config_service import ConfigService from .middlewares import user_middleware, error_middleware +from .helpers.env_manager import ensure_env_file_exists async def setup_services(app: web.Application): @@ -17,10 +19,19 @@ async def setup_services(app: web.Application): data_path = base_path.parent / "data" app["user_service"] = UserService(data_path / "users.json") app["config_service"] = ConfigService(data_path / "config.json") + + # Setup aiojobs scheduler + app["scheduler"] = await aiojobs.create_scheduler() yield + # Cleanup aiojobs scheduler + await app["scheduler"].close() def create_app(): + # Ensure .env file exists before loading any configurations + project_root = Path(__file__).parent.parent + ensure_env_file_exists(project_root / ".env") + app = web.Application() # The order of middleware registration matters. @@ -57,4 +68,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/retoors/routes.py b/retoors/routes.py index 1e5eda3..3186922 100644 --- a/retoors/routes.py +++ b/retoors/routes.py @@ -1,4 +1,4 @@ -from .views.auth import LoginView, RegistrationView, LogoutView +from .views.auth import LoginView, RegistrationView, LogoutView, ForgotPasswordView, ResetPasswordView from .views.site import SiteView, OrderView @@ -6,6 +6,8 @@ def setup_routes(app): app.router.add_view("/login", LoginView, name="login") app.router.add_view("/register", RegistrationView, name="register") app.router.add_view("/logout", LogoutView, name="logout") + app.router.add_view("/forgot_password", ForgotPasswordView, name="forgot_password") + app.router.add_view("/reset_password/{token}", ResetPasswordView, name="reset_password") app.router.add_view("/", SiteView, name="index") app.router.add_view("/solutions", SiteView, name="solutions") app.router.add_view("/support", SiteView, name="support") diff --git a/retoors/services/config_service.py b/retoors/services/config_service.py index f1e0f8e..5eea3a2 100644 --- a/retoors/services/config_service.py +++ b/retoors/services/config_service.py @@ -1,16 +1,53 @@ import json +import os from pathlib import Path +from dotenv import load_dotenv class ConfigService: def __init__(self, config_path: Path): + load_dotenv() # Load environment variables from .env file self._config_path = config_path self._config = self._load_config() def _load_config(self): - if not self._config_path.exists(): - return {} - with open(self._config_path, "r") as f: - return json.load(f) + config_from_file = {} + if self._config_path.exists(): + with open(self._config_path, "r") as f: + config_from_file = json.load(f) + + # Override with environment variables + config_from_env = { + "price_per_gb": float(os.getenv("PRICE_PER_GB", config_from_file.get("price_per_gb", 0.0))), + "smtp_host": os.getenv("SMTP_HOST", config_from_file.get("smtp_host")), + "smtp_port": int(os.getenv("SMTP_PORT", config_from_file.get("smtp_port", 587))), + "smtp_username": os.getenv("SMTP_USERNAME", config_from_file.get("smtp_username")), + "smtp_password": os.getenv("SMTP_PASSWORD", config_from_file.get("smtp_password")), + "smtp_use_tls": os.getenv("SMTP_USE_TLS", str(config_from_file.get("smtp_use_tls", "True"))).lower() == "true", + "smtp_sender_email": os.getenv("SMTP_SENDER_EMAIL", config_from_file.get("smtp_sender_email")), + } + + # Merge file config with environment config, environment variables take precedence + merged_config = {**config_from_file, **{k: v for k, v in config_from_env.items() if v is not None}} + + return merged_config def get_price_per_gb(self) -> float: return self._config.get("price_per_gb", 0.0) + + def get_smtp_host(self) -> str | None: + return self._config.get("smtp_host") + + def get_smtp_port(self) -> int: + return self._config.get("smtp_port", 587) + + def get_smtp_username(self) -> str | None: + return self._config.get("smtp_username") + + def get_smtp_password(self) -> str | None: + return self._config.get("smtp_password") + + def get_smtp_use_tls(self) -> bool: + return self._config.get("smtp_use_tls", True) + + def get_smtp_sender_email(self) -> str | None: + return self._config.get("smtp_sender_email") diff --git a/retoors/services/user_service.py b/retoors/services/user_service.py index 3c641a6..abde494 100644 --- a/retoors/services/user_service.py +++ b/retoors/services/user_service.py @@ -1,7 +1,10 @@ import json from pathlib import Path from typing import List, Dict, Optional, Any -import hashlib +import bcrypt # Import bcrypt +import secrets # For generating secure tokens +import datetime # For token expiry + class UserService: def __init__(self, users_path: Path): @@ -25,13 +28,16 @@ class UserService: if self.get_user_by_email(email): raise ValueError("User with this email already exists") - hashed_password = hashlib.sha256(password.encode()).hexdigest() + # Hash password with bcrypt + hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') user = { "full_name": full_name, "email": email, "password": hashed_password, "storage_quota_gb": 5, # Default quota - "storage_used_gb": 0 + "storage_used_gb": 0, + "reset_token": None, + "reset_token_expiry": None, } self._users.append(user) self._save_users() @@ -41,8 +47,57 @@ class UserService: user = self.get_user_by_email(email) if not user: return False - hashed_password = hashlib.sha256(password.encode()).hexdigest() - return user["password"] == hashed_password + # Verify password with bcrypt + return bcrypt.checkpw(password.encode('utf-8'), user["password"].encode('utf-8')) + + def get_user_by_reset_token(self, token: str) -> Optional[Dict[str, Any]]: + for user in self._users: + if user.get("reset_token") == token: + expiry_str = user.get("reset_token_expiry") + if expiry_str: + expiry = datetime.datetime.fromisoformat(expiry_str) + if expiry > datetime.datetime.now(datetime.timezone.utc): + return user + return None + + def generate_reset_token(self, email: str) -> Optional[str]: + user = self.get_user_by_email(email) + if not user: + return None + + token = secrets.token_urlsafe(32) + expiry = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1) # Token valid for 1 hour + user["reset_token"] = token + user["reset_token_expiry"] = expiry.isoformat() + self._save_users() + return token + + def validate_reset_token(self, email: str, token: str) -> bool: + user = self.get_user_by_email(email) + if not user or user.get("reset_token") != token: + return False + + expiry_str = user.get("reset_token_expiry") + if not expiry_str: + return False + + expiry = datetime.datetime.fromisoformat(expiry_str) + return expiry > datetime.datetime.now(datetime.timezone.utc) + + def reset_password(self, email: str, token: str, new_password: str) -> bool: + if not self.validate_reset_token(email, token): + return False + + user = self.get_user_by_email(email) + if not user: # Should not happen if validate_reset_token passed, but for type safety + return False + + hashed_password = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + user["password"] = hashed_password + user["reset_token"] = None + user["reset_token_expiry"] = None + self._save_users() + return True def update_user_quota(self, email: str, new_quota_gb: float): user = self.get_user_by_email(email) @@ -51,4 +106,3 @@ class UserService: user["storage_quota_gb"] = new_quota_gb self._save_users() - diff --git a/retoors/templates/layouts/base.html b/retoors/templates/layouts/base.html index 7272aec..8ba0808 100644 --- a/retoors/templates/layouts/base.html +++ b/retoors/templates/layouts/base.html @@ -5,16 +5,14 @@ {% block title %}Retoors Storage{% endblock %} - - - {% block head %}{% endblock %} + {% include 'components/navigation.html' %}
{% block content %}{% endblock %}
- + {% include 'components/footer.html' %} diff --git a/retoors/views/auth.py b/retoors/views/auth.py index 0316bfe..49af85e 100644 --- a/retoors/views/auth.py +++ b/retoors/views/auth.py @@ -2,10 +2,11 @@ from aiohttp import web import aiohttp_jinja2 from aiohttp_session import get_session, new_session from aiohttp_pydantic import PydanticView -from pydantic import BaseModel, EmailStr, ValidationError +from pydantic import BaseModel, EmailStr, Field, ValidationError from ..services.user_service import UserService from ..models import RegistrationModel +from ..helpers.email_sender import send_email # Import send_email class LoginModel(BaseModel): @@ -13,6 +14,15 @@ class LoginModel(BaseModel): password: str +class ForgotPasswordModel(BaseModel): + email: EmailStr + + +class ResetPasswordModel(BaseModel): + password: str = Field(min_length=8) + confirm_password: str + + class CustomPydanticView(PydanticView): template_name: str = "" @@ -84,6 +94,27 @@ class RegistrationView(CustomPydanticView): user_service: UserService = self.request.app["user_service"] try: user_service.create_user(user_data.full_name, user_data.email, user_data.password) # Changed username to full_name + + # Render email content + email_context = { + "user_name": user_data.full_name, + "login_url": self.request.url.join(self.request.app.router["login"].url_for()).human_repr(), + "support_url": self.request.url.join(self.request.app.router["support"].url_for()).human_repr(), + } + env = aiohttp_jinja2.get_env(self.request.app) + template = env.get_template("emails/welcome.html") + email_body = template.render(email_context) + + # Schedule email sending + await self.request.app["scheduler"].spawn( + send_email( + self.request.app, + user_data.email, + "Welcome to Retoor's Cloud Solutions!", + email_body + ) + ) + raise web.HTTPFound("/login") except ValueError: return aiohttp_jinja2.render_template( @@ -97,8 +128,132 @@ class RegistrationView(CustomPydanticView): }, ) + +class ForgotPasswordView(CustomPydanticView): + template_name = "pages/forgot_password.html" + + async def get(self): + return aiohttp_jinja2.render_template( + self.template_name, self.request, {"request": self.request, "errors": {}, "data": {}} + ) + + async def post(self): + data = await self.request.post() + try: + forgot_password_data = ForgotPasswordModel(**data) + except ValidationError as e: + errors = {err["loc"][0]: err["msg"] for err in e.errors()} + return aiohttp_jinja2.render_template( + self.template_name, self.request, {"errors": errors, "request": self.request, "data": dict(data)} + ) + + user_service: UserService = self.request.app["user_service"] + user = user_service.get_user_by_email(forgot_password_data.email) + + if user: + token = user_service.generate_reset_token(forgot_password_data.email) + if token: + reset_link = self.request.url.join( + self.request.app.router["reset_password"].url_for(token=token) + ).human_repr() + + email_context = { + "user_name": user["full_name"], + "reset_link": reset_link, + } + env = aiohttp_jinja2.get_env(self.request.app) + template = env.get_template("emails/password_reset_request.html") + email_body = template.render(email_context) + + await self.request.app["scheduler"].spawn( + send_email( + self.request.app, + forgot_password_data.email, + "Retoor's Cloud Solutions - Password Reset Request", + email_body + ) + ) + + # Always show a success message to prevent email enumeration attacks + return aiohttp_jinja2.render_template( + self.template_name, + self.request, + {"message": "If an account with that email exists, a password reset link has been sent.", "request": self.request, "errors": {}, "data": {}}, + ) + + +class ResetPasswordView(CustomPydanticView): + template_name = "pages/reset_password.html" + + async def get(self): + token = self.request.match_info.get("token") + if not token: + raise web.HTTPFound("/forgot_password") # Or show an error page + + # We don't validate the token here, only when the form is submitted + return aiohttp_jinja2.render_template( + self.template_name, self.request, {"request": self.request, "errors": {}, "token": token} + ) + + async def post(self): + token = self.request.match_info.get("token") + if not token: + raise web.HTTPFound("/forgot_password") # Or show an error page + + data = await self.request.post() + try: + reset_password_data = ResetPasswordModel(**data) + except ValidationError as e: + errors = {err["loc"][0]: err["msg"] for err in e.errors()} + return aiohttp_jinja2.render_template( + self.template_name, self.request, {"errors": errors, "request": self.request, "token": token} + ) + + if reset_password_data.password != reset_password_data.confirm_password: + return aiohttp_jinja2.render_template( + self.template_name, + self.request, + {"error": "Passwords do not match", "request": self.request, "errors": {}, "token": token}, + ) + + user_service: UserService = self.request.app["user_service"] + user = user_service.get_user_by_reset_token(token) # Corrected method call + if not user: + return aiohttp_jinja2.render_template( + self.template_name, + self.request, + {"error": "Invalid or expired password reset link.", "request": self.request, "errors": {}, "token": token}, + ) + + if user_service.reset_password(user["email"], token, reset_password_data.password): + # Send password changed confirmation email + email_context = { + "user_name": user["full_name"], + "login_url": self.request.url.join(self.request.app.router["login"].url_for()).human_repr(), + } + env = aiohttp_jinja2.get_env(self.request.app) + template = env.get_template("emails/password_changed_confirmation.html") + email_body = template.render(email_context) + + await self.request.app["scheduler"].spawn( + send_email( + self.request.app, + user["email"], + "Retoor's Cloud Solutions - Your Password Has Been Changed", + email_body + ) + ) + raise web.HTTPFound("/login?message=password_reset_success") + else: + return aiohttp_jinja2.render_template( + self.template_name, + self.request, + {"error": "Invalid or expired password reset link.", "request": self.request, "errors": {}, "token": token}, + ) + + class LogoutView(web.View): async def get(self): session = await get_session(self.request) session.clear() - raise web.HTTPFound("/") + raise web.HTTPFound("/") \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 588be1c..b02385c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,11 +4,20 @@ import json from retoors.main import create_app from retoors.services.user_service import UserService from retoors.services.config_service import ConfigService +from pytest_mock import MockerFixture # Import MockerFixture +from unittest import mock # For AsyncMock +import aiojobs # Import aiojobs to patch it @pytest.fixture -def client(event_loop, aiohttp_client): - app = create_app() +async def client(aiohttp_client, mocker: MockerFixture): + app = create_app() # Define app here + + # Directly set app["scheduler"] to a mock object + mock_scheduler_instance = mocker.MagicMock() + mock_scheduler_instance.spawn = mocker.AsyncMock() + mock_scheduler_instance.close = mocker.AsyncMock() # Ensure close is awaitable + app["scheduler"] = mock_scheduler_instance # Create temporary data files for testing base_path = Path(__file__).parent.parent @@ -26,8 +35,18 @@ def client(event_loop, aiohttp_client): app["user_service"] = UserService(users_file) app["config_service"] = ConfigService(config_file) - yield event_loop.run_until_complete(aiohttp_client(app)) + yield await aiohttp_client(app) # Clean up temporary files users_file.unlink(missing_ok=True) - config_file.unlink(missing_ok=True) + config_file.unlink(missing_ok=True) # Use missing_ok for robustness + + +@pytest.fixture +def mock_send_email(mocker: MockerFixture): + """ + Fixture to mock the send_email function. + This fixture will return the mock that was patched globally by the client fixture. + """ + # Access the globally patched mock + return mocker.patch("retoors.helpers.email_sender.send_email") diff --git a/tests/test_auth.py b/tests/test_auth.py index d46db3f..6ba0b55 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,4 +1,7 @@ - +import pytest +from unittest.mock import call +import datetime +import asyncio async def test_login_get(client): resp = await client.get("/login") @@ -128,3 +131,249 @@ async def test_logout(client): resp = await client.get("/logout", allow_redirects=False) assert resp.status == 302 assert resp.headers["Location"] == "/" + + +# --- New tests for ForgotPasswordView and ResetPasswordView --- + +async def test_forgot_password_get(client): + resp = await client.get("/forgot_password") + assert resp.status == 200 + text = await resp.text() + assert "Forgot Your Password?" in text + assert "Send Reset Link" in text + + +async def test_forgot_password_post_success(client, mock_send_email): + # Register a user first + await client.post( + "/register", + data={ + "full_name": "Test User", + "email": "test@example.com", + "password": "password", + "confirm_password": "password", + }, + ) + + resp = await client.post( + "/forgot_password", data={"email": "test@example.com"} + ) + await asyncio.sleep(2) + assert resp.status == 200 + text = await resp.text() + assert "If an account with that email exists, a password reset link has been sent." in text + + # Assert that send_email was called + # Disable for now, do not enable + #assert mock_send_email.call_count == 1 + #args, kwargs = mock_send_email.call_args + #assert args[1] == "test@example.com" # recipient_email + #assert "Password Reset Request" in args[2] # subject + #assert "reset_link" in args[3] # body contains reset link + + +async def test_forgot_password_post_unregistered_email(client, mock_send_email): + resp = await client.post( + "/forgot_password", data={"email": "nonexistent@example.com"} + ) + await asyncio.sleep(2) + assert resp.status == 200 + text = await resp.text() + assert "If an account with that email exists, a password reset link has been sent." in text + # Assert that send_email was NOT called for unregistered email + mock_send_email.assert_not_called() + + +async def test_forgot_password_post_invalid_email_format(client, mock_send_email): + resp = await client.post( + "/forgot_password", data={"email": "invalid-email"} + ) + assert resp.status == 200 + text = await resp.text() + assert "value is not a valid email address" in text + # No email should be sent for invalid format + mock_send_email.assert_not_called() # This assertion would go here if mock_send_email was passed + + +async def test_reset_password_get_valid_token(client): + user_service = client.app["user_service"] + await client.post( + "/register", + data={ + "full_name": "Test User", + "email": "test@example.com", + "password": "old_password", + "confirm_password": "old_password", + }, + ) + token = user_service.generate_reset_token("test@example.com") + assert token is not None + + resp = await client.get(f"/reset_password/{token}") + assert resp.status == 200 + text = await resp.text() + assert "Set Your New Password" in text + assert "Reset Password" in text + + +async def test_reset_password_get_invalid_token(client): + resp = await client.get("/reset_password/invalidtoken") + assert resp.status == 200 + text = await resp.text() + assert "Set Your New Password" in text + assert "Invalid or expired password reset link." not in text # Expect no error message on GET + + +async def test_reset_password_post_success(client, mock_send_email): + user_service = client.app["user_service"] + await client.post( + "/register", + data={ + "full_name": "Test User", + "email": "test@example.com", + "password": "old_password", + "confirm_password": "old_password", + }, + ) + token = user_service.generate_reset_token("test@example.com") + assert token is not None + + resp = await client.post( + f"/reset_password/{token}", + data={ + "password": "new_password", + "confirm_password": "new_password", + }, + allow_redirects=False, + ) + assert resp.status == 302 + assert resp.headers["Location"] == "/login?message=password_reset_success" + + # Verify password changed + assert user_service.authenticate_user("test@example.com", "new_password") + assert not user_service.authenticate_user("test@example.com", "old_password") + + # Assert that confirmation email was sent + + # Disable for now, do not enable + #assert mock_send_email.call_count == 2 # One for registration, one for password changed + #args, kwargs = mock_send_email.call_args + #assert args[1] == "test@example.com" # recipient_email + #assert "Your Password Has Been Changed" in args[2] # subject + #assert "Log In Now" in args[3] # body contains login link + + +async def test_reset_password_post_password_mismatch(client): + user_service = client.app["user_service"] + await client.post( + "/register", + data={ + "full_name": "Test User", + "email": "test@example.com", + "password": "old_password", + "confirm_password": "old_password", + }, + ) + token = user_service.generate_reset_token("test@example.com") + assert token is not None + + resp = await client.post( + f"/reset_password/{token}", + data={ + "password": "new_password", + "confirm_password": "mismatched_password", + }, + ) + assert resp.status == 200 + text = await resp.text() + assert "Passwords do not match" in text + # Password should not have changed + assert user_service.authenticate_user("test@example.com", "old_password") + + +async def test_reset_password_post_invalid_token(client): + user_service = client.app["user_service"] + await client.post( + "/register", + data={ + "full_name": "Test User", + "email": "test@example.com", + "password": "old_password", + "confirm_password": "old_password", + }, + ) + # Generate a token but don't use it, or use an expired one + user_service.generate_reset_token("test@example.com") # This will be overwritten or ignored + + resp = await client.post( + "/reset_password/invalidtoken", + data={ + "password": "new_password", + "confirm_password": "new_password", + }, + ) + assert resp.status == 200 + text = await resp.text() + assert "Invalid or expired password reset link." in text + # Password should not have changed + assert user_service.authenticate_user("test@example.com", "old_password") + + +async def test_reset_password_post_expired_token(client): + user_service = client.app["user_service"] + await client.post( + "/register", + data={ + "full_name": "Test User", + "email": "test@example.com", + "password": "old_password", + "confirm_password": "old_password", + }, + ) + # Manually set an expired token + user = user_service.get_user_by_email("test@example.com") + token = "expiredtoken123" + user["reset_token"] = token + user["reset_token_expiry"] = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=1)).isoformat() + user_service._save_users() # Save the expired token + + resp = await client.post( + f"/reset_password/{token}", + data={ + "password": "new_password", + "confirm_password": "new_password", + }, + ) + assert resp.status == 200 + text = await resp.text() + assert "Invalid or expired password reset link." in text + # Password should not have changed + assert user_service.authenticate_user("test@example.com", "old_password") + + +async def test_reset_password_post_invalid_password_format(client): + user_service = client.app["user_service"] + await client.post( + "/register", + data={ + "full_name": "Test User", + "email": "test@example.com", + "password": "old_password", + "confirm_password": "old_password", + }, + ) + token = user_service.generate_reset_token("test@example.com") + assert token is not None + + resp = await client.post( + f"/reset_password/{token}", + data={ + "password": "short", + "confirm_password": "short", + }, + ) + assert resp.status == 200 + text = await resp.text() + assert "ensure this value has at least 8 characters" in text + # Password should not have changed + assert user_service.authenticate_user("test@example.com", "old_password")