Sign in with Google บน Rails
ช่วงนี้กำลังหัดเขียน Ruby on Rails มีโจทย์ว่า อยากให้มีปุ่ม Sign in with Google ต้องทำยังไง โดยที่ของเดิมมี table Users เอาไว้ทำ Authenticate แบบใช้ credential ที่เป็น username/password อยู่แล้ว เลยไปหาข้อมูล ก็เจอหลายที่ที่แนะนำให้ใช้ gem devise ร่วมกับ omniauth มาช่วย มันจะทำให้ integrate กับ Google ได้ง่ายขึ้น ก็ไปดู blog คนโน้นคนนี้อ้างอิง แล้วก็มาบันทึกการเรียนรู้ไว้หน่อย
ระบบของเดิมมี table ที่ชื่อ Users อยู่แล้ว สามารถทำการ authenticate ได้ด้วย username/password (ใช้ has_secure_password) หลังจากที่ authenticate สำเร็จ ก็จะเก็บ current user ไว้ใน session[:user_id] ตอน sign out ก็ใช้วิธี session.delete(:user_id)
Gems
เริ่มจากลง Gem ที่จะต้องใช้ก่อน
gem 'omniauth'
gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
gem 'devise'
gem 'dotenv'
เสร็จแล้วก็ install (gem dotenv เอามาใช้ในการ load ค่าจาก environment variable เข้ามาจากไฟล์ .env มาไว้ที่ตัวแปร ENV ใน rails)
bundle install
Devise
devise เอาไว้ช่วยจัดการเรื่อง authentication ต่าง ๆ รวมไปถึงทำหน้าพวก registration, reset password อะไรพวกนี้อีกด้วย
เรามาจัดการกับ devise ก่อน เริ่มจากรันตัว generator
rails generate devise:install
เสร็จแล้วก็สั่งให้ devise มันไป modify class Users ของเรา
rails generate devise User
ลองเปิดไฟล์ User model มาดู มันจะเติมของประมาณนี้เข้าไปใน class เรา
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
ถ้าเราอยากเอาอะไรออกก็เอาออก ถ้าจะใส่ option อะไรเพิ่มก็ใส่เข้ามา เสร็จแล้วก็ลองดูว่ามันมี migration อะไรใหม่สร้างมาจากตอนที่เรา devise User ก่อนนี้ไหม ถ้ามีก็ไปดูว่า options ที่เราเพิ่มเข้าหรือเอาออกใน User model มันต้องไป uncomment อะไรใน migation file ก็ไป comment/uncomment ให้ถูก
จังหวะนี้ก็ลบตรงที่เขียนว่า has_secure_password ในไฟล์ User.rb ออกไปได้เลย เดี๋ยวเราจะเปลี่ยนมาใช้ devise แทน
ก่อนหน้าที่เขียนไว้ตอนต้นว่า ตอน authenticate ด้วย username/password มันใช้ has_secure_password อยู่ ซึ่งมันจะไป hash password ที่เราใส่เข้าไปตอน save ไปไว้ที่ field password_digest ซึ่งมันจะไปตีกับ field encrypted_password ที่ devise ใช้ ดังนั้น เราจะ rename field password_digest ไปเป็น encrypted_password แทน
ก่อนอื่นไปปิด migration ที่จะเพิ่มฟิลด์ encrypted_password ก่อน โดยการแก้ไฟล์ migration ที่เกิดขึ้นตอนสั่ง rail generate devise User ก่อนหน้า (ชื่อไฟล์เขียนประมาณว่า #######_add_devise_to_users.rb โดยที่ ####### เป็นตัวเลข timestamp) แล้วไป comment บรรทัดที่เขียนแบบนี้ออก
t.string :encrypted_password, null: false, default: ""
ต่อมาเราก็ไป rename ฟิลด์ password_digest เป็น encrypted_password แทนด้วย migration
rails generate migration rename_password_column
แล้วใส่ migration แบบนี้
class RenamePasswordColumn < ActiveRecord::Migration[7.2]
def change
rename_column :users, :password_digest, :encrypted_password
end
end
เสร็จแล้วก็สั่ง migrate
rails db:migrate
OmniAuth
ส่วน OmniAuth เอาไว้สำหรับช่วยในเรื่อง Authenticate กับพวก third party service ต่าง ๆ เช่น Facebook, Google ในบทความนี้เราเอามาใช้ต่อกับ Google
ดังนั้น หลังจากเราเตรียม devise เรียบร้อย เราก็มาจัดการกับ OmniAuth กันต่อ
ก่อนอื่น ถ้าเรายังไม่มี OAuth 2.0 Client ID สำหรับ Google Cloud กันก่อน โดยให้ไปที่ https://console.cloud.google.com/apis/credentials เลือก Google Account ของเรา แล้วเลือก Project ที่เราใช้งาน แล้วกดที่ “+ CREATE CREDENTIALS” แล้วเลือก “OAuth client ID” มันจะถาม Application type ก็เลือกเป็น Web Application เสร็จแล้วมันจะมาที่หน้า Console ในหน้านี้มันจะมีส่วนที่เราต้องกรอกคือ “Authorized JavaScript Origins” ซึ่งก็คือตัว Origin ที่เราจะใช้เรียก Sign in with Google นี่แหละ ถ้าเรา develop ด้วย http://localhost:3000 ก็ใส่ http://localhost:3000 ไป รวมถึงให้ใส่ http://127.0.0.1:3000 เข้าไปด้วย กับ “Authorized redirect URIs” ก็คือ redirect URI ที่เราจะส่งมาให้ตอนกด Sign in with Google นั่นเอง ซึ่งถ้าทำตามบทความนี้เราจะใช้ URI เป็น http://localhost:3000/users/auth/google_oauth2/callback ก็ให้ใส่แบบนี้ไปได้เลย เสร็จแล้วก็กด CREATE พอเสร็จแล้ว มันจะมี Client ID กับ Client Secret ให้เรา copy มาใช้ได้
เสร็จแล้วเรา copy ทั้ง 2 ค่ามาใส่ไว้ในไฟล์ .env แล้ว save ไว้ที่ root ของ project
GOOGLE_CLIENT_ID=<ค่าที่ copy มาจาก Client ID>
GOOGLE_CLIENT_SECRET=<ค่าที่ copy มาจาก Client secret>
หลังจากนั้นเราก็กลับมาที่ rails project ของเรากันต่อ
ไปเพิ่มบรรทัดนี้ลงไปต่อท้าย config/boot.rb เพื่อให้ rails ทำการโหลดค่าจากไฟล์ .env ที่เราสร้างไว้ก่อนหน้านี้
require 'dotenv/load'
เสร็จแล้วไปที่ไฟล์ config/initializers/devise.rb ไปตรงบรรทัดที่เขียนว่า
# ==> OmniAuth
เสร็จแล้วเพิ่มบรรทัดข้างล่างนี้เข้าไป
config.omniauth :google_oauth2, ENV['GOOGLE_CLIENT_ID'], ENV['GOOGLE_CLIENT_SECRET'], {
scope: "email, profile",
prompt: "select_account"
}
หลังจากนั้นก็ไปเพิ่มฟิลด์สำหรับ OAuth2 ด้วยการสร้าง migration
rails generate migration add_user_oauth2_fields
เราจะได้ไฟล์ migration มา เข้าไปเพิ่ม 2 fields ที่ต้องใช้สำหรับ OAuth2 คือ provider กับ uid เราจะได้ไฟล์ migration ประมาณนี้
class UpdateUsers < ActiveRecord::Migration[7.2]
def change
add_column :users, :provider, :string, limit: 50, default: ''
add_column :users, :uid, :string, limit: 500, default: ''
end
end
เสร็จแล้วก็ migrate ให้เรียบร้อย
rails db:migrate
ถัดมาเราก็สร้าง controller ให้กับ devise
rails generate devise:controllers users
เสร็จแล้วเราเข้าไปเพิ่ม route ให้สำหรับการทำ callback จาก Google ในไฟล์ config/routes.rb
devise_for :users, controllers: {
omniauth_callbacks: 'users/omniauth_callbacks'
}
แล้วไปเพิ่ม method ลงในไฟล์ controllers/users/omniauth_callbacks_controller.rb
def google_oauth2
@user = User.create_from_provider_data(request.env['omniauth.auth'])
if @user.persisted?
sign_in_and_redirect @user
set_flash_message(:notice, :success, kind: 'Google') if is_navigational_format?
else
flash[:error]='There was a problem signing you in through Google. Please register or try signing in later.'
redirect_to new_user_session_path
end
end
ต่อมาไปเพิ่มอันข้างล่างนี้ลงไปใน user model ต่อท้ายของเดิมที่มันเติมมาให้ตอนเรา install devise
:omniauthable, omniauth_providers: [:google_oauth2]
เราจะได้ของหน้าตาประมาณนี้
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable,
:omniauthable, omniauth_providers: [:google_oauth2]
แล้วก็ใน User model เราเพิ่ม method ใหม่ลงไปแบบนี้
def self.create_from_provider_data(provider_data)
where(provider: provider_data.provider, uid: provider_data.uid).first_or_create do |user|
user.email = provider_data.info.email
user.password = Devise.friendly_token[0, 20]
end
end
ตรงนี้คือ ตอนที่ Google มันมา authenticate กับเรา เราจะไปหาข้อมูลของ user คนนั้นใน database ด้วย provider กับ uid ที่ให้มา ถ้าไม่มี มันจะสร้าง record ใหม่ให้ โดยกำหนด email ตาม info ที่ส่งมา และ password เป็น random จาก Devise
ถ้าเราอยากจะเก็บข้อมูลอย่างอื่นก็แก้ตรงนี้ได้เลย
Sign in to Google button
ปิดท้ายก็ทำปุ่มในหน้า Login ให้กดแล้วปุ่ม Sign in to Google ทำงานได้เลย
<%= button_to omniauth_authorize_path("user", "google_oauth2"), method: :post, data: { turbo: false } do %>
Sign in with Google
<% end %>
สังเกตว่าจะสั่งให้ data: { turbo: false } ด้วย เพื่อปิดไม่ให้ใช้ turbo ตรงส่วนนี้ ส่วน UI อยากจะให้มันเป็นแบบไหนก็ไปจัดกันเองต่อได้เลยตามสะดวก
หลังจากนั้นก็ลอง start server แล้วทดสอบได้เลย
rails server
Login Session
หลังจากที่เปลี่ยนมาใช้ devise ตัว devise จะใช้ field email เป็น login ถ้าเราต้องการให้ devise ใช้ field login แทน ให้ไปแก้ที่ config/initializers/devise.rb โดยไป uncomment บรรทัดข้างล่าง แล้วแก้จาก :email เป็น :login แทน
# config.authentication_keys = [:email]
แก้เป็น
config.authentication_keys = [:login]
ส่วน Login form ก็ให้ปรับเป็นแบบประมาณนี้แทน
<form method="post" action="<%= new_user_session_path %>">
<div>
<label for="login">Login</label>
<div>
<input type="text" name="user[login]" id="login" autocomplete="login">
</div>
</div>
<div>
<label for="user_password">Password</label>
<div>
<input type="password" name="user[password]" id="user_password" autocomplete="">
</div>
</div>
<div>
<button type="submit">Login</button>
</div>
</form>
สังเกตว่าตรงส่วน action จะเปลี่ยนไปใช้ new_user_session_path แทนที่จะเป็น manual authentication แบบเดิม ดังนั้นถ้า controller เดิมเราไม่ได้ทำอะไรเพิ่มเป็นพิเศษ เราสามารถลบ controller ที่ใช้ login ตัวเดิมออกได้เลย
Logout
วิธี Logout ด้วย devise ก็จะใช้วิธียิง DELETE ไปที่ destroy_user_session_path เมื่อเอามาทำเป็น UI ก็จะได้ออกมาหน้าตาแบบนี้
<%= link_to "Log out", destroy_user_session_path, method: :delete, data: { turbo_method: :delete } %>
จบแล้ว ประมาณนี้แหละ ลองทำตามเองแล้ว น่าจะไม่มีอะไรตกหล่นนะ