用户注册与登录

要点

  • 注册流程的核心是输入校验、密码加密、唯一性检查
  • 登录流程的核心是凭据验证、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')

总结

注册和登录是用户系统的入口,核心是输入校验、密码加密、错误处理。

这一节涉及到的几个实践:

  1. 注册流程:输入校验、唯一性检查、密码加密、邮箱验证
  2. 登录流程:凭据验证、Token 签发、会话管理
  3. 错误处理:不泄露系统内部状态
  4. 邮箱验证:防止垃圾注册
  5. Token 过期:Access Token 短期,Refresh Token 长期
  6. AI 项目:初始化租户、配额、默认模型
  7. 安全:速率限制、CSRF 保护、密码强度

注册和登录的安全性直接影响整个系统的安全。输入校验要严格,错误信息要模糊,密码加密要用 bcrypt 或 argon2。

下一篇看密码加密与安全存储——bcrypt、argon2、密码策略、泄露检测。