Spaces:
Sleeping
Sleeping
| import os | |
| import time | |
| import random | |
| import smtplib | |
| import bcrypt | |
| from datetime import datetime, timedelta | |
| from email.mime.text import MIMEText | |
| from typing import List, Optional | |
| from fastapi import FastAPI, Depends, HTTPException, Request, status | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import JSONResponse, HTMLResponse | |
| from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm | |
| from fastapi.staticfiles import StaticFiles | |
| from jose import jwt, JWTError | |
| from pydantic import BaseModel, EmailStr, Field | |
| from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime | |
| from sqlalchemy.orm import sessionmaker, Session, DeclarativeBase | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CONFIGURATION | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| SECRET_KEY = os.getenv("SECRET_KEY", "super_secret_dev_key_change_me_123456789") | |
| ALGORITHM = os.getenv("ALGORITHM", "HS256") | |
| ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30")) | |
| REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7")) | |
| DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./users.db") | |
| SMTP_SERVER = os.getenv("SMTP_SERVER", "") | |
| SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) | |
| SMTP_USER = os.getenv("SMTP_USER", "") | |
| SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # DATABASE | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {} | |
| engine = create_engine(DATABASE_URL, connect_args=connect_args) | |
| SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) | |
| class Base(DeclarativeBase): | |
| pass | |
| def get_db(): | |
| db = SessionLocal() | |
| try: | |
| yield db | |
| finally: | |
| db.close() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MODELS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class User(Base): | |
| __tablename__ = "users" | |
| id = Column(Integer, primary_key=True, index=True) | |
| username = Column(String, unique=True, index=True, nullable=False) | |
| email = Column(String, unique=True, index=True, nullable=False) | |
| password = Column(String, nullable=False) | |
| is_active = Column(Boolean, default=True) | |
| role = Column(String, default="user") | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| is_verified = Column(Boolean, default=False) | |
| verification_code = Column(String, nullable=True) | |
| reset_code = Column(String, nullable=True) | |
| class RevokedToken(Base): | |
| __tablename__ = "revoked_tokens" | |
| id = Column(Integer, primary_key=True, index=True) | |
| token = Column(String, unique=True, index=True, nullable=False) | |
| revoked_at = Column(DateTime, default=datetime.utcnow) | |
| expires_at = Column(DateTime, nullable=False) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SCHEMAS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class RegisterInput(BaseModel): | |
| username: str = Field(..., min_length=3, max_length=50) | |
| email: EmailStr | |
| password: str = Field(..., min_length=8) | |
| class TokenOutput(BaseModel): | |
| access_token: str | |
| refresh_token: str | |
| token_type: str = "bearer" | |
| class RefreshInput(BaseModel): | |
| refresh_token: str | |
| class UserOutput(BaseModel): | |
| id: int | |
| username: str | |
| email: str | |
| role: str | |
| is_active: bool | |
| is_verified: bool | |
| class Config: | |
| from_attributes = True | |
| class ProfileUpdateInput(BaseModel): | |
| username: Optional[str] = Field(None, min_length=3, max_length=50) | |
| email: Optional[EmailStr] = None | |
| password: Optional[str] = Field(None, min_length=8) | |
| class VerifyEmailInput(BaseModel): | |
| email: EmailStr | |
| code: str = Field(..., min_length=6, max_length=6, description="6-digit verification code") | |
| class ForgotPasswordRequest(BaseModel): | |
| email: EmailStr | |
| class PasswordResetInput(BaseModel): | |
| email: EmailStr | |
| code: str = Field(..., min_length=6, max_length=6, description="6-digit reset code") | |
| new_password: str = Field(..., min_length=8, description="Must be at least 8 characters") | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # PASSWORD HASHING | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def hash_password(password: str) -> str: | |
| salt = bcrypt.gensalt() | |
| return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8") | |
| def check_password(password: str, hashed_password: str) -> bool: | |
| try: | |
| return bcrypt.checkpw(password.encode("utf-8"), hashed_password.encode("utf-8")) | |
| except Exception: | |
| return False | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # JWT TOKENS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def make_access_token(user_id: int, email: str) -> str: | |
| expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) | |
| data = {"sub": str(user_id), "email": email, "type": "access", "exp": expire} | |
| return jwt.encode(data, SECRET_KEY, algorithm=ALGORITHM) | |
| def make_refresh_token(user_id: int) -> str: | |
| expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) | |
| data = {"sub": str(user_id), "type": "refresh", "exp": expire} | |
| return jwt.encode(data, SECRET_KEY, algorithm=ALGORITHM) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # OTP GENERATOR | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def generate_otp() -> str: | |
| return "".join(random.choices("0123456789", k=6)) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # EMAIL SENDING (SMTP / Console Fallback) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def send_email(to_email: str, subject: str, body: str) -> bool: | |
| if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]): | |
| print(f"\nββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ") | |
| print(f"β [SIMULATION] Email Sent To: {to_email:<27} β") | |
| print(f"β Subject: {subject:<44} β") | |
| print(f"β Code/OTP: {body:<44} β") | |
| print(f"ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ\n") | |
| return True | |
| try: | |
| msg = MIMEText(body) | |
| msg["Subject"] = subject | |
| msg["From"] = SMTP_USER | |
| msg["To"] = to_email | |
| with smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=5) as server: | |
| server.starttls() | |
| server.login(SMTP_USER, SMTP_PASSWORD) | |
| server.send_message(msg) | |
| return True | |
| except Exception as e: | |
| print(f"\n[SMTP ERROR] Sending failed: {e}") | |
| print(f"ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ") | |
| print(f"β [SIMULATION FALLBACK] Email To: {to_email:<22} β") | |
| print(f"β Subject: {subject:<44} β") | |
| print(f"β Code/OTP: {body:<44} β") | |
| print(f"ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ\n") | |
| return False | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CURRENT USER DEPENDENCY | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") | |
| def get_current_user( | |
| token: str = Depends(oauth2_scheme), | |
| db: Session = Depends(get_db), | |
| ) -> User: | |
| credentials_exception = HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail="Could not validate credentials", | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| revoked = db.query(RevokedToken).filter(RevokedToken.token == token).first() | |
| if revoked: | |
| raise HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail="Session has expired or you logged out. Please sign in again.", | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| try: | |
| payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) | |
| if payload.get("type") != "access": | |
| raise HTTPException(status_code=401, detail="Invalid token type") | |
| user_id: str = payload.get("sub") | |
| if user_id is None: | |
| raise credentials_exception | |
| except JWTError: | |
| raise credentials_exception | |
| user = db.query(User).filter(User.id == int(user_id)).first() | |
| if user is None: | |
| raise HTTPException(status_code=404, detail="User not found") | |
| if not user.is_active: | |
| raise HTTPException(status_code=400, detail="User account is inactive") | |
| if not user.is_verified: | |
| raise HTTPException(status_code=400, detail="User email is not verified yet") | |
| return user | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # PASSWORD VALIDATION HELPER | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def validate_password_strength(password: str): | |
| if len(password) < 8: | |
| raise HTTPException(status_code=400, detail="Password must be at least 8 characters long.") | |
| if not any(char.isdigit() for char in password): | |
| raise HTTPException(status_code=400, detail="Password must contain at least one number.") | |
| if not any(char.isalpha() for char in password): | |
| raise HTTPException(status_code=400, detail="Password must contain at least one letter.") | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # FASTAPI APP | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| Base.metadata.create_all(bind=engine) | |
| app = FastAPI( | |
| title="SMTP-Enabled Authentication System", | |
| description="A beginner-friendly secure authentication system with modular routes and email verification.", | |
| version="1.0.0", | |
| ) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # βββ RATE LIMITING & REQUEST LOGGING ββββββββββββββββββββββββββββββββββββββ | |
| RATE_LIMIT_WINDOW = 60 | |
| RATE_LIMIT_MAX = 60 | |
| client_requests = {} | |
| async def rate_limiting_and_logging_middleware(request: Request, call_next): | |
| client_ip = request.client.host if request.client else "unknown" | |
| now = time.time() | |
| if client_ip not in client_requests: | |
| client_requests[client_ip] = [] | |
| client_requests[client_ip] = [t for t in client_requests[client_ip] if now - t < RATE_LIMIT_WINDOW] | |
| if len(client_requests[client_ip]) >= RATE_LIMIT_MAX: | |
| return JSONResponse( | |
| status_code=429, | |
| content={"detail": "Too many requests. Please slow down and try again in a minute."}, | |
| ) | |
| client_requests[client_ip].append(now) | |
| start_time = time.time() | |
| try: | |
| response = await call_next(request) | |
| except Exception as exc: | |
| log_line = f"{datetime.utcnow().isoformat()} - {client_ip} - {request.method} {request.url.path} - ERROR: {str(exc)}\n" | |
| with open("requests.log", "a") as log_file: | |
| log_file.write(log_line) | |
| return JSONResponse( | |
| status_code=500, | |
| content={"detail": "An internal server error occurred. Please contact the administrator."}, | |
| ) | |
| duration = time.time() - start_time | |
| log_line = ( | |
| f"{datetime.utcnow().isoformat()} - IP: {client_ip} - " | |
| f"{request.method} {request.url.path} - Status: {response.status_code} - " | |
| f"Took: {duration:.4f}s\n" | |
| ) | |
| with open("requests.log", "a") as log_file: | |
| log_file.write(log_line) | |
| return response | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # AUTH ROUTES | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def register(data: RegisterInput, db: Session = Depends(get_db)): | |
| validate_password_strength(data.password) | |
| if db.query(User).filter(User.username == data.username).first(): | |
| raise HTTPException(status_code=400, detail="Username is already taken") | |
| if db.query(User).filter(User.email == data.email).first(): | |
| raise HTTPException(status_code=400, detail="Email is already registered") | |
| otp_code = generate_otp() | |
| new_user = User( | |
| username=data.username, | |
| email=data.email, | |
| password=hash_password(data.password), | |
| is_verified=False, | |
| verification_code=otp_code, | |
| ) | |
| db.add(new_user) | |
| db.commit() | |
| db.refresh(new_user) | |
| send_email(to_email=new_user.email, subject="Email Verification Code", body=otp_code) | |
| return new_user | |
| def verify_email(data: VerifyEmailInput, db: Session = Depends(get_db)): | |
| user = db.query(User).filter(User.email == data.email).first() | |
| if not user: | |
| raise HTTPException(status_code=404, detail="User not found") | |
| if user.is_verified: | |
| return {"detail": "Email already verified. You can sign in."} | |
| if user.verification_code != data.code: | |
| raise HTTPException(status_code=400, detail="Invalid verification code") | |
| user.is_verified = True | |
| user.verification_code = None | |
| db.commit() | |
| return {"detail": "Email verified successfully! You can now sign in."} | |
| def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): | |
| user = db.query(User).filter( | |
| (User.username == form_data.username) | (User.email == form_data.username) | |
| ).first() | |
| if not user or not check_password(form_data.password, user.password): | |
| raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password") | |
| if not user.is_active: | |
| raise HTTPException(status_code=400, detail="User account is deactivated") | |
| if not user.is_verified: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="Email is not verified. Please verify your email first.", | |
| ) | |
| access = make_access_token(user.id, user.email) | |
| refresh = make_refresh_token(user.id) | |
| return TokenOutput(access_token=access, refresh_token=refresh) | |
| def forgot_password(data: ForgotPasswordRequest, db: Session = Depends(get_db)): | |
| user = db.query(User).filter(User.email == data.email).first() | |
| if not user: | |
| raise HTTPException(status_code=404, detail="Email not found") | |
| otp_code = generate_otp() | |
| user.reset_code = otp_code | |
| db.commit() | |
| send_email(to_email=user.email, subject="Password Reset Request Code", body=otp_code) | |
| return {"detail": "Verification code has been sent to your email."} | |
| def reset_password(data: PasswordResetInput, db: Session = Depends(get_db)): | |
| validate_password_strength(data.new_password) | |
| user = db.query(User).filter(User.email == data.email).first() | |
| if not user: | |
| raise HTTPException(status_code=404, detail="User not found") | |
| if user.reset_code != data.code: | |
| raise HTTPException(status_code=400, detail="Invalid password reset code") | |
| user.password = hash_password(data.new_password) | |
| user.reset_code = None | |
| db.commit() | |
| return {"detail": "Password has been reset successfully."} | |
| def refresh(data: RefreshInput, db: Session = Depends(get_db)): | |
| try: | |
| payload = jwt.decode(data.refresh_token, SECRET_KEY, algorithms=[ALGORITHM]) | |
| if payload.get("type") != "refresh": | |
| raise HTTPException(status_code=401, detail="Invalid token type") | |
| user_id = int(payload.get("sub")) | |
| exp_timestamp = payload.get("exp") | |
| expires_at = datetime.utcfromtimestamp(exp_timestamp) | |
| except JWTError: | |
| raise HTTPException(status_code=401, detail="Refresh token is expired or invalid") | |
| revoked = db.query(RevokedToken).filter(RevokedToken.token == data.refresh_token).first() | |
| if revoked: | |
| raise HTTPException(status_code=401, detail="Refresh token has been revoked") | |
| user = db.query(User).filter(User.id == user_id).first() | |
| if not user or not user.is_active or not user.is_verified: | |
| raise HTTPException(status_code=401, detail="User not found, inactive, or unverified") | |
| db_revoked = RevokedToken(token=data.refresh_token, expires_at=expires_at) | |
| db.add(db_revoked) | |
| access = make_access_token(user.id, user.email) | |
| new_refresh = make_refresh_token(user.id) | |
| db.commit() | |
| return TokenOutput(access_token=access, refresh_token=new_refresh) | |
| def logout( | |
| data: RefreshInput = None, | |
| access_token: str = Depends(oauth2_scheme), | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user), | |
| ): | |
| try: | |
| payload = jwt.decode(access_token, SECRET_KEY, algorithms=[ALGORITHM]) | |
| exp = datetime.utcfromtimestamp(payload.get("exp")) | |
| except JWTError: | |
| exp = datetime.utcnow() + timedelta(minutes=30) | |
| if not db.query(RevokedToken).filter(RevokedToken.token == access_token).first(): | |
| db.add(RevokedToken(token=access_token, expires_at=exp)) | |
| if data and data.refresh_token: | |
| try: | |
| r_payload = jwt.decode(data.refresh_token, SECRET_KEY, algorithms=[ALGORITHM]) | |
| r_exp = datetime.utcfromtimestamp(r_payload.get("exp")) | |
| if not db.query(RevokedToken).filter(RevokedToken.token == data.refresh_token).first(): | |
| db.add(RevokedToken(token=data.refresh_token, expires_at=r_exp)) | |
| except JWTError: | |
| pass | |
| db.commit() | |
| return {"detail": "Successfully logged out and session revoked"} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # USER ROUTES | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_profile(current_user: User = Depends(get_current_user)): | |
| return current_user | |
| def update_profile( | |
| data: ProfileUpdateInput, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user), | |
| ): | |
| if data.username and data.username != current_user.username: | |
| existing = db.query(User).filter(User.username == data.username).first() | |
| if existing: | |
| raise HTTPException(status_code=400, detail="Username is already taken") | |
| current_user.username = data.username | |
| if data.email and data.email != current_user.email: | |
| existing = db.query(User).filter(User.email == data.email).first() | |
| if existing: | |
| raise HTTPException(status_code=400, detail="Email is already registered") | |
| current_user.email = data.email | |
| if data.password: | |
| validate_password_strength(data.password) | |
| current_user.password = hash_password(data.password) | |
| db.commit() | |
| db.refresh(current_user) | |
| return current_user | |
| def delete_account(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): | |
| db.delete(current_user) | |
| db.commit() | |
| return {"detail": "Account deleted successfully"} | |
| def get_all_users_admin(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): | |
| if current_user.role != "admin": | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail="Forbidden: Admin access required", | |
| ) | |
| users = db.query(User).all() | |
| return users | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # HEALTH & HOME | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def home(): | |
| return { | |
| "status": "online", | |
| "message": "Welcome to the SMTP Auth API! Go to /docs to test all endpoints.", | |
| } | |
| def health(): | |
| return {"status": "ok"} | |
| # βββ SERVE FRONTEND STATIC FILES ββββββββββββββββββββββββββββββββββββββββββ | |
| if os.path.exists("static"): | |
| app.mount("/", StaticFiles(directory="static", html=True), name="static") | |
| async def spa_fallback_404_handler(request: Request, exc: Exception): | |
| path = request.url.path | |
| if not path.startswith("/auth") and not path.startswith("/admin") and path != "/api": | |
| if os.path.exists("static/index.html"): | |
| with open("static/index.html", "r") as f: | |
| return HTMLResponse(content=f.read(), status_code=200) | |
| return JSONResponse(status_code=404, content={"detail": "Not Found"}) | |