yamiclaws's picture
Upload folder using huggingface_hub
4f8b3ca verified
Raw
History Blame Contribute Delete
26 kB
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 = {}
@app.middleware("http")
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
# ══════════════════════════════════════════════════════════════════════════════
@app.post("/auth/register", response_model=UserOutput, status_code=201)
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
@app.post("/auth/verify-email")
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."}
@app.post("/auth/login", response_model=TokenOutput)
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)
@app.post("/auth/forgot-password")
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."}
@app.post("/auth/reset-password")
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."}
@app.post("/auth/refresh", response_model=TokenOutput)
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)
@app.post("/auth/logout")
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
# ══════════════════════════════════════════════════════════════════════════════
@app.get("/me", response_model=UserOutput)
def get_profile(current_user: User = Depends(get_current_user)):
return current_user
@app.put("/me", response_model=UserOutput)
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
@app.delete("/me", status_code=200)
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"}
@app.get("/admin/users", response_model=List[UserOutput])
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
# ══════════════════════════════════════════════════════════════════════════════
@app.get("/api")
def home():
return {
"status": "online",
"message": "Welcome to the SMTP Auth API! Go to /docs to test all endpoints.",
}
@app.get("/health")
def health():
return {"status": "ok"}
# ─── SERVE FRONTEND STATIC FILES ──────────────────────────────────────────
if os.path.exists("static"):
app.mount("/", StaticFiles(directory="static", html=True), name="static")
@app.exception_handler(404)
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"})