diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3566160 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# SMTP Configuration for Email Verification +SMTP_HOST=smtp.qq.com +SMTP_PORT=465 +SMTP_USER=your_qq_email@qq.com +SMTP_PASSWORD=your_16_digit_auth_code + +# Frontend URL for the verification link +FRONTEND_URL=http://localhost:5173 diff --git a/.gitignore b/.gitignore index 027e6b4..b944c4e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.env .vscode nanobot-0.1.4.post4 data diff --git a/README.md b/README.md index 6ee23bc..a6442e4 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,25 @@ DataClaw 的架构主要分为三只“大钳子”: 准备好大显身手了吗?让我们把龙虾问数在你的本地跑起来! -### 1. 后端服务启动 🐍 +### 1. 配置环境变量 🔧 + +在项目根目录下,复制并重命名环境配置模板: + +```bash +cp .env.example .env +``` + +请记得编辑根目录下的 `.env` 文件,填入你的实际配置(如 QQ 邮箱 SMTP 授权码等)。 + +> **QQ 邮箱获取 SMTP 授权码最新教程:** +> 1. 登录 QQ 邮箱网页版 (mail.qq.com) +> 2. 点击页面顶部的“设置” -> “账号”选项卡 +> 3. 向下滚动找到“POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务” +> 4. 确保“POP3/SMTP服务”已点击“开启” +> 5. 点击下方的“生成授权码”,使用手机 QQ 扫码或按提示发送短信 +> 6. 验证通过后将获得一串 **16位随机字母组合**,将其复制填入 `.env` 文件中的 `SMTP_PASSWORD` 字段 + +### 2. 后端服务启动 🐍 请确保你已安装 Python 3.10 或以上版本。 diff --git a/README_en.md b/README_en.md index 049ac9b..e8eb4ed 100644 --- a/README_en.md +++ b/README_en.md @@ -57,7 +57,25 @@ DataClaw is divided into three main claws (components): Ready to dive in? Let's get DataClaw running on your local machine! -### 1. Backend Setup 🐍 +### 1. Configure Environment Variables 🔧 + +In the root directory of the project, copy and rename the environment template: + +```bash +cp .env.example .env +``` + +Please edit the `.env` file in the root directory and fill in your actual configurations (e.g., QQ Mail SMTP Auth Code). + +> **Guide to getting QQ Mail SMTP Auth Code:** +> 1. Log in to QQ Mail web version (mail.qq.com) +> 2. Click "Settings" (设置) at the top of the page -> "Account" (账号) tab +> 3. Scroll down to find the "POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV Service" section +> 4. Ensure "POP3/SMTP Service" is toggled to "On" (开启) +> 5. Click "Generate Authorization Code" (生成授权码) below it, scan the QR code with mobile QQ or send an SMS as prompted +> 6. After verification, you will get a **16-digit random letter combination**. Copy and paste it into the `SMTP_PASSWORD` field in your `.env` file + +### 2. Backend Setup 🐍 Ensure you have Python 3.10+ installed. diff --git a/backend/app/api/users.py b/backend/app/api/users.py index fa6d711..65f562e 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -1,19 +1,28 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session from typing import List +import secrets +import hashlib +from datetime import datetime, timedelta, timezone from app.database import get_db, engine, Base -from app.models.user import User -from app.schemas.user import UserCreate, UserUpdate, UserResponse +from app.models.user import User, EmailVerification +from app.schemas.user import UserCreate, UserUpdate, UserResponse, ResendVerificationRequest from app.core.security import get_password_hash, verify_password, create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES -from datetime import timedelta +from app.core.email import send_verification_email # Create tables Base.metadata.create_all(bind=engine) router = APIRouter() +def generate_verification_token() -> str: + return secrets.token_urlsafe(32) + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + @router.post("/auth/login") def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): user = db.query(User).filter(User.username == form_data.username).first() @@ -45,7 +54,7 @@ def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depend } @router.post("/auth/register", response_model=UserResponse) -def register_user(user: UserCreate, db: Session = Depends(get_db)): +def register_user(user: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)): db_user = db.query(User).filter(User.username == user.username).first() if db_user: raise HTTPException(status_code=400, detail="Username already registered") @@ -58,19 +67,92 @@ def register_user(user: UserCreate, db: Session = Depends(get_db)): # If this is the first user, make them an admin is_first_user = db.query(User).count() == 0 + is_admin = is_first_user or user.is_admin + is_active = True if is_first_user else False db_user = User( username=user.username, email=user.email, hashed_password=hashed_password, - is_active=True, - is_admin=is_first_user or user.is_admin + is_active=is_active, + is_admin=is_admin ) db.add(db_user) db.commit() db.refresh(db_user) + + if not is_active: + token = generate_verification_token() + hashed = hash_token(token) + expires_at = datetime.now(timezone.utc) + timedelta(hours=24) + verification = EmailVerification( + user_id=db_user.id, + token_hash=hashed, + expires_at=expires_at + ) + db.add(verification) + db.commit() + + # 将用户的 email 保存到局部变量中,防止在后台任务执行前 session 关闭导致延迟加载失败 + user_email = db_user.email + background_tasks.add_task(send_verification_email, user_email, token) + return db_user +@router.get("/auth/verify-email") +def verify_email(token: str, db: Session = Depends(get_db)): + hashed = hash_token(token) + verification = db.query(EmailVerification).filter( + EmailVerification.token_hash == hashed, + EmailVerification.is_used == False + ).first() + + if not verification: + raise HTTPException(status_code=400, detail="Invalid or used token") + + # Check if expired (make timezone-aware if naive) + expires_at = verification.expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + + if expires_at < datetime.now(timezone.utc): + raise HTTPException(status_code=400, detail="Token expired") + + user = db.query(User).filter(User.id == verification.user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user.is_active = True + verification.is_used = True + db.commit() + + return {"status": "success", "message": "Email verified successfully"} + +@router.post("/auth/resend-verification") +def resend_verification(request: ResendVerificationRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)): + user = db.query(User).filter(User.username == request.username).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if user.is_active: + raise HTTPException(status_code=400, detail="User already active") + + token = generate_verification_token() + hashed = hash_token(token) + expires_at = datetime.now(timezone.utc) + timedelta(hours=24) + verification = EmailVerification( + user_id=user.id, + token_hash=hashed, + expires_at=expires_at + ) + db.add(verification) + db.commit() + + # 提取 email,避免后台任务访问已断开的 db session + user_email = user.email + background_tasks.add_task(send_verification_email, user_email, token) + return {"status": "success", "message": "Verification email sent"} + @router.get("/users", response_model=List[UserResponse]) def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): users = db.query(User).offset(skip).limit(limit).all() diff --git a/backend/app/core/email.py b/backend/app/core/email.py new file mode 100644 index 0000000..eeb3903 --- /dev/null +++ b/backend/app/core/email.py @@ -0,0 +1,43 @@ +import smtplib +import os +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +def send_verification_email(to_email: str, token: str): + smtp_host = os.getenv("SMTP_HOST", "smtp.qq.com") + smtp_port = int(os.getenv("SMTP_PORT", "465")) + smtp_user = os.getenv("SMTP_USER", "") + smtp_password = os.getenv("SMTP_PASSWORD", "") + frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173") + + if not smtp_user or not smtp_password: + print("SMTP configuration is missing. Skip sending email.") + return + + msg = MIMEMultipart() + msg['From'] = smtp_user + msg['To'] = to_email + msg['Subject'] = "Please verify your email address" + + verify_link = f"{frontend_url}/verify-email?token={token}" + body = f""" + + +

Welcome to DataClaw!

+

Please click the link below to verify your email address and activate your account:

+

{verify_link}

+

If you did not request this, please ignore this email.

+ + + """ + msg.attach(MIMEText(body, 'html')) + + try: + # Use SMTP_SSL for port 465 + server = smtplib.SMTP_SSL(smtp_host, smtp_port) + server.login(smtp_user, smtp_password) + server.send_message(msg) + server.quit() + print(f"Verification email sent to {to_email}") + except Exception as e: + print(f"Failed to send email: {e}") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index ffd3dfd..be26bae 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.database import Base @@ -15,3 +15,16 @@ class User(Base): created_at = Column(DateTime(timezone=True), server_default=func.now()) projects = relationship("Project", back_populates="owner") + email_verifications = relationship("EmailVerification", back_populates="user", cascade="all, delete-orphan") + +class EmailVerification(Base): + __tablename__ = "email_verifications" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + token_hash = Column(String, index=True, nullable=False) + expires_at = Column(DateTime(timezone=True), nullable=False) + is_used = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + user = relationship("User", back_populates="email_verifications") diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 15587b8..2af1c30 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -18,6 +18,9 @@ class UserUpdate(BaseModel): is_admin: Optional[bool] = None password: Optional[str] = None +class ResendVerificationRequest(BaseModel): + username: str + class UserResponse(UserBase): id: int created_at: datetime diff --git a/backend/main.py b/backend/main.py index e958e1c..e75808f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,6 +4,11 @@ import binascii from typing import Any, Dict, List, Optional, Literal, Tuple import mimetypes from pathlib import Path +from dotenv import load_dotenv + +# 加载项目根目录下的 .env 文件 +env_path = Path(__file__).resolve().parent.parent / ".env" +load_dotenv(dotenv_path=env_path) from fastapi import FastAPI, HTTPException, Query from fastapi.encoders import jsonable_encoder @@ -35,7 +40,7 @@ from app.context import ( from app.services.knowledge_index import knowledge_index_service from app.database import engine, Base # Import all models to ensure they are registered -from app.models.user import User +from app.models.user import User, EmailVerification from app.models.project import Project from app.models.datasource import DataSource from app.models.subagent import Subagent diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5e7529e..1be3119 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "psycopg2-binary>=2.9.11", "pydantic>=2.12.0,<3.0.0", "pydantic-settings>=2.12.0,<3.0.0", + "python-dotenv>=1.0.1", "python-jose[cryptography]>=3.5.0", "python-multipart>=0.0.22", "python-socketio>=5.16.0,<6.0.0", diff --git a/backend/uv.lock b/backend/uv.lock index 7dcb723..f54b250 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -245,6 +245,7 @@ dependencies = [ { name = "psycopg2-binary" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "python-dotenv" }, { name = "python-jose", extra = ["cryptography"] }, { name = "python-multipart" }, { name = "python-socketio" }, @@ -292,6 +293,7 @@ requires-dist = [ { name = "psycopg2-binary", specifier = ">=2.9.11" }, { name = "pydantic", specifier = ">=2.12.0,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.12.0,<3.0.0" }, + { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" }, { name = "python-multipart", specifier = ">=0.0.22" }, { name = "python-socketio", specifier = ">=5.16.0,<6.0.0" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index efa5070..5506328 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { KnowledgeBases } from "./pages/KnowledgeBases"; import { DataSources } from "./pages/DataSources"; import { Modeling } from "./pages/Modeling"; import { Subagents } from "./pages/Subagents"; +import { VerifyEmail } from "./pages/VerifyEmail"; import { useAuthStore } from "./store/authStore"; import { ThemeToggle } from "./components/ThemeToggle"; @@ -62,6 +63,7 @@ function App() { } /> + } /> {/* Protected Routes */} state.login); @@ -28,9 +31,26 @@ export function Login() { password: "", }); + const handleResendVerification = async () => { + setIsResending(true); + setResendStatus(""); + try { + await api.post("/api/v1/auth/resend-verification", { + username: formData.username, + }); + setResendStatus(t("verificationSent")); + } catch (err: any) { + setResendStatus(err.message || t("errorOccurred")); + } finally { + setIsResending(false); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); + setIsInactiveError(false); + setResendStatus(""); setIsLoading(true); try { @@ -66,10 +86,17 @@ export function Login() { // Auto login after successful registration setIsLogin(true); - setError(t("registrationSuccess")); + // Assuming backend returns is_active=false for users requiring verification + // For now, we will show the verification prompt based on the translation + setError(t("registrationSuccessWithVerification")); } } catch (err: any) { - setError(err.message || t("errorOccurred")); + if (err.message && err.message.toLowerCase().includes("inactive user")) { + setError(t("inactiveUserError")); + setIsInactiveError(true); + } else { + setError(err.message || t("errorOccurred")); + } } finally { setIsLoading(false); } @@ -108,8 +135,27 @@ export function Login() { {error && ( -
- {error} +
+ {error} + {isInactiveError && ( + + )} +
+ )} + + {resendStatus && ( +
+ {resendStatus}
)} diff --git a/frontend/src/pages/VerifyEmail.tsx b/frontend/src/pages/VerifyEmail.tsx new file mode 100644 index 0000000..54fc7d9 --- /dev/null +++ b/frontend/src/pages/VerifyEmail.tsx @@ -0,0 +1,69 @@ +import { useEffect, useState, useRef } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Loader2, CheckCircle2, XCircle } from "lucide-react"; +import { api } from "@/lib/api"; + +export function VerifyEmail() { + const { t } = useTranslation(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const [status, setStatus] = useState<"loading" | "success" | "error">("loading"); + const [errorMessage, setErrorMessage] = useState(""); + const hasAttempted = useRef(false); + + useEffect(() => { + const token = searchParams.get("token"); + if (!token) { + setStatus("error"); + setErrorMessage(t("verifyEmailFailed")); + return; + } + + if (hasAttempted.current) return; + hasAttempted.current = true; + + const verifyToken = async () => { + try { + await api.get(`/api/v1/auth/verify-email?token=${encodeURIComponent(token)}`); + setStatus("success"); + } catch (err: any) { + setStatus("error"); + setErrorMessage(err.message || t("verifyEmailFailed")); + } + }; + + verifyToken(); + }, [searchParams, t]); + + return ( +
+
+
+ {status === "loading" && } + {status === "success" && } + {status === "error" && } +
+ +

+ {t("verifyEmailTitle")} +

+ +
+ {status === "loading" &&

{t("verifyingEmail")}

} + {status === "success" &&

{t("verifyEmailSuccess")}

} + {status === "error" &&

{errorMessage}

} +
+ + +
+
+ ); +}