用户注册与登录
要点
- 注册流程的核心是输入校验、密码加密、唯一性检查
- 登录流程的核心是凭据验证、Token 签发、会话管理
- 输入校验应该在进入业务逻辑之前完成,zod 是常用选择
- 错误信息不能泄露系统内部状态(例如「邮箱已注册」vs「注册失败」)
- 注册成功后通常需要邮箱验证,防止垃圾注册
- AI 项目的注册可能需要关联租户、初始化配额、分配默认模型
1. 注册流程
1.1 Schema 定义
// Drizzle
import { pgTable, uuid, text, timestamp, boolean } from 'drizzle-orm/pg-core'
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),
name: text('name').notNull(),
emailVerified: boolean('email_verified').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})1.2 输入校验
使用 zod 校验输入:
import { z } from 'zod'
const registerSchema = z.object({
email: z.string().email('Invalid email format'),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: z.string().min(1, 'Name is required').max(100),
})
type RegisterInput = z.infer<typeof registerSchema>1.3 注册接口
import { Hono } from 'hono'
import { HTTPException } from 'hono/http-exception'
import { hashPassword } from './lib/password'
import { db } from './lib/db'
import { users } from './schema'
import { eq } from 'drizzle-orm'
const app = new Hono()
app.post('/auth/register', async (c) => {
const body = await c.req.json()
// 1. 输入校验
const result = registerSchema.safeParse(body)
if (!result.success) {
return c.json({
error: 'Validation failed',
details: result.error.flatten(),
}, 400)
}
const { email, password, name } = result.data
// 2. 检查邮箱是否已注册
const existing = await db.query.users.findFirst({
where: eq(users.email, email),
})
if (existing) {
// 不泄露「邮箱已注册」的信息
throw new HTTPException(400, { message: 'Registration failed' })
}
// 3. 密码加密
const passwordHash = await hashPassword(password)
// 4. 创建用户
const [user] = await db.insert(users).values({
email,
passwordHash,
name,
}).returning({
id: users.id,
email: users.email,
name: users.name,
})
// 5. 发送验证邮件(异步)
sendVerificationEmail(user.email).catch(console.error)
// 6. 返回结果
return c.json({
user,
message: 'Registration successful. Please check your email to verify your account.',
}, 201)
})1.4 密码加密
使用 bcrypt 或 argon2 加密密码:
import { hash, compare } from 'bcrypt'
const SALT_ROUNDS = 12
export async function hashPassword(password: string): Promise<string> {
return hash(password, SALT_ROUNDS)
}
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return compare(password, hash)
}argon2 比 bcrypt 更安全,但 bcrypt 更成熟:
import { argon2id } from '@noble/hashes/argon2'
import { randomBytes } from '@noble/hashes/utils'
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16)
const hash = argon2id(password, salt)
return `${salt.toString('hex')}:${hash.toString('hex')}`
}2. 登录流程
2.1 登录接口
import { sign } from 'hono/jwt'
const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
})
app.post('/auth/login', async (c) => {
const body = await c.req.json()
// 1. 输入校验
const result = loginSchema.safeParse(body)
if (!result.success) {
return c.json({ error: 'Invalid credentials' }, 401)
}
const { email, password } = result.data
// 2. 查找用户
const user = await db.query.users.findFirst({
where: eq(users.email, email),
})
if (!user) {
// 不泄露「用户不存在」的信息
return c.json({ error: 'Invalid credentials' }, 401)
}
// 3. 验证密码
const valid = await verifyPassword(password, user.passwordHash)
if (!valid) {
return c.json({ error: 'Invalid credentials' }, 401)
}
// 4. 检查邮箱是否验证
if (!user.emailVerified) {
return c.json({ error: 'Please verify your email first' }, 403)
}
// 5. 签发 JWT
const token = await sign(
{
userId: user.id,
email: user.email,
role: 'user',
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, // 7 天
},
process.env.JWT_SECRET!
)
// 6. 返回结果
return c.json({
token,
user: {
id: user.id,
email: user.email,
name: user.name,
},
})
})2.2 错误信息处理
登录失败时不要泄露具体原因:
// ❌ 泄露信息
if (!user) return c.json({ error: 'User not found' }, 404)
if (!valid) return c.json({ error: 'Wrong password' }, 401)
// ✅ 统一错误
return c.json({ error: 'Invalid credentials' }, 401)攻击者可以利用「用户不存在」和「密码错误」的区别来枚举邮箱。
2.3 Token 过期时间
Token 过期时间需要权衡:
- 短过期时间:安全,但用户频繁重新登录
- 长过期时间:方便,但 Token 泄露风险大
常见策略:
- Access Token:15 分钟到 1 小时
- Refresh Token:7 天到 30 天
- 记住我:30 天到 1 年
// Access Token(短期)
const accessToken = await sign(
{ userId: user.id, type: 'access' },
process.env.JWT_SECRET!,
'1h'
)
// Refresh Token(长期)
const refreshToken = await sign(
{ userId: user.id, type: 'refresh' },
process.env.JWT_SECRET!,
'7d'
)3. 邮箱验证
3.1 验证 Token
// 生成验证 Token
const verifyToken = randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 小时
await db.insert(emailVerificationTokens).values({
userId: user.id,
token: verifyToken,
expiresAt,
})
// 发送验证邮件
const verifyUrl = `${process.env.FRONTEND_URL}/verify-email?token=${verifyToken}`
await sendEmail({
to: user.email,
subject: 'Verify your email',
html: `<a href="${verifyUrl}">Click here to verify</a>`,
})3.2 验证接口
app.get('/auth/verify-email', async (c) => {
const token = c.req.query('token')
if (!token) {
return c.json({ error: 'Token is required' }, 400)
}
// 查找 Token
const record = await db.query.emailVerificationTokens.findFirst({
where: eq(emailVerificationTokens.token, token),
})
if (!record) {
return c.json({ error: 'Invalid token' }, 400)
}
// 检查是否过期
if (record.expiresAt < new Date()) {
return c.json({ error: 'Token has expired' }, 400)
}
// 标记邮箱已验证
await db
.update(users)
.set({ emailVerified: true })
.where(eq(users.id, record.userId))
// 删除 Token
await db
.delete(emailVerificationTokens)
.where(eq(emailVerificationTokens.id, record.id))
return c.json({ message: 'Email verified successfully' })
})4. AI 项目的特殊处理
4.1 初始化租户
AI SaaS 项目通常是多租户架构,注册时需要创建租户:
app.post('/auth/register', async (c) => {
// ... 输入校验 ...
// 在事务中创建用户和租户
const result = await db.transaction(async (tx) => {
// 创建用户
const [user] = await tx.insert(users).values({
email,
passwordHash,
name,
}).returning()
// 创建租户
const [tenant] = await tx.insert(tenants).values({
name: `${name}'s workspace`,
ownerId: user.id,
}).returning()
// 关联用户和租户
await tx.insert(userTenants).values({
userId: user.id,
tenantId: tenant.id,
role: 'owner',
})
return { user, tenant }
})
// ... 返回结果 ...
})4.2 初始化配额
注册时给用户分配初始配额:
// 创建配额
await tx.insert(quotas).values({
userId: user.id,
model: 'gpt-4',
maxCount: 100, // 每月 100 次
maxTokens: 1000000, // 每月 100 万 token
period: 'monthly',
})4.3 分配默认模型
根据注册计划分配默认模型:
// 免费用户
await tx.insert(userModels).values({
userId: user.id,
model: 'gpt-3.5-turbo',
isDefault: true,
})
// 付费用户
await tx.insert(userModels).values([
{ userId: user.id, model: 'gpt-4', isDefault: true },
{ userId: user.id, model: 'claude-3-opus', isDefault: false },
])5. 安全考虑
5.1 速率限制
注册和登录接口需要速率限制,防止暴力破解:
import { rateLimit } from 'hono-rate-limiter'
app.post('/auth/login',
rateLimit({
windowMs: 15 * 60 * 1000, // 15 分钟
limit: 5, // 每个 IP 最多 5 次
message: 'Too many login attempts',
}),
async (c) => {
// ...
}
)5.2 CSRF 保护
如果使用 Cookie 认证,需要 CSRF Token:
import { csrf } from 'hono/csrf'
app.use('*', csrf({
secret: process.env.CSRF_SECRET!,
}))5.3 密码强度
强制密码强度要求:
const passwordSchema = z.string()
.min(8, 'At least 8 characters')
.regex(/[A-Z]/, 'Must contain uppercase letter')
.regex(/[a-z]/, 'Must contain lowercase letter')
.regex(/[0-9]/, 'Must contain number')
.regex(/[^A-Za-z0-9]/, 'Must contain special character')总结
注册和登录是用户系统的入口,核心是输入校验、密码加密、错误处理。
这一节涉及到的几个实践:
- 注册流程:输入校验、唯一性检查、密码加密、邮箱验证
- 登录流程:凭据验证、Token 签发、会话管理
- 错误处理:不泄露系统内部状态
- 邮箱验证:防止垃圾注册
- Token 过期:Access Token 短期,Refresh Token 长期
- AI 项目:初始化租户、配额、默认模型
- 安全:速率限制、CSRF 保护、密码强度
注册和登录的安全性直接影响整个系统的安全。输入校验要严格,错误信息要模糊,密码加密要用 bcrypt 或 argon2。
下一篇看密码加密与安全存储——bcrypt、argon2、密码策略、泄露检测。