给AI应用加上用户系统:JWT + 邮箱验证从零实现(附可直接运行的代码)
为什么AI应用不能跳过头等大事
很多人做一个AI产品,第一版就是:前端一个输入框,后端一个调用大模型的接口,完事。用户进来,API key焊死在代码里,谁都能调。等到有人真用了,才发现连”这是谁在调用”都分不清。
用户系统不是可有可无的加分项。你要限流、要计费、要区分免费和付费用户、要做使用量统计,前提都是”知道每个请求是谁发的”。没有身份层,这些全做不了。而且现在主流大模型API按量计费,一个没有认证的接口,等于把钱包敞开给全网。
这篇文章给你一套能跑的方案:JWT做令牌,邮箱验证码做注册,不需要手机号,不需要第三方登录SDK,一个数据库两张表,Node.js版,10分钟内能落地。
你需要准备什么
- Node.js 18+
- 一个PostgreSQL数据库(本地或Supabase/Neon免费档都够用)
- 一个能发邮件的服务(Resend、Postmark,或者先用控制台打印验证码本地测试)
- npm装三个包:
express、jsonwebtoken、bcryptjs,再加一个Postgres客户端
这套思路跟语言无关。用Python的FastAPI、Go的Gin,原理一模一样,只是库名换一下。
数据库表设计:两张表解决问题
先建两张表。第一张存用户,第二张存邮箱验证码。分开存的原因是验证码有生命周期,到期要清掉,混在用户表里会很乱。
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE email_verifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
code TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used BOOLEAN DEFAULT false
);
关键点:邮箱存成小写,密码只存哈希(bcrypt),验证码一定要有过期时间。这三个是新手最容易踩的坑。
注册流程:先验证码再建账号
标准流程是:用户填邮箱和密码 → 后端发一封带验证码的邮件 → 用户填验证码 → 验证通过才创建账户。这样能挡住大量机器人注册,也能确认邮箱是真实存在的。
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET; // 务必用环境变量,别硬编码
// 1. 请求验证码
app.post('/api/auth/request-code', async (req, res) => {
const email = req.body.email.toLowerCase();
const code = crypto.randomInt(100000, 999999).toString();
await db.query(
`INSERT INTO email_verifications (email, code, expires_at)
VALUES ($1, $2, now() + interval '10 minutes')`,
[email, code]
);
await sendEmail(email, `你的验证码是 ${code},10分钟内有效`);
res.json({ ok: true });
});
// 2. 验证码 + 密码 → 创建用户
app.post('/api/auth/register', async (req, res) => {
const { email, code, password } = req.body;
const lower = email.toLowerCase();
const row = await db.query(
`SELECT code FROM email_verifications
WHERE email = $1 AND used = false AND expires_at > now()
ORDER BY expires_at DESC LIMIT 1`, [lower]
);
if (!row.rows.length || row.rows[0].code !== code) {
return res.status(400).json({ error: '验证码无效或已过期' });
}
const hash = await bcrypt.hash(password, 10);
const user = await db.query(
`INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id`,
[lower, hash]
);
await db.query(`UPDATE email_verifications SET used = true WHERE email = $1`, [lower]);
const token = jwt.sign({ sub: user.rows[0].id }, JWT_SECRET, { expiresIn: '7d' });
res.json({ token });
});
验证码设置10分钟有效期是我从多个邮件服务商的实践里看到的主流做法。太短用户来不及看邮箱,太长安全性下降。10分钟是个比较好的平衡点。
登录与鉴权中间件
注册完登录就简单了,比对密码哈希,签发JWT。关键是后面这个中间件,它负责解析令牌、把用户身份挂到请求上,这就是”每个请求是谁发的”的答案。
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
const lower = email.toLowerCase();
const user = await db.query(
`SELECT id, password_hash FROM users WHERE email = $1`, [lower]
);
if (!user.rows.length ||
!await bcrypt.compare(password, user.rows[0].password_hash)) {
return res.status(401).json({ error: '邮箱或密码错误' });
}
const token = jwt.sign({ sub: user.rows[0].id }, JWT_SECRET, { expiresIn: '7d' });
res.json({ token });
});
// 鉴权中间件:所有需要登录的接口套上它
function requireAuth(req, res, next) {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: '未登录' });
try {
const payload = jwt.verify(token, JWT_SECRET);
req.userId = payload.sub;
next();
} catch {
return res.status(401).json({ error: '令牌无效或已过期' });
}
}
// 示例:限流的调用接口
app.post('/api/chat/completions', requireAuth, async (req, res) => {
// req.userId 就是当前用户,这里可以做限流/计费/统计
// ... 调用大模型
});
前端拿到token后,每次请求在请求头里带 Authorization: Bearer 就行。用axios的话一行:
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
三个必须现在就想清楚的事
1. token存哪里。 存localStorage最简单,但有XSS风险。存HttpOnly Cookie更安全,但要处理CSRF。个人项目可以先localStorage跑起来,等有真实用户量了再升级。我的建议是别纠结,先能跑,安全层后面补。
2. 密码强度。 bcrypt的cost参数默认10,够用。别自己写加密算法,用成熟的库。这个坑踩过的人多了去了。
3. 限流。 有了用户系统,下一步就是给每个用户加使用限制。免费用户每天调多少次、每分钟几次,这个表结构要提前留好字段(比如在users表加 tokens_used、plan 列),不然后面加会很痛苦。
收尾
到这里你已经有了一套能用的用户系统:注册带着邮箱验证码,登录签发JWT,中间件保护所有需要身份的接口。这套东西不依赖任何第三方登录SDK,代码全在你手里,想怎么改都行。
下一步的自然延伸是费率计划和用量统计,那属于商业化的范畴了。先把身份这层地基打好,后面的楼才能盖得稳。
如果你卡在某个环节,多半是数据库连不上或者环境变量没配——这两类是独立开发者做认证最常遇到的两个问题,排查顺序从后往前看。
