这篇博客介绍了Easy Dataset项目的架构,并指导你如何添加登录验证页面。
📋 项目架构分析
Easy Dataset是一个基于Next.js的全栈应用,使用:
- 前端: Next.js 14 + React 18 + Material-UI
- 后端: Next.js API Routes + Prisma ORM
- 数据库: SQLite (Prisma)
- 状态管理: Jotai
- 主题: next-themes
当前项目没有身份验证系统,所有页面都是公开访问的,所以本博客将介绍如何添加一个简单的登录验证系统,保护敏感页面,并提供用户管理功能。
🎯 登录验证实现方案
1. 拉取仓库
git clone https://github.com/ConardLi/easy-dataset.git
cd easy-dataset
## 这个是本地构建的时候的命令行
npm install
2. 需要修改的核心文件
后端部分(API Routes):
- 创建认证API:
app/api/auth/login/route.js- 登录验证app/api/auth/logout/route.js- 登出app/api/auth/me/route.js- 获取当前用户信息app/api/auth/register/route.js- 用户注册(可选)
- 数据库模型:
prisma/schema.prisma- 添加User模型lib/db/users.js- 用户数据库操作
- 中间件:
middleware.js- 路由保护中间件
前端部分:
- 登录页面:
app/login/page.js- 登录页面组件
- 上下文和状态:
contexts/AuthContext.js- 认证状态管理hooks/useAuth.js- 认证相关hook
- 现有组件修改:
app/layout.js- 添加认证providerapp/page.js- 添加登录检查components/Navbar/index.js- 添加登录/登出按钮
3. 详细实现步骤
步骤1: 修改数据库模型
在prisma/schema.prisma中添加用户模型:
model User {
id String @id @default(cuid())
email String @unique
password String
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
步骤2: 创建认证API
在app/api/auth/login/route.js:
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { getUserByEmail } from "@/lib/db/users";
export async function POST(request) {
try {
const { email, password } = await request.json();
// 验证用户
const user = await getUserByEmail(email);
if (!user || !bcrypt.compareSync(password, user.password)) {
return Response.json({ error: "Invalid credentials" }, { status: 401 });
}
// 生成JWT token
const token = jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: "7d" },
);
return Response.json({
token,
user: { id: user.id, email: user.email, name: user.name },
});
} catch (error) {
return Response.json({ error: "Login failed" }, { status: 500 });
}
}
步骤3: 创建登录页面
在app/login/page.js:
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import {
Container,
TextField,
Button,
Typography,
Box,
Paper,
} from "@mui/material";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const { login } = useAuth();
const router = useRouter();
const handleSubmit = async (e) => {
e.preventDefault();
try {
await login(email, password);
router.push("/");
} catch (error) {
alert("登录失败");
}
};
return (
<Container maxWidth="sm">
<Box
sx=
>
<Paper elevation={3} sx=>
<Typography component="h1" variant="h5" align="center">
登录
</Typography>
<Box component="form" onSubmit={handleSubmit} sx=>
<TextField
margin="normal"
required
fullWidth
id="email"
label="邮箱"
name="email"
autoComplete="email"
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<TextField
margin="normal"
required
fullWidth
name="password"
label="密码"
type="password"
id="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx=
>
登录
</Button>
</Box>
</Paper>
</Box>
</Container>
);
}
步骤4: 创建认证上下文
在contexts/AuthContext.js:
"use client";
import { createContext, useState, useContext, useEffect } from "react";
import { useRouter } from "next/navigation";
const AuthContext = createContext();
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const router = useRouter();
useEffect(() => {
// 检查本地存储的token
const token = localStorage.getItem("token");
if (token) {
// 验证token有效性
fetchUser(token);
} else {
setLoading(false);
}
}, []);
const fetchUser = async (token) => {
try {
const response = await fetch("/api/auth/me", {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const userData = await response.json();
setUser(userData);
} else {
localStorage.removeItem("token");
}
} catch (error) {
localStorage.removeItem("token");
} finally {
setLoading(false);
}
};
const login = async (email, password) => {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem("token", data.token);
setUser(data.user);
return data;
} else {
throw new Error("Login failed");
}
};
const logout = () => {
localStorage.removeItem("token");
setUser(null);
router.push("/login");
};
return (
<AuthContext.Provider value=>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
};
步骤5: 修改主布局
更新app/layout.js:
import { AuthProvider } from "@/contexts/AuthContext";
export default function RootLayout({ children }) {
return (
<html lang="zh" suppressHydrationWarning>
<body suppressHydrationWarning>
<AuthProvider>
<Provider>
<ThemeRegistry>
<I18nProvider>
{children}
<Toaster richColors position="top-right" duration={1000} />
</I18nProvider>
</ThemeRegistry>
</Provider>
</AuthProvider>
</body>
</html>
);
}
步骤6: 添加路由保护
创建middleware.js:
import { NextResponse } from "next/server";
import { jwtVerify } from "jose";
export async function middleware(request) {
const token = request.cookies.get("token")?.value;
const isAuthPage = request.nextUrl.pathname.startsWith("/login");
if (!token && !isAuthPage) {
return NextResponse.redirect(new URL("/login", request.url));
}
if (token && isAuthPage) {
return NextResponse.redirect(new URL("/", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
4. 本地构建和部署流程
安装依赖
npm install bcryptjs jsonwebtoken jose
环境变量配置
创建.env.local文件:
JWT_SECRET=your-secret-key-here
数据库管理
# 由于本项目添加了一个登陆账号验证的功能,新创建了一张表来做管理员账户
# 1. 把你的数据库结构 → 同步到真实数据库
npm run db:push
# 2. 启动一个网页版数据库可视化管理工具
npm rum db:studio
开发模式运行
npm run dev
生产构建
npm run build
npm run start
# 访问 http://localhost:1717
Docker部署
# 构建镜像
docker build -t easy-dataset-with-auth .
docker build -t easy-dataset-v1 .
docker build -t easy-dataset-v2 .
1. 镜像源.npmrc文件优化
# 下载慢,镜像源更改,配置.npmrc文件
registry=https://registry.npmjs.org # 比https://registry.npmmirror.com要好用
strict-ssl=false
fetch-retry-mintimeout=20000
fetch-retry-maxtimeout=120000
fetch-timeout=300000
electron_mirror=https://npmmirror.com/mirrors/electron/
canvas_binary_host_mirror=https://npmmirror.com/mirrors/node-canvas-prebuilt/
prisma_engines_mirror=https://registry.npmmirror.com/-/binary/prisma
2. Dockerfile优化
# 创建包含pnpm的基础镜像
# http://r.cnpmjs.org/
FROM node:20-alpine AS pnpm-base
# 1. 修改 Alpine 软件源为阿里云镜像 (加速 apk add)
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# 2. 设置 pnpm 镜像源并安装
RUN npm config set registry https://registry.npmjs.org && \
npm install -g pnpm@9
# 构建阶段
FROM pnpm-base AS builder
WORKDIR /app
# 添加构建参数,用于识别目标平台
ARG TARGETPLATFORM
# 安装构建依赖 (此时已使用国内 apk 镜像)
RUN apk add --no-cache --virtual .build-deps \
python3 \
make \
g++ \
cairo-dev \
pango-dev \
jpeg-dev \
giflib-dev \
librsvg-dev \
build-base \
pixman-dev \
pkgconfig
# 复制依赖文件
COPY package.json pnpm-lock.yaml .npmrc ./
# 核心优化:
# 1. 设置 Electron 镜像 (加速 electron 下载)
# 2. 设置 Canvas 镜像 (加速 canvas 下载,避免本地编译)
# 3. 设置 Prisma 镜像 (加速 prisma 引擎下载)
RUN pnpm config set registry https://registry.npmjs.org && \
pnpm config set fetch-retries 5 && \
pnpm config set fetch-retry-maxtimeout 600000 && \
export ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron/" && \
export CANVAS_BINARY_HOST_MIRROR="https://npmmirror.com/mirrors/node-canvas-prebuilt/" && \
export PRISMA_ENGINES_MIRROR="https://registry.npmmirror.com/-/binary/prisma" && \
pnpm install --no-frozen-lockfile
# 复制源代码
COPY . .
# 根据目标平台设置Prisma二进制目标并构建应用
# 增加 PRISMA_ENGINES_MIRROR 确保 build 过程中的下载也走国内
RUN export PRISMA_ENGINES_MIRROR=https://registry.npmmirror.com/-/binary/prisma && \
if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
echo "Configuring for ARM64 platform"; \
sed -i 's/binaryTargets = \[.*\]/binaryTargets = \["linux-musl-arm64-openssl-3.0.x"\]/' prisma/schema.prisma; \
PRISMA_CLI_BINARY_TARGETS="linux-musl-arm64-openssl-3.0.x" pnpm build; \
else \
echo "Configuring for AMD64 platform (default)"; \
sed -i 's/binaryTargets = \[.*\]/binaryTargets = \["linux-musl-openssl-3.0.x"\]/' prisma/schema.prisma; \
PRISMA_CLI_BINARY_TARGETS="linux-musl-openssl-3.0.x" pnpm build; \
fi
# 保留Prisma CLI用于数据库管理
# RUN pnpm prune --prod
# 运行阶段
FROM pnpm-base AS runner
WORKDIR /app
# 只安装运行时依赖 (同样受惠于第一步的 apk 加速)
RUN apk add --no-cache \
cairo \
pango \
jpeg \
giflib \
librsvg \
pixman
# 复制文件... (保持不变)
COPY package.json .env ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/electron ./electron
COPY --from=builder /app/prisma /app/prisma-template
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENV NODE_ENV=production
EXPOSE 1717
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["pnpm", "start"]
3. 运行容器
# 运行容器
docker run -d \
-p port:port \
-v ./local-db:/app/local-db \
-v ./prisma:/app/prisma \
-e JWT_SECRET=your-secret-key-here \
--name easy-dataset \
easy-dataset
#还需要设置关机自启动
docker run -d \
-p port:port \
-v ./local-db:/app/local-db \
-v ./prisma:/app/prisma \
-e JWT_SECRET=your-secret-key-here \
--name easy-dataset \
--restart unless-stopped \
easy-dataset
--restart参数的说明
| 参数值 | 说明 |
|---|---|
| no | 默认值。容器退出或系统重启后不会自动重启。 |
| always | 只要 Docker 服务在运行,容器就会自动重启。即使你手动停止了它,重启电脑后它仍会尝试启动。 |
| unless-stopped | 最推荐。 重启电脑后会自动启动,但前提是你在关机前没有手动停止(docker stop)这个容器。 |
| on-failure | 只有当容器非正常退出(退出状态码非 0)时才会重启。 |
4. 容器报错管理
# 查看日志
docker logs easy-dataset
# 查看正在运行的容器日志(实时滚动)
docker logs -f easy-dataset
# 查看最近 100 行日志(最常用)
docker logs --tail 100 easy-dataset
# 把日志保存到本地文件里
docker logs easy-dataset > easy-dataset-logs.txt
# 进入容器
docker exec -it easy-dataset sh
4. 启动容器内数据库管理功能
由于easydataset内置有一个数据库可视化管理工具,运行以下命令后,在浏览器访问 http://localhost:5555/studio 就可以看到数据库管理界面了,是用来管理用户数据的。
# 1. 进入容器
easy-dataset $ docker exec -it easy-dataset sh
/app $ pnpm prisma studio
ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL Command "prisma" not found
# 2. 执行上述命令行发现docker的image里未安装prisma工具
# 由于安装prisma需要先安装openssl,故先配置环境,由于本环境是Alpine 精简系统,缺库,直接装上就好:
apk update # 可以不执行
apk add --no-cache openssl libc6-compat # 一定要加上--no-cache
# 3. 再次执行命令行,成功启动数据库可视化工具
pnpm dlx prisma@5.20.0 studio --schema /app/prisma/schema.prisma
# http://localhost:5555
# 4. 若需要配置镜像
pnpm config set registry http://r.cnpmjs.org/
5. 常见登录问题
5.1 公网访问问题
如果是公网访问问题,可以在.env文件中进行配置
ALLOWED_ORIGINS=http://localhost:port,http://your.domain.name:port
点击浏览器的network 状态,查看输入账号密码后的状态

如果\login状态为 200, 但是无法进行跳转,则有可能是因为 CORS 跨域问题导致。
进一步解释: login 请求的状态码已经是 200 OK 了!这说明后端已经验证通过并返回了成功信息。之所以没有跳转,是因为在跨域或穿透环境下,浏览器没有成功处理后端返回的 Set-Cookie(登录凭证),导致前端认为你还是未登录状态,从而卡在原地
ps: 解释CORS(跨域资源共享): 安全机制导致的访问失败

🔍 核心原因排查
-
协议不匹配:你使用的是
http://domain.cn(非加密)。现代浏览器对于非https的网站,在处理跨域Cookie`转发时有非常严格的限制。 -
Cookie写入失败:由于你是通过frp穿透访问,请求头里的Host是domain.cn:port。如果后端代码中设置Cookie时指定了Domain=localhost或者开启了Secure属性(要求必须https),浏览器会直接丢弃这个Cookie。
🔍 处理方法
方案一:修改浏览器的安全策略(最快验证法)
如果只是你自己使用,可以让 Chrome 暂时对你的域名放开 Secure 限制。
-
在 Chrome 地址栏输入并打开:
chrome://flags/#unsafely-treat-insecure-origin-as-secure -
在输入框中填写你的域名和端口,例如:
http://domain.cn:port -
把右侧的状态从
Disabled改为Enabled。 -
点击右下角的
Relaunch重启浏览器。 -
验证:重启后再次登录。此时浏览器会把你的 HTTP 域名当成“安全环境”,即便有
Secure标志,它也会强行存入 Cookie 并允许跳转。
方案二:给自己的域名配置 ssl 服务
详细我回再出一篇博客讲解,简单来说就是给自己的域名申请一个免费的 SSL 证书(比如通过 Let’s Encrypt),然后在你的服务器上配置 HTTPS 服务。这样浏览器就会认为你的域名是安全的,自然就不会丢弃 Cookie 了。
5.2 数据库权限问题
如果是数据库无法读写的问题,需要配置读取权限:Error code 14: Unable to open the database file 是 SQLite的标准错误,意味着 Prisma 找到了路径,但是 没有权限读写文件 或者 该路径指向了一个不存在的目录。
这通常是因为 `Docker 挂载(Volume)后的文件权限变成了 root,而容器内的服务(以其他用户运行)无法操作它。
# 给数据库目录和文件读写权限
chmod -R 777 $(pwd)/prisma-data
chmod -R 777 $(pwd)/local-db
🔧 额外建议
- 用户注册: 可以添加注册页面和API
- 密码重置: 实现忘记密码功能
- 用户管理: 管理员可以管理用户
- 权限控制: 不同用户访问不同项目
- 会话管理: 实现refresh token机制
- 安全加固: 添加CSRF保护、输入验证等
这个方案提供了完整的身份验证系统,你可以根据具体需求进行调整和扩展。
