用户认证系统 #

一、认证概述 #

本章将实现一个完整的用户认证系统,包括注册、登录、JWT令牌和权限控制。

1.1 功能设计 #

功能 路由 说明
注册 POST /auth/register 用户注册
登录 POST /auth/login 用户登录
登出 POST /auth/logout 用户登出
刷新令牌 POST /auth/refresh 刷新访问令牌
获取用户 GET /auth/me 获取当前用户

1.2 技术栈 #

  • JWT认证:@fastify/jwt
  • 密码加密:bcrypt
  • Cookie:@fastify/cookie

二、项目结构 #

text
auth-api/
├── src/
│   ├── app.js
│   ├── server.js
│   ├── config/
│   │   └── index.js
│   ├── plugins/
│   │   ├── database.js
│   │   ├── auth.js
│   │   └── security.js
│   ├── routes/
│   │   ├── index.js
│   │   └── auth.js
│   ├── schemas/
│   │   └── auth.js
│   ├── services/
│   │   ├── user.service.js
│   │   └── token.service.js
│   └── middleware/
│       └── auth.js
├── package.json
└── .env

三、依赖安装 #

3.1 安装依赖 #

bash
npm install fastify @fastify/jwt @fastify/cookie @fastify/cors @fastify/helmet @fastify/mongodb bcrypt dotenv

3.2 环境变量 #

.env

env
NODE_ENV=development
PORT=3000
HOST=0.0.0.0

MONGODB_URL=mongodb://localhost:27017
MONGODB_DB=auth_api

JWT_SECRET=your-super-secret-key-change-in-production
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d

BCRYPT_SALT_ROUNDS=10

四、认证插件 #

4.1 JWT插件 #

src/plugins/auth.js

javascript
import fp from 'fastify-plugin'
import jwt from '@fastify/jwt'
import cookie from '@fastify/cookie'

async function authPlugin(fastify, opts) {
  await fastify.register(jwt, {
    secret: opts.jwtSecret,
    sign: {
      expiresIn: opts.jwtExpiresIn
    }
  })
  
  await fastify.register(cookie, {
    secret: opts.cookieSecret
  })
  
  fastify.decorate('authenticate', async function (request, reply) {
    try {
      const token = request.headers.authorization?.replace('Bearer ', '')
      
      if (!token) {
        const cookieToken = request.cookies.refreshToken
        if (!cookieToken) {
          throw fastify.httpErrors.unauthorized('No token provided')
        }
        request.user = await request.jwtVerify({ token: cookieToken })
        return
      }
      
      request.user = await request.jwtVerify({ token })
    } catch (err) {
      throw fastify.httpErrors.unauthorized('Invalid token')
    }
  })
  
  fastify.decorate('optionalAuth', async function (request, reply) {
    try {
      const token = request.headers.authorization?.replace('Bearer ', '')
      if (token) {
        request.user = await request.jwtVerify({ token })
      }
    } catch (err) {
      // Optional auth, ignore errors
    }
  })
}

export default fp(authPlugin, {
  name: 'auth',
  dependencies: ['@fastify/sensible']
})

4.2 密码工具 #

src/utils/password.js

javascript
import bcrypt from 'bcrypt'

const SALT_ROUNDS = parseInt(process.env.BCRYPT_SALT_ROUNDS, 10) || 10

export async function hashPassword(password) {
  return bcrypt.hash(password, SALT_ROUNDS)
}

export async function comparePassword(password, hash) {
  return bcrypt.compare(password, hash)
}

五、服务层 #

5.1 用户服务 #

src/services/user.service.js

javascript
import { hashPassword, comparePassword } from '../utils/password.js'

class UserService {
  constructor(db) {
    this.collection = db.collection('users')
  }
  
  async findById(id) {
    const { ObjectId } = await import('mongodb')
    return this.collection.findOne({ _id: new ObjectId(id) })
  }
  
  async findByEmail(email) {
    return this.collection.findOne({ email })
  }
  
  async create(data) {
    const hashedPassword = await hashPassword(data.password)
    
    const user = {
      email: data.email,
      password: hashedPassword,
      name: data.name,
      role: data.role || 'user',
      isActive: true,
      createdAt: new Date(),
      updatedAt: new Date()
    }
    
    const result = await this.collection.insertOne(user)
    return this.findById(result.insertedId)
  }
  
  async validatePassword(email, password) {
    const user = await this.findByEmail(email)
    
    if (!user) {
      return null
    }
    
    const isValid = await comparePassword(password, user.password)
    
    if (!isValid) {
      return null
    }
    
    return user
  }
  
  async updatePassword(id, newPassword) {
    const { ObjectId } = await import('mongodb')
    const hashedPassword = await hashPassword(newPassword)
    
    await this.collection.updateOne(
      { _id: new ObjectId(id) },
      {
        $set: {
          password: hashedPassword,
          updatedAt: new Date()
        }
      }
    )
    
    return this.findById(id)
  }
  
  async updateLastLogin(id) {
    const { ObjectId } = await import('mongodb')
    
    await this.collection.updateOne(
      { _id: new ObjectId(id) },
      {
        $set: {
          lastLoginAt: new Date(),
          updatedAt: new Date()
        }
      }
    )
  }
  
  async toSafeUser(user) {
    if (!user) return null
    
    const { password, ...safeUser } = user
    return safeUser
  }
}

export default UserService

5.2 令牌服务 #

src/services/token.service.js

javascript
class TokenService {
  constructor(db) {
    this.collection = db.collection('refresh_tokens')
  }
  
  async create(userId, token, expiresAt) {
    const { ObjectId } = await import('mongodb')
    
    await this.collection.insertOne({
      userId: new ObjectId(userId),
      token,
      expiresAt,
      createdAt: new Date()
    })
  }
  
  async findByToken(token) {
    return this.collection.findOne({ token })
  }
  
  async deleteByToken(token) {
    await this.collection.deleteOne({ token })
  }
  
  async deleteByUserId(userId) {
    const { ObjectId } = await import('mongodb')
    await this.collection.deleteMany({ userId: new ObjectId(userId) })
  }
  
  async deleteExpired() {
    await this.collection.deleteMany({
      expiresAt: { $lt: new Date() }
    })
  }
}

export default TokenService

六、认证路由 #

6.1 认证路由 #

src/routes/auth.js

javascript
export default async function (fastify, opts) {
  const { userService, tokenService } = fastify.services
  
  fastify.post('/register', {
    schema: {
      body: {
        type: 'object',
        required: ['email', 'password', 'name'],
        properties: {
          email: { type: 'string', format: 'email' },
          password: { type: 'string', minLength: 8 },
          name: { type: 'string', minLength: 2 }
        }
      },
      response: {
        201: {
          type: 'object',
          properties: {
            user: {
              type: 'object',
              properties: {
                id: { type: 'string' },
                email: { type: 'string' },
                name: { type: 'string' }
              }
            },
            accessToken: { type: 'string' },
            refreshToken: { type: 'string' }
          }
        }
      }
    }
  }, async (request, reply) => {
    const existing = await userService.findByEmail(request.body.email)
    
    if (existing) {
      reply.code(409)
      return { error: 'Conflict', message: 'Email already registered' }
    }
    
    const user = await userService.create(request.body)
    
    const accessToken = fastify.jwt.sign(
      { userId: user._id, role: user.role },
      { expiresIn: process.env.JWT_EXPIRES_IN }
    )
    
    const refreshToken = fastify.jwt.sign(
      { userId: user._id, type: 'refresh' },
      { expiresIn: process.env.JWT_REFRESH_EXPIRES_IN }
    )
    
    const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
    await tokenService.create(user._id, refreshToken, expiresAt)
    
    reply.setCookie('refreshToken', refreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      path: '/auth/refresh',
      maxAge: 7 * 24 * 60 * 60
    })
    
    reply.code(201)
    return {
      user: await userService.toSafeUser(user),
      accessToken,
      refreshToken
    }
  })
  
  fastify.post('/login', {
    schema: {
      body: {
        type: 'object',
        required: ['email', 'password'],
        properties: {
          email: { type: 'string', format: 'email' },
          password: { type: 'string' }
        }
      }
    }
  }, async (request, reply) => {
    const user = await userService.validatePassword(
      request.body.email,
      request.body.password
    )
    
    if (!user) {
      reply.code(401)
      return { error: 'Unauthorized', message: 'Invalid credentials' }
    }
    
    if (!user.isActive) {
      reply.code(403)
      return { error: 'Forbidden', message: 'Account is disabled' }
    }
    
    await userService.updateLastLogin(user._id)
    
    const accessToken = fastify.jwt.sign(
      { userId: user._id, role: user.role },
      { expiresIn: process.env.JWT_EXPIRES_IN }
    )
    
    const refreshToken = fastify.jwt.sign(
      { userId: user._id, type: 'refresh' },
      { expiresIn: process.env.JWT_REFRESH_EXPIRES_IN }
    )
    
    const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
    await tokenService.create(user._id, refreshToken, expiresAt)
    
    reply.setCookie('refreshToken', refreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      path: '/auth/refresh',
      maxAge: 7 * 24 * 60 * 60
    })
    
    return {
      user: await userService.toSafeUser(user),
      accessToken,
      refreshToken
    }
  })
  
  fastify.post('/logout', {
    preHandler: fastify.authenticate
  }, async (request, reply) => {
    const refreshToken = request.cookies.refreshToken
    
    if (refreshToken) {
      await tokenService.deleteByToken(refreshToken)
    }
    
    reply.clearCookie('refreshToken', {
      path: '/auth/refresh'
    })
    
    return { message: 'Logged out successfully' }
  })
  
  fastify.post('/refresh', async (request, reply) => {
    const refreshToken = request.cookies.refreshToken || request.body?.refreshToken
    
    if (!refreshToken) {
      reply.code(401)
      return { error: 'Unauthorized', message: 'No refresh token' }
    }
    
    try {
      const decoded = await fastify.jwt.verify(refreshToken)
      
      if (decoded.type !== 'refresh') {
        throw new Error('Invalid token type')
      }
      
      const storedToken = await tokenService.findByToken(refreshToken)
      
      if (!storedToken) {
        reply.code(401)
        return { error: 'Unauthorized', message: 'Invalid refresh token' }
      }
      
      await tokenService.deleteByToken(refreshToken)
      
      const user = await userService.findById(decoded.userId)
      
      if (!user || !user.isActive) {
        reply.code(401)
        return { error: 'Unauthorized', message: 'User not found or inactive' }
      }
      
      const newAccessToken = fastify.jwt.sign(
        { userId: user._id, role: user.role },
        { expiresIn: process.env.JWT_EXPIRES_IN }
      )
      
      const newRefreshToken = fastify.jwt.sign(
        { userId: user._id, type: 'refresh' },
        { expiresIn: process.env.JWT_REFRESH_EXPIRES_IN }
      )
      
      const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
      await tokenService.create(user._id, newRefreshToken, expiresAt)
      
      reply.setCookie('refreshToken', newRefreshToken, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict',
        path: '/auth/refresh',
        maxAge: 7 * 24 * 60 * 60
      })
      
      return {
        accessToken: newAccessToken,
        refreshToken: newRefreshToken
      }
    } catch (err) {
      reply.code(401)
      return { error: 'Unauthorized', message: 'Invalid refresh token' }
    }
  })
  
  fastify.get('/me', {
    preHandler: fastify.authenticate
  }, async (request, reply) => {
    const user = await userService.findById(request.user.userId)
    
    if (!user) {
      reply.code(404)
      return { error: 'Not Found', message: 'User not found' }
    }
    
    return userService.toSafeUser(user)
  })
}

七、权限控制 #

7.1 角色中间件 #

src/middleware/auth.js

javascript
export function requireRole(...roles) {
  return async function (request, reply) {
    if (!request.user) {
      reply.code(401)
      throw new Error('Unauthorized')
    }
    
    if (!roles.includes(request.user.role)) {
      reply.code(403)
      throw new Error('Forbidden')
    }
  }
}

export function requireAdmin(request, reply) {
  if (!request.user || request.user.role !== 'admin') {
    reply.code(403)
    throw new Error('Admin access required')
  }
}

7.2 使用权限控制 #

javascript
import { requireRole, requireAdmin } from '../middleware/auth.js'

fastify.get('/admin/users', {
  preHandler: [fastify.authenticate, requireAdmin]
}, async (request, reply) => {
  return { users: [] }
})

fastify.delete('/users/:id', {
  preHandler: [fastify.authenticate, requireRole('admin', 'moderator')]
}, async (request, reply) => {
  return { deleted: true }
})

八、完整应用 #

8.1 应用入口 #

src/app.js

javascript
import fp from 'fastify-plugin'
import sensible from '@fastify/sensible'
import databasePlugin from './plugins/database.js'
import authPlugin from './plugins/auth.js'
import securityPlugin from './plugins/security.js'
import UserService from './services/user.service.js'
import TokenService from './services/token.service.js'

async function app(fastify, opts) {
  await fastify.register(sensible)
  
  await fastify.register(databasePlugin, opts.mongodb)
  await fastify.register(securityPlugin)
  await fastify.register(authPlugin, {
    jwtSecret: opts.jwtSecret,
    jwtExpiresIn: opts.jwtExpiresIn,
    cookieSecret: opts.cookieSecret
  })
  
  fastify.decorate('services', {
    userService: new UserService(fastify.db),
    tokenService: new TokenService(fastify.db)
  })
  
  fastify.register(import('./routes/auth.js'), { prefix: '/auth' })
}

export default fp(app)

九、测试API #

9.1 注册用户 #

bash
curl -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","password":"password123","name":"Test User"}'

9.2 登录 #

bash
curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","password":"password123"}'

9.3 获取当前用户 #

bash
curl http://localhost:3000/auth/me \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

9.4 刷新令牌 #

bash
curl -X POST http://localhost:3000/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"YOUR_REFRESH_TOKEN"}'

十、总结 #

本章我们学习了:

  1. 认证设计:功能规划和技术选型
  2. JWT插件:令牌生成和验证
  3. 密码处理:bcrypt加密
  4. 服务层:用户服务和令牌服务
  5. 认证路由:注册、登录、登出、刷新
  6. 权限控制:角色中间件
  7. API测试:完整的测试流程

接下来让我们学习数据库集成!

最后更新:2026-03-28