JWT and using JWT in Rails
Với sự xuất hiện của Single Page Applications(SPA) và mobile app, các API dần dần trở thành tiên phong trong quá trình phát triển web. Chúng ta thường phát triển các API để hỗ trợ cho các SPA và mobile app, vì vậy API chiếm vị trí quan trọng trong quá trình phát triển web. Token-based ...
Với sự xuất hiện của Single Page Applications(SPA) và mobile app, các API dần dần trở thành tiên phong trong quá trình phát triển web. Chúng ta thường phát triển các API để hỗ trợ cho các SPA và mobile app, vì vậy API chiếm vị trí quan trọng trong quá trình phát triển web. Token-based authentication là một trong những cơ chế authenticate được ưa chuộng nhất hiện nay. Tuy nhiên, các tokens dễ bị tấn công bằng nhiều cách khác nhau. Để khắc phục, một trong những cách để thực hiện, là hướng đến một giải pháp nhằm tạo ra các token có độ an toàn, bảo mật cao, tránh bị đánh cắp giữa các hệ thống khác nhau. JSON Web Tokens(JWT) đã được tạo ra nhằm xây dựng bộ tiêu chuẩn cơ sở cho việc handle và xác minh token làm cho quá trình trao đổi giữa các hệ thống khác nhau một cách an toàn.
JWTs mang những thông tin(claims) thông qua JSON, vì vậy nó có tên là JSON Web Tokens. JWT là 1 tiêu chuẩn mở (RFC 7519) định nghĩa cách thức truyền tin an toàn giữa các thành viên bằng 1 đối tượng JSON. Thông tin này có thể được xác thực và đánh dấu tin cậy nhờ vào "chữ ký" của nó. Phần chữ ký của JWT sẽ được mã hóa lại bằng HMAC hoặc RSA. Nó được implement trên hầu hết những ngôn ngữ lập trình phổ biến hiện nay. Do vậy, ta có thể dễ dàng sử dụng và trao đổi trong các hệ thống được thực hiện trong các nền tảng khác nhau. JWT thực chất là 1 đoạn string, vì vậy chúng có thể dễ dàng được truyền thông qua URL hoặc HTTP header, chúng cũng mang theo những thông tin trong phần payload và signatures.
Một JWT gồm 3 đoạn string được ngăn cách nhau bởi dấu "."
aaaaa.bbbbbbb.ccccccc
Header
Phần đầu tiên là header, phần thứ hai là payload và phần cuối là signatures.
Phần header gồm 2 thành phần:
- Kiểu của token, ví dụ như JWT (để phân biệt với JWS hay JWE)
- Thuật toán mã hóa sẽ dùng cho cái Token của chúng ta
{ "typ": "JWT", "alg": "HS256" }
Đoạn header trên sẽ được mã hóa bằng base64 và ta thu được kết quả phần header của JWT là:
aeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Payload
Phần thứ hai của JWT là payload. Phần này sẽ mang những thông tin thú vị trong token, còn được gọi bằng cái tên khác là JWT Claims. Claims có 3 kiểu là: private, public và registered.
1, Registered Claims là những claims mà tên của chúng được khai báo nhưng không bắt buộc phải sử dụng, ví dụ như: iss, sub,aud...Là những thông tin được quy đinh ở trong IANA JSON Web Token Claims registry. Chúng bao gồm: Chú ý rằng các khóa của claim đều chỉ dài 3 ký tự vì mục đích giảm kích thước của Token:
- iss (issuer): tổ chức phát hành token
- sub (subject): chủ đề của token
- aud (audience): đối tượng sử dụng token
- exp (expired time): thời điểm token sẽ hết hạn
- nbf (not before time): token sẽ chưa hợp lệ trước thời điểm này
- iat (issued at): thời điểm token được phát hành, tính theo UNIX time
- jti: JWT ID
2, Private Claims là phần thông tin thêm dùng để truyền qua giữa các máy thành viên. Ví dụ:
{ "sub": "1234567890", "name": "paduvi", "admin": true }
3, Public Claims chứa thông tin mà chúng ta tạo ra mỗi khi authenticate như: username, thông tin của user... Ví dụ:
“https://www.abc.com/jwt_claims/is_admin”: true
Chúng ta có thể tạo một payload mẫu như sau:
{ "iss": "sitepoint.com", "name": "Devdatta Kane", "admin": true }
Và nó sẽ được mã hóa thành:
ew0KICAiaXNzIjogInNpdGVwb2ludC5jb20iLA0KICAibmFtZSI6ICJEZXZkYXR0YSBLYW5lIiwNCiAgImFkbWluIjogdHJ1ZQ0KfQ
Signatures
Phần thứ 3 của JWT và cũng là phần quan trọng nhất: signatures. Nó là phần chữ kí được tạo thành bằng cách kết hợp 3 thành phần: header, payload và secrets. Phần Header + Payload sẽ được tổng hợp và mã hóa lại bằng một giải thuật encode nào đó, càng phức tạp càng tốt như HMACSHA256 chẳng hạn với "secret" từ phía server-side secret. Giống như sau:
require "openssl" require "base64" var encodedString = Base64.encode64(header) + "." + Base64.encode64(payload); hash = OpenSSL::HMAC.digest("sha256", "secret", encodedString)
Vì chỉ có server biết được secret. Nên không ai có thể giả mạo để giải mã được signatures.
Phần chữ ký của chúng ta sau khi được mã hóa sẽ trông như sau:
2b3df5c199c0b31d58c3dc4562a6e1ccb4a33cced726f3901ae44a04c8176f37
Bây giờ, chúng ta đã có đầy đủ 3 phần của JWT. Tổng hợp lại ta được:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ew0KICAiaXNzIjogInNpdGVwb2ludC5jb20iLA0KICAibmFtZSI6ICJEZXZkYXR0YSBLYW5lIiwNCiAgImFkbWluIjogdHJ1ZQ0KfQ.2b3df5c199c0b31d58c3dc4562a6e1ccb4a33cced726f3901ae44a04c8176f37
JWT có những thư viện trên hầu hết các nền tảng và Ruby cũng không phải ngoại lệ. Chúng ta sẽ tạo một Rails app đơn giản, sử dụng gem devise cho authenticate và gem JWT cho việc tạo và xác minh JWT tokens.
rails new jwt_on_rails
Sau khi app được khởi tạo xong, tạo Home controller để sử dụng cho việc check authenticate:
rails g controller Home index
Và thu được code của Home controller:
class HomeController < ApplicationController def index end end
Map trong config/routes.rb:
Rails.application.routes.draw do get 'home' => 'home#index' end
Check server:
rails s
Tiếp theo, chúng ta add 2 gem devise và jwt vào Gemfile:
gem 'devise' gem 'jwt'
Và tiến hành bundle:
bundle install
Tạo files config cho devise:
rails g devise:install
Tạo Devise User model và migrate database
rails g devise User rake db:migrate
Đây là lúc chúng ta tích hợp JWT vào ứng dụng. Đầu tiên, tạo một lớp có tên JsonWebToken trong lib/json_web_token.rb. Lớp này sẽ chứa logic encode và decode cho JWT tokens:
class JsonWebToken def self.encode(payload) JWT.encode(payload, Rails.application.secrets.secret_key_base, 'HS256') end def self.decode(token) return hIndifferentAccess.new(JWT.decode(token, Rails.application.secrets.secret_key_base, true, { algorithm: 'HS256' })[0]) rescue nil end end
Ở đây, mình sử dụng giải thuật HS256(HMAC) để mã hóa, các bạn có thể dùng các giải thuật khác như: RSASSA, ECDSA, RSASSA-PSS...
Sau đó, add một khởi tạo cho việc include lớp JsonWebToken trong config/initializers/jwt.rb:
require 'json_web_token'
Bây giờ, chúng ta sẽ thêm một số method trong ApplicationController, chúng sẽ được sử dụng trong AuthenticateController:
class ApplicationController < ActionController::Base attr_reader :current_user protected def authenticate_request! unless user_id_in_token? render json: { errors: ['Not Authenticated'] }, status: :unauthorized return end @current_user = User.find(auth_token[:user_id]) rescue JWT::VerificationError, JWT::DecodeError render json: { errors: ['Not Authenticated'] }, status: :unauthorized end private def http_token @http_token ||= if request.headers['Authorization'].present? request.headers['Authorization'].split(' ').last end end def auth_token @auth_token ||= JsonWebToken.decode(http_token) end def user_id_in_token? http_token && auth_token && auth_token[:user_id].to_i end end
Chúng ta đã add method authenticate_request! như là before_filter mỗi khi user credential. Tiếp theo chúng ta sẽ tạo AuthenticationController để handle tất cả authenticate request đến API Trong app/controllers/authentication_controller.rb:
class AuthenticationController < ApplicationController def authenticate_user user = User.find_for_database_authentication(email: params[:email]) if user.valid_password?(params[:password]) render json: payload(user) else render json: {errors: ['Invalid Username/Password']}, status: :unauthorized end end private def payload(user) return nil unless user and user.id { auth_token: JsonWebToken.encode({user_id: user.id}), user: {id: user.id, email: user.email} } end end
Sau đó update routes:
Rails.application.routes.draw do post 'auth_user' => 'authentication#authenticate_user' get 'home' => 'home#index' end
Add before_filter vào Home controller:
class HomeController < ApplicationController before_filter :authenticate_request! def index render json: {'logged_in' => true} end end
Bây giờ, ta tạo một user mẫu để test cơ chế authenticate trong rails console:
rails c User.create(email:'a@a.com', password:'changeme', password_confirmation:'changeme')
Khi bạn request đến server bằng lệnh:
curl http://localhost:3000/home
thì server sẽ trả về:
{"errors":["Not Authenticated"]}
Bây giờ, nếu ta authenticate lại và nhận một JWT cho những lần request tiếp theo:
curl -X POST -d email="a@a.com" -d password="changeme" http://localhost:3000/auth_user
Bạn sẽ nhận được một response thành công kèm JWT với thông tin user đã được add thêm:
{"auth_token":"token_nhận_được","user":{"id":1,"email":"a@a.com"}}
Sử dụng auth_token đó và request đến ./home:
curl --header "Authorization: Bearer token_nhận_được" http://localhost:3000/home
Và nhận được response thành công như sau:
{"logged_in":true}
Các giải pháp để lưu trữ token ở phía client có một vài lựa chọn giúp bạn tìm được sự lựa chọn tốt nhât. Hãy điểm qua các option sau:
Option 1 - Web Storage (localStorage or sessionStorage)
Ưu điểm:
- Trình duyệt không tự động include bất kì cái gì từ web storage vào trong http request. Vì vậy nó không thể bị tấn công bằng CSRF
- Chỉ có thể được truy cập bằng javascript và chạy trên cùng domain.
- Cho phép truyền token vào HTTP(phần header Authorization với Bearer schema)
- Dễ dàng để pick các request cần authenticate
Nhược điểm
- Không thể được truy cập bằng javascript chạy trên 1 sub-domain(một giá trị được viết trên example.com không thể được đọc trên sub.domain.com)
- Có khả năng bị tấn công XSS
- Để thực hiện authenticate, bạn chỉ có thể sử dụng trình duyệt hay các thư viện API cho phép bạn custome request(truyền token vào Authorization header)
Usage
Sử dụng localStorage hoặc sessionStorage để lưu và get token khi request được thực hiện:
localStorage.setItem('token', 'asY-x34SfYPk'); // write console.log(localStorage.getItem('token')); // read
Option 2 - HTTP-only cookie
Ưu điểm:
- Không bị tấn công bằng XSS
- Trình duyệt tự động include token vào request
- Cookie có thể được tạo tại main domian và được sử dụng ở cả các sub-domain
Nhược điểm:
- Có nguy cơ bị tấn công bằng CSRF
- Cần cân nhắc và luôn lưu ý việc sử dụng cookie tại các sub-domain
- Việc chọn riêng các request cần include cookie hoàn toàn có thể làm được nhưng dễ gây lộn xộn
- Bạn có thể gặp phải một số vấn đề giữa những ràng buộc của trình duyệt và cookie
- Phía server cần validate cookie cho việc authenticate chứ không phải là Authorization header
Usage
Bạn không cần làm gì tại client side vì trình duyệt sẽ tự động làm tất cả
Option 3 - Javascript accessible cookie ignored by server-side
Ưu điểm:
- Không bị tấn công bằng CSRF (vì nó bị ignore bởi server)
- Cookie có thể được tạo tại main domian và được sử dụng ở cả các sub-domain
- Cho phép truyền token vào HTTP(phần header Authorization với Bearer schema)
- Dễ dàng để pick các request cần được authenticate
Nhược điểm:
- Có thể bị tấn công bằng XSS
- Nếu không cẩn thận thì trình duyệt sẽ tự động include cookie vào những request không cần thiết
- Để thực hiện authenticate, bạn chỉ có thể sử dụng trình duyệt hay các thư viện API cho phép bạn custome request(truyền token vào Authorization header)
Usage
Sử dụng document.cookie để lưu trữ cũng như get token khi request được thực hiện:
document.cookie = "token=asY-x34SfYPk"; // write console.log(document.cookie); // read
Nói chung, option 1 nên được lựa chọn và sử dụng vì:
- Khi bạn tạo một web app, bạn cần deal với XSS, luôn luôn độc lập với nơi lưu trữ token
- Không sử dụng cookie để authenticate, do vậy sẽ không lo một cuộc tấn công CSRF
Tham khảo: https://jwt.io/introduction/ https://www.sitepoint.com/introduction-to-using-jwt-in-rails/ https://github.com/jwt/ruby-jwt https://stormpath.com/blog/where-to-store-your-jwts-cookies-vs-html5-web-storage