Bảo mật API bằngToken based trong Rails 5
- Introduction
Với một ứng dụng Rails 5 API-only, việc authentication phổ biến đang là token-based. Trong bài viết này, mình sẽ nói về cách triển khai token-based vào một ứng dụng Rails 5 API-only.
1.1 What is Token-based Authentication?
Xác thực bằng token (Token-based authentication hoặc JSON Web token authentication) là một cách mới để xác thực người dùng khi truy cập vào ứng dụng. Nó thay thế cho xác thực bằng session (session-based authentication).
Điểm khác nhau lớn nhất giữa 2 hình thức xác thực này là khi xác thực bằng session ta cần phải tạo mới 1 bản ghi trên server ứng với mỗi lần người dùng login. Còn với token-based là không phụ thuộc vào server, nó không lưu lại một thông tin gì lên server. Việc của nó là tạo 1 mã token duy nhất với mỗi lần có request đến.
Không giống session-based, token-based tiếp cận không liên kết với người dùng thông qua việc đăng nhập mà thông qua mã token duy nhất để kiểm soát các giao dịch.
1.2 Benefits of Token-base Authentication?
- Cross-domain / CORS
Sử dụng token-based sẽ cho phép call AJAX đến mọi server, mọi domain
- Không phụ thuộc(Stateless)
Ta không cần phải lưu thông tin gì cả vì chính mã token đã chưa thông tin để xác thực rồi.
- Không ràng buộc(Decoupling)
Mã token có thể sinh ở bất kì đâu và API cũng có thể gọi từ mọi nơi bằng 1 lệnh xác thực duy nhất.
- Mobile ready
Cookies là một vấn đề khi lưu trữ thông tin người dùng trong các ứng dụng di động, nhưng với việc sử dụng token-based việc này trở lên đơn giản.
- CSRF(Cross Site Request Forgery)
Vì cơ chế xác thực không dựa vào việc lưu cookies nên nó không thể bị tấn công bằng CSRF.
- Hiệu năng (Perfomance)
Ở phía server, việc query một sesssion trên database chắc chắn sẽ tốn thời gian hơn việc tính toán một mã HMACSHA256 để xác thực token và phân tích nội dung của nó. Do đó việc dùng token-based authentication sẽ nhanh hơn cách truyền thống.
1.3 How does Token-based authentication work?
Cách hoạt động của token-based authentication rất đơn giản. Đầu tiên người dùng sẽ gửi đến server thông tin đăng nhập. Nếu thông tin đó đúng, server sẽ tạo ra một mã HMACSHA256 duy nhất (Json Web Token). Sau đó người dùng lưu lại và mỗi lần gửi request đến server sẽ gửi kèm theo mã token đó. Sau đó server sẽ so sánh mã người dùng gửi với mã đã lưu ở database để xác thực.
2. Setting up Token-based authentication with Rails 5
Tạo mới một ứng dụng rails api với lệnh:
rails new api-app --api
Với tham số --api ứng dụng tạo ra sẽ loại bỏ những phần không cần thiết cho 1 ứng dụng API như helpers, assets, views. Cần có một số yêu cầu chính để xây dựng 1 ứng dụng token-based:
- Một model.
- Một cách mã hoá và giải mã JWT.
- Phương pháp kiểm tra việc người dùng đã được xác thực hay chưa.
- Controller, routes để kiểm soát việc creating và logging của user.
Creating the User model
Tạo một model User
rails g model User name email password_digest
Chạy lệnh migration
rails db:migrate
Mật khẩu cần được mã hoá khi lưu vào DB, ta dùng gem bcrypt
. Thêm vào gemfile:
# Gemfile
gem 'bcrypt', '~> 3.1'
And install it:
bundle install
Khi cài đặt gem xong, ta thêm vào model:
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
end
Mã hoá và giải mã JWT
Ta sử dụng gem jwt
để mã hoá và giải mã HMACSHA256, cài đặt như sau:
# Gemfile
gem 'jwt'
Và chạy lệnh
bundle install
Khi cài đặt xong gem, ta sử dụng thông qua biến global JWT
. Sử dụng một singleton class
cho việc mã hoá và giải mã là cách tốt nhất vì nó sẽ chỉ sinh một đối tượng duy nhất.
# lib/json_web_token.rb
class JsonWebToken
class << self
def encode(payload, exp = 24.hours.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, Rails.application.secrets.secret_key_base)
end
def decode(token)
body = JWT.decode(token, Rails.application.secrets.secret_key_base)[0]
HashWithIndifferentAccess.new body
rescue
nil
end
end
end
Ở phương thức mã hoá, ta truyền vào 3 tham số: user ID, thời gian expire, và unique key ứng dụng của bạn.
Ở phương thức giải mã, sử dụng mã token và key của ứng dụng để giải mã
Để chắc chắn 2 hàm này luôn sẵn sàng, ta thêm vào config của ứng dụng:
# config/application.rb
module ApiApp
class Application < Rails::Application
#.....
config.autoload_paths << Rails.root.join('lib')
#.....
end
end
Authenticating Users
Để thay thế việc sử dụng method private trong controller ta sử dụng gem simple_command
. Gem này còn có tác dụng trong việc tạo các object services nhanh chóng. Việc sử dụng nó tương tự việc sử dụng helper nhưng thuận tiện hơn cho việc kết nối giữa controller và model.
Cài đặt gem bằng cách:
# Gemfile
gem 'simple_command'
và chạy lệnh
bundle install
Để sử dụng trong class ta cần thêm prepend SimpleCommand
. Đây là một đoạn lệnh mẫu:
class AuthenticateUser
prepend SimpleCommand
def initialize()
#this is where parameters are taken when the command is called
end
def call
#this is where the result gets returned
end
end
Việc kiểm tra thông tin email và mật khẩu của user và trả về giá trị được thực hiện như sau:
# app/commands/authenticate_user.rb
class AuthenticateUser
prepend SimpleCommand
def initialize(email, password)
@email = email
@password = password
end
def call
JsonWebToken.encode(user_id: user.id) if user
end
private
attr_accessor :email, :password
def user
user = User.find_by_email(email)
return user if user && user.authenticate(password)
errors.add :user_authentication, 'invalid credentials'
nil
end
end
Class private user làm nhiệm vụ kiểm tra email đã tồn tại trong database hay chưa, sau đó kiểm tra password bằng việc mã hoá password qua hàm authenticate
đã nói ở trên. Nếu khớp email và password hàm sẽ trả về user, nếu không trả về nil
.
Checking User Authorization
Token đã được tạo nhưng ta chưa có cách kiểm tra xem nếu token được gửi cùng 1 request là valid hay không. Cách ta authorization là kiểm tra header
của requesst và giải mã token sử dụng hàm decode của JWT singleton. Cách làm như sau:
# app/commands/authorize_api_request.rb
class AuthorizeApiRequest
prepend SimpleCommand
def initialize(headers = {})
@headers = headers
end
def call
user
end
private
attr_reader :headers
def user
@user ||= User.find(decoded_auth_token[:user_id]) if decoded_auth_token
@user || errors.add(:token, 'Invalid token') && nil
end
def decoded_auth_token
@decoded_auth_token ||= JsonWebToken.decode(http_auth_header)
end
def http_auth_header
if headers['Authorization'].present?
return headers['Authorization'].split(' ').last
else
errors.add(:token, 'Missing token')
end
nil
end
end
Implementing into Controllers
Mọi logic để thực hiện JWT token đã xong, việc tiếp theo là thực hiện nó ở controller và gắn với các hành vi của user.
Login Users
# app/controllers/authentication_controller.rb
class AuthenticationController < ApplicationController
skip_before_action :authenticate_request
def authenticate
command = AuthenticateUser.call(params[:email], params[:password])
if command.success?
render json: { auth_token: command.result }
else
render json: { error: command.errors }, status: :unauthorized
end
end
end
Thêm vào routes:
# config/routes.rb
post 'authenticate', to: 'authentication#authenticate'
Authorizing Request
#app/controllers/application_controller.rb
class ApplicationController < ActionController::API
before_action :authenticate_request
attr_reader :current_user
private
def authenticate_request
@current_user = AuthorizeApiRequest.call(request.headers).result
render json: { error: 'Not Authorized' }, status: 401 unless @current_user
end
end
Does It work?
Đầu tiên ta tạo mới 1 user
User.create!(email: 'example@mail.com' , password: '123123123' , password_confirmation: '123123123')
Ta cần 1 resource để test chức năng xác thực:
rails g scaffold Item name:string description:textrails db:migrate
Ta chạy server trên local, và test lệnh lấy mã token:
curl -H "Content-Type: application/json" -X POST -d '{"email":"example@mail.com","password":"123123123"}' http://localhost:3000/authenticate
Thử truy cập vào 1 items khi không có dùng mã token:
$ curl http://localhost:3000/items
{"error":"Not Authorized"}
Thử truy cập vào items và dùng mã token đã lấy ở trên:
curl -H “Authorization: eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE1NjYwMzY1NTN9.F_byOWBNNx5msHfCnxBuhQ3ubFXzfwEUrzSxV4Cqhpo” http://localhost:3000/items
[]
Awesome! Everything works.