項目結構
└── src/
├── @types/
├── controllers/
├── middlewares/
├── models/
├── routes/
├── utils/
├── app.ts
└── index.ts
安裝所需套件
npm install jsonwebtoken bcrypt mongoose cookie-parser
npm install -D @types/jsonwebtoken @types/bcrypt
環境變量
建立一個.env文件:
ACCESS_TOKEN_SECRET=
ACCESS_TOKEN_EXPIRY=
REFRESH_TOKEN_SECRET=
REFRESH_TOKEN_EXPIRY=
定制应答格式 & 应答错误
构建认证之先,吾以定制助记类,应答格式与错误处理,务求一致。
其利有:
- 控制之文,愈洁。
- 应答之式,愈一。
- 前端之理,愈便。
- 调试之术,愈明。
- 可扩展之架构
ApiError 助手
src/utils/ApiError.ts
export class ApiError extends Error {
statusCode: number;
message: string;
errors: any[];
stack?: string;
data: any;
success: boolean;
constructor(
statusCode: number,
message = "Something went wrong",
errors = [],
stack = ""
) {
super(message)
this.statusCode = statusCode
this.data = null
this.message = message
this.success = false
this.errors = errors
if (stack) {
this.stack = stack
} else {
Error.captureStackTrace(this, this.constructor)
}
}
}
ApiResponse 助手
src/utils/ApiResponse.ts
export class ApiResponse {
statusCode: number;
data: any;
message: string;
success: boolean;
constructor(
statusCode: number,
data: any,
message: string = "Success"
) {
this.statusCode = statusCode
this.data = data
this.message = message
this.success = statusCode < 400
}
}
何以用此模式?
非亲手书应如:
return res.status(200).json({
success: true,
message: "Logged in",
data,
});
处处,辅助类使响应于整个后端标准化
为何使用&刷新令牌?
初学者常犯之误,乃仅用一JWT令牌
然,吾等所用者:
- 访问令牌→短期认证
- 刷新令牌→生成新之访问令牌
益处:
- 更安之策
- 用户恒在登录之态
- 使会话失效易
- 刷新令牌轮换之助
此乃多生产应用所用之构架
用户之模
src/models/User.model.ts
import { model, Schema, type HydratedDocument } from "mongoose";
import jwt, { type Secret, type SignOptions } from "jsonwebtoken";
import bcrypt from "bcrypt";
export interface IUser {
username: string;
fullName: string;
email: string;
password: string;
refreshToken?: string;
generateAccessToken: () => string;
generateRefreshToken: () => string;
isPasswordCorrect: (password: string) => Promise<boolean>;
}
export type IUserDocument = HydratedDocument<IUser>;
const userSchema = new Schema<IUser>({
username: String
fullName: String
email: String
password: String
refreshToken: String
});
userSchema.pre("save", async function (): Promise<void> {
if (!this.isModified("password")) return;
this.password = await bcrypt.hash(this.password, 10);
});
userSchema.methods.isPasswordCorrect = async function (
password: string
): Promise<boolean> {
return await bcrypt.compare(password, this.password);
};
userSchema.methods.generateAccessToken = function (): string {
return jwt.sign(
{
_id: this._id,
email: this.email,
username: this.username,
fullName: this.fullName,
},
process.env.ACCESS_TOKEN_SECRET!! as Secret,
{
expiresIn: process.env.ACCESS_TOKEN_EXPIRY!!,
} as SignOptions
);
};
userSchema.methods.generateRefreshToken = function (): string {
return jwt.sign(
{
_id: this._id,
},
process.env.REFRESH_TOKEN_SECRET!! as Secret,
{
expiresIn: process.env.REFRESH_TOKEN_EXPIRY!!,
} as SignOptions
);
};
export const User = model<IUser>("User", userSchema);
为何使用 Mongoose 方法?
不欲别立效用之函数,直将方法附于模式之侧.
其利有:
- 代码愈洁
- TypeScript 之支持更佳
- 更易复用
- 逻辑近于模型
以 Mongoose 中介之法,行密码之散列
userSchema.pre("save", async function (): Promise<void> {
if (!this.isModified("password")) return;
this.password = await bcrypt.hash(this.password, 10);
});
此法之善者:
- 密码散列,不假外求
- 防不慎而露真文
- 免重理散列之术
比较密码
userSchema.methods.isPasswordCorrect = async function (
password: string
): Promise<boolean> {
return await bcrypt.compare(password, this.password);
};
吾用bcrypt.compare()者,盖因散列之密码不可解密也
生成令牌之助
const generateTokens = async (userId: Types.ObjectId | string): Promise<{ accessToken: string; refreshToken: string }> => {
try {
const user = await User.findById(userId);
if (!user) {
throw new ApiError(404, "User not found");
}
const refreshToken = user.generateRefreshToken();
const accessToken = user.generateAccessToken();
user.refreshToken = refreshToken;
await user.save({ validateBeforeSave: false });
return { accessToken, refreshToken };
} catch (err) {
console.log(err)
throw new ApiError(500, "Error while generating tokens");
}
}
为何将刷新令牌存于数据库?
多教程略此不谈
存刷新令牌,使吾等得以:
- 正確登出用戶
- 使會話無效
- 輪換刷新令牌
- 檢測令牌重用攻擊
此法遠勝純粹無狀態的JWT認證
註冊用戶控制器
export const registerUser = async (req: Request, res: Response) => {
const { username, email, fullName, password } = req.body;
if (
[fullName, email, username, password].some((field) => field?.trim() === "")
) {
throw new ApiError(400, "All fields are required");
}
const userExists = await User.findOne({
$or: [{ username }, { email }]
});
if (userExists) {
throw new ApiError(409, "User with the same username or email already exists");
}
const user = await User.create({
fullName,
email,
password,
username: username.toLowerCase(),
});
const createdUser = await User.findById(user._id).select("-password -refreshToken");
if (!createdUser) {
throw new ApiError(500, "Error while creating user");
}
return res.status(201).json(
new ApiResponse(201, createdUser, "User registered successfully")
)
};
何故刪除密碼 & 刷新令牌乎?
.select("-password -refreshToken")
敏感字段勿返于客。
即或散列密码亦不可离后端。
登录控制器
export const loginUser = async (req: Request, res: Response) => {
const { username, email, password } = req.body;
if (!username && !email) {
throw new ApiError(400, "Username or email is required");
}
if (!password) {
throw new ApiError(400, "Password is required");
}
const user = await User.findOne({
$or: [{ username }, { email }]
});
if (!user) {
throw new ApiError(404, "User not found");
}
const isPassValid: boolean = await user.isPasswordCorrect(password);
if (!isPassValid) {
throw new ApiError(401, "Invalid password");
}
const { accessToken, refreshToken } = await generateTokens(user._id);
const userData = {
_id: user._id,
fullName: user.fullName,
username: user.username,
email: user.email,
avatarUrl: user.avatarUrl,
}
return res
.status(200)
.cookie("accessToken", accessToken, cookieOptions)
.cookie("refreshToken", refreshToken, cookieOptions)
.json(
new ApiResponse(200, {
user: userData,
accessToken,
refreshToken
}, "User logged in successfully")
);
};
何以用饼干代本地存储乎?
吾等所用者:
httpOnly: true
益处:
- 御XSS之攻
- JavaScript不能访问令牌
- 安于localStorage之更固
饼食之选
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict" as const,
};
验证 JWT 中间件
src/middlewares/auth.middleware.ts
import type { NextFunction, Request, Response } from "express";
import { User } from "../models/User.model.js";
import { ApiError } from "../utils/ApiError.js";
import { asyncHandler } from "../utils/asyncHandler.js";
import jwt, { type JwtPayload } from "jsonwebtoken";
export const verifyJWT = async (req: Request, _: Response, next: NextFunction) => {
try {
const accessToken = req.cookies?.accessToken || req.header("Authorization")?.replace("Bearer ", "");
if (!accessToken) {
throw new ApiError(401, "Access token is missing");
}
const decodedToken = jwt.verify(accessToken, process.env.ACCESS_TOKEN_SECRET!!) as JwtPayload;
const user = await User.findById(decodedToken._id).select("-password -refreshToken");
if (!user) {
throw new ApiError(401, "Invalid Access Token");
}
req.user = user;
next();
} catch (error: any) {
throw new ApiError(401, error?.message || "Invalid Access Token");
}
};
何以验证 JWT 后仍需查数据库?
诸般教程仅验 JWT。
吾辈更验其人犹存否。
其利有:
- 防止亡者之访问
- 认证更安
- 安泰
刷新權杖控制
export const refreshAccessToken = async (req: Request, res: Response) => {
const incomingRefreshToken = req.cookies.refreshToken || req.body.refreshToken
if (!incomingRefreshToken) {
throw new ApiError(401, "Refresh token is missing");
}
try {
const decodedToken = jwt.verify(incomingRefreshToken, process.env.REFRESH_TOKEN_SECRET!!) as JwtPayload;
const user = await User.findById(decodedToken?._id);
if (!user) {
throw new ApiError(401, "Invalid refresh token - user not found");
}
if (user.refreshToken !== incomingRefreshToken) {
throw new ApiError(401, "Invalid refresh token");
}
const { accessToken, refreshToken: newRefreshToken } = await generateTokens(user._id);
return res
.status(200)
.cookie("accessToken", accessToken, cookieOptions)
.cookie("refreshToken", newRefreshToken, cookieOptions)
.json(
new ApiResponse(200, {
accessToken,
refreshToken: newRefreshToken,
}, "Access token refreshed successfully"
)
);
} catch (err: any) {
throw new ApiError(401, err?.message || "Invalid refresh token");
}
};
注銷控制
export const logoutUser = async (req: Request, res: Response) => {
const userId = req.user?._id;
if (!userId) {
throw new ApiError(400, "User ID is required");
}
await User.findByIdAndUpdate(userId, {
$unset: { refreshToken: 1 }
}, { returnDocument: "after" });
return res
.status(200)
.clearCookie("accessToken", cookieOptions)
.clearCookie("refreshToken", cookieOptions)
.json(new ApiResponse(200, {}, "User logged out successfully"));
};
擴展簡易請求類型
src/@types/express/index.d.ts
import type { IUserDocument } from "../../models/User.model.js";
declare module "express-serve-static-core" {
interface Request {
user?: IUserDocument;
}
}
export {};
tsconfig.json
{
"compilerOptions": {
"typeRoots": [
"./node_modules/@types",
"./src/@types"
]
},
"include": [
"src/**/*",
"src/@types/**/*.d.ts"
]
}
终篇之思
于此,汝已得 JWT 身份验证之系统,具:
- 访问令牌
- 刷新令牌
- HTTP-独占之 cookie
- 保护区之路
- 密码之哈希
- 令牌轮换
- 安全登出
尔可更进此术,增:
- 邮验证实
- 密钥重置
- 速率限制
- 双因素认证
- OAuth登录
- Redis会话存储
编程愉快 🚀












