diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..01689db --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# Database Configuration +DATABASE_URL=postgresql://user:password@localhost:5432/membership_db + +# JWT Authentication +JWT_SECRET=your-secret-key-change-this-in-production +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# SMTP Email Configuration (Port 465 - SSL/TLS) +SMTP_HOST=p.konceptkit.com +SMTP_PORT=465 +SMTP_SECURE=false +SMTP_USER=koncept-kit/koncept-kit +SMTP_PASS=TOBYjqk3ZOWXUsEzlP1Kj3p1 +SMTP_FROM=noreply@konceptkit.id +SMTP_FROM_NAME=LOAF Membership + +# Alternative SMTP Configuration (Port 587 - STARTTLS) +# If using port 587, use these settings instead: +# SMTP_HOST=smtp.gmail.com +# SMTP_PORT=587 +# SMTP_SECURE=false +# SMTP_USER=your-email@gmail.com +# SMTP_PASS=your-app-password +# SMTP_FROM=noreply@yourdomain.com +# SMTP_FROM_NAME=Your App Name + +# Frontend URL +FRONTEND_URL=http://localhost:3000 + +# Stripe Configuration (for future payment integration) +# STRIPE_SECRET_KEY=sk_test_... +# STRIPE_WEBHOOK_SECRET=whsec_... diff --git a/__pycache__/auth.cpython-312.pyc b/__pycache__/auth.cpython-312.pyc index bc7dcd3..b357148 100644 Binary files a/__pycache__/auth.cpython-312.pyc and b/__pycache__/auth.cpython-312.pyc differ diff --git a/__pycache__/email_service.cpython-312.pyc b/__pycache__/email_service.cpython-312.pyc index 6296597..d622758 100644 Binary files a/__pycache__/email_service.cpython-312.pyc and b/__pycache__/email_service.cpython-312.pyc differ diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc index 873b1cd..fc20ace 100644 Binary files a/__pycache__/models.cpython-312.pyc and b/__pycache__/models.cpython-312.pyc differ diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc index 82142e2..6c451b6 100644 Binary files a/__pycache__/server.cpython-312.pyc and b/__pycache__/server.cpython-312.pyc differ diff --git a/auth.py b/auth.py index ad38cdf..9d92d08 100644 --- a/auth.py +++ b/auth.py @@ -6,6 +6,7 @@ from fastapi import Depends, HTTPException, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.orm import Session import os +import secrets from database import get_db from models import User, UserRole @@ -22,6 +23,33 @@ def verify_password(plain_password, hashed_password): def get_password_hash(password): return pwd_context.hash(password) +def generate_reset_token(): + """Generate secure random token for password reset""" + return secrets.token_urlsafe(32) + +def create_password_reset_token(user, db): + """Create reset token with 1-hour expiration""" + token = generate_reset_token() + expires = datetime.now(timezone.utc) + timedelta(hours=1) + + user.password_reset_token = token + user.password_reset_expires = expires + db.commit() + + return token + +def verify_reset_token(token, db): + """Verify token is valid and not expired""" + user = db.query(User).filter(User.password_reset_token == token).first() + + if not user: + return None + + if user.password_reset_expires < datetime.now(timezone.utc): + return None # Token expired + + return user + def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): to_encode = data.copy() if expires_delta: @@ -78,3 +106,21 @@ async def get_current_admin_user(current_user: User = Depends(get_current_user)) detail="Not enough permissions" ) return current_user + +async def get_active_member(current_user: User = Depends(get_current_user)) -> User: + """Require user to be active member with valid payment""" + from models import UserStatus + + if current_user.status != UserStatus.active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Active membership required. Please complete payment." + ) + + if current_user.role not in [UserRole.member, UserRole.admin]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Member access only" + ) + + return current_user diff --git a/email_service.py b/email_service.py index 05c7c33..1933a26 100644 --- a/email_service.py +++ b/email_service.py @@ -1,50 +1,129 @@ import os +import ssl +import smtplib +import asyncio +from pathlib import Path from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart -import aiosmtplib import logging +from dotenv import load_dotenv logger = logging.getLogger(__name__) +# Load .env file +ROOT_DIR = Path(__file__).parent +load_dotenv(ROOT_DIR / '.env') + +# SMTP Configuration - supports both new (SMTP_USER/SMTP_PASS) and old (SMTP_USERNAME/SMTP_PASSWORD) variable names SMTP_HOST = os.environ.get('SMTP_HOST', 'smtp.gmail.com') SMTP_PORT = int(os.environ.get('SMTP_PORT', 587)) -SMTP_USERNAME = os.environ.get('SMTP_USERNAME', '') -SMTP_PASSWORD = os.environ.get('SMTP_PASSWORD', '') -SMTP_FROM_EMAIL = os.environ.get('SMTP_FROM_EMAIL', 'noreply@membership.com') -SMTP_FROM_NAME = os.environ.get('SMTP_FROM_NAME', 'Membership Platform') +SMTP_SECURE = os.environ.get('SMTP_SECURE', 'false').lower() == 'true' +SMTP_VERIFY_CERT = os.environ.get('SMTP_VERIFY_CERT', 'true').lower() == 'true' +SMTP_AUTH_METHOD = os.environ.get('SMTP_AUTH_METHOD', None) # PLAIN, LOGIN, or CRAM-MD5 + +# Log configuration at startup +logger.info(f"šŸ“§ SMTP Configuration Loaded:") +logger.info(f" Host: {SMTP_HOST}:{SMTP_PORT}") +logger.info(f" Secure (SSL from start): {SMTP_SECURE}") +logger.info(f" Verify Certificate: {SMTP_VERIFY_CERT}") +logger.info(f" Auth Method: {SMTP_AUTH_METHOD or 'auto-detect'}") + +# Support both SMTP_USER and SMTP_USERNAME (new and old) +SMTP_USER = os.environ.get('SMTP_USER') or os.environ.get('SMTP_USERNAME', '') +SMTP_PASS = os.environ.get('SMTP_PASS') or os.environ.get('SMTP_PASSWORD', '') + +# Support both SMTP_FROM and SMTP_FROM_EMAIL (new and old) +SMTP_FROM = os.environ.get('SMTP_FROM') or os.environ.get('SMTP_FROM_EMAIL', 'noreply@membership.com') +SMTP_FROM_NAME = os.environ.get('SMTP_FROM_NAME', 'LOAF Membership') + FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:3000') +def _send_email_sync(message: MIMEMultipart, to_email: str): + """ + Synchronous email sending (will be wrapped in asyncio.to_thread) + Matches the working pattern from previous implementation + """ + # Create SSL context for certificate verification control + ssl_context = None + if not SMTP_VERIFY_CERT: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + logger.warning(f"āš ļø SSL certificate verification DISABLED for {SMTP_HOST}") + + # Determine connection method based on configuration + if not SMTP_SECURE: + # Plain SMTP without encryption (some providers require this) + logger.info(f"šŸ”Œ Using plain SMTP connection (SMTP_SECURE=false) to {SMTP_HOST}:{SMTP_PORT}") + with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server: + logger.info(f" āœ… SMTP connection established") + server.login(SMTP_USER, SMTP_PASS) + logger.info(f" āœ… Login successful") + server.send_message(message) + logger.info(f" āœ… Message sent") + elif SMTP_PORT == 465: + # Use SSL/TLS from start (standard for port 465) + logger.info(f"šŸ”Œ Using SMTP_SSL connection to {SMTP_HOST}:{SMTP_PORT}") + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=ssl_context) as server: + logger.info(f" āœ… SSL connection established") + server.login(SMTP_USER, SMTP_PASS) + logger.info(f" āœ… Login successful") + server.send_message(message) + else: + # Use STARTTLS (standard for port 587) + logger.info(f"šŸ”Œ Using SMTP with STARTTLS to {SMTP_HOST}:{SMTP_PORT}") + with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server: + server.starttls(context=ssl_context) + logger.info(f" āœ… STARTTLS enabled") + server.login(SMTP_USER, SMTP_PASS) + logger.info(f" āœ… Login successful") + server.send_message(message) + + async def send_email(to_email: str, subject: str, html_content: str): - """Send an email using SMTP""" + """ + Send an email using SMTP + + Configuration via environment variables: + - SMTP_SECURE=false: Plain SMTP without encryption + - SMTP_SECURE=true + Port 465: SSL/TLS from start + - SMTP_SECURE=true + Port 587: STARTTLS + """ try: message = MIMEMultipart('alternative') - message['From'] = f"{SMTP_FROM_NAME} <{SMTP_FROM_EMAIL}>" + message['From'] = f"{SMTP_FROM_NAME} <{SMTP_FROM}>" message['To'] = to_email message['Subject'] = subject - + html_part = MIMEText(html_content, 'html') message.attach(html_part) - + # For development/testing, just log the email - if not SMTP_USERNAME or not SMTP_PASSWORD: + if not SMTP_USER or not SMTP_PASS: logger.info(f"[EMAIL] To: {to_email}") logger.info(f"[EMAIL] Subject: {subject}") - logger.info(f"[EMAIL] Content: {html_content}") + logger.info(f"[EMAIL] Content: {html_content[:200]}...") return True - - # Send actual email - await aiosmtplib.send( - message, - hostname=SMTP_HOST, - port=SMTP_PORT, - username=SMTP_USERNAME, - password=SMTP_PASSWORD, - start_tls=True - ) - logger.info(f"Email sent successfully to {to_email}") + + logger.info(f"šŸ“§ Sending email to {to_email} via {SMTP_HOST}:{SMTP_PORT}") + + # Run synchronous SMTP in thread pool to avoid blocking + await asyncio.to_thread(_send_email_sync, message, to_email) + + logger.info(f"āœ“ Email sent successfully to {to_email}") return True + + except smtplib.SMTPAuthenticationError as e: + logger.error(f"āŒ SMTP Authentication Error for {to_email}: {str(e)}") + logger.error(f" Check SMTP_USER and SMTP_PASS") + return False + except smtplib.SMTPException as e: + logger.error(f"āŒ SMTP Error sending to {to_email}: {str(e)}") + logger.exception(e) + return False except Exception as e: - logger.error(f"Failed to send email to {to_email}: {str(e)}") + logger.error(f"āœ— Failed to send email to {to_email}: {str(e)}") + logger.exception(e) return False async def send_verification_email(to_email: str, token: str): @@ -180,3 +259,119 @@ async def send_payment_prompt_email(to_email: str, first_name: str): """ return await send_email(to_email, subject, html_content) + +async def send_password_reset_email(to_email: str, first_name: str, reset_url: str): + """Send password reset link email""" + subject = "Reset Your Password - LOAF Membership" + + html_content = f""" + + + + + + +
+
+

Reset Your Password

+
+
+

Hi {first_name},

+

You requested to reset your password. Click the button below to create a new password:

+ +

+ Reset Password +

+ +
+

ā° This link will expire in 1 hour.

+

If you didn't request this, please ignore this email.

+
+ +

+ Or copy and paste this link into your browser:
+ {reset_url} +

+
+
+ + + """ + + return await send_email(to_email, subject, html_content) + +async def send_admin_password_reset_email( + to_email: str, + first_name: str, + temp_password: str, + force_change: bool +): + """Send temporary password when admin resets user password""" + subject = "Your Password Has Been Reset - LOAF Membership" + + force_change_text = ( + """ +
+

āš ļø You will be required to change this password when you log in.

+
+ """ + ) if force_change else "" + + login_url = f"{FRONTEND_URL}/login" + + html_content = f""" + + + + + + +
+
+

Password Reset by Administrator

+
+
+

Hi {first_name},

+

An administrator has reset your password. Here is your temporary password:

+ +
+ {temp_password} +
+ + {force_change_text} + +

Please log in and change your password to something memorable.

+ +

+ Go to Login +

+ +

+ Questions? Contact us at support@loaf.org +

+
+
+ + + """ + + return await send_email(to_email, subject, html_content) diff --git a/fix_enum.sql b/fix_enum.sql new file mode 100644 index 0000000..ee92669 --- /dev/null +++ b/fix_enum.sql @@ -0,0 +1,11 @@ +-- Add pending_approval to the userstatus enum if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_enum + WHERE enumlabel = 'pending_approval' + AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'userstatus') + ) THEN + ALTER TYPE userstatus ADD VALUE 'pending_approval' BEFORE 'pre_approved'; + END IF; +END$$; diff --git a/migrate_password_reset.py b/migrate_password_reset.py new file mode 100644 index 0000000..3d144cb --- /dev/null +++ b/migrate_password_reset.py @@ -0,0 +1,43 @@ +""" +Migration script to add password reset fields to users table. +Run this once to update the database schema for password reset functionality. +""" + +from database import engine +from sqlalchemy import text + +def add_password_reset_columns(): + """Add password reset token, expiration, and force change columns to users table""" + + migrations = [ + # Password reset token (secure random string) + "ALTER TABLE users ADD COLUMN IF NOT EXISTS password_reset_token VARCHAR", + + # Password reset expiration (1 hour from token creation) + "ALTER TABLE users ADD COLUMN IF NOT EXISTS password_reset_expires TIMESTAMP", + + # Force password change on next login (for admin resets) + "ALTER TABLE users ADD COLUMN IF NOT EXISTS force_password_change BOOLEAN NOT NULL DEFAULT FALSE" + ] + + try: + print("Adding password reset columns to users table...") + with engine.connect() as conn: + for sql in migrations: + print(f" Executing: {sql[:70]}...") + conn.execute(text(sql)) + conn.commit() + + print("\nāœ… Migration completed successfully!") + print("\nAdded columns:") + print(" - password_reset_token (VARCHAR)") + print(" - password_reset_expires (TIMESTAMP)") + print(" - force_password_change (BOOLEAN, default=FALSE)") + print("\nYou can now run password reset functionality.") + + except Exception as e: + print(f"\nāŒ Migration failed: {e}") + raise + +if __name__ == "__main__": + add_password_reset_columns() diff --git a/models.py b/models.py index b8e347e..79c812f 100644 --- a/models.py +++ b/models.py @@ -77,6 +77,11 @@ class User(Base): directory_dob = Column(DateTime, nullable=True) directory_partner_name = Column(String, nullable=True) + # Password Reset Fields + password_reset_token = Column(String, nullable=True) + password_reset_expires = Column(DateTime, nullable=True) + force_password_change = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) diff --git a/server.py b/server.py index 04110c8..d6e45a2 100644 --- a/server.py +++ b/server.py @@ -20,9 +20,18 @@ from auth import ( verify_password, create_access_token, get_current_user, - get_current_admin_user + get_current_admin_user, + get_active_member, + create_password_reset_token, + verify_reset_token +) +from email_service import ( + send_verification_email, + send_approval_notification, + send_payment_prompt_email, + send_password_reset_email, + send_admin_password_reset_email ) -from email_service import send_verification_email, send_approval_notification, send_payment_prompt_email from payment_service import create_checkout_session, verify_webhook_signature, get_subscription_end_date # Load environment variables @@ -125,6 +134,20 @@ class LoginResponse(BaseModel): token_type: str user: dict +class ForgotPasswordRequest(BaseModel): + email: EmailStr + +class ResetPasswordRequest(BaseModel): + token: str + new_password: str = Field(min_length=6) + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str = Field(min_length=6) + +class AdminPasswordUpdateRequest(BaseModel): + force_change: bool = True + class UserResponse(BaseModel): id: str email: str @@ -314,9 +337,32 @@ async def verify_email(token: str, db: Session = Depends(get_db)): db.refresh(user) logger.info(f"Email verified for user: {user.email}") - + return {"message": "Email verified successfully", "status": user.status.value} +@api_router.post("/auth/resend-verification-email") +async def resend_verification_email( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """User requests to resend their verification email""" + + # Check if email already verified + if current_user.email_verified: + raise HTTPException(status_code=400, detail="Email is already verified") + + # Generate new token + verification_token = secrets.token_urlsafe(32) + current_user.email_verification_token = verification_token + db.commit() + + # Send verification email + await send_verification_email(current_user.email, verification_token) + + logger.info(f"Verification email resent to: {current_user.email}") + + return {"message": "Verification email has been resent. Please check your inbox."} + @api_router.post("/auth/login", response_model=LoginResponse) async def login(request: LoginRequest, db: Session = Depends(get_db)): user = db.query(User).filter(User.email == request.email).first() @@ -338,10 +384,60 @@ async def login(request: LoginRequest, db: Session = Depends(get_db)): "first_name": user.first_name, "last_name": user.last_name, "status": user.status.value, - "role": user.role.value + "role": user.role.value, + "force_password_change": user.force_password_change } } +@api_router.post("/auth/forgot-password") +async def forgot_password(request: ForgotPasswordRequest, db: Session = Depends(get_db)): + """Request password reset - sends email with reset link""" + user = db.query(User).filter(User.email == request.email).first() + + # Always return success (security: don't reveal if email exists) + if user: + token = create_password_reset_token(user, db) + reset_url = f"{os.getenv('FRONTEND_URL')}/reset-password?token={token}" + + await send_password_reset_email(user.email, user.first_name, reset_url) + + return {"message": "If email exists, reset link has been sent"} + +@api_router.post("/auth/reset-password") +async def reset_password(request: ResetPasswordRequest, db: Session = Depends(get_db)): + """Complete password reset using token""" + user = verify_reset_token(request.token, db) + + if not user: + raise HTTPException(status_code=400, detail="Invalid or expired reset token") + + # Update password + user.password_hash = get_password_hash(request.new_password) + user.password_reset_token = None + user.password_reset_expires = None + user.force_password_change = False # Reset flag if it was set + db.commit() + + return {"message": "Password reset successful"} + +@api_router.put("/users/change-password") +async def change_password( + request: ChangePasswordRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """User changes their own password""" + # Verify current password + if not verify_password(request.current_password, current_user.password_hash): + raise HTTPException(status_code=400, detail="Current password is incorrect") + + # Update password + current_user.password_hash = get_password_hash(request.new_password) + current_user.force_password_change = False # Clear flag if set + db.commit() + + return {"message": "Password changed successfully"} + @api_router.get("/auth/me", response_model=UserResponse) async def get_me(current_user: User = Depends(get_current_user)): return UserResponse( @@ -412,6 +508,7 @@ async def update_profile( # Event Routes @api_router.get("/events", response_model=List[EventResponse]) async def get_events( + current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): # Get published events for all users @@ -445,6 +542,7 @@ async def get_events( @api_router.get("/events/{event_id}", response_model=EventResponse) async def get_event( event_id: str, + current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): event = db.query(Event).filter(Event.id == event_id).first() @@ -478,7 +576,7 @@ async def get_event( async def rsvp_to_event( event_id: str, request: RSVPRequest, - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): event = db.query(Event).filter(Event.id == event_id).first() @@ -743,6 +841,73 @@ async def activate_payment_manually( "subscription_id": str(subscription.id) } +@api_router.put("/admin/users/{user_id}/reset-password") +async def admin_reset_user_password( + user_id: str, + request: AdminPasswordUpdateRequest, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Admin resets user password - generates temp password and emails it""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Generate random temporary password + temp_password = secrets.token_urlsafe(12) + + # Update user + user.password_hash = get_password_hash(temp_password) + user.force_password_change = request.force_change + db.commit() + + # Email user the temporary password + await send_admin_password_reset_email( + user.email, + user.first_name, + temp_password, + request.force_change + ) + + # Log admin action + logger.info( + f"Admin {current_user.email} reset password for user {user.email} " + f"(force_change={request.force_change})" + ) + + return {"message": f"Password reset for {user.email}. Temporary password emailed."} + +@api_router.post("/admin/users/{user_id}/resend-verification") +async def admin_resend_verification( + user_id: str, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Admin resends verification email for any user""" + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Check if email already verified + if user.email_verified: + raise HTTPException(status_code=400, detail="User's email is already verified") + + # Generate new token + verification_token = secrets.token_urlsafe(32) + user.email_verification_token = verification_token + db.commit() + + # Send verification email + await send_verification_email(user.email, verification_token) + + # Log admin action + logger.info( + f"Admin {current_user.email} resent verification email to user {user.email}" + ) + + return {"message": f"Verification email resent to {user.email}"} + @api_router.post("/admin/events", response_model=EventResponse) async def create_event( request: EventCreate,