feat: add email verification

This commit is contained in:
qixinbo
2026-03-29 17:12:46 +08:00
parent 320817e6db
commit 551898c19b
16 changed files with 344 additions and 15 deletions
+8
View File
@@ -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
+1
View File
@@ -1,3 +1,4 @@
.env
.vscode
nanobot-0.1.4.post4
data
+19 -1
View File
@@ -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 或以上版本。
+19 -1
View File
@@ -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.
+89 -7
View File
@@ -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()
+43
View File
@@ -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"""
<html>
<body>
<h2>Welcome to DataClaw!</h2>
<p>Please click the link below to verify your email address and activate your account:</p>
<p><a href="{verify_link}">{verify_link}</a></p>
<p>If you did not request this, please ignore this email.</p>
</body>
</html>
"""
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}")
+14 -1
View File
@@ -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")
+3
View File
@@ -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
+6 -1
View File
@@ -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
+1
View File
@@ -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",
+2
View File
@@ -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" },
+2
View File
@@ -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() {
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/verify-email" element={<VerifyEmail />} />
{/* Protected Routes */}
<Route path="/" element={
+9
View File
@@ -338,6 +338,15 @@
"dontHaveAccount": "Don't have an account?",
"alreadyHaveAccount": "Already have an account?",
"registrationSuccess": "Registration successful! Please login.",
"registrationSuccessWithVerification": "Registration successful! Please check your email to verify your account.",
"inactiveUserError": "Account is inactive. Please verify your email.",
"resendVerification": "Resend Verification Email",
"verificationSent": "Verification email sent. Please check your inbox.",
"verifyEmailTitle": "Email Verification",
"verifyingEmail": "Verifying your email...",
"verifyEmailSuccess": "Email verified successfully! You can now log in.",
"verifyEmailFailed": "Email verification failed or link expired.",
"goToLogin": "Go to Login",
"errorOccurred": "An error occurred",
"mcpConfig": "MCP Configuration",
"mcp": "MCP",
+9
View File
@@ -338,6 +338,15 @@
"dontHaveAccount": "还没有账号?",
"alreadyHaveAccount": "已经有账号了?",
"registrationSuccess": "注册成功!请登录。",
"registrationSuccessWithVerification": "注册成功!请前往邮箱点击验证链接激活账号。",
"inactiveUserError": "账号未激活,请前往邮箱激活。",
"resendVerification": "重新发送验证邮件",
"verificationSent": "验证邮件已发送,请查收。",
"verifyEmailTitle": "邮箱验证",
"verifyingEmail": "正在验证您的邮箱...",
"verifyEmailSuccess": "邮箱验证成功!您现在可以登录了。",
"verifyEmailFailed": "邮箱验证失败或链接已过期。",
"goToLogin": "前往登录",
"errorOccurred": "发生了一个错误",
"mcpConfig": "MCP 配置",
"mcp": "MCP",
+50 -4
View File
@@ -19,6 +19,9 @@ export function Login() {
const [isLogin, setIsLogin] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [isInactiveError, setIsInactiveError] = useState(false);
const [resendStatus, setResendStatus] = useState("");
const [isResending, setIsResending] = useState(false);
const navigate = useNavigate();
const login = useAuthStore((state) => 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() {
</h2>
{error && (
<div className={`p-3 rounded-lg mb-6 text-sm ${error.includes(t("registrationSuccess")) ? "bg-emerald-50 text-emerald-600" : "bg-red-50 text-red-600"}`}>
{error}
<div className={`p-3 rounded-lg mb-6 text-sm flex flex-col gap-2 ${error.includes(t("registrationSuccessWithVerification")) ? "bg-emerald-50 text-emerald-600" : "bg-red-50 text-red-600"}`}>
<span>{error}</span>
{isInactiveError && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleResendVerification}
disabled={isResending}
className="w-fit mt-1 border-red-200 text-red-700 hover:bg-red-100"
>
{isResending ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
{t("resendVerification")}
</Button>
)}
</div>
)}
{resendStatus && (
<div className={`p-3 rounded-lg mb-6 text-sm ${resendStatus.includes(t("verificationSent")) ? "bg-emerald-50 text-emerald-600" : "bg-red-50 text-red-600"}`}>
{resendStatus}
</div>
)}
+69
View File
@@ -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 (
<div className="min-h-screen flex flex-col items-center justify-center bg-muted/50 px-4">
<div className="w-full max-w-md bg-background rounded-2xl shadow-xl border border-border p-8 text-center">
<div className="mb-6 flex justify-center">
{status === "loading" && <Loader2 className="h-16 w-16 text-indigo-600 animate-spin" />}
{status === "success" && <CheckCircle2 className="h-16 w-16 text-emerald-500" />}
{status === "error" && <XCircle className="h-16 w-16 text-red-500" />}
</div>
<h2 className="text-2xl font-bold text-foreground/90 mb-4">
{t("verifyEmailTitle")}
</h2>
<div className="mb-8 text-muted-foreground">
{status === "loading" && <p>{t("verifyingEmail")}</p>}
{status === "success" && <p className="text-emerald-600 font-medium">{t("verifyEmailSuccess")}</p>}
{status === "error" && <p className="text-red-600 font-medium">{errorMessage}</p>}
</div>
<Button
onClick={() => navigate("/login")}
className="w-full h-11 bg-indigo-600 hover:bg-indigo-700 text-primary-foreground font-medium text-base rounded-xl transition-all shadow-md"
>
{t("goToLogin")}
</Button>
</div>
</div>
);
}