احراز هویت و محافظت از مسیرهای REST API با JWT و چرخش توکن refersh

بررسی اجمالی
پس از خواندن این مقاله، شما قادر خواهید بود
- احراز هویت کاربران با نام کاربری/ایمیل و رمز عبور آنها
- کاربردهای accessToken و refreshToken را درک کنید
- با اعتبارسنجی accessToken، از نقاط پایانی api در برابر مشتریان غیرمجاز محافظت کنید
- اجازه ورود چندگانه با قابلیت لغو تمام جلسه
- یک الگو با محافظت از ثبت نام، ورود به سیستم و نقطه پایانی برای شروع api استراحت بعدی خود داشته باشید
کد در github موجود است
پیشنهاد می کنم ابتدا مقاله را کامل بخوانید و سپس شروع به کدنویسی کنید
ساختار کد منبع به شرح زیر است
.
├── controllers
│ ├── auth
│ │ ├── login.ts
│ │ ├── logout.ts
│ │ ├── refreshAccessToken.ts
│ │ └── register.js
│ └── users.ts
├── db # functions to make calls to the database
│ ├── connect.ts
│ ├── tokens.ts
│ └── users.ts
├── index.ts
├── middlewares
│ ├── validateRegistrationData.ts
│ └── verifyTokens.ts
├── routes
│ ├── auth.ts
│ ├── index.ts
│ └── users.ts
└── utils
├── genToken.ts
├── hashString.ts
└── verifyToken.ts
فهرست مطالب
طرحواره پایگاه داده
- پایگاه داده از sqlite استفاده می کند، بنابراین شما نیازی به راه اندازی هیچ پایگاه داده در سیستم خود ندارید
- این پروژه از Prisma ORM استفاده می کند که به شما پشتیبانی از تایپ اسکریپت را با تجربه تکمیل خودکار عالی می دهد
prisma/schema.prisma:
model User {
id String @id @default(uuid())
email String @unique
username String @unique
password String
refreshTokens RefreshToken[]
}
model RefreshToken {
id String @id
hashedToken String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
createdAt DateTime @default(now())
}
- کاربر و RefreshToken یک تا چندین رابطه دارند
- یک کاربر می تواند چندین refreshToken داشته باشد تا ورود از چندین دستگاه ادامه یابد
- اجرا کن
npx prisma generate
برای تولید کد مشتری پریسما سپس اجرا کنیدnpx prisma migrate dev
برای ایجاد جداول لازم مطابق طرح - نکته: می توانید استفاده کنید
npx prisma studio
برای ارتباط با پایگاه داده در یک رابط کاربری گرافیکی وب
مروری بر مسیرهای auth
routes/auth.ts:
const router = express.Router();
router.post("/auth/login", login);
router.post("/auth/register", validateRegistrationData, register);
router.get("/auth/refresh", verifyRefreshToken, refreshAccessToken);
router.delete("/auth/logout", logout);
router.delete("/auth/logout_all", logout_all);
export { router as authRouter };
همه مسیرها با “/api” پیشوند هستند
ثبت
مسیر: router.post("/auth/register", validateRegistrationData, register)
- بدنه درخواست باید شامل نام کاربری، ایمیل و رمز عبور باشد
- ما یک میان افزار برای اعتبارسنجی داده های ثبت نام ایجاد خواهیم کرد
Middlewares/validateRegistrationData.ts:
export const validateRegistrationData = async (req, res, next) => {
const { email, username, password } = req.body;
if (!password) return res.status(400).json({ error: "No password provided" });
if (!username) return res.status(400).json({ error: "No username provided" });
if (!email) return res.status(400).json({ error: "No email provided" });
let user = await findUserByUsernameOrEmail(username, email);
if (user) {
let error = "Email already exits";
if (user.email !== email) error = "Username already exits";
return res.json({ error });
}
req.user = {
email,
username,
password,
};
next();
};
- اگر یک حساب کاربری با همان نام کاربری یا ایمیل قبلاً وجود داشته باشد، برگردید
- در غیر این صورت ضمیمه کنید
user
اعتراض بهreq
و به تابع بعدی بروید
controllers/auth/register.ts:
export const register = async (req, res) => {
const user = await createUser(req.user);
if (!user) return res.json({ error: "Registration Failed" });
const data = {
username: user.username,
email: user.email,
};
res.json({ data });
};
- رمز عبور را قبل از ذخیره در پایگاه داده هش کنید
const createUser = async (user: any) => {
user.password = await hashString(user.password);
return db.user.create({
data: user,
});
};
وارد شدن
مسیر: router.post("/auth/login", login)
- متن درخواست باید حاوی { نام کاربری، رمز عبور } باشد.
- در اینجا فیلد نام کاربری می تواند حاوی ایمیل نیز باشد و گزینه ای را برای ورود با نام کاربری و ایمیل ارائه می دهد
- اگر کاربر وجود ندارد یا رمز عبور نادرست است، خطا را برگردانید
- AccessToken و refreshToken ایجاد کنید
- refreshToken را ارسال کنید تا به عنوان یک کوکی httpOnly با اعتبار 30 روز ذخیره شود
- AccessToken را ارسال کنید
controllers/auth/login.ts:
export const login = async (req, res) => {
try {
if (!req.body.username)
return res.status(400).json({ error: "No Username provided" });
if (!req.body.password)
return res.status(400).json({ error: "No Password provided" });
const { username, password } = req.body;
// User can log in with username or email
const user = await findUserByUsernameOrEmail(username, username);
if (!user) return res.status(404).json({ error: "User Not Found" });
const match = await bcrypt.compare(password, user.password);
if (!match) return res.status(401).json({ error: "Wrong Password" });
const accessToken = genAccessToken(user);
const tokenId = randomUUID();
const refreshToken = genRefreshToken(user, tokenId);
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
maxAge: 24 * 60 * 60 * 30 * 1000, // 30 days
});
// add the token to the database
addRefreshToken(tokenId, user.id, refreshToken);
return res.json({ accessToken });
} catch (error) {
return res.status(500).json({ error: "Internal Error" });
}
};
- refreshToken داده های حساسی است، بنابراین شما نباید آن را در متن ساده ذخیره کنید
- می توانید آن را هش کنید یا رمزگذاری کنید
const addRefreshToken = async (
id: string,
userId: number,
refreshToken: string
) => {
const hashedToken = await hashString(refreshToken);
return db.refreshToken.create({
data: {
id,
userId,
hashedToken,
},
});
};
Refresh Access Token
مسیر: router.get("/auth/refresh", verifyRefreshToken, refreshAccessToken)
- اگر توکن منقضی شده باشد یا در آن دستکاری شده باشد، تأیید ناموفق خواهد بود
- اگر تأیید تأیید شود اما رمز در db وجود نداشته باشد، میتوانید مشکوک شوید که شخصی در تلاش است از یک توکن قدیمی استفاده کند که ممکن است به سرقت رفته باشد، بنابراین شما غیرمجاز را برگردانید.
- در غیر این صورت، refreshToken را که در کوکی درخواست بود حذف کنید، توکن جدید ایجاد کنید، آن را در پایگاه داده ذخیره کنید و آن را به عنوان یک کوکی httpOnly ارسال کنید، به این عمل چرخش توکن refresh می گویند.
- AccessToken را ارسال کنید
export const refreshAccessToken = async (req, res) => {
const user = req.user;
const newTokenId = randomUUID();
const newRefreshToken = genRefreshToken(user, newTokenId);
res.cookie("refreshToken", newRefreshToken, {
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 * 30,
});
// refresh token rotation
deleteRefreshTokenById(user.jwtid);
addRefreshToken(newTokenId, user.id, newRefreshToken);
//
const accessToken = genAccessToken(user);
return res.json({ accessToken });
};
دسترسی به منابع محافظت شده
به عنوان مثال، /users را می توان به عنوان یک نقطه پایانی محافظت شده استفاده کرد
مسیر: router.use("/users", verifyAccessToken, listUsers)
Middlewares/verifyAccessToken.ts:
export const verifyAccessToken = (req, res, next) => {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1];
if (token == null) return res.sendStatus(401);
const user = tokenVerifier.validateAccessToken(token);
if (user.tokenError)
return res.status(401).json({
error: "Invalid Access token",
tokenError: user.tokenError,
});
req.user = user;
return next();
};
- مشتری باید رمز را در سربرگ مجوز به دنبال قالب ارسال کند
Bearer $token
- اگر توکن معتبر نباشد، خطا برگردانده خواهد شد (مانند TokenExpiredError یا JsonWebTokenError در صورت اصلاح توکن)
- در غیر این صورت سرور پایگاه داده را پرس و جو می کند و لیست کاربران را برای مشتری ارسال می کند
خروج
مسیر: router.delete("/auth/logout", logout)
از جلسه جاری خارج شوید
export const logout = async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.status(401).json({ error: "No Refresh Token" });
// does not check if it exists in the db
const user = tokenVerifier.verifyRefreshToken(refreshToken);
if (user.tokenError)
return res.status(401).json({
error: "Invalid Refresh token",
tokenError: user.tokenError,
});
res.clearCookie("refreshToken");
deleteRefreshTokenById(user.jwtid);
return res.sendStatus(200);
};
از همه دستگاهها خارج شوید
مسیر: router.delete("/auth/logout", logout_all)
export const logout_all = async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.status(401).json({ error: "No Refresh Token" });
res.clearCookie("refreshToken");
// does not check if it exists in the db
const user = tokenVerifier.verifyRefreshToken(refreshToken);
if (user.tokenError)
return res.status(401).json({
error: "Invalid Refresh token",
tokenError: user.tokenError,
});
// delete all tokens associated with this user
deleteAllRefreshTokens(user.id);
return res.sendStatus(200);
};
به 2 چیز توجه کنید،
- هیچ بررسی برای دیدن اینکه آیا توکن در db وجود دارد وجود ندارد
- تأیید رمز دسترسی نادیده گرفته شده است
بیایید سناریویی را در نظر بگیریم که در آن کوکی مشتری ربوده شده است، بنابراین مهاجم دارای refreshToken است.
- اکنون او از آن refreshToken برای دریافت AccessToken جدید استفاده می کند
- که refreshToken کلاینت را که از آن ربوده شده است باطل می کند
- آن مشتری ممکن است کاربر قانونی باشد
- و اکنون آن مشتری نمی تواند AccessToken جدیدی دریافت کند
- بنابراین اگر تأیید accesToken یا بررسی وجود نشانه در db وجود داشته باشد، آن کلاینت نمی تواند از سیستم خارج شود.
تست api
می توانید از curl برای انجام تمام درخواست ها استفاده کنید
تمام دستورات فهرست شده در زیر در tests/api-test-curl.sh نوشته شده است
اگر از Insomnia استفاده میکنید، که ابزاری عالی برای تست api منبع باز است، میتوانید تمام درخواستها را از tests/api-test-insomnia.json وارد کنید.
ثبت نام
curl --request POST \
--url http://localhost:5000/api/auth/register \
--header 'Content-Type: application/json' \
--data '{
"username" : "gr523",
"email" : "gr523@gmail.com",
"password" : "Pass82G9"
}'
وارد شدن
curl --request POST \
--url http://localhost:5000/api/auth/login \
--header 'Content-Type: application/json' \
--cookie-jar "cookie.txt" \
--data '{
"username" : "gr523",
"password" : "Pass82G9"
}'
خروجی: {"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjNjY2Q2ZjNhLWQxMzItNDQzZi05NWM0LTRmMDJjYmU3ZDRlMSIsInVzZXJuYW1lIjoiZ3I1MjMiLCJlbWFpbCI6ImdyNTIzQGdtYWlsLmNvbSIsImlhdCI6MTY4MTkyOTYyNywiZXhwIjoxNjgxOTI5OTI3fQ.qbfKNvMk2W9JojB7O9CAtshOKoPQ1n2whLWrP4lzEJo"}
- کوکی در cookie.txt ذخیره خواهد شد
- مقدار accessToken را در کلیپ بورد خود کپی کنید
دسترسی به نقطه پایانی محافظت شده
- AccessToken را بعد از Bearer قرار دهید
curl --request GET \
--url http://localhost:5000/api/api/users \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjNjY2Q2ZjNhLWQxMzItNDQzZi05NWM0LTRmMDJjYmU3ZDRlMSIsInVzZXJuYW1lIjoiZ3I1MjMiLCJlbWFpbCI6ImdyNTIzQGdtYWlsLmNvbSIsImlhdCI6MTY4MTkyOTYyNywiZXhwIjoxNjgxOTI5OTI3fQ.qbfKNvMk2W9JojB7O9CAtshOKoPQ1n2whLWrP4lzEJo'
خروجی: {"users":[{"id":"3ccd6f3a-d132-443f-95c4-4f02cbe7d4e1","username":"gr523","email":"gr523@gmail.com"}]}
- مقدار accessToken را تغییر دهید
curl --request GET \
--url http://localhost:5000/api/api/users \
--header 'Authorization: Bearer xxxxxxxxxxxxxxxxNiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjNjY2Q2ZjNhLWQxMzItNDQzZi05NWM0LTRmMDJjYmU3ZDRlMSIsInVzZXJuYW1lIjoiZ3I1MjMiLCJlbWFpbCI6ImdyNTIzQGdtYWlsLmNvbSIsImlhdCI6MTY4MTkyOTYyNywiZXhwIjoxNjgxOTI5OTI3fQ.qbfKNvMk2W9JojB7O9CAtshOKoPQ1n2whLWrP4lzEJo'
خروجی {"error":"Invalid Access token","tokenError":"JsonWebTokenError"}
مدت اعتبار accessToken روی 5 دقیقه تنظیم شده است، پس از آن نمی توانید از آن نشانه برای دسترسی به منابع محافظت شده استفاده کنید.
پاسخ خواهد بود {"error":"Invalid Access token","tokenError":"TokenExpiredError"}
می توانید مدت اعتبار را در utils/genToken.ts تغییر دهید
Refresh Access Token
- از مقدار refreshToken از cookie.txt استفاده کنید
curl --request GET \
--url http://localhost:5000/api/auth/refresh \
--cookie refreshToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqd3RpZCI6IjQwY2VkYmRjLWM2NmQtNGJlYy1hNjc4LTg0MWJkZDhlMTBkMyIsImlkIjoiM2NjZDZmM2EtZDEzMi00NDNmLTk1YzQtNGYwMmNiZTdkNGUxIiwidXNlcm5hbWUiOiJncjUyMyIsImVtYWlsIjoiZ3I1MjNAZ21haWwuY29tIiwiaWF0IjoxNjgxOTI4MzgwLCJleHAiOjE2ODQ1MjAzODB9.WDk-YbqxX7_yCr8ATbDxbCV-W6EUNzxZPchPaHnuZAI
یا در لینوکس می توانید از sed استفاده کنید،
curl --request GET \
--url http://localhost:5000/api/auth/refresh \
--cookie refreshToken="$(sed -En '/refreshToken/s/.*refreshToken\s*(.*)/\1/p' cookie.txt)"
دوباره وارد شوید و درخواست رفرش کنید، پاسخ خواهد بود {"error":"Invalid Refresh Token","tokenError":"OldToken"}
شما تا این مرحله خوانده اید، آماده هستید تا پروژه rest-api بعدی خود را شروع کنید. قدردان هر گونه بازخوردی هستم. به من بگویید، اگر این سبک از آموزش را دوست دارید یا چه چیزی را می توان برای بهتر کردن آن تغییر داد