用户认证系统 #
一、认证概述 #
本章将实现一个完整的用户认证系统,包括注册、登录、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"}'
十、总结 #
本章我们学习了:
- 认证设计:功能规划和技术选型
- JWT插件:令牌生成和验证
- 密码处理:bcrypt加密
- 服务层:用户服务和令牌服务
- 认证路由:注册、登录、登出、刷新
- 权限控制:角色中间件
- API测试:完整的测试流程
接下来让我们学习数据库集成!
最后更新:2026-03-28