استفاده از JWT در Deno و Oak

?How to Use JWT in Deno and Oak

استفاده از JWT در Deno و Oak

به مقاله ای جدید در رابطه با JWT خوش آمدید. در مقاله ای که قبلا با نام «Deno در یک مقاله! +‌ ساخت API با Oak» منتشر کرده بودم، یک API ساده را با فریم ورک Oak و deno ساخته بودیم. در این مقاله می خواهم پس از ارائه اطلاعاتی کلی در مورد JWT یا JSON Web Token ها از همان API ساخته شده استفاده کرده و JWT را در عمل به آن اضافه کنیم.

سطح مقاله: این مقاله برای افراد مبتدی در نظر گرفته نشده است. آشنایی با مبانی REST API و آشنایی با فریم ورک Oak و Deno از ملزومات این پروژه است. ما در این مقاله از پایگاه داده Redis و کتابخانه bcrypt برای deno نیز استفاده خواهیم کرد.

JWT چیست؟

ما در دنیای وب نیاز به authentication یا احراز هویت کاربران داریم چرا که باید بدانیم درخواست های حساس ارسال شده از سمت چه کسی بوده است و آیا این فرد واقعا همان کسی است که ادعا می کند؟ مثلا اگر کسی ادعای ادمین بودن در سیستم ما را داشته باشد باید روشی برای تشخیص هویت واقعی او داشته باشیم. روش های بسیار مختلف و متعددی برای انجام authentication وجود دارد. در گذشته معمولا از cookie ها و session ها برای انجام احراز هویت استفاده می شده است و هنوز هم استفاده می شود. با اینکه cookie ها و session ها به هیچ عنوان منسوخ نشده اند اما روش محبوب دیگری نیز وجود دارد که JWT یا JSON Web Token نام دارد.

JWT یک استاندارد و مستقل برای انتقال داده بین دو دستگاه است و از قالب JSON برای این کار استفاده می کند. چطور؟ JWT ها داده هایی که دارند را به صورت دیجیتالی امضا می کنند بنابراین هر کسی که داده های آن ها را ویرایش کند، امضای دیجیتالی را باطل خواهد کرد و ما متوجه می شویم که داده ها دستکاری شده اند. برای امضا کردن JWT ها می توانید از یک secret (به معنی «رمز») استفاده کنید که یک رشته تصادفی و طولانی است که برای کسی قابل حدس نباشد یا اینکه می توانید از جفت های public key & private key استفاده کنید.

توجه داشته باشید که توکن های JWT رمزنگاری یا encrypt نشده اند بلکه encode یا کد بندی شده اند که آن هم از نوع Base64Url-encoded می باشد. این مسئله یعنی چه؟ یعنی نباید داده های حساس را درون این توکن ها قرار بدهید چرا که هر کسی که به یک توکن دسترسی داشته باشد می تواند داده هایش را بخواند.

ساختار توکن های JWT

هر توکن JWT از سه قسمت ساخته شده است:

  • Header که اطلاعاتی را در رابطه با توکن دارد.
  • Payload یا داده های مورد نظرمان که ما در آن می گذاریم.
  • Signature یا امضای دیجیتالی که از دو بخش قبلی ساخته می شود.

قسمت header معمولا دو بخش اصلی دارد که alg (الگوریتم امضای دیجیتال) و typ (نوع توکن) نام دارند. الگوریتم امضای دیجیتال می تواند RSA و HMAC SHA256 و غیره باشد. مثال:

{

  "alg": "HS256",

  "typ": "JWT"

}

قسمت Payload نیز دارای claim یا «ادعا» ها است که معمولا سه حرفی هستند تا حجم توکن را بالا نبرند. منظور از ادعا می تواند هر داده ای باشد. از آنجایی که توکن های JWT معمولا برای احراز هویت استفاده می شوند، داده های درون payload یک نوع ادعا حساب می شوند: مثلا ادعای اینکه کاربر لاگین شده است یا ادعای اینکه کاربر ادمین است و الی آخر. به همین دلیل است که به آن ها ادعا می گوییم. ادعاهای بخش payload خودشان به ۳ دسته تقسیم می شوند:

  • Registered claims یا ادعاهای ثبت شده: این دسته از ادعاها، ادعاهایی هستند که به صورت استاندارد تصویب شده اند و با اینکه استفاده از آن ها اجباری نیست اما پیشنهاد می شود. اطلاعات بیشتر در مقاله بنیاد IETF.
  • Public claims یا ادعاهای عمومی: شما می توانید ادعاهای مورد نظرتان را بسازید و با دیگران به اشتراک بگذارید
  • Private claims یا ادعاهای خصوصی: این دسته از ادعاها به عنوان یک قرار داد بین دو گروه استفاده می شوند. مثلا من به عنوان توسعه دهنده سرور یک ادعا با نام دلخواه خودم را ایجاد می کنم و به شما که توسعه دهنده front-end هستید می گویم از آن استفاده کنید. به زبان ساده تر این ادعاها کاملا سلیقه ای و قراردادی است.

مثال ساده ای از payload بدین شکل است:

{

  "sub": "1234567890",

  "name": "John Doe",

  "admin": true

}

قسمت signature یا امضای دیجیتال نیز ابتدا بخش های header و payload را کدبندی (encode) می کند و سپس آن ها را با secret یا رشته رمز (یک رشته طولانی و تصادفی) ترکیب می کند. نهایتا نیز این رشته را به یک الگوریتم پاس می دهیم تا امضا شود. مثلا:

HMACSHA256(

  base64UrlEncode(header) + "." +

  base64UrlEncode(payload),

  secret)

نتیجه یک رشته طولانی و عجیب و غریب خواهد بود که همان توکن شما است. باز هم تکرار می کنم هر کسی که به این توکن دسترسی داشته باشد، می تواند آن را decode کرده و محتویاتش را بخواند بنابراین نباید داده های حساس را در آن قرار بدهیم.

پروژه API آماده

همانطور که در ابتدای مقاله توضیح دادم، ما قبلا یک API را با فریم ورک Oak طراحی کرده بودیم که deno API نام داشت و در این قسمت از همان API استفاده خواهیم کرد. ساختار این API بسیار ساده بود و شما می توانید سورس کد آن را از لینک زیر دانلود نمایید:

دانلود پروژه تکمیل شده

.در صورتی که با این پروژه آشنایی ندارید و می خواهید با روش ساخت API در Oak آشنا شوید پیشنهاد می کنم به مقاله «Deno در یک مقاله! +‌ ساخت API با Oak» مراجعه کنید، در غیر این صورت می توانید پروژه را از لینک بالا دانلود کرده و با من شروع کنید.

تعریف کنترلرها و مسیرهای جدید

از پروژه ای که دانلود کرده اید ابتدا به پوشه controllers رفته و سپس یک فایل جدید به نام authentication.ts را در آن بسازید. ما در این فایل کنترلرهای مربوط به ثبت نام (signup) و ورود به حساب کاربری (login) و خروج از حساب کاربری (logout) را می سازیم:

import { RouterCTX } from "../types.ts";




export const signupHandler = ({ params, request, response }: RouterCTX) => {};

export const loginHandler = ({ params, request, response }: RouterCTX) => {};

export const logoutHandler = ({ params, request, response }: RouterCTX) => {};

همانطور که می بینید فعلا این متد ها خالی هستند اما بعدا تک تک آن ها را کدنویسی می کنیم. در مرحله بعدی به فایل routes.ts از پروژه بروید و سه مسیر جداگانه را ایجاد کنید:

  • مسیر ثبت نام: api/v1/signup
  • مسیر ورود به حساب: api/v1/login
  • مسیر خروج از حساب: api/v1/logout

تعریف این مسیرها و سپس پاس دادن کنترلرهای ساخته شده در authentication.ts کار بسیار آسانی است. پس از انجام این کار، محتوای فایل routes.ts شما باید بدین شکل باشد:

import { Router } from "./deps.ts";

import {

  loginHandler,

  logoutHandler,

  signupHandler,

} from "./controllers/authentication.ts";

import {

  getProducts,

  addProduct,

  deleteProduct,

  getProduct,

  updateProduct,

} from "./controllers/products.ts";




const router = new Router();




router.post("/api/v1/signup", signupHandler);

router.post("/api/v1/login", loginHandler);

router.post("/api/v1/logout", logoutHandler);




router.get("/api/v1/products", getProducts);

router.get("/api/v1/products/:id", getProduct);

router.post("/api/v1/products", addProduct);

router.put("/api/v1/products/:id", updateProduct);

router.delete("/api/v1/products/:id", deleteProduct);




export default router;

طبیعتا کدهای دیگر متعلق به دیگر قسمت های API است که در مقاله قبلی نوشته بودیم.

کنترلر اول: تولید توکن JWT و ثبت نام کاربر

اولین مسیر ما، مسیر sign up یا ثبت نام است. بسته به سلیقه شما روش های مختلفی برای انجام این کار وجود دارد اما من می خواهم فرآیند ثبت نام کاربران در API ما بدین شکل باشد:

  • کاربر درخواستی را با داده های مورد نیاز (ایمیل و رمز عبور) برای ما ارسال می کند.
  • پس از اعتبارسنجی داده ها، داده های کاربر را در پایگاه داده Redis ذخیره می کنیم.
  • یک توکن JWT را ساخته و به کاربر برمی گردانیم.

نصب درایور redis

دلیل انتخاب پایگاه داده Redis این است که کار با این پایگاه داده بسیار ساده است و به ما اجازه می دهد سریع تر کارمان را تکمیل کنیم. من فرض می کنم که شما redis را روی سیستم خود نصب کرده اید، با این حساب تنها کاری که باقی می ماند انتخاب یک درایور برای کار با redis است. من از پکیجی به نام redis استفاده می کنم که درایور deno برای پایگاه داده redis است. طبیعتا باید این پکیج را در authentication.ts وارد کنیم بنابراین ابتدا به فایل deps.ts می رویم و این پکیج را در آن وارد می کنیم:

export {

  Application,

  Router,

  Context,

} from "https://deno.land/x/oak@v7.5.0/mod.ts";




export type {

  RouterContext,

  Middleware,

} from "https://deno.land/x/oak@v7.5.0/mod.ts";




export { connect } from "https://deno.land/x/redis@v0.22.1/mod.ts";

متد connect از پکیج redis به ما اجازه می دهد که به پایگاه داده redis خود متصل شویم. در مقاله قبلی هم توضیح داده بودم که deno زیر بخش from و آدرس پکیج های جدید خط قرمز می کشد. شما باید یک بار deno run --allow-net server.ts را اجرا کنید تا این پکیج دانلود شده و برایتان کش شود. برای توضیحات بیشتر به مقاله قبلی مراجعه کنید.

در مرحله بعدی به فایل types.ts می رویم تا یک اینترفیس را برای کاربران خود تعریف کنیم:

import type { Context, RouterContext } from "./deps.ts";




export interface ProductsInterface {

  id: string;

  name: string;

  description: string;

  price: number;

}




export interface UsersInterface {

  [name: string]: string;

  email: string;

  password: string;

}




export type CTX = Context;

export type RouterCTX = RouterContext;

همانطور که می بینید من می خواهم API را ساده نگه دارم بنابراین هر کاربر فقط خصوصیت name (نام) و email (آدرس ایمیل) و password (رمز عبور) را خواهد داشت و تمام آن ها نیز به صورت رشته خواهند بود. در خصوصیت اول از این اینترفیس من از index signature ها استفاده کرده ام که یک قابلیت تایپ اسکریپتی هستند و به تایپ اسکریپت می گویند که تایپ کلیدهای ما حتما رشته ای است.

حالا به فایل authentication.ts برمی گردیم و در آن به redis متصل می شویم:

import { connect } from "../deps.ts";

import { RouterCTX } from "../types.ts";




const redis = await connect({ hostname: "127.0.0.1", port: 6379 });




export const signupHandler = ({ params, request, response }: RouterCTX) => {

// بقیه کدها

متد connect به یک آرگومان نیاز دارد که یک شیء است و دو خصوصیت hostname و port را دارد. من redis را روی سرور خاصی نصب نکرده ام بلکه روی سیستم خودم نصب است بنابراین hostname را روی مقدار پیش فرض 127.0.0.1 گذاشته ام. پورت پیش فرض redis نیز همان 6379 است و من پورت را از تنظیمات redis تغییر نداده ام بنابراین همان پورت را برایش گذاشته ام.

نصب پکیج djwt

در مرحله بعدی به پکیج djwt نیاز خواهیم داشت. این پکیج مسئولیت ساخت توکن های JWT و امضا کردن آن ها را بر عهده دارد. ابتدا به فایل deps.ts می رویم و آن را export می کنیم:

export {

  Application,

  Router,

  Context,

} from "https://deno.land/x/oak@v7.5.0/mod.ts";




export type {

  RouterContext,

  Middleware,

} from "https://deno.land/x/oak@v7.5.0/mod.ts";




export { connect } from "https://deno.land/x/redis@v0.22.1/mod.ts";




export { create } from "https://deno.land/x/djwt@v2.2/mod.ts";

در مرحله بعدی به فایل authentication.ts برمی گردیم و آن را وارد می کنیم:

import { connect, create as createJWT } from "../deps.ts";

import { RouterCTX } from "../types.ts";




const redis = await connect({ hostname: "127.0.0.1", port: 6379 });

// بقیه کدها

همانطور که می بینید من برای واضح تر بودن کدها، متد create را با نام مستعار createJWT وارد کرده ام. شما می توانید از همان نام create استفاده کرده و قسمت as createJWT را حذف کنید.

نصب پکیج bcrypt

آخرین پکیج مورد نیاز ما، پکیج bcrypt است که مسئول هش کردن رمز عبور کاربران است. همانطور که می دانید هیچ گاه نباید رمز عبور کاربران را به صورت ساده در پایگاه داده ذخیره کرد بلکه حتما باید آن ها را هش کنید. من باز هم به فایل deps.ts برمی گردم و این بار این پکیج را export می کنم:

// بقیه کدها

export { connect } from "https://deno.land/x/redis@v0.22.1/mod.ts";




export { create } from "https://deno.land/x/djwt@v2.2/mod.ts";




export * as bcrypt from "https://deno.land/x/bcrypt@v0.2.4/mod.ts";

توجه داشته باشید که در زمان نگارش این مقاله، پکیج bcrypt برای اجرا به فلگ unstable-- نیاز دارد بنابراین در هنگام اجرای سرور خود باید این فلگ را نیز پاس بدهیم (denon run --allow-net --unstable server.ts).

نوشتن چند متد کمکی برای اعتبارسنجی

حالا که پکیج های مورد نیاز آماده شده اند نوبت به کدنویسی کنترلر خودمان، یعنی متد signupHandler، می رسد. برای انجام این کار ابتدا باید بررسی کنیم که درخواست ارسال شده حتما بدنه داشته باشد و همچنین حتما در قالب JSON باشد. ما این کار را در مقاله قبلی نیز انجام داده بودیم بنابراین به سادگی می توانیم این کار را انجام بدهیم:

import { connect, create as createJWT } from "../deps.ts";

import { RouterCTX } from "../types.ts";




const redis = await connect({ hostname: "127.0.0.1", port: 6379 });




export const signupHandler = ({ params, request, response }: RouterCTX) => {

  if (!request.hasBody) {

    response.status = 400;

    response.body = {

      success: false,

      msg: "Your request does not have a body field",

    };

    return;

  }




  if (request.body().type !== "json") {

    response.status = 400;

    response.body = {

      success: false,

      msg: "Your request is not in JSON format",

    };

    return;

  }

};




export const loginHandler = ({ params, request, response }: RouterCTX) => {};

export const logoutHandler = ({ params, request, response }: RouterCTX) => {};

همانطور که می بینید این کار به راحتی با دو شرط if انجام می شود. اگر از این دو بخش رد شدیم، یعنی کاربر تا این قسمت را درست انجام داده است بنابراین مرحله بعدی، مخصوص دریافت داده های ارسال شده توسط کاربر و اعتبارسنجی آن ها است. من یک اعتبارسنجی بسیار ساده را انجام می دهم اما در پروژه های واقعی باید از پکیج های واقعی مانند validasaur استفاده کنید. برای انجام این کار در مسیر اصلی (پوشه deno API) یک پوشه به نام utility ایجاد می کنم و در آن فایلی به نام validator.ts می سازم. در این فایل چند متد ساده برای اعتبارسنجی سطحی را می نویسیم:

export const objectIsEmpty = (data: { [key: string]: string }) => {

  let result = false;

  for (const item in data) {

    if (data[item] == null || data[item] == "") {

      result = true;

    }

  }

  return result;

};




export const allFieldsPresent = (

  data: { [key: string]: string },

  fields: string[]

) => {

  const hasAllKeys = fields.every(item => data.hasOwnProperty(item));

  return hasAllKeys;

};




export const stringIsEmpty = (data: string) => {

  if (!data) {

    return true;

  }

  return false;

};

من در فایل validator.ts سه متد را به شکل بالا نوشته ام. متد اول (objectIsEmpty) یک شیء را گرفته و بررسی می کند که آن شیء به صورت خالی برایمان ارسال نشده باشد و خصوصیات آن به شکل رشته خالی یا null یا undefined نباشد. متد دوم (allFieldsPresent) دو پارامتر را می گیرد که اولی data (یک شیء) و دومی fields (آرایه ای از نام فیلدهای مورد نظر) است. ما در اینجا بررسی کرده ایم که تمام فیلدهای درون آرایه fields حتما به صورت یک خصوصیت در شیء data وجود داشته باشد. متد سوم (stringIsEmpty) یک رشته را گرفته و بررسی می کند که آیا این رشته خالی است یا خیر. من در متد های اول و دوم به تایپ اسکریپت گفته ام که کلیدهای شیء ما حتما رشته ای هستند تا بعدا از ما اشکال نگیرد.

ثبت کاربر جدید در پایگاه داده

حالا به فایل authentication.ts برگشته و از این متد های اعتبارسنجی استفاده می کنیم:

const redis = await connect({ hostname: "127.0.0.1", port: 6379 });




export const signupHandler = async ({ request, response }: RouterCTX) => {

  if (!request.hasBody) {

    response.status = 400;

    response.body = {

      success: false,

      msg: "Your request does not have a body field",

    };

    return;

  }




  if (request.body().type !== "json") {

    response.status = 400;

    response.body = {

      success: false,

      msg: "Your request is not in JSON format",

    };

    return;

  }




  const userData: UsersInterface = await request.body().value;

  let dataIsValid = allFieldsPresent(userData, ["name", "email", "password"]);

  dataIsValid = !objectIsEmpty(userData);




  if (dataIsValid) {

    // ثبت اطلاعات کاربر در پایگاه داده

    // تولید توکن احراز هویت

    // پاسخ دادن به کاربر

  } else {

    // ثبت اطلاعات کاربر در پایگاه داده

  }

};

همانطور که در این کد می بینید من ابتدا داده ها را از سمت کاربر گرفته ام. از آنجایی که value یک خصوصیت async یا ناهمگام است، باید برای دریافت آن از await استفاده کنیم که خودش نیاز به تبدیل کردن تابع به یک تابع async دارد و همانطور که از کد بالا مشخص است من دقیقا همین کار را کرده ام.  در مرحله بعدی متغیری به نام dataIsValid را تعریف کرده ایم و نتیجه دو متد  allFieldsPresent و objectIsEmpty را در آن قرار داده ایم (البته نتیجه objectIsEmpty را با علامت ! برعکس کرده ام). در صورتی که شیء ما تمام فیلدهای مورد نظرمان را داشته باشد و همچنین هیچ کدام از این فیلدها خالی نباشند، ما فرض می کنیم اعتبارسنجی موفقیت آمیز بوده است. توجه کنید که این سیستم اعتبارسنجی بسیار ساده و برای مثال در نظر گرفته شده است، در پروژه های واقعی باید از کتابخانه های اعتبارسنجی استفاده کنید. مثلا باید اعتبارسنجی کنید که ساختار ایمیل صحیح باشد یا کدهای مخرب برایتان ارسال نشده باشند.

بر همین اساس در کد بالا یک شرط if ... else را داریم و به صورت کامنت برایتان نوشته ام که باید در هر قسمت چه کاری انجام بدهیم. سعی کنید بدون اینکه به کدهای من نگاه کنید خودتان این کار را انجام بدهید.

قدم اول ثبت داده های کاربر در پایگاه داده است. redis داده ساختار های مختلفی برای ذخیره داده های ما دارد. بهترین داده ساختار برای ذخیره داده های کاربران Hash ها هستند بنابراین من از آن ها استفاده می کنم. همچنین در redis هر داده ای یک کلید دارد و من از ایمیل کاربر به عنوان کلید استفاده می کنم (مثال user:somone@gmail.com). البته قبل از ثبت داده های کاربر باید بررسی کنیم که این کاربر از قبل در پایگاه داده ما وجود نداشته باشد. با کنار هم گذاشتن تمام این مسائل می توان گفت:

import { connect, create as createJWT, bcrypt } from "../deps.ts";

import { RouterCTX, UsersInterface } from "../types.ts";

import { allFieldsPresent, objectIsEmpty } from "../utility/validator.ts";




const redis = await connect({ hostname: "127.0.0.1", port: 6379 });




export const signupHandler = async ({ request, response }: RouterCTX) => {

  if (!request.hasBody) {

    response.status = 400;

    response.body = {

      success: false,

      msg: "Your request does not have a body field",

    };

    return;

  }




  if (request.body().type !== "json") {

    response.status = 400;

    response.body = {

      success: false,

      msg: "Your request is not in JSON format",

    };

    return;

  }




  const userData: UsersInterface = await request.body().value;

  let dataIsValid = allFieldsPresent(userData, ["name", "email", "password"]);

  dataIsValid = !objectIsEmpty(userData);




  const userExists = await redis.exists(`user:${userData.email}`);




  if (dataIsValid && userExists === 0) {

    // ثبت اطلاعات کاربر در پایگاه داده

    const hashedPassowrd = await bcrypt.hash(userData.password);

    await redis.hset(

      `user:${userData.email}`,

      ["name", userData.name],

      ["password", hashedPassowrd]

    );




    response.status = 200;

    response.body = {

      msg: `new user with email=${userData.email} created`,

    };




    // تولید توکن احراز هویت

    // پاسخ دادن به کاربر

  } else {

    // ثبت اطلاعات کاربر در پایگاه داده

    response.status = 400;

    response.body = {

      msg: "your request body is not correct",

    };

  }

};

همانطور که می بینید من ابتدا پکیج bcrypt را وارد این فایل کرده ام تا بتوانیم با آن رمز عبور کاربران را هش کنیم. در مرحله بعدی از تابع exists در redis استفاده کرده ام تا بفهمیم آیا این کاربر قبلا ثبت نام کرده است یا خیر؟ این متد اگر کلید پاس داده شده را پیدا نکند، عدد صفر را برمی گرداند. دقت کنید که من از ساختار خاصی به شکل user:email استفاده کرده ام که یک قرارداد بین توسعه دهندگان redis است اما شما می توانید از هر چیز دیگری استفاده کنید. در صورتی که داده ها معتبر باشند (متغیر dataIsValid) و کاربر نیز در پایگاه داده وجود نداشته باشد (userExists برابر صفر) رمز عبور کاربر را هش کرده و سپس فیلدهای مورد نیازمان را با hset در قالب یک hash (شیء) در redis ذخیره می کنیم. این متد ها async هستند بنابراین بهتر است آن ها را await کنید. نهایتا status code برابر ۲۰۰ را به کاربر برمی گردانیم و می گوییم کاربر با email پاس داده شده ثبت نام کرده است.

برای تست این کدها از ترمینال دستور denon run --allow-net --unstable server.ts  را اجرا می کنم (این دستور را در مقاله قبلی توضیح دادیم). سپس از thunder client یا هر برنامه تست API دیگری مانند postman استفاده کرده و یک درخواست POST را به آدرس localhost:5000/api/v1/signup ارسال می کنیم. توجه داشته باشید که بدنه دستور باید بدین شکل باشد:

{

  "name": "Amir",

  "email": "amir@roxo.ir",

  "password": 123456789

}

نتیجه باید بدین شکل باشد:

ساخت یک کاربر جدید
ساخت یک کاربر جدید

بنابراین همه چیز مرتب است. در مرحله بعدی باید به فکر ساخت توکن JWT باشیم.

دو روش برای امضای توکن های JWT

قبلا توضیح داده بودم که برای کار با توکن های JWT به یک رشته طولانی و تصادفی به نام secret (رمز) نیاز داریم اما اگر بخواهیم دقیق تر توضیح بدهیم برای کار با JWT دو روش اصلی وجود دارد:

  • استفاده از یک الگوریتم امضای دیجیتال مانند HS256: در این روش رمز را گرفته و توکن را با آن امضا می کنیم و بعدا برای تایید کردن توکن (اعتبارسنجی آن) نیز از همان رمز استفاده می کنیم.
  • استفاده از جفت های public key و private key: در این روش هنوز هم از یک الگوریتم امضای دیجیتال استفاده می کنیم اما به جای یک رشته رمز، از کلیدهای عمومی و خصوصی برای امضا و سپس بررسی صحت توکن استفاده می کنیم.

پکیج djwt که ما از آن استفاده می کنیم، از هر دو روش پشتیبانی می کند. روش اول بسیار آسان است:

const jwt = await create({ alg: "HS512", typ: "JWT" }, { foo: "bar" }, "secret")

آرگومان اول header و آرگومان دوم payload و آرگومان سوم رشته secret است. در اینجا رشته secret فقط کلمه secret است تا درک آن آسان باشد اما در واقعیت هیچ گاه نباید چنین کلمه کوتاهی را برای رشته secret انتخاب کنید. با اجرای این کد توکن JWT شما ایجاد می شود که چیزی شبیه به رشته زیر است:

eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.WePl7achkd0oGNB8XRF_LJwxlyiPZqpdNgdKpDboAjSTsWq-aOGNynTp8TOv8KjonFym8vwFwppXOLoLXbkIaQ

این روش هیچ مشکلی ندارد و اگر وب سایت شما monolithic است (یعنی فقط یک سرور دارد) این روش هیچ مشکلی برایتان ایجاد نمی کند اما اگر وب سایت شما روی چندین سرور قرار دارد با مشکل روبرو می شویم: در این حالت برای امضا کردن و همچنین برای تایید امضا و اعتبارسنجی توکن ها باید به رشته secret دسترسی داشته باشیم. چطور چندین سرور به این کلید دسترسی داشته باشند؟ این مشکل سه راه حل اصلی دارد:

راه حل اول: از یک سرور جداگانه برای احراز هویت خود استفاده کنید. با این حساب تمام سیستم هایی که توکن را از کلاینت (کاربر) دریافت می کنند باید یک درخواست را به سرور احراز هویت ارسال کنند تا ابتدا توکن تایید شود. مشکل اینجاست که هزینه داشتن یک سرور جداگانه برای احراز هویت زیاد است و همچنین تعداد درخواست های ارسال شده به چنین سروری بسیار زیاد خواهد بود بنابراین سرعت کلی وب سایت ما کاهش پیدا می کند چرا که این سرور سرعت دیگر سرور ها را کم خواهد کرد.

راه حل دوم: رشته secret را بین تمام سیستم ها به اشتراک بگذاریم. این روش نیز معایب خودش را دارد. به طور مثال به اشتراک گذاری رشته رمز بین چندین سرور، شانس نشت این رشته و دزدیده شدن آن را افزایش می دهد. اگر هکر بتواند به این رشته دسترسی پیدا کند، می تواند خودش را جای تمام کاربران دیگر جا بزند. همچنین اگر بعدا بخواهیم رشته رمز را تغییر بدهیم باید آن را در تمام سیستم های دیگر نیز تغییر بدهیم که کار ما را دو چندان می کند.

راه حل سوم: استفاده از جفت کلیدهای private و public. در این روش ما با استفاده از کلید private (خصوصی) توکن را امضا می کنیم و هر سروری که بخواهد این امضا را تایید کند می تواند از کلید public (عمومی) استفاده کند. در این روش می توانیم کلید public را با هر کسی به اشتراک بگذاریم چرا که هیچکس نمی تواند با کلید public توکن را امضا کند بلکه فقط می تواند امضای آن را تایید کند.

همانطور که می بینید استفاده از کلیدهای عمومی و خصوصی روش خوبی است اما از طرفی پیاده سازی آن ها کمی پیچیده تر است و هزینه بیشتری برای سرور دارند. من به همین دلیل هر دو روش را به شما نشان می دهم تا شما با حالت پیچیده تر توکن های JWT نیز آشنا شده باشید.

نکته: HMAC کاملا امن است و از الگوریتم های RSA بسیار سریع تر است و طبیعتا هزینه کمتری برای سرور شما دارد بنابراین توصیه می شود تا حد ممکن است HMAC استفاده کنید مگر آنکه به یکی از مشکلات بالا برخورد کرده باشید.

ساخت کلید RSA برای تولید توکن JWT

بیایید ابتدا با روش پیچیده تر، یعنی استفاده از کلیدهای public و private، شروع کنیم. ابتدا باید بدانیم پکیج djwt از چه کلیدهایی پشتیبانی می کند. اگر به صفحه اصلی این پکیج بروید می توانید لیست این کلیدها را مشاهده کنید:

HS256 (HMAC SHA-256)

HS512 (HMAC SHA-512)

RS256 (RSASSA-PKCS1-v1_5 SHA-256)

RS512 (RSASSA-PKCS1-v1_5 SHA-512)

PS256 (rsassa-pss SHA-256)

PS512 (rsassa-pss SHA-512)

none (Unsecured JWTs).

شما می توانید هر کلیدی را که خواستید انتخاب کنید اما من RS512 را انتخاب می کنم. برای تولید چنین کلیدی ابتدا یک پوشه به نام keys را در پوشه اصلی پروژه ایجاد کنید. در مرحله بعدی ترمینال خود را در این پوشه (keys) باز کرده و دستور زیر را در آن اجرا کنید:

openssl genrsa -out private.pem 2048

اگر با اجرای دستور بالا از شما خواسته شد یک passphrase تعیین کنید، این کار را انجام ندهید و فیلد را به صورت خالی باقی گذاشته و enter بزنید تا به مرحله بعدی بروید. این دستور یک کلید RS256 را می سازد و آن را در فایلی به نام private.pem ذخیره می کند. عدد ۲۰۴۸ در انتهای این دستور اندازه کلید را مشخص می کند که من ۲۰۴۸ را برایش انتخاب کرده ام.همچنین فرمت این کلید PEM است. شما می توانید به جای private.pem از هر نام دیگری برای آن استفاده کنید.

با این کار کلید خصوصی ما ساخته شده است بنابراین باید بر اساس آن یک کلید عمومی بسازیم. برای این کار باید دستور زیر را در ترمینال خود اجرا کنید:

openssl rsa -in private.pem -pubout > public.pem

نکته: کاربران ویندوز بهتر است از git for windows استفاده کنند تا به دستورات openssl دسترسی داشته باشند. البته می توانید openssl را مستقیما در ویندوز نصب کنید.

در حال حاضر اگر به پوشه keys بروید باید دو فایل به نام های private.pem و public.pem داشته باشید که به ترتیب کلیدهای عمومی (public) و خصوصی (private) هستند. در مرحله بعدی در اسکریپت خود باید این کلیدها را خوانده و توکن را بررسی کنید. ابتدا به فایل deps.ts می رویم تا متد verify را نیز export کنیم:

export {

  Application,

  Router,

  Context,

} from "https://deno.land/x/oak@v7.5.0/mod.ts";




export type {

  RouterContext,

  Middleware,

} from "https://deno.land/x/oak@v7.5.0/mod.ts";




export { connect } from "https://deno.land/x/redis@v0.22.1/mod.ts";




export { create, verify } from "https://deno.land/x/djwt@v2.2/mod.ts";




export * as bcrypt from "https://deno.land/x/bcrypt@v0.2.4/mod.ts";

در مرحله بعدی به فایل authentication.ts برگشته و آن ها را import می کنیم:

import {

  connect,

  create as createJWT,

  verify as verifyJWT,

  bcrypt,

} from "../deps.ts";

import { RouterCTX, UsersInterface } from "../types.ts";

import { allFieldsPresent, objectIsEmpty } from "../utility/validator.ts";

// بقیه کدها

حالا به متد signupHandler برمی گردیم و بدین شکل عمل می کنیم:

// بقیه کدها

if (dataIsValid && userExists === 0) {

  // ثبت اطلاعات کاربر در پایگاه داده

  const hashedPassowrd = await bcrypt.hash(userData.password);

  await redis.hset(

    `user:${userData.email}`,

    ["name", userData.name],

    ["password", hashedPassowrd]

  );




  const privateKey = await Deno.readTextFile(

    Deno.cwd() + "/keys/private.pem"

  );




  const jwt = await createJWT(

    { alg: "RS256", typ: "JWT" },

    {

      sub: `user:${userData.email}`,

      loggedIn: true,

      iss: "roxo",

      iat: new Date().getTime(),

    },

    privateKey

  );




  response.status = 200;

  response.body = {

    msg: `new user with email=${userData.email} created`,

    token: jwt,

  };




  // تولید توکن احراز هویت

  // پاسخ دادن به کاربر

} else {

  // ثبت اطلاعات کاربر در پایگاه داده

  response.status = 400;

  response.body = {

    msg: "your request body is not correct",

  };

}

// بقیه کدها

من در اینجا ابتدا private key را با متد readTextFile خوانده ام. از آنجایی که این متد ناهمگام است حتما باید آن را await کنید. متد cwd مخفف current working directorry (مسیر کاری فعلی) مسیر فعلی را به شما برمی گرداند اما مسیر فعلی چه فایلی را؟ برای مشخص کردن مسیر کلید باید نکته بسیار مهمی را به یاد داشته باشید: deno یک برنامه CLI است بنابراین از هر جایی صدا زده بشود، مسیر فعلی همان جا خواهد بود. یعنی چه؟ یعنی ما ترمینال را در پوشه deno API (پوشه اصلی پروژه) باز می کنیم و از آنجا دستور deno run --unstable --allow-all server.ts یا هر دستور مشابهی را برای اجرای پروژه اجرا می کنیم. با این حساب cwd در هر فایل یا پوشه ای که باشید یکسان و برابر با مسیر server.ts خواهد بود. با همین منطق من cwd را صدا زده ام و آن را به مسیر کلید (keys/private.pem) اضافه کرده ام تا مسیر کامل را داشته باشیم و محتوای این کلید را در متغیر privateKey ذخیره کنیم.

در مرحله بعدی متد createJWT را صدا زده ام و آن را نیز await کرده ام. آرگومان اولی که به آن پاس داده ام همان شیء Header است، آرگومان دوم شیء payload است و شما می توانید داده های دلخواهتان را در آن قرار بدهید. مثلا من از خصوصیت loggedIn استفاده کرده ام تا مشخص کنم کاربر log in شده است یا خیر، سپس از iss استفاده کرده ام که صادر کننده (issuer) توکن را مشخص می کند و iat نیز زمان صدور توکن (issued at) را مشخص می کند. مهم ترین بخش نیز همان sub (مخفف subject) است که معمولا حاوی id کاربر است بنابراین من برای سادگی از id کاربر در پایگاه داده به عنوان مقدارش استفاده کرده ام. شما می توانید هر داده دیگری را نیز در آن قرار بدهید (مثلا username و الی آخر) اما هیچ وقت داده های حساس مانند رمز عبور را در آن قرار ندهید. آرگومان آخر نیز همان privateKey ما است.

در قدم بعدی نیز توکن را در بدنه درخواست گذشته و به سمت کاربر ارسال کرده ام. با این حساب اگر کامنت ها را حذف کرده و همه چیز را در بلوک try & catch قرار دهیم و کمی تمیز کاری کنیم، کل متد signupHandler باید به شکل زیر باشد:

export const signupHandler = async ({ request, response }: RouterCTX) => {

  try {

    if (!request.hasBody) {

      response.status = 400;

      response.body = {

        success: false,

        msg: "Your request does not have a body field",

      };

      return;

    }




    if (request.body().type !== "json") {

      response.status = 400;

      response.body = {

        success: false,

        msg: "Your request is not in JSON format",

      };

      return;

    }




    const userData: UsersInterface = await request.body().value;

    let dataIsValid = allFieldsPresent(userData, ["name", "email", "password"]);

    dataIsValid = !objectIsEmpty(userData);




    const userExists = await redis.exists(`user:${userData.email}`);




    if (dataIsValid && userExists === 0) {

      const hashedPassowrd = await bcrypt.hash(userData.password);

      await redis.hset(

        `user:${userData.email}`,

        ["name", userData.name],

        ["password", hashedPassowrd]

      );




      const privateKey = await Deno.readTextFile(

        Deno.cwd() + "/keys/private.pem"

      );




      const jwt = await createJWT(

        { alg: "RS256", typ: "JWT" },

        {

          sub: `user:${userData.email}`,

          loggedIn: true,

          iss: "roxo",

          iat: new Date().getTime(),

        },

        privateKey

      );




      response.status = 200;

      response.body = {

        msg: `new user with email=${userData.email} created`,

        token: jwt,

      };

    } else {

      response.status = 400;

      response.body = {

        msg: "Request invalid or email already exists",

      };

    }

  } catch (error) {

    response.status = 400;

    response.body = {

      msg: "Something went wrong",

    };

  }

};

حالا بیایید API خود را تست کنیم تا متوجه شویم آیا واقعا توکن را می گیریم یا خیر:

دریافت توکن JWT پس از ثبت نام
دریافت توکن JWT پس از ثبت نام

همانطور که می بینید توکن را به راحتی دریافت کرده ایم. تبریک می گویم، فرآیند ثبت نام به صورت کامل انجام شد!

انقضای توکن ها

یکی از مشکلات فعلی توکن ما این است که تا ابد معتبر بوده و هیچگاه منقضی نمی شود! این مسئله از نظر امنیتی دارای مشکل است چرا که اگر توکن به نحوی به دست فرد دیگری بیفتد، آن فرد تا ابد می تواند صاحب حساب دیگری شود. برای حل این مشکل معمولا توکن ها را به صورت خودکار منقضی می کنند. خوشبختانه پکیج djwt یک متد کمکی به نام getNumericDate را در این زمینه دارد. بنابراین به فایل deps.ts رفته و آن را export می کنیم:

export {

  create,

  verify,

  getNumericDate,

  decode,

} from "https://deno.land/x/djwt@v2.2/mod.ts";




export * as bcrypt from "https://deno.land/x/bcrypt@v0.2.4/mod.ts";

متد decode به ما اجازه می دهد به مقادیر درون توکن دسترسی داشته باشیم. در مرحله بعدی به authentication.ts برگشته و آن را import می کنیم:

import {

  connect,

  create as createJWT,

  verify as verifyJWT,

  getNumericDate,

  decode,

  bcrypt,

} from "../deps.ts";

// بقیه کدها

متد  getNumericDate یا یک شیء Date و یا یک عدد (در واحد ثانیه) را دریافت می کند و یک timestamp را برایمان برمی گرداند. به طور مثال:

// یک تاریخ خاص

getNumericDate(new Date("2025-07-01"))

// تاریخ نسبی برابر با یک ساعت بعد

getNumericDate(60 * 60)

حالا تنها کاری که ما باید انجام بدهیم، تعریف ادعای exp (مخفف expiration) در payload توکن است:

// بقیه کدها

const jwt = await createJWT(

  { alg: "RS256", typ: "JWT" },

  {

    sub: `user:${userData.email}`,

    loggedIn: true,

    iss: "roxo",

    exp: getNumericDate(10),

    iat: new Date().getTime(),

  },

  privateKey

);

// بقیه کدها

همانطور که می بینید من آن را روی ۱۰ ثانیه گذاشته ام تا سریعا منقضی شود. چرا؟ به دلیل اینکه می خواهیم API را تست کنیم و ببینیم آیا توکن کار می کند یا خیر.

کنترلر دوم: ورود به حساب کاربری با loginHandler

کار کنترلر بعدی ما این است که اطلاعات کاربر (رمز عبور و ایمیل) را گرفته و اگر کاربر در پایگاه داده وجود داشت، یک توکن را برایش تولید کرده و به او بدهد. انجام این کار نیز بسیار ساده و مشابه با کاری است که در کنترلر ثبت نام انجام دادیم. من در ابتدا کدهای آماده شده را در اختیار شما قرار می دهم و سپس خط به خط آن ها را بررسی می کنیم:

export const loginHandler = async ({ request, response }: RouterCTX) => {

  try {

    if (!request.hasBody) {

      response.status = 400;

      response.body = {

        success: false,

        msg: "Your request does not have a body field",

      };

      return;

    }




    if (request.body().type !== "json") {

      response.status = 400;

      response.body = {

        success: false,

        msg: "Your request is not in JSON format",

      };

      return;

    }




    const publicKey = await Deno.readTextFile(Deno.cwd() + "/keys/public.pem");

    const authorizationHeader = request.headers.get("Authorization");

    const reqToken = authorizationHeader?.split("Bearer ")[1] as string;




    if (reqToken) {

      try {

        await verifyJWT(reqToken, publicKey, "RS256");

        response.status = 400;

        response.body = {

          success: false,

          msg: "You are already logged in",

        };

        return;

      } catch (error) {}

    }




    const reqData: UsersInterface = await request.body().value;

    let dataIsValid = allFieldsPresent(reqData, ["email", "password"]);

    dataIsValid = !objectIsEmpty(reqData);




    if (dataIsValid) {

      const userData = await redis.hgetall(`user:${reqData.email}`);




      const correctPassowrd = await bcrypt.compare(

        reqData.password,

        userData[3],

      );




      if (correctPassowrd) {

        const privateKey = await Deno.readTextFile(

          Deno.cwd() + "/keys/private.pem",

        );




        const jwt = await createJWT(

          { alg: "RS256", typ: "JWT" },

          {

            sub: `user:${reqData.email}`,

            loggedIn: true,

            iss: "roxo",

            exp: getNumericDate(10),

            iat: new Date().getTime(),

          },

          privateKey,

        );




        response.status = 200;

        response.body = {

          msg: `Logged in successfully`,

          token: jwt,

        };

      } else {

        response.status = 403;

        response.body = {

          msg: "Password or email was incorrect. No such user exists.",

        };

      }

    }

  } catch (error) {

    response.status = 400;

    response.body = {

      msg: `There was something wrong with your request. Login failed.`,

    };

  }

};

در ابتدا مثل همیشه دو شرط if را داریم تا اگر درخواست بدنه نداشت و یا اینکه در قالب JSON نبود، خطایی را برای کلاینت برگردانیم. در مرحله بعدی کلید خصوصی خود را خوانده ام و آن را در متغیری به نام publicKey گذاشته ام. همچنین هدری (header) در درخواست به نام Authorization را دریافت کرده ام و در متغیر authorizationHeader قرار داده ام. چرا این کار را کرده ایم؟ برای کار با توکن ها JWT راه های مختلفی وجود دارد اما یکی از رایج ترین روش ها این است که کاربر توکن را درون هدر Authorization به شکل زیر قرار می دهد:

Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyOmFtaXJAcm94by5pciIsImxvZ2dlZEluIjp0cnVlLCJpc3MiOiJyb3hvIiwiZXhwIjoxNjIyMjY4NzEwLCJpYXQiOjE2MjIyNjg2OTk3MjF9.E5ctQxKXx7hucVHJqro8GIQwopD0RGGq-_pDZYbIAxToM2RSDxyttufDGFiIZw9uvmR7eyh7o0NB5QHatz2FLNiY9MHK4VwyJHyG3axb-mrPM7ZIAUEisFthdLSwYU-Ux5PHCy8Vpl9oqbI0fIt5Fw2eHtsnPwizkiYLWs1OvX09S4VLxszJB9sZL72zZ7hZ7qX84rG7xV6LOU4eZNkIVi28xi2-UNGm6W4kshlyxp5mFK58Ffu7hvMbWfi6zAsE0oXignCy6TU_AGb5JvAkVemI6MtvIprex3nhDox478fYsswC07bYDxgSBUxsShpydDTqq6WcNPUMQlHx08R7gw

همانطور که می بینید ابتدا کلمه Bearer (به معنی حامل) را داریم و سپس یک اسپیس و سپس توکن به دنبال آن آمده است. این یک روش استاندارد در کار با توکن های JWT است و من هم از همین روش استفاده خواهم کرد. به همین خاطر است که هدر Authorization را گرفته ایم و سپس با استفاده از متد split توکن را از آن جدا کرده ایم. دقت کنید که اگر Bearer و یک اسپیس را به متد split پاس بدهیم آرایه ای با دو عضو برگردانده می شود که عوض اول یک رشته خالی (“”) و عضو دوم توکن ما است بنابراین من عضو دوم (ایندکس ۱) را گرفته ام.

اگر کاربر توکنی نداشته باشد چطور؟ در این حالت یا هدر Authorization وجود ندارد یا اینکه توکن کاربر منقضی شده است بنابراین باید چک کنیم. من گفته ام اگر متغیر reqToken وجود داشت (توکن وجود داشت) ابتدا متد verifyJWT را صدا می زنیم تا ببینیم آیا توکن کاربر معتبر است یا خیر؟ پکیج djwt در صورت نامعتبر بودن توکن (مثلا منقضی شدن آن) یک خطا را پرتاب می کند بنابراین باید باز هم این بخش را درون یک بلوک try & catch بگذاریم.

در صورتی که متد verifyJWT با موفقیت اجرا شود یعنی کاربر توکنی دارد که منقضی نشده است بنابراین یک پاسخ را برایش می فرستیم که در حال حاضر لاگین شده است و نیازی به درخواست دوباره لاگین وجود ندارد. از طرفی اگر متد verifyJWT با موفقیت اجرا نشود، یک خطا پرتاب می شود و از اجرای خط های بعدی بلوک try جلوگیری می کند (مستقیما وارد catch می شویم). من catch را برای شما خالی گذاشته ام تا خودتان تصمیم بگیرید با آن چه کار کنید. مثلا می توانید خطا های ایجاد شده را log کنید اما نظر شخصی خودم این است که آن را رها کنید.

اگر توکن (متغیر reqToken) وجود نداشته باشد یا اینکه منقضی شده باشد (متد verifyJWT با موفقیت اجرا نشود) از این بخش عبور کرده و به متغیر reqData می رسیم که همان بدنه درخواست ارسال شده توسط کاربر است. مثل کنترلر ثبت نام، با متد های allFieldsPresent و objectIsEmpty بررسی می کنیم که بدنه ارسال شده خالی نباشد و تمام فیلدهای مورد نظر ما را داشته باشد. اگر اینچنین بود (متغیر dataIsValid برابر true بود) چه کار باید کرد؟

در این حالت ابتدا از متد hgetall در redis استفاده می کنم تا کاربر مورد نظر را از پایگاه داده بگیریم. همانطور که می دانید کلیدهای ما در redis به شکل user:email است بنابراین من ایمیل کاربر را به رشته :user چسبانده ام و سعی در دریافت کاربر کرده ام. مثلا اگر کاربری با کلید user:amir@roxo.ir در پایگاه داده وجود داشته باشد با اجرای دستور hgetall آرایه ای از اعضای زیر را دریافت می کنیم:

1) "name"

2) "Amir"

3) "password"

4) "$2a$10$mah9xoiJmXoGHJxUD/U4m.rpaoKXA0b2s7jX/O9fVUyAOcsgSDk0i"

دقت کنید که رمز عبور هش شده است و به همین خاطر چنین رشته طولانی را داریم. با این حساب عضو چهارم از آرایه برگردانده شده با hgetall (ایندکس سوم) همان رمز عبور کاربر خواهد بود. از طرفی پکیج bcrypt تابعی به نام compare دارد که به ما اجازه می دهد یک رشته را با مقدار هش شده اش مقایسه کنیم. در صورتی که نتیجه ای متد صحیح باشد باید کلید private خود را بخواهیم (متغیر privateKey) یک توکن جدید را برای کاربر بسازیم. بقیه کدها عینا همان کدهایی است که در کنترلر ثبت نام نوشته بودیم.

توجه داشته باشید که ما انقضای توکن ها را روی ۱۰ ثانیه گذاشته ام تا قابل تست باشند. من برای تست این کد یک بار درخواست لاگین را به آدرس localhost:5000/api/v1/login ارسال می کنم و سپس توکن دریافت شده را در هدر Authorization قرار می دهم و دوباره درخواست لاگین ارسال می کنم:

کاربر لاگین شده است و توکن جدید را دریافت کرده ایم
کاربر لاگین شده است و توکن جدید را دریافت کرده ایم
توکن جدید را در Authorization قرار داده ام بنابراین خطا گرفته ایم
توکن جدید را در Authorization قرار داده ام بنابراین خطا گرفته ایم

همانطور که می بینید به دلیل معتبر بودن توکن، خطا دریافت کرده ایم بنابراین کدهایمان به خوبی کار می کند.

خواندن کلیدهای خصوصی و عمومی

همانطور که می دانید خواندن فایل ها از روی دیسک عملیات کُندی محسوب می شود، مخصوصا اگر سرور شما از هارد دیسک های HDD به جای SSD استفاده کند. با این حساب هر چه تعداد خواندن فایل ها بیشتر باشد برنامه ما کند تر خواهد شد. در حال حاضر برنامه ما در کنترلر ثبت نام و در کنترلر ورود به حساب کاربری، کلیدها را جداگانه می خواند در صورتی که کلیدها یکی هستند. برای جلوگیری از این مشکل می توانیم کلیدها را در همان ابتدای فایل authentication.ts بخوانیم تا فقط یک بار خوانده شوند:

import {

  bcrypt,

  connect,

  create as createJWT,

  decode,

  getNumericDate,

  verify as verifyJWT,

} from "../deps.ts";

import { RouterCTX, UsersInterface } from "../types.ts";

import { allFieldsPresent, objectIsEmpty } from "../utility/validator.ts";




const redis = await connect({ hostname: "127.0.0.1", port: 6379 });




const publicKey = await Deno.readTextFile(Deno.cwd() + "/keys/public.pem");

const privateKey = await Deno.readTextFile(Deno.cwd() + "/keys/private.pem");




export const signupHandler = async ({ request, response }: RouterCTX) => {

  try {

    if (!request.hasBody) {

      response.status = 400;

      response.body = {

        success: false,

        msg: "Your request does not have a body field",

      };

      return;

    }




    if (request.body().type !== "json") {

      response.status = 400;

      response.body = {

        success: false,

        msg: "Your request is not in JSON format",

      };

      return;

    }




    const userData: UsersInterface = await request.body().value;

    let dataIsValid = allFieldsPresent(userData, ["name", "email", "password"]);

    dataIsValid = !objectIsEmpty(userData);




    const userExists = await redis.exists(`user:${userData.email}`);




    if (dataIsValid && userExists === 0) {

      const hashedPassowrd = await bcrypt.hash(userData.password);

      await redis.hset(

        `user:${userData.email}`,

        ["name", userData.name],

        ["password", hashedPassowrd]

      );




      const jwt = await createJWT(

        { alg: "RS256", typ: "JWT" },

        {

          sub: `user:${userData.email}`,

          loggedIn: true,

          iss: "roxo",

          exp: getNumericDate(10),

          iat: new Date().getTime(),

        },

        privateKey

      );




      response.status = 200;

      response.body = {

        msg: `new user with email=${userData.email} created`,

        token: jwt,

      };

    } else {

      response.status = 400;

      response.body = {

        msg: "Request invalid or email already exists",

      };

    }

  } catch (error) {

    response.status = 400;

    response.body = {

      msg: "Something went wrong",

    };

  }

};




export const loginHandler = async ({ request, response }: RouterCTX) => {

  try {

    if (!request.hasBody) {

      response.status = 400;

      response.body = {

        success: false,

        msg: "Your request does not have a body field",

      };

      return;

    }




    if (request.body().type !== "json") {

      response.status = 400;

      response.body = {

        success: false,

        msg: "Your request is not in JSON format",

      };

      return;

    }




    const authorizationHeader = request.headers.get("Authorization");

    const reqToken = authorizationHeader?.split("Bearer ")[1] as string;




    if (reqToken) {

      try {

        console.log("HERE");




        await verifyJWT(reqToken, publicKey, "RS256");

        response.status = 400;

        response.body = {

          success: false,

          msg: "You are already logged in",

        };

        return;

      } catch (error) {}

    }




    const reqData: UsersInterface = await request.body().value;

    let dataIsValid = allFieldsPresent(reqData, ["email", "password"]);

    dataIsValid = !objectIsEmpty(reqData);




    if (dataIsValid) {

      const userData = await redis.hgetall(`user:${reqData.email}`);




      const correctPassowrd = await bcrypt.compare(

        reqData.password,

        userData[3]

      );




      if (correctPassowrd) {

        const jwt = await createJWT(

          { alg: "RS256", typ: "JWT" },

          {

            sub: `user:${reqData.email}`,

            loggedIn: true,

            iss: "roxo",

            exp: getNumericDate(10),

            iat: new Date().getTime(),

          },

          privateKey

        );




        response.status = 200;

        response.body = {

          msg: `Logged in successfully`,

          token: jwt,

        };

      } else {

        response.status = 403;

        response.body = {

          msg: "Password or email was incorrect. No such user exists.",

        };

      }

    }

  } catch (error) {

    response.status = 400;

    response.body = {

      msg: `There was something wrong with your request. Login failed.`,

    };

  }

};

حالا کدهایمان تمیز تر شده است.

استفاده از HMAC برای تولید توکن JWT

روش اول تولید توکن های JWT استفاده از جفت کلیدهای عمومی و خصوصی RSA بود و من به جهت آشنایی شما با آن روش برنامه را به شکل بالا نوشته ام اما شخصا ترجیح می دهم از HMAC و یک رشته رمز (secret) استفاده کنم چرا که کار با آن هم ساده تر و هم سریع تر است. استفاده از جفت کلیدهای RSA فقط در مواردی کاربرد دارد که بخواهید وب سایت خود را روی چندین سرور قرار بدهید و حتی در چنین حالتی هنوز هم مجبور به استفاده از این روش نیستید.

در این روش نیازی به داشتن کلید نداریم بنابراین می توانید پوشه keys را حذف کنید. بیایید کنترلر ثبت نام را بدین روش بازنویسی کنیم. در ابتدا باید یک رشته طولانی و تصادفی به نام secret را داشته باشیم بنابراین من آن را در ابتدا این فایل تعریف می کنم:

import {

  bcrypt,

  connect,

  create as createJWT,

  decode,

  getNumericDate,

  verify as verifyJWT,

} from "../deps.ts";

import { RouterCTX, UsersInterface } from "../types.ts";

import { allFieldsPresent, objectIsEmpty } from "../utility/validator.ts";




const redis = await connect({ hostname: "127.0.0.1", port: 6379 });

const secret = "gx5P(.i/yGd|VU~<eLaeoReM;,Qx*phg]!.m%'QMBbL:mGR]4wH0SuuB^ly:}Hw!e4fhbd,N:x9.f4B^oJ&NLWtlHXF-u)>M`Qv`N7?LKpwWf@dzsJP>f8LkdX";

// بقیه کدها

هشدار: در پروژه های واقعی هیچگاه secret را بدین شکل درون کدها قرار ندهید چرا که اگر کدها را روی گیت هاب commit کنید یا با دیگران به اشتراک بگذارید، رمز شما فاش خواهد شد و از نظر امنیتی دچار مشکل می شوید. معمولا از پکیج هایی مانند dotenv برای این کار استفاده می شود. من تنها برای سادگی کار از این روش استفاده کرده ام.

در مرحله بعدی به بخش ساخت توکن در کنترلر signupHandler رفته و به جای privateKey از secret استفاده می کنیم:

// بقیه کدها

const jwt = await createJWT(

  { alg: "HS512", typ: "JWT" },

  {

    sub: `user:${userData.email}`,

    loggedIn: true,

    iss: "roxo",

    exp: getNumericDate(10),

    iat: new Date().getTime(),

  },

  secret

);

// بقیه کدها

به همین سادگی کنترلر ثبت نام بازنویسی شده است و نیازی به کار دیگری نداریم. در مرحله بعدی به سراغ کنترلر ورود به حساب کاربری می رویم. ابتدا بخش بررسی توکن را تصحیح می کنیم:

// بقیه کدها

if (reqToken) {

  try {

    await verifyJWT(reqToken, secret, "HS512");

    response.status = 400;

    response.body = {

      success: false,

      msg: "You are already logged in",

    };

    return;

  } catch (error) {}

}

// بقیه کدها

سپس بخش تولید توکن جدید را نیز تصحیح می کنیم:

// بقیه کدها

if (correctPassowrd) {

  const jwt = await createJWT(

    { alg: "HS512", typ: "JWT" },

    {

      sub: `user:${reqData.email}`,

      loggedIn: true,

      iss: "roxo",

      exp: getNumericDate(10),

      iat: new Date().getTime(),

    },

    secret

  );




  response.status = 200;

  response.body = {

    msg: `Logged in successfully`,

    token: jwt,

  };

} else {

  response.status = 403;

  response.body = {

    msg: "Password or email was incorrect. No such user exists.",

  };

}

// بقیه کدها

حالا کدهای ما از الگوریتم HS512 استفاده می کنند که سریع تر است.

خروج از حساب کاربری

حالا به سراغ بحثی چالشی می رویم و آن خروج از حساب کاربری است. ما در سال های قبلی و در برنامه های ساده MPA از session ها برای مدیریت وضعیت کاربر و ورود یا خروج او از حساب کاربری استفاده می کردیم اما حالا که از JWT استفاده می کنیم موضوع کاملا متفاوت است. چرا؟ به دلیل اینکه خروج از حساب کاربری با JWT ممکن نیست! چطور؟ توکن های JWT به طور کامل stateless هستند. یعنی تمام اطلاعات مورد نیازشان را در خودشان دارند بنابراین به سرور تکیه نمی کنند و طبیعتا توسط سرور نیز از بین نمی روند. این یکی از مزیت های توکن های JWT است چرا که برخلاف session ها دیگر هیچ نیازی به ارتباط با سرور و پایگاه داده ندارند بنابراین تعداد کوئری های ارسال شده به پایگاه داده را کم می کنند.

آیا این مسئله بدین معنی است که خروج از حساب کاربری با JWT به طور کامل مختومه است؟ خیر! چند راه مختلف برای خروج از حساب کاربری داریم.

۱. حذف توکن از front-end

شما می توانید در front-end خود دکمه ای برای logout قرار بدهید و زمانی که کاربر روی آن دکمه کلیک کرد، بدون ارتباط با سرور و با جاوا اسکریپت مستقیما توکن را از مرورگر (کوکی یا هر بخش دیگری که توکن را در آن ذخیره کرده اید) حذف کنید. طبیعتا انجام این کار هیچ ارتباطی با سرور ندارد و به طور خاص به front-end مربوط می شود بنابراین یادتان باشد که منطق نوشته شده در سمت سرور به شکلی باشد که حذف شدن توکن سرور را بهم نریزد.

طبیعتا با حذف شدن توکن از کلاینت (مرورگر یا برنامه موبایل) دیگر کلاینت نمی تواند توکنی ارسال کند بنابراین درخواست هایش نامعتبر خواهند بود تا زمانی که دوباره لاگین شده و یک توکن جدید دریافت کند. مزیت این روش این است که کاربر به سادگی از حساب کاربری خود خارج می شود و شانس به سرقت رفتن توکن به صفر می رسد.

البته توجه داشته باشید که این استراتژی می تواند شکل های مختلفی داشته باشد. مثلا اگر توکن را در localStorage ذخیره کرده باشید (این کار از نظر امنیتی پیشنهاد نمی شود) می توانید با جاوا اسکریپت آن را حذف کنید اما اگر توکن خود را درون کوکی های HTTPS و secure ذخیره کرده باشید، حذف کوکی فقط از طریق درخواست های HTTPS قابل انجام است. بنابراین جزئیات انجام این کار ممکن است تفاوت داشته باشد اما کلیت آن یکی است: توکن را از کلاینت بگیرید!

۲. ساخت لیست سیاه

یکی از تکنیک های استفاده از JWT این است که توکن های نامعتبر را در یک پایگاه داده سریع مانند redis یا در مموری ذخیره کنیم و درخواست های ورودی به سرور را با این توکن ها بررسی کنیم. مثلا یک دکمه را برای logout در front-end خود قرار می دهید و اگر کاربر روی آن کلیک کرد، درخواست خروج از حساب کاربری را به همراه توکن به سرور ارسال می کنید. در مرحله بعدی آن توکن را به عنوان یک توکن نامعتبر در پایگاه داده خود ذخیره می کنید. از این به بعد زمانی که درخواستی ارسال می شود باید توکن درونش را با توکن های لیست سیاه در پایگاه داده ذخیره کنید. اگر چنین توکنی پیدا شد یعنی آن توکن نامعتبر است و به آن اجازه پردازش ندهید.

در مرحله آخر زمانی که تاریخ انقضای توکن (ادعای exp در بدنه توکن) فرا رسید، مطمئن می شویم که توکن به صورت طبیعی منقضی شده است بنابراین می توانیم آن توکن را از لیست سیاه (پایگاه داده) حذف کنیم. پایگاه های داده ای مانند redis می توانند داده ها را به صورت خودکار حذف کنند، همچنین سرعت بسیار بالایی دارند چرا که داده ها را در مموری ذخیره می کنند بنابراین برای این منظور عالی هستند.

البته باید خاطر نشان کرد که انجام این کار دقیقا برخلاف روحیه stateless توکن های JWT است. یکی از دلایل اصلی استفاده از JWT ها این بود که مجبور نباشیم برای هر درخواست به پایگاه داده کوئری بدهیم اما با این روش مانند session ها باید برای هر درخواست یک کوئری را برای بررسی توکن ارسال کنیم. البته از آنجایی که لیست سیاه به صورت خودکار حذف می شود، حجم توکن ها در پایگاه داده هیچگاه زیاد نخواهد بود اما باز هم باید به این مسئله فکر کنید.

۳. روش های دیگر؟

روی های دیگری نیز در سطح اینترنت مشاهده می شود اما من شخصا با آن ها مخالف هستم و دو روش بالا را بهترین روش های موجود می دانم. به طور مثال ذخیره کردن IP کاربر درون بدنه توکن یکی از این روش ها است. با این حساب زمانی که IP کاربر عوض شود (مثلا روز بعد به وب سایت ما بیاید) امضای توکن او تغییر می کند و نامعتبر می شود. این روش در بسیاری از مواقع کار نمی کند. چرا؟ به دلیل اینکه اگر کاربر مودم خود را خاموش و روشن نکند، IP او هرگز تغییر نخواهد کرد. بسیاری از افراد مودم خود را خاموش نمی کنند بنابراین این روش بی فایده است. همچنین اگر کاربر به یک ابزار دور زدن تحریم متصل شود IP او تغییر کرده و از حساب کاربری بیرون انداخته می شود.

روش دیگری نیز معرفی می شود و آن ذخیره تمام توکن ها در پایگاه داده است تا دقیقا بدانیم چه افرادی درون سیستم ما login هستند. این روش شباهت بسیار زیادی به session ها دارد و در این حالت بهتر است از همان session ها استفاده کنید تا اینکه بخواهید به سراغ توکن های JWT بروید. در عین حال از آنجایی که خروج کاربر از حساب کاربری وابسته به front-end است من مسیر logout را تکمیل نمی کنم و آن را به عنوان تمرین به خودتان واگذار می کنم.

refresh token چیست؟

با توضیحاتی که دادم همه می دانیم که احراز هویت با توکن ها چطور کار می کند:

  • ابتدا کاربر درخواست ورود به حساب کاربری را می دهد. این کار معمولا از طریق ارسال یک فرم با نام کاربری و رمز عبور انجام می شود.
  • در صورتی که درخواست کاربر معتبر بود یک توکن احراز هویت (Access token) برایش صادر می شود که معمولا زمان انقضایی نزدیک به ۱۵ دقیقه دارد اما برخی افراد آن را تا یک ساعت نیز تنظیم می کنند.
  • از این به بعد کاربر این توکن را به همراه تک تک درخواست های خودش به API ارسال می کند تا هویت او مشخص باشد.
  • پس از آنکه توکن کاربر منقضی شد از حساب کاربری خود خارج می شود بنابراین باید دوباره لاگین کند.

مرحله آخر برای کاربران آزار دهنده است. فرض کنید می خواهید به مدت نیم ساعت در وب سایتی بچرخید. در این حالت باید دو بار یا چند بار لاگین کنید که طبیعتا تجربه کاربری را پایین می آورد. دو راه حل برای مقابله با این موضوع پیشنهاد می شود:

  • طولانی کردن زمان انقضای توکن ها
  • استفاده از refresh token ها

معمولا طولانی کردن زمان انقضای توکن ها یک ریسک امنیتی محسوب می شود بنابراین معمولا پیشنهاد نمی شود بنابراین refresh token ها تنها راه بهتر برای ارتقاء تجربه کاربری هستند. توکن هایی که با آن ها کار کرده ایم همگی Access token یا توکن دسترسی بوده اند که برای احراز هویت استفاده می شوند اما refresh token ها برای تولید Access token ها استفاده می شوند!

refresh token ها معمولا زمان انقضای بسیار طولانی تری نسبت به access token ها دارند. مثلا اگر یک access token زمان انقضایی حدود ۱ ساعت داشته باشد، refresh token ها زمان انقضایی حدود چند روز دارند. معمولا به دلیل طول عمر زیاد refresh token ها، آن ها در پایگاه داده ذخیره می شوند تا از نظر امنیت ریسک کمتری را داشته باشیم و بتوانیم آن ها را حذف کنیم اما برخی از افراد این کار را نمی کنند و نهایتا انجام آن به نظر شخصی شما بستگی دارد. زمانی که زمان انقضای یک access token فرا می رسد کلاینت (برنامه front-end) می تواند درخواستی را با refresh token به سرور ارسال کند تا یک access token جدید برایش تولید شود و بدین صورت نیازی به لاگین کردن دوباره نخواهد بود.

سوال بعدی اینجاست که این توکن ها را در کجا ذخیره کنیم؟ معمولا دو بخش در مرورگر ها وجود دارد که برای ذخیره توکن ها استفاده می شوند:

راه اول، localStorage: هر مرورگر قابلیتی به نام localStorage را دارد که می تواند داده های مختلف را در خود ذخیره کند اما در عین حال مشکلی وجود دارد. localStorage به سادگی از طریق جاوا اسکریپت در دسترس است بنابراین حملات XSS به راحتی در آن انجام می شوند. همچنین هیچ مکانیسمی برای محافظت از داده های ذخیره شده در localStorage وجود ندارد بنابراین متخصصین امنیتی معمولا پیشنهاد نمی دهند که از localStorage برای ذخیره توکن هایتان استفاده کنید. توجه داشته باشید که localStorage و sessionStorage هر دو در گروه HTML5 Web Storage قرار دارند و مواردی که برایتان توضیح دادم برای هر دو صادق است.

راه دوم،‌ Cookies: کوکی ها معمولا امن تر از localStorage هستند. به طور مثال اگر گزینه HttpOnly را به کوکی خود پاس بدهید، این کوکی دیگر توسط جاوا اسکریپت قابل دسترسی نخواهد بود و فقط با درخواست های HTTP ویرایش می شود. همچنین فلگ دیگری به نام Secure وجود دارد که اگر روی کوکی تنظیم شود باعث می شود آن کوکی فقط در اتصالات HTTPS به سرور ارسال شود و در HTTP هیچ ارسالی صورت نمی گیرد. با این حال کوکی ها ۱۰۰% امن نیستند و حملات cross-site request forgery یکی از تهدیدات کوکی ها محسوب می شوند اما برای مقابله با آن نیز راه حل هایی وجود دارد بنابراین پیشنهاد من استفاده از کوکی ها است.

نمودار زیر این رابطه را به خوبی نشان می دهد. در این نمودار یک سرور جداگانه برای احراز هویت داریم:

نحوه ی عملکرد بازتولید توکن های JWT
نحوه عملکرد بازتولید توکن های JWT

در عین حال اگر بخواهید نظر شخصی من را بدانید، من ترجیح می دهم زمان انقضای توکن را در حد ۱ ساعت نگه دارم و پس از آن از کاربر بخواهم دوباره لاگین شود. این کار هم امن است و هم اینکه نیازی به نوشتن منطق پیچیده در سمت سرور ندارد.

مقابله با CSRF

روش های مختلفی برای مقابله با CSRF وجود دارد پلاگین های مختلفی برای آن نوشته شده است بنابراین شما می توانید از هر روشی که خواستید استفاده کنید. مثلا تیم توسعه Agular از روش خاص خودشان استفاده می کنند. در این روش هنگام اجرای درخواست های XHR یا AJAX یک توکن خاص از کوکی ها خوانده می شود. این توکن XSRF-TOKEN نام دارد و کاملا از توکن های JWT جدا است. توکن های XSRF-TOKEN به صورت جداگانه تولید می شوند و هدفشان مقابله با CSRF است و هیچ ربطی به احراز هویت با JWT ندارند.

پس از اینکه توکن XSRF-TOKEN خوانده شد، این توکن در header درخواست AJAX قرار می گیرد و به سمت سرور ارسال می شود. چطور این روش جلوی حملات CSRF را می گیرد؟ جاوا اسکریپتی که روی دامنه شما اجرا شود به کوکی هایتان دسترسی دارد اما جاوا اسکریپتی که خارج از دامنه شما باشد به کوکی هایتان دسترسی نخواهد داشت. با این حساب مقدار XSRF-TOKEN فقط زمانی صحیح خواهد بود که درخواست از دامنه شما ارسال شود.

طبیعتا برای اینکه چنین کاری قابل انجام باشد، سرور شما باید یک توکن XSRF-TOKEN را تولید کرده و در کوکی های مرورگر کاربر ذخیره کند. از آنجایی که انجام این کار نیز کاملا وابسته به front-end است من توضیحات بیشتری در این باره نمی دهم.

امیدوارم با دنیای JWT آشنا شده باشید و بتوانید در پروژه های آینده خود از آن استفاده کنید.


منبع: وب سایت medium

نویسنده شوید

دیدگاه‌های شما

در این قسمت، به پرسش‌های تخصصی شما درباره‌ی محتوای مقاله پاسخ داده نمی‌شود. سوالات خود را اینجا بپرسید.