本文演示GraphQL API
风格的 JWT 认证。用到:Node.js
、MongoDB
、Redis
、Apollo Server
。
此外,还会学到以访问(Access Token)和刷新令牌(Refresh Token)为 HTTPOnly cookies,来实现 JWT 认证。
Node.js GraphQL API
建项目
要初始化一个新的Node.js项目,在你的终端运行以下命令。
mkdir node-graphql-api # 建目录
cd node-graphql-api # 进目录
pnpm init # 初始化
用 Docker-compose 设置 MongoDB 和 Redis
Docker 辅以 Docker-compose 可快速启用 MongoDB 和 Redis服务。
本文假设你电脑上已安装 Docker 及 Docker-compose。
在根目录下,创建文件 docker-compose.yml
,并添加以下代码片段,以便配置并运行容器 MongoDB、Redis。
# docker-compose.yml
version: '3.8'
services:
mongo:
image: mongo
container_name: mongodb
ports:
- '6000:27017'
volumes:
- mongodb:/data/db
env_file:
- ./.env
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD}
MONGO_INITDB_DATABASE: ${MONGO_INITDB_DATABASE}
redis:
image: redis:latest
container_name: redis
ports:
- '6379:6379'
volumes:
- redis:/data
volumes:
redis:
mongodb:
下一步,设置环境变量,以提供 MongoDB Docker 镜像所需凭证
设置环境变量
环境变量乃程序中最关键部分。安全总是重中之重,敏感数据需如 API 密钥、密码等,要专门存储。
本例将上述信息存储在名为 .env
文件中,并使用dotenv
包来加载。
另外,用 config
库来设置和检索环境变量。
pnpm add dotenv config -D
# .env
NODE_ENV=development
PORT=8000
MONGODB_URI_LOCAL=mongodb://admin:password123@localhost:6000/node_graphql?authSource=admin
MONGO_INITDB_ROOT_USERNAME=admin
MONGO_INITDB_ROOT_PASSWORD=password123
MONGO_INITDB_DATABASE=node_graphql
JWT_ACCESS_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlDV3dJQkFBS0JnR2hDMjI2ZEtBeW5NL0tpV3dKVFcySjRnLzl5QTdNWGtHOGppd1p1WGhyeWMzMURxeEdPClZhL0dIa1dxV1lnTkR5WG1lQ1RzVmsvQWdsS3FrOEJHQU1tYUpYSGM4YjVmd0QrRWFXRTVOUUFEMHQyM0VsZ1MKQkovK3ZkK1FwZTFtbjcrT1dZdmt2ODI3VlBDRXZHT1hKYVpLU0VMVDMyaHdialpUTUdTKzBEK0RBZ01CQUFFQwpnWUE4VDJCTGJoRTZzSVcyTndCYUtnblV2azNzdC9FMzZjdWMzbnlQTGN5MTNjVzhraVlrczZjUlZKTXlUVVlaCkV5VE9FYkV4K3B3NjZlcjVFcjhCRy8xYjVBUDYwL2dOZXlPWXE5d3huSWpLRmFzbFE0dDNsa29EdUNMRS9qT1QKcWw4NmpNbis3VG1CRHI2L1V1WmZmM0t1UXB5K2tPN3pSak9wUzh0bU1NUkNRUUpCQUtkcGVTSHJCUFVaWUFkWgpZei85M2Q3NU5DRHhVeGt2YTFmVUdTaFZEd3NDTEhRaVFDZXluZUc0bEpIUjNCTVRiMXlUbFhoVTUxaTR3WGNBCnhDN1JQVEVDUVFDZmJxSG5LQ2VQa2E4VENYL2FSV3NPMTFqQUZnOEZtUys0cnUvTHVpL01JNFNFSXlXMk52aTMKM0lZdStsZUJJNk9Md1gvbVVLaDNwWlBEeHM5TjgwcnpBa0JTYXRKL3FEd2dqZ1dBbUxrTDMrZEN4bHlyZXlMMQoyeXAxYXEyWDlZZ2FXMCtYUE9Wb3BiNmtTVUtiSnoyNUJuQmtteU9Td2ZuQzYvSVNyQVhwSm9tQkFrRUFuTVZSCnlKWndpRGt0MlY5ZTdBZVVwU3hXSmRQTEE2dEZCS3BvTzRFaVhPNlQ4TWNLM3lrZzJ0S3EwMmp4UUpnRnluZ0UKUnpvSzNsUGZnQVJ2ZG13RXh3SkFBVnNPblZNUk8vUkFac0w3VS9MYlB6NmVDQVdzdkN6azU1QWx4bU4zTndoOApsbEt2aElaeG9uc3dGTnB5U2d4ZmExOTVWdlZFemMxMG9KQlhLaUl6M0E9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQ==
JWT_ACCESS_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlHZU1BMEdDU3FHU0liM0RRRUJBUVVBQTRHTUFEQ0JpQUtCZ0doQzIyNmRLQXluTS9LaVd3SlRXMko0Zy85eQpBN01Ya0c4aml3WnVYaHJ5YzMxRHF4R09WYS9HSGtXcVdZZ05EeVhtZUNUc1ZrL0FnbEtxazhCR0FNbWFKWEhjCjhiNWZ3RCtFYVdFNU5RQUQwdDIzRWxnU0JKLyt2ZCtRcGUxbW43K09XWXZrdjgyN1ZQQ0V2R09YSmFaS1NFTFQKMzJod2JqWlRNR1MrMEQrREFnTUJBQUU9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQ==
JWT_REFRESH_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlDWFFJQkFBS0JnUUN6MGhITG9zZXdQcUJTemt6MzdlWHhUb2FPMHB4aDlRb085ME1udU5YNnJwQndNYW9KCng1M1pvYmZiWkFRNktWaXZ3eFd5dkU1ZVdGTlFjRVhkRzlFMlpmQmZ5TTJ3UW9lY1BDVUViaWxjZEFEbGZuR0cKWUdaSTQ2MzBxNGUremRSUHpQdHNNSGpZNk1JSDErSUgxWDlkR3pOVUdXSTllOVRyNmRXRWk2azRZUUlEQVFBQgpBb0dCQUptSGkwQVZURzl6R1FsNk1JY2liRWdhSnBLOHlWNXRpOWY1VHpJVUEwWlVVdWduenlrb2NPS0d5K2FhClAyMjFMWTVyZnhRcTZMSnVzVHVsa0ZEWFZraXF1akVFRHk0cDR6eGJaZFVjc1hZb2dxUHk0T3M0RE43RGt5emYKVGdHZFB3Wm9OeXZlT0lpbzhBV0g5QTJ2aEhkeU5KZ0VIdi8rUHhUbnF2N1dWRXhOQWtFQS83WHB0SUlTNktOcQpUd0NGM1o0bU1IamdwcUZWZHZpM3k0TjVkN3VtTnlqaXVOTlcwd0hGN1VTeFVrV2I0Y2ZRdHZIZ3hMaEhianFtCm00NElqZHMvMXdKQkFMUUdLMEhCM2NxSzJNQUVkalZFa0ZveFYybUNFTzFxeGJFOWRobFJMVlMzY1NCcEgyMjAKZFhrY0ZsL2VTVnRtNG9oLzNQTEZNMmQzYStTek1zUHdJb2NDUVFEVjlpd1FHd3FoV0NOYTZYQVppUHdoY1BOZwoyZnYrS1l6NG9CRWlLNFNnQVBqOGQvSGRhMDFuQkNSdlY4bGdPV2FkdlhRNmhvdFdZNE1IQStpS2NodFBBa0IvCm9Td0R4NjAxam5DUzJkYndkdmFjYXdUYzhYQ00wYmpzcW5WVEI2Rkt3Vzg3bWl2RS9ENllxVmdYaWFHYVludlMKYUV3OTlaODNDSXgrcktrZUR0NTdBa0JGN3hGT0RlVHo1dS9yNE91cHFLeVNJY0gvZWUyckcydkRsVUNZYm1mVgpDeTZXL3lOWDBRTWJYa3kyS3M4d3hZRUlERGlZU1JERk4zaEtPcDlheURiQgotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQ==
JWT_REFRESH_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlHZk1BMEdDU3FHU0liM0RRRUJBUVVBQTRHTkFEQ0JpUUtCZ1FDejBoSExvc2V3UHFCU3prejM3ZVh4VG9hTwowcHhoOVFvTzkwTW51Tlg2cnBCd01hb0p4NTNab2JmYlpBUTZLVml2d3hXeXZFNWVXRk5RY0VYZEc5RTJaZkJmCnlNMndRb2VjUENVRWJpbGNkQURsZm5HR1lHWkk0NjMwcTRlK3pkUlB6UHRzTUhqWTZNSUgxK0lIMVg5ZEd6TlUKR1dJOWU5VHI2ZFdFaTZrNFlRSURBUUFCCi0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQ==
注:
通常,环境变量用大写。
建议把 .env
文件添加到 .gitignore
中,以防止误提交。
接下来,在根目录下创建 config/default.json
文件并添加以下代码:
// config/default.json
{
"port": 8000,
"nodeEnv": "development",
"origin": "http://localhost:3000",
"jwtAccessTokenExpiresIn": 15,
"jwtRefreshTokenExpiresIn": 60
}
校验环境变量
.env
文件中,若环境变量赋错值或忘记赋值,会导致各种程序中错误。
为避免上述情况,应当校验环境变量,用到包: envalid:
pnpm add envalid -D
此包会校验环境变量,若校验不通过,抛出错误。
根目录下,创建 src
文件夹,并在其中创建 utils
文件夹。
接下来,在 utils
文件夹中创建 validateEnv.js
文件,并添加以下代码:
// src/utils/validateEnv.js
import { cleanEnv, port, str } from 'envalid'
function validateEnv() {
cleanEnv(process.env, {
NODE_ENV: str(),
PORT: port(),
MONGODB_URI_LOCAL: str(),
MONGO_INITDB_ROOT_USERNAME: str(),
MONGO_INITDB_ROOT_PASSWORD: str(),
MONGO_INITDB_DATABASE: str(),
JWT_ACCESS_PRIVATE_KEY: str(),
JWT_ACCESS_PUBLIC_KEY: str(),
JWT_REFRESH_PRIVATE_KEY: str(),
JWT_REFRESH_PUBLIC_KEY: str(),
})
}
export default validateEnv
配置好 docker-compose 和环境变量,便可启动 Redis 和 MongoDB Docker 容器:
docker-compose up -d
用 Mongoose 创建数据库模式(Schema)
在 src
文件夹中创建 models
文件夹,并在其中创建 user.model.js
文件。
安装 Mongoose
、BcryptJs
、Validator
:
pnpm add mongoose bcryptjs validator
下面是一个基本的Mongoose模式,列出了创建一个用户所需的字段。
// src/models/user.model.js
import mongoose from 'mongoose'
import validator from 'validator'
import bcrypt from 'bcryptjs'
const userSchema = new mongoose.Schema(
{
name: {
type: String,
required: true,
},
email: {
type: String,
required: true,
unique: true,
validate: [validator.isEmail, '请提供正确的邮箱'],
lowercase: true,
},
password: {
type: String,
required: true,
minlength: [8, '密码需长于 8 位'],
select: false,
},
passwordConfirm: {
type: String,
required: [true, '请确认密码'],
validate: {
validator(val) {
return val === this.password
},
message: '密码不匹配',
},
},
photo: {
type: String,
default: 'default.png',
},
role: {
type: String,
default: 'user',
},
verified: {
type: Boolean,
default: true,
select: false,
},
},
{ timestamps: true, toJSON: { virtuals: true }, toObject: { virtuals: true } }
)
userSchema.index({ email: 1 })
userSchema.pre('save', async function (next) {
// 检查是否改密码
if (!this.isModified('password')) { return next() }
// 散列强度为 12
this.password = await bcrypt.hash(this.password, 12)
// 删除密码确认字段
this.passwordConfirm = undefined
next()
})
// 实例方法
userSchema.methods.comparePasswords = async function (
candidatePassword,
hashedPassword
) {
return await bcrypt.compare(candidatePassword, hashedPassword)
}
const userModel = mongoose.model('User', userSchema)
export default userModel
以下是上述内容中需要注意的一些关键事项:
定义 GraphQL 模式
本文用 Apollo 来构建后端服务器。
运行以下命令来安装 Apollo 服务器及其依赖项:
pnpm add apollo-server-express apollo-server-core express graphql
编写 GraphQL 类型定义,用以描述查询(Query)和变更(Mutation)。
创建 src/schemas/index.js
文件并添加以下代码:
import { gql } from 'apollo-server-express'
const typeDefs = gql`
scalar DateTime # 标量类型
type Query {
# Auth
refreshAccessToken: TokenResponse!
logoutUser: Boolean!
# User
getMe: UserResponse!
}
type Mutation {
# Auth
loginUser(input: LoginInput!): TokenResponse!
signupUser(input: SignUpInput!): UserResponse!
}
input SignUpInput {
name: String!
email: String!
password: String!
passwordConfirm: String!
photo: String
}
input LoginInput {
email: String!
password: String!
}
type TokenResponse {
status: String!
access_token: String!
}
type UserResponse {
status: String!
user: UserData!
}
type UserData {
id: ID!
name: String!
email: String!
photo: String!
role: String!
createdAt: DateTime
updatedAt: DateTime
}
`
export default typeDefs
上述代码分解:
typeDefs
变量定义 GraphQL 类型- 定义标量类型
DateTime
,因为我们将与MongoDB的时间戳一起工作。 - 定义了两个查询Query -
refreshAccessToken
,用以刷新过期访问令牌;getMe
,获取当前登录用户凭证。 - 定义了两个变更Mutation -
signupUser
用于注册新用户,loginUser
用于登录已注册用户。
用 Express 连接 Redis 和 MongoDB
注:
确保 Redis 和 MongoDB Docker 容器正在运行。
连接到 MongoDB Docker 容器
在 src
文件夹中,创建 utils/connectDB.js
文件,并添加以下代码:
// src/utils/connectDB.js
import mongoose from 'mongoose'
import dotenv from 'dotenv'
dotenv.config()
const localUri = process.env.MONGODB_URI_LOCAL
async function connectDB() {
try {
await mongoose.connect(localUri)
console.log('数据库已连接')
}
catch (error) {
console.log(error.message)
process.exit(1)
}
}
export default connectDB
连接到 Redis Docker 容器
安装 Redis
包:
pnpm add redis
接到Redis容器。创建 src/utils/connectRedis.js
文件,并添加以下代码:
// src/utils/connectRedis.js
import { createClient } from 'redis'
const redisUrl = 'redis://localhost:6379'
const redisClient = createClient({
url: redisUrl,
})
async function connectRedis() {
try {
await redisClient.connect()
}
catch (error) {
console.error(error.message)
setInterval(5000, connectRedis)
}
}
connectRedis()
redisClient.on('connect', () =>
console.log('Redis 已连接'))
redisClient.on('error', err => console.error(err))
export default redisClient
处理报错
设计 API 时,建议返回正确的 HTTP 状态码,以告知客户端在服务器上发生了什么。
Apollo 服务器为提供了一个错误类,可以利用其向客户端发送错误信息。
下面的代码中,捕获 Mongoose 错误,并向客户端返回。
import { ApolloError } from 'apollo-server-core'
function handleCastError(error) {
const message = `Invalid ${error.path}: ${error.value}`
throw new ApolloError(message, 'GRAPHQL_VALIDATION_FAILED')
}
function handleValidationError(error) {
const message = Object.values(error.errors).map(el => el.message)
throw new ApolloError(
`Invalid input: ${message.join(', ')}`,
'GRAPHQL_VALIDATION_FAILED'
)
}
function errorHandler(err) {
if (err.name === 'CastError') { handleCastError(err) }
if (err.name === 'ValidationError') { handleValidationError(err) }
throw err
}
export default errorHandler
签署、验证 JSON Web Token
JSON Web Token 用于跨域认证,十分流行。JWT 只有在过期时才失效,这有可能被黑客利用,但瑕不掩瑜。
本文使用 Redis 来存储用户会话,以给 JWt 增加一个额外的安全层。
运行以下命令来安装 JSON Web Token 包:
pnpm add jsonwebtoken
将刷新令牌、访问令牌存储于 HTTPOnly cookies 中,以提高安全级别。此举可防止其被攻击者用 Javascript 获取。
生成公私钥
在上文提到的 .env
文件中提供了一对密钥,但你可以自己生成:
第一步:导航到这个网站,点击蓝色的 "生成密钥"
按钮。
第二步:接下来,访问这个网站,将私钥和公钥编码为 Base64。
注:
将密钥编码为 Base64,以避免在构建 Docker 容器时报警。
第三步:更新密钥至 .env
文件。
注:
刷新令牌^Refresh Token^ 亦如上述步骤。
接着,创建两个函数来签署和校验令牌。
俩函数中,应先将密钥解码,而后传给 JSONWebToken 方法。
// src/utils/jwt.js
import jwt from 'jsonwebtoken'
import errorHandler from '../controllers/error.controller.js'
export function signJwt(payload, Key, options) {
const privateKey = Buffer.from(process.env[Key], 'base64').toString('ascii')
return jwt.sign(payload, privateKey, {
...(options && options),
algorithm: 'RS256',
})
}
export function verifyJwt(token, Key) {
try {
const publicKey = Buffer.from(process.env[Key], 'base64').toString('ascii')
const decoded = jwt.verify(token, publicKey)
return decoded
}
catch (error) {
errorHandler(error)
}
}
创建认证控制器^Authentication Controller^
认证控制器,负责注册,刷新访问令牌,登录,发送验证邮件,以及注销。
为访问令牌和刷新令牌定义 cookie 选项:
// src/controllers/auth.controller.js
import config from 'config'
const accessTokenExpireIn = config.get('jwtAccessTokenExpiresIn')
const refreshTokenExpireIn = config.get('jwtRefreshTokenExpiresIn')
const cookieOptions = {
httpOnly: true,
// domain: 'localhost',
sameSite: 'none',
secure: true,
}
const accessTokenCookieOptions = {
...cookieOptions,
maxAge: accessTokenExpireIn * 60 * 1000,
expires: new Date(Date.now() + accessTokenExpireIn * 60 * 1000),
}
const refreshTokenCookieOptions = {
...cookieOptions,
maxAge: refreshTokenExpireIn * 60 * 1000,
expires: new Date(Date.now() + refreshTokenExpireIn * 60 * 1000),
}
if (process.env.NODE_ENV === 'production') { cookieOptions.secure = true }
注册用户控制器
由于 MongoDB 区分大小写,Johndoe@gmail.com
和 johndoe@gmail.com
,MongoDB 认为这是两个邮箱,故而在定义用户模型时,需给邮箱字段转小写。
另外,MongoDB 返回错误代码 11000
,表明文档中某个唯一索引unique重复。
本例中邮箱字段为唯一索引。
// src/controllers/auth.controller.js
import { AuthenticationError, ForbiddenError } from 'apollo-server-core'
import config from 'config'
import userModel from '../models/user.model.js'
import redisClient from '../utils/connectRedis.js'
import { signJwt, verifyJwt } from '../utils/jwt.js'
import errorHandler from './error.controller.js'
// ? Cookie Options
// ? 注册
async function signup(parent, { input: { name, email, password, passwordConfirm } }, { req }) {
try {
const user = await userModel.create({
name,
email, // 邮箱唯一
password,
passwordConfirm,
})
return {
status: 'success',
user,
}
}
catch (error) {
if (error.code === 11000) {
throw new ForbiddenError('用户已存在')
}
errorHandler(error)
}
}
登录用户控制器
用户登录后,向客户端返回访问令牌和刷新令牌 cookies。
登录时,需先校验邮箱。
// src/controllers/auth.controller.js
// ? Cookie Options
// ? SignUp User
// ? Sign Tokens
async function signTokens(user) {
// 创建 session
await redisClient.set(user.id, JSON.stringify(user), {
EX: 60 * 60,
})
// 生成访问令牌
const access_token = signJwt({ user: user.id }, 'JWT_ACCESS_PRIVATE_KEY', {
expiresIn: `${config.get('jwtAccessTokenExpiresIn')}m`,
})
// 生成刷新令牌
const refresh_token = signJwt({ user: user.id }, 'JWT_REFRESH_PRIVATE_KEY', {
expiresIn: `${config.get('jwtRefreshTokenExpiresIn')}m`,
})
return { access_token, refresh_token }
}
async function login(parent, { input: { email, password } }, { req, res }) {
try {
// 检查用户是否存在,密码是否正确
const user = await userModel
.findOne({ email })
.select('+password +verified') // 明确要这俩字段
if (!user || !(await user.comparePasswords(password, user.password))) {
throw new AuthenticationError('邮箱或密码错误')
}
user.password = undefined
// 生成 session 和 token
const { access_token, refresh_token } = await signTokens(user)
// 将刷新令牌添加到 cookie
res.cookie('refresh_token', refresh_token, refreshTokenCookieOptions)
res.cookie('access_token', access_token, accessTokenCookieOptions)
res.cookie('logged_in', true, {
...accessTokenCookieOptions,
httpOnly: false,
})
return {
status: 'success',
access_token,
}
}
catch (error) {
errorHandler(error)
}
}
下面是一些值得注意的事情,你应该考虑上述问题。
- 在定义用户模型时,
password
和verified
字段设置为默认查询时不返回,所以要获取,必须明确告诉 mongoose。 - 在校验登录密码后,需将密码字段从返回给用户的文档中删除,此处设置为
undefined
。
刷新访问令牌控制器
访问令牌过期后调用此控制器,以返回一个新的访问令牌。
注:
只有当用户在 Redis 数据库中有一个有效的会话时,访问令牌才能被刷新。
// src/controllers/auth.controller.js
// ? Cookie Options
// ? SignUp User
// ? Sign Tokens
// ? Login User
// ? Refresh Tokens
async function refreshAccessToken(parent, args, { req, res }) {
try {
// 从 cookie 中获取刷新令牌
const { refresh_token } = req.cookies
// 解码刷新令牌
const decoded = verifyJwt(refresh_token, 'JWT_REFRESH_PUBLIC_KEY')
if (!decoded) {
throw new ForbiddenError('无法刷新访问令牌')
}
// 从 Redis 中获取用户的 session
const session = await redisClient.get(decoded.user)
if (!session) {
throw new ForbiddenError('会话已过期')
}
// 检查用户是否存在并且已验证
const user = await userModel
.findById(JSON.parse(session)._id)
.select('+verified')
if (!user || !user.verified) {
throw new ForbiddenError('无法刷新访问令牌')
}
// 生成新的访问令牌
const access_token = signJwt({ user: user._id }, 'JWT_ACCESS_PRIVATE_KEY', {
expiresIn: config.get('jwtAccessTokenExpiresIn'),
})
// 将访问令牌添加到 cookie
res.cookie('access_token', access_token, accessTokenCookieOptions)
res.cookie('logged_in', 'true', {
...accessTokenCookieOptions,
httpOnly: false,
})
return {
status: 'success',
access_token,
}
}
catch (error) {
errorHandler(error)
}
}
注销控制器
注销控制器将过期的 cookies 返回给客户。
此外,还需要从 Redis 数据库中删除该用户会话。
// src/controllers/auth.controller.js
import checkIsLoggedIn from '../middleware/checkIsLoggedIn.js'
// ? Cookie Options
// ? SignUp User
// ? Sign Tokens
// ? Login User
// ? Refresh Tokens
// ? Logout User
async function logoutHandler(_, args, { req, res, getAuthUser }) {
try {
await checkIsLoggedIn(req, getAuthUser)
const user = await getAuthUser(req)
// 从 Redis 中删除用户的 session
await redisClient.del(user.id)
// 将过期的 cookies 返回给客户端
res.cookie('access_token', '', { maxAge: -1 })
res.cookie('refresh_token', '', { maxAge: -1 })
res.cookie('logged_in', '', { maxAge: -1 })
return true
}
catch (error) {
errorHandler(error)
}
}
export default {
signup,
login,
refreshAccessToken,
logoutHandler,
}
创建用户控制器^User Controller^
为了测试认证逻辑,让我们创建一个getMe处理程序来返回当前登录用户的凭证。
// src/controllers/user.controller.js
import checkIsLoggedIn from '../middleware/checkIsLoggedIn.js'
import errorHandler from './error.controller.js'
async function getMe(_, args, { req, getAuthUser }) {
try {
await checkIsLoggedIn(req, getAuthUser)
const user = await getAuthUser(req)
return {
status: 'success',
user,
}
}
catch (error) {
errorHandler(error)
}
}
export default {
getMe,
}
定义认证中间件^Authentication Middleware^
使用 cookie-parser
包,来解析请求头中的 cookies。
pnpm add cookie-parser
反序列化用户中间件
现在让我们创建一个中间件,作为所有受保护资源的保护者。
// src/middleware/authUser.js
import { ForbiddenError } from 'apollo-server-core'
import errorHandler from '../controllers/error.controller.js'
import userModel from '../models/user.model.js'
import redisClient from '../utils/connectRedis.js'
import { verifyJwt } from '../utils/jwt.js'
async function authUser(req) {
try {
// 从请求头中获取访问令牌
let access_token
if (
req.headers.authorization
&& req.headers.authorization.startsWith('Bearer')
) {
access_token = req.headers.authorization.split(' ')[1]
}
else if (req.cookies.access_token) {
const { access_token: token } = req.cookies
access_token = token
}
if (!access_token) { return false }
// 解码访问令牌
const decoded = verifyJwt(access_token, 'JWT_ACCESS_PUBLIC_KEY')
if (!decoded) { return false }
// 检查会话是否有效
const session = await redisClient.get(decoded.user)
if (!session) {
throw new ForbiddenError('会话已过期')
}
// 检查用户是否存在并且已验证
const user = await userModel
.findById(JSON.parse(session).id)
.select('+verified')
if (!user || !user.verified) {
throw new ForbiddenError(
'无法访问此资源,请先登录'
)
}
return user
}
catch (error) {
errorHandler(error)
}
}
export default authUser
下面是上述代码的摘要:
- 从请求头 或 req.cookies 对象中获取访问令牌
- 验证访问令牌,提取其中存储的有效载荷。
- 检查用户是否有一个有效的会话并仍然存在于MongoDB数据库中。
- 如果没有任何错误,将用户返回到下一个中间件。
*** 登录检查
接下来,创建登录检查中间件。
// src/middleware/checkIsLoggedIn.js
import { AuthenticationError } from 'apollo-server-core'
import errorHandler from '../controllers/error.controller.js'
async function checkIsLoggedIn(req, getAuthUser) {
try {
// 检查用户是否已登录
const authUser = await getAuthUser(req)
if (!authUser) {
throw new AuthenticationError('未登录')
}
}
catch (error) {
errorHandler(error)
}
}
export default checkIsLoggedIn
创建解析器
截止目前,逻辑已实现九成,下来定义 GraphQL 解析器。
定义一个标量DateTime
下面的 GraphQLScalarType 对象 指定了一个代表日期的自定义标量的交互方式。它假定后端用Date JavaScript对象表示日期。
// src/resolvers/datetime.js
import { GraphQLScalarType, Kind } from 'graphql'
export default new GraphQLScalarType({
name: 'DateTime',
description: '标量类型 DateTime',
serialize(value) {
return new Date(value).toISOString()
},
parseValue(value) {
return new Date(value)
},
parseLiteral(ast) {
if (ast.Kind === Kind.INT) {
return new Date(Number.parseInt(ast.value, 10))
}
return null
},
})
创建查询解析器
// src/resolvers/query.resolver.js
import authController from '../controllers/auth.controller.js'
import userController from '../controllers/user.controller.js'
export default {
// Users
getMe: userController.getMe,
// Auth
refreshAccessToken: authController.refreshAccessToken,
logoutUser: authController.logoutHandler,
}
创建变更解析器
// src/resolvers/mutation.resolver.js
import authController from '../controllers/auth.controller.js'
export default {
// Auth
signupUser: authController.signup,
loginUser: authController.login,
}
导出解析器
// src/resolvers/index.js
import Mutation from './mutation.resolver.js'
import Query from './query.resolver.js'
export { Mutation, Query }
配置 Apollo 服务器
下一步,连接模式Schema和解析器Resolver。
创建 src/app.js
文件并添加以下代码:
// src/app.js
import express from 'express'
import dotenv from 'dotenv'
import cookieParser from 'cookie-parser'
import validateEnv from './utils/validateEnv.js'
dotenv.config()
validateEnv()
const app = express()
// 中间件
app.use(cookieParser())
process.on('uncaughtException', (err) => {
console.error('异常,未捕获。关闭服务...')
console.error('错误?', err.message)
process.exit(1)
})
export default app
运行以下命令来安装 cors
和 nodemon
。
cors
包解决跨域问题。
nodemon
包用于热重载服务器。
pnpm add cors nodemon -D
// src/server.js
import http from 'node:http'
import { ApolloServer } from 'apollo-server-express'
import { ApolloServerPluginDrainHttpServer } from 'apollo-server-core'
import config from 'config'
import cors from 'cors'
import connectDB from './utils/connectDB.js'
import typeDefs from './schemas/index.js'
import app from './app.js'
import { Mutation, Query } from './resolvers/index.js'
import DateTime from './resolvers/datetime.js'
import getAuthUser from './middleware/authUser.js'
const httpServer = http.createServer(app)
const corsOptions = {
origin: ['https://studio.apollographql.com', 'http://localhost:8000'],
credentials: true,
}
app.use(cors(corsOptions))
const resolvers = {
DateTime,
Query,
Mutation,
};
(async function () {
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
context: async ({ req, res }) => ({ req, res, getAuthUser }),
})
// 连接数据库
await connectDB()
// 启动 Apollo 服务器
await server.start()
server.applyMiddleware({ app, cors: corsOptions })
const port = config.get('port')
await new Promise(resolve => httpServer.listen(port, '0.0.0.0', resolve))
console.log(
`服务器启动于 http://localhost:${port}${server.graphqlPath}`
)
})()
process.on('unhandledRejection', (err) => {
console.log('异常,未捕获。关闭服务..')
console.error('错误', err.message)
httpServer.close(async () => {
process.exit(1)
})
})
注:
确保在 package.json
文件中把属性 type
设置为 module
。
运行此命令来启动 Apollo 服务器:
nodemon ./src/server.js
结尾
至此,代码完成