前回で基本的なAPI通信の基礎はできたので、今回は認証付きのAPIアクセスをやってみる。 具体的には、Auth0からもらった公開鍵を使用して、nuxtから送られてきたjwtを検証する。
認証に何使うか?
通常Railsで認証を行う場合、deviseやらsorceryやらを使用することが多いです。 今回はSPAということもあり、別の選択肢が候補に上がります。firebaseを使うパターンもありますが、今回はKnockを使ってみたいと思います。
https://github.com/nsarno/knock
KnockはRails Apiモードに特化したステートレスなセッションを行いたい場合に選択肢に上がる事が多いです。トークンの管理は今回Auth0に任せたいので、そのあたりも相性が良さそうです。
RailsアプリにKnock入れる
Rails側にknockを入れていきます。環境変数も入れたいので、ついでにdotenvも導入します。
今回はKnockを採用しましたが、ruby-jwtというgemもあり、そちらのほうが更新頻度も多いのでそっちを使ったほうがいいかも https://github.com/jwt/ruby-jwt
Gemfile
gem "knock"
gem "dotenv-rails"
Knockを入れると、コマンドで設定ファイルを作成できるようになるので、下記生成する。
rails g knock:install
実行するとconfig/initializers/knock.rb が生成される。この設定ファイルを今回の要件に対応させるように書き換える。
参考 https://rwehresmann.medium.com/ruby-on-rails-auth0-knock-7f114ed87e4b
require "net/http"
require "knock/version"
require "knock/authenticable"
Knock.setup do |config|
## Expiration claim
## ----------------
##
## How long before a token is expired. If nil is provided, token will
## last forever.
##
## Default:
# config.token_lifetime = 1.day
## Audience claim
## --------------
##
## Configure the audience claim to identify the recipients that the token
## is intended for.
##
## Default:
config.token_audience = -> {ENV['AUTH0_CLIENT_ID']}
## If using Auth0, uncomment the line below
# config.token_audience = -> { Rails.application.secrets.auth0_client_id }
## Signature algorithm
## -------------------
##
## Configure the algorithm used to encode the token
##
## Default:
config.token_signature_algorithm = "RS256"
jwks_raw = Net::HTTP.get URI(ENV['AUTH0_JWKS'])
jwks_keys = Array(JSON.perse(jwks_raw)["keys"])
## Signature key
## -------------
##
## Configure the key used to sign tokens.
##
## Default:
# config.token_secret_signature_key = -> { Rails.application.secrets.secret_key_base }
## If using Auth0, uncomment the line below
# config.token_secret_signature_key = -> { JWT.base64url_decode Rails.application.secrets.auth0_client_secret }
## Public key
## ----------
##
## Configure the public key used to decode tokens, if required.
##
## Default:
config.token_public_key = OpenSSL::X509::Certificate.new(Base64.decode64(jwks_keys[0]["x5c"].first)).public_key
## Exception Class
## ---------------
##
## Configure the exception to be used when user cannot be found.
##
## Default:
# config.not_found_exception_class_name = 'ActiveRecord::RecordNotFound'
end
AUTHO0_JWKSにはAuth0の公開鍵情報を取得できるurlを設定します。この公開鍵を用いて、JWTを検証します。
Userモデルを作る
Rails側にログインしたらユーザー情報を保存したいので、ユーザーモデルの作成を行います。 現状はemailアドレスとどんな媒体でログインしたかを知りたいので、emailとsubのカラムを追加します。それ以外でももし欲しい情報があればカラムを用意して保存できるようにすると良いでしょう。
rails g model User email:string sub:string
rails db:migrate
マイグレーションができたら、UserモデルにKnockの認証用のメソッドを定義していきます。
class User < ApplicationRecord
def self.from_token_payload(payload)
find_by(sub: payload["sub"]) || create!(sub: payload["sub"], email: payload['email'])
end
end
Knockを適用した範囲のAPIでは、自動でfrom_token_payloadというメソッドが呼ばれる。このメソッドの第一引数に、JWTのペイロードが入ってくる。
また、適用したAPIの範囲と言っているように、APIにKnockを使用することを明示してあげます。 ApplicationControllerに入れておきましょう。
class ApplicationController < ActionController::API
include Knock::Authenticable
end
ここまでできたら、認証付きのAPIコントローラーを設定します。今回はauth_controller.rbという名前にしました。
module Api
module V1
class AuthController < ApplicationController
before_action :authenticate_user
def index
render json: {
message: "ID : #{current_user.id}, SUB: #{current_user.sub} ",
}
end
end
end
end
ルーティングも変更。
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :hello
resources :auth
end
end
end
before_action :authenticate_user
を行っているので、authコントローラーにだけauthorizeするようにしています。 この状態でauthにアクセスしても、401が返ってきます。
ポストマンやcurlを用いてBearerにid_tokenを入れて検証してみます。 戻り値として、authコントローラーで指定した形で値が取得できればOKです。
{"message":"ID : 6, SUB: google-oauth2|hogehogefugafuga "}
Nuxtから呼べるようにする
最後に、フロントエンド側から、この情報を取得できるようにしておきます。
id_rokenを検証にわたしてあげたいので、LocalStrageからid_tokenを抜き出すメソッドを定義します。
auth0.ts
getIdtoken () {
return this.isAuthenticated() ? localStorage.getItem('idToken') : null
}
ログインしていればローカルストレージからidTokenを取得し、そうでないならnullを返すようにします。
index.vueにも、auth用のボタンを置きましょう。 全体像はこうなりました。
index.vue
<template>
<main>
<ul>
<li
v-for="page in ['Options API', 'Class API', 'Composition API']"
:key="page"
>
<nuxt-link
:to="
`/${page
.toLowerCase()
.split(' ')
.join('-')}`
"
>
With {{ page }}
</nuxt-link>
</li>
</ul>
<div v-if="!loggedIn()">
<div>ログインしてください。</div>
<nuxt-link class="button is-primary" to="/login">
<span>login</span>
</nuxt-link>
</div>
<div v-else>
<div>{{ userInfo().nickname }}</div>
<button @click="logout()">
ログアウトする
</button>
</div>
<div>
<button @click="greet()">
挨拶する
</button>
</div>
<div>
<button @click="auth()">
authする
</button>
</div>
</main>
</template>
<script lang="ts">
export default {
methods: {
loggedIn () {
return this.$auth0.isAuthenticated()
},
userInfo () {
const user = this.$auth0.getUserData()
console.log(user)
return user
},
logout () {
this.$auth0.logOut()
this.$router.replace('/')
},
async greet () {
const ret = await this.$axios.$get('/api/v1/hello')
console.log(ret)
},
async auth () {
const data = await this.$axios.$get('/api/v1/auth', { headers: { Authorization: 'Bearer ' + this.$auth0.getIdToken() } })
console.log(data)
}
}
}
</script>
この状態で、authボタンを押すと、console.logによってapi/v1/authにアクセスした結果のidとsubが返ってきています。
これで、フロントでAuth0を用いてログイン、バックエンドアプリでid_tokenを使った検証、検証データをフロントでっ取得するという流れができました。 本番で運用する際は、ログイン後の画面に関しては共通のヘッダーを設定したり、検証したデータでリソースの表示を行ったりなど、色々やることはありますが、仕組みがいい感じにわかったので今回はここまでにします。