احراز هویت با توکنهای وب JSON
در این پیوست، فرآیند احراز هویت API خود را به استفاده از توکنهای وب JSON (JWTs) تغییر خواهیم داد.
همانطور که قبلاً در کتاب به طور خلاصه توضیح دادیم، JWTs نوعی توکن بی状态 هستند. آنها مجموعهای از ادعاهایی (claims) را شامل میشوند که امضا شدهاند (با استفاده از الگوریتم امضای متقارن یا نامتقارن) و سپس با استفاده از base64 رمزگذاری میشوند. در مورد ما، از JWTs برای حمل ادعای subject حاوی شناسه کاربر احراز هویت شده استفاده خواهیم کرد.
چندین package مختلف وجود دارد که کار با JWTs را در Go نسبتاً آسان میکنند. در بیشتر موارد، package pascaldekloe/jwt انتخاب خوبی است — دارای API ساده و شفافی است و به طور پیشفرض برای جلوگیری از چند آسیبپذیری امنیتی اصلی JWT طراحی شده است.
اگر میخواهید همراه با ما پیش بروید، لطفاً آن را به این صورت نصب کنید:
$ go get github.com/pascaldekloe/jwt@v1
یکی از اولین مواردی که هنگام استفاده از JWTs باید در نظر بگیرید، انتخاب الگوریتم امضا است.
اگر قرار است JWT شما توسط یک application متفاوت نسبت به application سازنده آن مصرف شود، باید معمولاً از الگوریتم کلید نامتقارن مانند ECDSA یا RSA استفاده کنید. application «سازنده» از کلید خصوصی خود برای امضای JWT استفاده میکند و application «مصرفکننده» از کلید عمومی متناظر برای تأیید امضا استفاده میکند.
اما اگر قرار است JWT شما توسط همان application سازنده مصرف شود، انتخاب مناسب یک الگوریتم کلید متقارن (سادهتر و سریعتر) مانند HMAC-SHA256 با یک کلید تصادفی محرمانه است. این همان چیزی است که ما برای API خود استفاده خواهیم کرد.
بنابراین، برای راهاندازی احراز هویت با JWTs، ابتدا باید یک کلید محرمانه برای امضای JWTs به فایل .envrc خود اضافه کنید. به عنوان مثال:
export GREENLIGHT_DB_DSN=postgres://greenlight:pa55word@localhost/greenlight export JWT_SECRET=pei3einoh0Beem6uM6Ungohn2heiv5lah1ael4joopie5JaigeikoozaoTew2Eh6
و سپس باید فایل Makefile خود را به روز کنید تا کلید محرمانه را به عنوان یک flag خط فرمان هنگام راهاندازی application پاس دهید، به این صورت:
... # ==================================================================================== # # DEVELOPMENT # ==================================================================================== # ## run/api: run the cmd/api application .PHONY: run/api run/api: @go run ./cmd/api -db-dsn=${GREENLIGHT_DB_DSN} -jwt-secret=${JWT_SECRET} ...
در مرحله بعد باید فایل cmd/api/main.go را ویرایش کنید تا secret JWT را از flag خط فرمان در ساختار config تجزیه کند:
package main ... type config struct { port int env string db struct { dsn string maxOpenConns int maxIdleConns int maxIdleTime time.Duration } limiter struct { enabled bool rps float64 burst int } smtp struct { host string port int username string password string sender string } cors struct { trustedOrigins []string } jwt struct { secret string // Add a new field to store the JWT signing secret. } } ... func main() { var cfg config ... // Parse the JWT signing secret from the command-line flag. Notice that we leave the // default value as the empty string if no flag is provided. flag.StringVar(&cfg.jwt.secret, "jwt-secret", "", "JWT secret") displayVersion := flag.Bool("version", false, "Display version and exit") flag.Parse() ... } ...
و پس از اتمام این کار، میتوانید createAuthenticationTokenHandler() را به گونهای تغییر دهید که به جای توکن حالتدار، یک JWT تولید و ارسال کند. به این صورت:
package main import ( "errors" "net/http" "strconv" // New import "time" "greenlight.alexedwards.net/internal/data" "greenlight.alexedwards.net/internal/validator" "github.com/pascaldekloe/jwt" // New import ) func (app *application) createAuthenticationTokenHandler(w http.ResponseWriter, r *http.Request) { var input struct { Email string `json:"email"` Password string `json:"password"` } err := app.readJSON(w, r, &input) if err != nil { app.badRequestResponse(w, r, err) return } v := validator.New() data.ValidateEmail(v, input.Email) data.ValidatePasswordPlaintext(v, input.Password) if !v.Valid() { app.failedValidationResponse(w, r, v.Errors) return } user, err := app.models.Users.GetByEmail(input.Email) if err != nil { switch { case errors.Is(err, data.ErrRecordNotFound): app.invalidCredentialsResponse(w, r) default: app.serverErrorResponse(w, r, err) } return } match, err := user.Password.Matches(input.Password) if err != nil { app.serverErrorResponse(w, r, err) return } if !match { app.invalidCredentialsResponse(w, r) return } // Create a JWT claims struct containing the user ID as the subject, with an issued // time of now and validity window of the next 24 hours. We also set the issuer and // audience to a unique identifier for our application. var claims jwt.Claims claims.Subject = strconv.FormatInt(user.ID, 10) claims.Issued = jwt.NewNumericTime(time.Now()) claims.NotBefore = jwt.NewNumericTime(time.Now()) claims.Expires = jwt.NewNumericTime(time.Now().Add(24 * time.Hour)) claims.Issuer = "greenlight.alexedwards.net" claims.Audiences = []string{"greenlight.alexedwards.net"} // Sign the JWT claims using the HMAC-SHA256 algorithm and the secret key from the // application config. This returns a []byte slice containing the JWT as a base64- // encoded string. jwtBytes, err := claims.HMACSign(jwt.HS256, []byte(app.config.jwt.secret)) if err != nil { app.serverErrorResponse(w, r, err) return } // Convert the []byte slice to a string and return it in a JSON response. err = app.writeJSON(w, http.StatusCreated, envelope{"authentication_token": string(jwtBytes)}, nil) if err != nil { app.serverErrorResponse(w, r, err) } } ...
برای ادامه کار، dependency جدید github.com/pascaldekloe/jwt را به صورت vendor درآورید و API را به این صورت اجرا کنید:
$ make vendor $ make run/api
سپس وقتی درخواستی به endpoint POST /v1/tokens/authentication با یک آدرس ایمیل و رمز عبور معتبر ارسال میکنید، اکنون باید پاسخی حاوی JWT مانند این دریافت کنید (شکستن خطوط برای خوانایی اضافه شده است):
$ curl -X POST -d ''{"email": "faith@example.com", "password": "pa55word"}'' localhost:4000/v1/tokens/authentication
{
"authentication_token": "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJncmVlbmxpZ2h0LmFsZXhlZHdhcm
RzLm5ldCIsInN1YiI6IjciLCJhdWQiOlsiZ3JlZW5saWdodC5hbGV4ZWR3YXJkcy5uZXQiXSwiZXhwIjoxNj
E4OTM4MjY0LjgxOTIwNSwibmJmIjoxNjE4ODUxODY0LjgxOTIwNSwiaWF0IjoxNjE4ODUxODY0LjgxOTIwND
h9.zNK1bJPl5rlr_YvjyOXuimJwaC3KgPqmW2M1u5RvgeA"
}
اگر کنجکاو هستید، میتوانید دادههای رمزگذاریشده base64 JWT را رمزگشایی کنید. باید ببینید که محتوای ادعاها با اطلاعاتی که انتظار دارید مطابقت دارد، مشابه این:
{"alg":"HS256"}{"iss":"greenlight.alexedwards.net","sub":"7","aud":["greenlight.alexedwards.net"],
"exp":1618938264.819205,"nbf":1618851864.819205,"iat":1618851864.8192048}...
در مرحله بعد باید middleware authenticate() را به روز کنید تا JWTها را در هدر Authorization: Bearer <jwt> بپذیرد، JWT را تأیید کند و شناسه کاربر را از ادعای subject استخراج کند.
وقتی میگوییم «JWT را تأیید کن»، در واقع چهار مورد زیر را منظور داریم:
- بررسی کنید که امضای JWT با محتوای JWT مطابقت دارد، با توجه به کلید محرمانه ما. این تأیید میکند که توکن توسط client تغییر نیافته است.
- بررسی کنید که زمان فعلی بین زمانهای «قبل از» و «منقضی شدن» JWT باشد.
- بررسی کنید که «صادرکننده» JWT برابر با
"greenlight.alexedwards.net"باشد. - بررسی کنید که
"greenlight.alexedwards.net"در «مخاطبان» JWT وجود داشته باشد.
برای ادامه کار، middleware authenticate() را به صورت زیر به روز کنید:
package main import ( "errors" "expvar" "fmt" "net/http" "strconv" "strings" "sync" "time" "greenlight.alexedwards.net/internal/data" "github.com/pascaldekloe/jwt" // New import "github.com/tomasen/realip" "golang.org/x/time/rate" ) ... func (app *application) authenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Vary", "Authorization") authorizationHeader := r.Header.Get("Authorization") if authorizationHeader == "" { r = app.contextSetUser(r, data.AnonymousUser) next.ServeHTTP(w, r) return } headerParts := strings.Split(authorizationHeader, " ") if len(headerParts) != 2 || headerParts[0] != "Bearer" { app.invalidAuthenticationTokenResponse(w, r) return } token := headerParts[1] // Parse the JWT and extract the claims. This will return an error if the JWT // content doesn't match the signature (i.e. the token has been tampered with) // or the algorithm isn't valid. claims, err := jwt.HMACCheck([]byte(token), []byte(app.config.jwt.secret)) if err != nil { app.invalidAuthenticationTokenResponse(w, r) return } // Check if the JWT is still valid at this moment in time. if !claims.Valid(time.Now()) { app.invalidAuthenticationTokenResponse(w, r) return } // Check that the issuer is our application. if claims.Issuer != "greenlight.alexedwards.net" { app.invalidAuthenticationTokenResponse(w, r) return } // Check that our application is in the expected audiences for the JWT. if !claims.AcceptAudience("greenlight.alexedwards.net") { app.invalidAuthenticationTokenResponse(w, r) return } // At this point, we know that the JWT is all OK and we can trust the data in // it. We extract the user ID from the claims subject and convert it from a // string into an int64. userID, err := strconv.ParseInt(claims.Subject, 10, 64) if err != nil { app.serverErrorResponse(w, r, err) return } // Lookup the user record from the database. user, err := app.models.Users.Get(userID) if err != nil { switch { case errors.Is(err, data.ErrRecordNotFound): app.invalidAuthenticationTokenResponse(w, r) default: app.serverErrorResponse(w, r, err) } return } // Add the user record to the request context and continue as normal. r = app.contextSetUser(r, user) next.ServeHTTP(w, r) }) }
در نهایت، برای راهاندازی این قابلیت، باید یک method جدید UserModel.Get() ایجاد کنید تا جزئیات کاربر را بر اساس شناسه آنها از پایگاه داده بازیابی کند.
package data ... func (m UserModel) Get(id int64) (*User, error) { query := ` SELECT id, created_at, name, email, password_hash, activated, version FROM users WHERE id = $1` var user User ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() err := m.DB.QueryRowContext(ctx, query, id).Scan( &user.ID, &user.CreatedAt, &user.Name, &user.Email, &user.Password.hash, &user.Activated, &user.Version, ) if err != nil { switch { case errors.Is(err, sql.ErrNoRows): return nil, ErrRecordNotFound default: return nil, err } } return &user, nil }
اکنون باید بتوانید درخواستی به یکی از endpointهای محافظتشده ارسال کنید و تنها در صورتی موفقیتآمیز خواهد بود که درخواست شما حاوی یک JWT معتبر در هدر Authorization باشد. به عنوان مثال:
$ curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJncmVlbmxpZ..." localhost:4000/v1/movies/2
{
"movie": {
"id": 2,
"title": "Black Panther",
"year": 2018,
"runtime": "134 mins",
"genres": [
"sci-fi",
"action",
"adventure"
],
"version": 2
}
}
$ curl -H "Authorization: Bearer INVALID" localhost:4000/v1/movies/2
{
"error": "invalid or missing authentication token"
}
اگر قصد دارید سیستمی را با استفاده از JWTs به مرحله production برسانید، همچنین خواندن و درک کامل دو مقاله زیر را توصیه میکنم: