پیاده‌سازی احراز هویت مبتنی بر JWT با Golang

احراز هویت به نرم‌افزار و سایت شما این امکان را می‌دهد تا مشخص کنید درخواست‌دهنده آیا همان‌ کسی‌ست که ادعایش را دارد یا خیر. JWT یک روش برای اعمال احراز هویت است که نیازی به ذخیره‌سازی داده‌های اضافی در مورد کاربر در سمت سرور را ندارد. این روش در مقابل سازکارهای Session based قرار می‌گیرد.

مقدمه‌ای بر JWT

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

قالب JWT

فرض کنید یک کاربر با نام‌کاربری user1 داریم که می‌خواهد در سایت ما لاگین کند. اگر موفق به ورود شود، از طرف سایت یک توکن به شکل زیر دریافت می‌کند. توکن در واقع مانند کلید یا مجوزنامه‌ی ورود به سایت است. کاربر برای ورود اولیه ممکن است با گذرواژه یا otp وارد شود. اما برای دفعات بعد، نیازی به ورود رمز ندارد.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNTQ3OTc0MDgyfQ.2Ye5_w1z3zpD4dSGdRp3s98ZipCNQqmsHRB9vioOx54

رشته‌ی بالایی یک JWT یا JSON Web Token است. این رشته با نقطه به ۳ قسمت تقسیم شده است.

  1. بخش اول Header است. این قسمت شامل اطلاعاتی مانند الگوریتم استفاده شده برای امضا (بخش سوم) است. این قسمت دارای استاندارد است و برای همه‌ی پیام‌های JWT یکسان است.
  2. بخش دوم Payload یا Claim نام دارد. در این‌جا اطلاعات مخصوص سایت قرار می‌گیرد. برای نمونه username و یا اطلاعات جانبی دیگر. زمان انقضای توکن هم در این بخش جا می‌گیرد.
  3. بخش سوم، امضا یا Signature است. داده‌ی این بخش از ترکیب دو قسمت اول و Hash شدن آن‌ها توسط یک کلید سرّی انجام می‌شود.

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

در لینوکس با نوشتن دستور زیر در ترمینال می‌توانید آن‌را بازیابی کنید:

echo eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 | base64 -d

که خروجی آن مانند زیر می‌شود:

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

امضای JWT چگونه کار می‌کند؟

اگر Header و Payload توکن به راحتی قابل خواندن هستند، پس چطور است که می‌گوییم JWT امن است؟

در شکل بالا برای بار اول کاربر در Browser اطلاعات اعتباری خود برای لاگین را به سمت سرور ارسال می‌کند. سرور هم پس از تأیید اعتبار، مثلا بررسی رمز عبور کاربر، آن‌را مجاز می‌داند و یک توکن به سمت مرورگر می‌فرستد. از این به بعد، کاربر برای هر فعالیتی در سرور علاوه بر درخواستش، نیاز به ارسال توکن خود به سمت سرور نیز دارد.

حالا فرض کنید کاربری پس از لاگین و دریافت توکن با نام‌کاربری user1، نام خود را در توکن به Admin تغییر می‌دهد و آن را به سمت سرور می‌فرستد تا بتواند اطلاعات سطح بالا را دریافت کند. یا فرضاً زمان انقضا را بسیار بلندمدت قرار می‌دهد. با توجه به این‌که بخش Payload رمزنگاری نمی‌شود و سمت سرور هم اطلاعت جانبی وجود ندارد، این کار شدنی‌ست. اما چرا می‌گوییم JWT امن است و این کار مجاز شناخته نمی‌شود؟

همان‌طور که در تصویر بالا می‌بینید، ترکیب انکدشده‌ی Header و Payload به وسیله‌ی یک الگوریتمِ Hash ِدارای کلید، امضا می‌شود. از آن‌جایی که کاربرِ متقلب، کلید امضا را ندارد، توانایی تغییر بخش سوم با توجه به تغییرات انجام داده در Payload را ندارد. بنابراین، امضا متعلق به محتویات قبلی Payload است. حالا اگر این توکن به سمت سرور ارسال شود؛ سرور با ترکیب دو بخش اول و هَش کردن آن، متوجه‌ی مغایرت آن با بخش امضا می‌شود و اطمینان حاصل می‌کند که تغییری در داده صورت گرفته است. بنابراین توکن را غیر معتبر می‌داند. بخش سوم یا همان امضا، ضامن امنیت توکن است.

در کل برای تعیین تغییرناپذیری یک پیام از Hash کردن استفاده می‌شود. فرض کنید دو میزبان به دنبال این هستند که با هم پیامی را ردوبدل کنند. این پیام اگر به سمت مقصد هدایت شود ممکن است در میانه‌ی راه دچار دستکاری یا تغییر شود. مقصد هم متوجه نمی‌شود. اما اگر مبدأ، پیام را با یک کلید سرّی Hash کند، و مقدار خروجی Hash را به اصل پیام بچسباند، می‌تواند به مقصد، عدمِ تغییر پیام را بفهماند. به‌این‌صورت که مقصد با دریافت پیام+مقدار هش، ابتدا پیام را با همان کلید خصوصی هش می‌کند و سپس با مقدار هش دریافت شده مقایسه می‌کند. اگر برابر بود به معنی عدم تغییر پیام در طول مسیر است.

تفاوت Hash و Encypting
Hash به درهم‌سازی یا امضا مشهور است. Hash به دنبال این است تا پیام را به یک رشته‌ی یکتای نامفهوم با طول ثابت تبدیل کند. این عملیات یک‌طرفه است و قابل برگشت نیست. هش‌ها دارای انواع دارای کلید و فاقد کلید هستند.
Enctyption یا رمزنگاری، عملیاتی‌ست که در آن، پیام توسط یک رمز به رشته‌ای غیرقابل خوانا تبدیل می‌شود. این عملیات قابل بازگشت است و نیاز به کلید دارد.

بنابراین امکان ساخت توکن از روی توکن مجاز وجود ندارد. برای به‌سرقت نرفتن توکن هم باید از پروتکل https استفاده کرد.

پیاده‌سازی در Go

حالا که با مقدمات احراز هویت مبتنی بر JWT آشنا شده‌ایم، وقت آن است که برویم سراغ پیاده‌سازی. برای پیاده‌سازی از زبان ‌Go استفاده شده است.

ساخت یک سرور HTTP

در ابتدا Routeها را تعریف می‌کنیم.

package main

import (
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/signin", Signin)
	http.HandleFunc("/welcome", Welcome)
	http.HandleFunc("/refresh", Refresh)

	// start the server on port 8000
	log.Fatal(http.ListenAndServe(":8000", nil))
}

ورود کاربر به سایت

روت /signin وظیفه‌ی لاگین کاربر را به‌عهده دارد. در این‌جا اطلاعات شناسای کاربر شامل نام‌کاربری و گذرواژه را می‌گیرد و با رمز ذخیره‌شده‌ی کاربر مقایسه می‌کند. برای راحتی، رمز کاربر را در یک ساختار ذخیره می‌کنیم. در حالت عادی ممکن است در پایگاه‌داده ذخیره شود.

var users = map[string]string{
	"user1": "password1",
	"user2": "password2",
}

در پایگاه‌داده‌ی موقت ما تنها دو کاربر وجود دارد. کاربر user1 و کاربر user2. حالا نوبت پیاده‌سازی تابع Signin می‌شود. برای این‌کار از کتاب‌خانه‌ی dgrijalva/jwt-go استفاده می‌کنیم. این کتاب‌خانه ما را در ایجاد و اعتبارسنجی توکن یاری می‌کند.

import (
  //...
  // import the jwt-go library
	"github.com/dgrijalva/jwt-go"
	//...
)

// Create the JWT key used to create the signature
var jwtKey = []byte("my_secret_key")

var users = map[string]string{
	"user1": "password1",
	"user2": "password2",
}

// Create a struct to read the username and password from the request body
type Credentials struct {
	Password string `json:"password"`
	Username string `json:"username"`
}

// Create a struct that will be encoded to a JWT.
// We add jwt.StandardClaims as an embedded type, to provide fields like expiry time
type Claims struct {
	Username string `json:"username"`
	jwt.StandardClaims
}

// Create the Signin handler
func Signin(w http.ResponseWriter, r *http.Request) {
	var creds Credentials
	// Get the JSON body and decode into credentials
	err := json.NewDecoder(r.Body).Decode(&creds)
	if err != nil {
		// If the structure of the body is wrong, return an HTTP error
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	// Get the expected password from our in memory map
	expectedPassword, ok := users[creds.Username]

	// If a password exists for the given user
	// AND, if it is the same as the password we received, the we can move ahead
	// if NOT, then we return an "Unauthorized" status
	if !ok || expectedPassword != creds.Password {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

	// Declare the expiration time of the token
	// here, we have kept it as 5 minutes
	expirationTime := time.Now().Add(5 * time.Minute)
	// Create the JWT claims, which includes the username and expiry time
	claims := &Claims{
		Username: creds.Username,
		StandardClaims: jwt.StandardClaims{
			// In JWT, the expiry time is expressed as unix milliseconds
			ExpiresAt: expirationTime.Unix(),
		},
	}

	// Declare the token with the algorithm used for signing, and the claims
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	// Create the JWT string
	tokenString, err := token.SignedString(jwtKey)
	if err != nil {
		// If there is an error in creating the JWT return an internal server error
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	// Finally, we set the client cookie for "token" as the JWT we just generated
	// we also set an expiry time which is the same as the token itself
	http.SetCookie(w, &http.Cookie{
		Name:    "token",
		Value:   tokenString,
		Expires: expirationTime,
	})
}

اگر کاربر باموفقیت به سایت وارد شود، این Handler در سمت کلاینت کوکی را با مقدار JWT مقداردهی می‌کند. وقتی این اتفاق بیفتد، از این به بعد، مقدار توکن همراه را هر درخواستی به سمت سرور ارسال می‌شود. حالا می‌رویم سراغ تابع Welcome.

func Welcome(w http.ResponseWriter, r *http.Request) {
	// We can obtain the session token from the requests cookies, which come with every request
	c, err := r.Cookie("token")
	if err != nil {
		if err == http.ErrNoCookie {
			// If the cookie is not set, return an unauthorized status
			w.WriteHeader(http.StatusUnauthorized)
			return
		}
		// For any other type of error, return a bad request status
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	// Get the JWT string from the cookie
	tknStr := c.Value

	// Initialize a new instance of `Claims`
	claims := &Claims{}

	// Parse the JWT string and store the result in `claims`.
	// Note that we are passing the key in this method as well. This method will return an error
	// if the token is invalid (if it has expired according to the expiry time we set on sign in),
	// or if the signature does not match
	tkn, err := jwt.ParseWithClaims(tknStr, claims, func(token *jwt.Token) (interface{}, error) {
		return jwtKey, nil
	})
	if !tkn.Valid {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
	if err != nil {
		if err == jwt.ErrSignatureInvalid {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	// Finally, return the welcome message to the user, along with their
	// username given in the token
	w.Write([]byte(fmt.Sprintf("Welcome %s!", claims.Username)))
}

تمدید توکن

در این مثال، مدت اعتبارِ توکن ۵ دقیقه در نظر گرفته‌ شده است. برای این‌که کاربر هر ۵ دقیقه مجبور به لاگین نباشد، یک route دیگر با نام refresh در نظر می‌گیریم. در این روت، توکن قبلی که هنوز معتبر است، دریافت می‌شود و توکن جدید با تاریخ انقضای جدید اعطا می‌شود. اگر هم کاربر برای مدت بیش از پنج دقیقه آنلاین نباشد، کلاً توکن غیر معتبر می‌شود و نیاز به لاگین مجدد دارد.

func Refresh(w http.ResponseWriter, r *http.Request) {
	// (BEGIN) The code uptil this point is the same as the first part of the `Welcome` route
	c, err := r.Cookie("token")
	if err != nil {
		if err == http.ErrNoCookie {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	tknStr := c.Value
	claims := &Claims{}
	tkn, err := jwt.ParseWithClaims(tknStr, claims, func(token *jwt.Token) (interface{}, error) {
		return jwtKey, nil
	})
	if !tkn.Valid {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
	if err != nil {
		if err == jwt.ErrSignatureInvalid {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	// (END) The code up-till this point is the same as the first part of the `Welcome` route

	// We ensure that a new token is not issued until enough time has elapsed
	// In this case, a new token will only be issued if the old token is within
	// 30 seconds of expiry. Otherwise, return a bad request status
	if time.Unix(claims.ExpiresAt, 0).Sub(time.Now()) > 30*time.Second {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	// Now, create a new token for the current use, with a renewed expiration time
	expirationTime := time.Now().Add(5 * time.Minute)
	claims.ExpiresAt = expirationTime.Unix()
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	tokenString, err := token.SignedString(jwtKey)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	// Set the new token as the users `token` cookie
	http.SetCookie(w, &http.Cookie{
		Name:    "token",
		Value:   tokenString,
		Expires: expirationTime,
	})
}

در مثال بالا، توکن از طریق کوکی به سمت کلاینت ارسال می‌شود. اما امکان بازگشت توکن در response هم وجود دارد. برای این کار، کافی‌ست به‌جای ثبت توکن در کوکی، توکن را در response بنویسید.

w.Write(tokenString)

لغو مجوز از توکن

با توجه به این‌که JWT در سمت سرور اطلاعاتی را نگه‌داری نمی‌کند، برای logout کمی به مشکل می‌خوریم. شاید یک راه‌کار ساده، ساختن یک blcklist از توکن‌هایی‌ست که هنوز منقضی نشده‌اند اما کاربر از سایت لاگین کرده است.

  1. ثبت یک تاریخ انقضا در توکن.
  2. حذف توکن ذخیره‌شده در سمت کلاینت، پس از logout.
  3. نگه‌داری توکن‌های منقضی‌نشده‌ای که logout شده‌اند در یک پایگاه‌داده (ترجیحاً reddis).
  4. کوئری زدن در پایگاه‌داده‌ی توکن‌های blacklist با هر درخواست مبتنی بر توکن.

منابع

jwt.io

How to log out when using JWT?

Securing Golang API using Json Web Token (JWT)

Implementing JWT based authentication in Golang ?