Trong bài Identity Provider with OAuth 2.0 and OpenID Connect chúng ta đã xây dựng thành công một ID Provider với các chức năng cơ bản.
Giờ là lúc chúng ta triển khai một Relying Party (RP) để có thể kết nối và đăng nhập sử dụng IdP mà chúng ta đã xây dựng trước đó.
Mục tiêu của bài này là:
- Tạo một project Ruby on Rails
- Kết nối với ID Provider để đăng nhập
- Sử dụng OpenID Connect
- Verify
id_token
được trả về từ ID Provider
Initializing the Project
Đầu tiên chúng ta cần tạo một Rails project mới với command: rails new project_name
. Và database mình sử dụng trong dự án lần này sẽ là MySQL vì vậy chúng ta sẽ chạy command sau:
rails new relying_party_web -d mysql # Additional commands to run the project.
rails db:create
rails db:migrate
Sau đó các bạn sử dụng command: rails server
để chạy dự án ở môi trường development.
➜ rails server => Booting Puma
=> Rails 7.1.3.4 application starting in development ...
* Listening on http://127.0.0.1:3000
* Listening on http://[::1]:3000
Truy cập http://localhost:3000
chúng ta sẽ được kết quả!
Như này là ổn rồi, giờ mình sẽ xoá một số code không cần thiết và cài đặt Rubocop cho ứng dụng, bạn có thể xem chi tiết trong các commit mình đính kèm bên dưới.
Note: Rubocop là một trình phân tích mã tĩnh Ruby (còn gọi là linter) và trình định dạng mã. Nó kiểu kiểu giống ESLint, giúp bạn đảm bảo chất lượng mã và thống nhất code style.
Refer: Getting Started with Rails
RP Configuration
Trước tiên mình sẽ cài đặt biến môi trường để lưu các thông tin của ID Provider và Authorization Request. Hãy tạo một file .envrc
với nội dung như sau:
export STUDY_TOGETHER_CLIENT_ID=slfxSDOnrbc1dEsbFEIuZTw3HxmR2kGpzNk4vffPzKg
export STUDY_TOGETHER_CLIENT_SECRET=ihYuWYySDnbqTzcQtfqRWMAkSams9oCPzadNEmZXJ_I
export STUDY_TOGETHER_REDIRECT_URI=http://localhost:8080/auth/callback
export STUDY_TOGETHER_GRANT_TYPE=authorization_code
export STUDY_TOGETHER_SCOPE=openid email profile export IDP_ISSUER=http://localhost:3000/
export IDP_DISCOVERY_ENDPOINT=http://localhost:3000/.well-known/openid-configuration
export IDP_AUTHORIZE_ENDPOINT=http://localhost:3000/oauth/authorize
export IDP_TOKEN_ENDPOINT=http://localhost:3000/oauth/token
export IDP_USERINFO_ENDPOINT=http://localhost:3000/oauth/userinfo
client_id
,secret
,redirect_uri
,grant_type
vàscopes
: là những thông tin về Client mà chúng ta đã đăng ký với ID Provider trước đó.issuer
: ám chỉ bên phát hành token, vì vậy nó sẽ là địa chỉ của ID Provider.discovery_endpoint
: là nơi chúng ta có thể lấy được mọi thông tin về ID Provider để verifyid_token
.authorization_endpoint
: nơi mà chúng ta sẽ gửi yêu cầu uỷ quyền tới.token_endpoint
: nơi chúng ta sẽ dùngauthorization_code
để đổi lấyaccess_token
từ ID Provider.userinfo_endpoint
: là điểm cuối để chúng ta lấy thông tin của user, tuy nhiên vì chúng ta sẽ sử dụng scope làopenid
vì vậy mộtid_token
sẽ được trả về cùng vớiaccess_token
. Cũng không cần endpoint này lắm nhưng nếu bạn không muốn verifyid_token
một cách thủ công thì có thể sử dụng điểm cuối này kèm vớiaccess_token
để lấy thông tin user.- Tuy nhiên mình sẽ verify
id_token
sử dụngdiscovery_endpoint
cho nó chuẩn bài nhé.
- Tuy nhiên mình sẽ verify
Authenticate with ID Provider
Đầu tiên chúng ta sẽ tạo bảng User trước để lưu thông tin người dùng cho ứng dụng RP của chúng ta.
rails generate model User uuid:string email:string
Sau đó chạy rails db:migrate
để update cấu trúc DB.
uuid
: là định danh người dùng, tuy nhiên chúng ta sẽ không tự động generate từ phía RP nữa mà sẽ sử dụng để lưu giá trịsub
mà IdP trả về trongid_token
.email
: cũng sẽ lưu giá trị mà IdP trả về bên trongid_token
.
Tiếp theo mình sẽ customize lại root_page
để dễ dàng truy cập và authentication hơn về mặt giao diện thôi.
- Tạo top page sử dụng
rails g controller Top index
: lệnh này giúp chúng ta tạo view, controller và route cho top page. - Tiếp theo là thêm flash component để hiển thị
error/info
cho user.
Add sessions_controller
Tạo một sessions_controller.rb
có nội dung sau:
# frozen_string_literal: true class SessionsController < ApplicationController def new; end def destroy; end
end
new
: generateauthorization_request
và redirect người dùng đếnauthorize_endpoint
.destroy
: xử lý đăng xuất cho user.
Và thêm nội dung sau vào routes.rb
.
delete '/logout', to: 'sessions#destroy'
get '/auth/stid', to: 'sessions#new'
Add activerecord-session_store
gem to manage user's session
Trong bước này chúng ta sẽ sử dụng gem activerecord-session_store
để quản lý session của người dùng thông qua ActiveRecord để làm việc với session dễ dàng hơn trong tương lai.
Thêm gem này vào Gemfile và chạy bundle install
.
gem 'activerecord-session_store'
Chạy rails generate active_record:session_migration
để tạo các file migration tự động. Sau đó tạo một bảng user_session
nữa với nội dung sau:
# chạy lệnh rails g migration CreateUserSessions và đổi nội dung file như bên dưới! # frozen_string_literal: true class CreateUserSessions < ActiveRecord::Migration[7.1] def change create_table :user_sessions do |t| t.references :user, null: false, foreign_key: true t.string :session_id t.timestamps end end
end
Run rails db:migrate
và thêm nội dung sau:
config.session_store :active_record_store, key: '_relying_party_session'
Và một số thay đổi khác, xem chi tiết commit này. Tóm lại phần này mình chỉ tạo ra các bảng cần thiết để quản lý session, và thêm một số helper methods để lấy thông tin người dùng hiện tại và kiểm tra xem người dùng có đang đăng nhập hay không?
Login with Study Together ID
Trước tiên chúng ta sẽ sửa lại sessions#new
để tạo authorization_request và redirect đến authorization_endpoint.
def new redirect_to authorization_url
end ... private def authorization_url client_id = ENV['STUDY_TOGETHER_CLIENT_ID'] redirect_uri = ENV['STUDY_TOGETHER_REDIRECT_URI'] scope = ENV['STUDY_TOGETHER_SCOPE'] state = SecureRandom.hex(16) session[:state] = state "#{ENV['IDP_AUTHORIZE_ENDPOINT']}?response_type=code&client_id=#{client_id}&redirect_uri=#{redirect_uri}&scope=#{scope}&state=#{state}"
end
Khởi động lại server rails s -p 8080
và thử đăng nhập xem như nào?
- Nhớ khởi động cả ID Provider project ở cổng
3000
nữa nhé mọi người.
Sau khi click vào Login with Study Together ID
button, bạn sẽ được chuyển hướng đến ID Provider.
Lúc này chúng ta nhập thông tin đăng nhập và click Sign in
button.
Nếu thông tin email
và password
chính xác, bạn sẽ được chuyển hướng đến Authorization Page để uỷ quyền cho ứng dụng. Click Yes, I Authorize
bạn sẽ thấy màn hình sau.
Sau khi bạn uỷ quyền cho ứng dụng, IdP sẽ redirect bạn về redirect_uri
mà bạn đã đăng ký trước đó với IdP. Do chúng ta chưa cấu hình route cho /auth/callback
vì vậy một thông báo lỗi xuất hiện trên màn hình như bạn thấy.
Giờ hãy tạo một controller mới có tên là omniauth_callbacks_controller.rb
với nội dung sau:
# frozen_string_literal: true class OmniauthCallbacksController < ApplicationController include HttpRequestable before_action :verify_state, only: :callback def callback token_response = fetch_token(params[:code]) validate_id_token(token_response['id_token']) user = find_or_create_user create_user_session(user) redirect_to root_path, notice: 'Signed in successfully' rescue StandardError => e redirect_to root_path, alert: "Authentication failed: #{e.message}" end def failure redirect_to root_path, alert: 'Authentication failed' end private def verify_state redirect_to root_path, alert: 'State mismatch error' if params[:state] != session[:state] end def fetch_token(code) post_request(ENV['IDP_TOKEN_ENDPOINT'], { grant_type: ENV['STUDY_TOGETHER_GRANT_TYPE'], code:, redirect_uri: ENV['STUDY_TOGETHER_REDIRECT_URI'], client_id: ENV['STUDY_TOGETHER_CLIENT_ID'], client_secret: ENV['STUDY_TOGETHER_CLIENT_SECRET'] }) end def validate_id_token(id_token) discovery_response = get_request(ENV['IDP_DISCOVERY_ENDPOINT']) jwks_uri = discovery_response['jwks_uri'] keys = get_request(jwks_uri)['keys'] decoded_token = JWT.decode(id_token, nil, true, { algorithm: 'RS256', jwks: { keys: }, verify_aud: true, aud: ENV['STUDY_TOGETHER_CLIENT_ID'] }) @payload = decoded_token.first validate_token_timestamps end def validate_token_timestamps raise 'ID token has expired' if Time.zone.at(@payload['exp']) < Time.zone.now raise 'ID token is not valid yet' if Time.zone.at(@payload['iat']) > Time.zone.now end def find_or_create_user User.find_or_create_by(uuid: @payload['sub']) do |user| user.email = @payload['email'] end end def create_user_session(user) UserSession.create(user:, session_id: request.session_options[:id].private_id) session[:uuid] = user.uuid end
end
Ở đây chúng ta sẽ có hai methods chính là callback
và failure
.
- Callback: khi user xác thực thành công và đồng ý uỷ quyền thì chúng ta sẽ nhận được
authorization_code
ở đây. - Failure: trong trường hợp lỗi xác thực hoặc người dùng không đồng ý yêu cầu uỷ quyền.
Giải thích code:
- Trước khi
callback
được thực thi chúng ta cần phảiverify_state
, chi tiết xem phần Authorization Request trong OAuth2.0 RFC 6749. - Phần đầu tiên trong callback, chúng ta sẽ phải gọi đến
token_endpoint
của IdP để đổiauthorization_code
lấyaccess_token
vàid_token
. - Vì hiện tại chúng ta không sử dụng đến
access_token
vì vậy ở đây mình chỉvalidate_id_token
, bao gồm việc xác minh chữ ký, và một số claims quan trọng (aud
,timestamps
). - Sau khi xác mình thành công thì chúng ta sẽ tạo một người dùng mới nếu họ chưa tồn tại, và lưu
session
mới cho người dùng.
Chi tiết thay đổi trong phần này xem tại đây!
Giờ hãy đăng xuất và thử đăng nhập lại!
Conclusion
Như vậy là chúng ta đã thành công tạo một sample RP và triển khai đăng nhập với ID Provider thành công, phần triển khai code hơi dài và khó theo dõi nếu đưa hết lên đây, vì vậy các bạn chịu khó xem thông qua Github nhá.
Ở các bài sau mình sẽ thêm các chức năng để hoàn thiện dự án này:
- Backchannel Logout
- Withdrawable Check
- etc.
Link Github: https://github.com/learnforward2023/stid_web
Tài liệu tham khảo:
- The OAuth 2.0 Authorization Framework: RFC 6749
- OpenID Connect Core
- OAuth2 and OpenID Connect: The Professional Guide
Again, nếu bạn thấy bài viết này hữu ích, hãy cho mình một upvote và follow để mình có thêm động lực viết những bài sau tốt và chất lượng hơn! Thank you!