自分が参加しているネイティブアプリ開発のプロジェクトでは、webの管理画面をNuxt.jsを使い、SPAで作成しています。アプリで使っているAPIのロジックなどをそのまま使いまわせる部分も多くあり、割とスムーズに作れています。
アプリ側の認証にAuth0を使っているので、管理画面のログイン機能についてもAuth0で簡易的に作ってしまおうということで、その内容で今回記事を書きました。
ログインは基本はemailとパスワードの簡易なもので、管理者はみんなgoogleアカウントを持っているので、googleログインも一番簡易にできるようにしようと言うことでgoogleログインのみSNS認証を入れました。(チェックを入れるだけでサクッと入れることができました。)
一番簡易なやり方を記事にしているので、実際の運用している構成とは異なりますが、最低限のログイン機能はこの記事を見ればできると思います。
バージョン情報
- node v14.13.1
- yarn v1.22.10
- nuxt v2.15.7
- Vue 3 Composition API
Auth Moduleとは
Nuxt.jsアプリケーションに簡単に認証を追加することができる公式モジュールを使います。
middlewareについて
ページがレンダリングされる前に設定された関数を実行することができるmiddlewareと言うプロパティーを使います。
これを使って、認証しているかのチェックを行い、認証をしていない場合は遷移しようとした時に対象の画面を表示しないように制御します。もちろん、サーバーサイドでのAPIリクエストの認可処理も行なっていますが、表示の制御も同時に行う想定です。
Auth Moduleの追加
パッケージを追加していきます。
$ docker-compose exec nuxt bash
$ yarn add @nuxtjs/auth
設定の追加
modules: [
...
'@nuxtjs/auth', // 追加
]
以下はAuth0のアプリケーションのsettingsで確認した値を入れていきます。
環境ごとに環境変数で管理するために、Nodeのcross-envと言うライブラリを使いました。
cross-envはスクリプト実行時に任意の環境変数を設定できます。
- env.development.js (開発環境用ファイル)
- env.staging.js (検証環境用ファイル)
- env.production.js (本番環境用ファイル)
スクリプトのbuildとstartを、開発、検証、本番環境用に3つに分けます。そして先頭にcross-env NODE_ENV=hogehogeと足すことで環境変数NODE_ENVを切り替えてコマンドを実行できます。
コマンドは、開発用はbuildコマンド、ステージングはgenerate:staging、本番はgenerate:productionといった形に分けています。
"scripts": {
"dev": "cross-env NODE_ENV=local nuxt",
"build": "nuxt build",
"start": "nuxt start",
"generate": "nuxt generate",
"generate:staging": "cross-env NODE_ENV='staging' nuxt generate",
"generate:production": "cross-env NODE_ENV='production' nuxt generate",
"lint:js": "eslint --fix --ext \".js,.vue\" --ignore-path .gitignore .",
"lint": "yarn lint:js"
},
設定値は以下のような形式で設定します。
- 設定値
// 〜〜〜省略〜〜〜
const environment = process.env.NODE_ENV || 'dev'
const crossEnv = require(`./env.${environment}.js`)
// 〜〜〜省略〜〜〜
export default {
// 〜〜〜省略〜〜〜
auth: {
strategies: {
auth0: {
domain: crossEnv.authAppDomain, // Auth0 App Domain
client_id: crossEnv.authAppClientId, // Auth0 App Client ID
audience: crossEnv.authAppAudience, // Auth0 App endpoint
responseType: 'code',
grantType: 'authorization_code',
codeChallengeMethod: 'RS256',
}
},
redirect: {
login: '/login', // 未ログイン時のリダイレクト先
logout: '/login', // ログアウト処理を実行した直後のリダイレクト先
callback: '/callback', // コールバックURL
home: '/', // ログイン後に遷移するページ
}
},
}
- 環境変数
export const apiBaseUrl = 'https://hoge-app.com'
export const authAppDomain = 'hoge-admin-production.jp.auth0.com' // Auth0 App Domain
export const authAppClientId = 'hogehuga1234' // Auth0 App Client ID
export const authAppAudience = 'https://hoge-admin-production-api-endpoint/' // Auth0 App endpoint
export const apiBaseUrl = 'https://stg.hoge-app.com/api/v1/'
export const authAppDomain = 'hoge-admin-staging.jp.auth0.com' // Auth0 App Domain
export const authAppClientId = '1234hogehuga' // Auth0 App Client ID
export const authAppAudience = 'https://hoge-admin-staging-api-endpoint/' // Auth0 App endpoint
export const apiBaseUrl = 'http://localhost:3000/api/v1/'
export const authAppDomain = 'hoge-admin-development.jp.auth0.com' // Auth0 App Domain
export const authAppClientId = 'hogehogehoge1234' // Auth0 App Client ID
export const authAppAudience = 'https://hoge-admin-development-api-endpoint/' // Auth0 App endpoint
cross-envを使っても設定値のファイルも含めてビルドされるので、完全に秘匿化することはできないのですが、シークレットキーはサーバー側の秘匿ファイルの中で管理していて絶対に漏れないので、仮にクライアントIDがバレても認証行為などはできません。
参考記事
https://auth0.com/docs/tokens/access-tokens/get-access-tokens
https://auth.nuxtjs.org/api/auth
- store/index.js を空で用意する
無い場合は以下のエラーが出てしまうので注意。
FATAL Enable vuex store by creating store/index.js.
Auth0セットアップ
-
テナント作成
- テナント作成画面から
-
APIエンドポイント生成
- デフォルトのものが最初に存在しているが新規で作成する
- APIエンドポイントを作成すると自動でテスト用のmachine to machineのアプリケーションも作成される模様
- 作成したエンドポイントがmachine to machineのアプリケーションに紐づくことによりcurl でエンドポイントを叩いて開発用のtokenを取得することができるようになる(machine to machineのアプリケーションに紐付けておかないとcurlで叩けなかった)
- curlコマンドはAPIのエンドポイントページに行きtestタブから見れる
ログイン画面の実装
- ログインボタンをクリックすると
loginWithAuthZero()
のcontext.$auth.loginWith('auth0')
が呼ばれログインモーダルが立ち上がる- 仕組みとしては使う側ではsetupメソッドの第2引数に渡されてくるcontext引数にAuth0のライブラリに関するコンテキストが詰め込まれているので、そこに対してメソッドを実行する。rootプロパティの中には定義されているメソッドが含まれている。
- ログインしていないとアクセスできないリンクは非ログイン状態の時は隠してある。
<template>
<div class="container has-text-centered mt-6">
<div>
<h1 class="title">ログインはこちら</h1>
<b-button size="lg" variant="outline-secondary" @click="loginWithAuthZero">ログイン</b-button>
</div>
</div>
</template>
<script>
import { defineComponent } from '@nuxtjs/composition-api'
export default defineComponent({
setup(_props, context) {
const loginWithAuthZero = () => {
context.root.$auth.loginWith('auth0')
}
return {
loginWithAuthZero
}
}
})
</script>
<template>
<div class="bg-light h-100">
<b-navbar-brand tag="h1" class="p-3">Loupe</b-navbar-brand>
<p v-if="loggedInWithAuthZero" class="p-3">{{ loginInformation.nickname }}でログイン中</p>
<p v-else class="p-3">{{ loginInformation.message }}</p>
<b-nav pills vertical class="pt-2">
<template v-if="loggedInWithAuthZero">
<b-nav-item to="/sample">サンプル</b-nav-item>
<b-nav-item to="/posts">投稿一覧</b-nav-item>
<b-nav-item to="/reports/user">ユーザー報告</b-nav-item>
<b-nav-item to="/reports/post">投稿報告</b-nav-item>
<b-nav-item to="/reports/comment">コメント報告</b-nav-item>
<b-nav-item to="/notifications">お知らせ配信</b-nav-item>
<b-nav-item @click="logoutWithAuthZero">ログアウト</b-nav-item>
</template>
<b-nav-item v-else @click="loginWithAuthZero">ログイン</b-nav-item>
</b-nav>
</div>
</template>
<script>
import {
defineComponent,
reactive,
ref
} from '@nuxtjs/composition-api'
export default defineComponent({
setup(_props, { root }) {
const loggedInWithAuthZero = ref(root.$auth.$state.loggedIn)
const loginInformation = reactive({
nickname: loggedInWithAuthZero.value ? root.$auth.user.nickname : '',
message: !loggedInWithAuthZero.value ? 'ログアウトされています' : ''
})
const loginWithAuthZero = () => {
root.$auth.loginWith('auth0')
}
const logoutWithAuthZero = () => {
root.$auth.logout()
loggedInWithAuthZero.value = false
loginInformation.message = 'ログアウトされています'
}
return {
loginWithAuthZero,
logoutWithAuthZero,
loggedInWithAuthZero,
loginInformation
}
}
})
</script>
<style scoped>
.nuxt-link-exact-active {
background: #ddd;
border-radius: unset;
color: #333;
}
.nav-link {
transition: .2s;
}
</style>
middleware/userAuth.js
- ログインしていない場合にalertモーダルを出しつつリダイレクトする処理を追加
- ログインしていないと見ることができないページについて、コンポーネントにmiddlewareの記述をする
export default function ({ store, redirect }) {
if (!store.state.auth.loggedIn && process.client) {
window.alert('ログインしてください')
redirect('/login')
}
}
Rails側(API側)での認可処理
-
実際にフロント側でログインボタンが押された際にAuth0で生成したJWTトークンがローカルストレージにセットされるので、それをヘッダーに入れてAPIリクエストを送り、サーバー側でデコード処理を行い認可を行います。
-
nuxt.js側からリクエストを送る際には
localStorage.getItem('auth._token.auth0')
でローカルストレージにあるトークンを取り出してリクエストヘッダーに入れてからリクエストを投げます。 -
ローカルストレージにJWTトークンを入れていいのかという議論はあると思いますが、我々の運用はIPアドレスでアクセス制限をかけて関係者のみのログインとしているのでそこはあまり考慮していません。ローカルストレージ以外にJWTを入れる方法はあると思いますが、今回の記事では言及していません。デフォルトはローカルストレージに入るみたいです。
-
流れ
-
公開鍵の取得
- 公開鍵のセットがJWKS(JSON Web Key Set)形式でweb経由で公開されて提供されているので、そこにアクセス(
https://#{Rails.application.credentials.auth0.fetch(:domain)}/.well-known/jwks.json
)
- 公開鍵のセットがJWKS(JSON Web Key Set)形式でweb経由で公開されて提供されているので、そこにアクセス(
-
JWTの署名の検証
- JWTが発行されると、そのヘッダー部分には
kid
(Key ID)というフィールドが含まれ、このkid
は、JWTの署名を生成した際に使われた公開鍵を一意に識別するためのもの。 - 公開鍵のセット(JWKS)を取得すると、その中には複数の公開鍵(各公開鍵には一意の
kid
が付与されている)が含まれる。 - JWTの署名を検証するには、JWTのヘッダー部分の
kid
と、公開鍵セットの中のkid
を照合し、一致する公開鍵を見つけ出し、その公開鍵を使って、JWTの署名を正しいものかどうか検証する。
- JWTが発行されると、そのヘッダー部分には
-
公開鍵の取得
#
# 管理画面のトークン検証(Auth0認証のみ)
#
def verify_token_for_cms
domain = "https://#{Rails.application.credentials.auth0.fetch(:admin_domain)}/"
api_identifier = Rails.application.credentials.auth0.fetch(:admin_api_identifier)
render_401 message: t('response.401') if verify_auth0_token(domain, api_identifier).blank?
end
#
# Auth0用トークンの検証
#
def verify_auth0_token(domain, api_identifier)
options = {
algorithm: 'RS256',
iss: domain,
verify_iss: true,
aud: api_identifier,
verify_aud: true
}
decoded_token = JWT.decode(token, nil, true, options) { |header| jwks_hash(domain)[header['kid']] }
decoded_token.first
rescue StandardError => e
Rails.logger.warn "[WARN]#{e}"
nil
end
#
# Auth0用JWKSの取得
#
def jwks_hash(domain)
jwks_raw = Net::HTTP.get URI("#{domain}.well-known/jwks.json")
jwks_keys = Array(JSON.parse(jwks_raw)['keys'])
Hash[
jwks_keys.map do |k|
[
k['kid'],
OpenSSL::X509::Certificate.new(
Base64.decode64(k['x5c'].first)
).public_key
]
end
]
end
OAuthについて(googleloginを使っているため一応整理)
Oauthとは
- 認可のフレームワーク
- 作成したアプリケーションからhttpサービスに限定的にアクセスできる
- ID/パスワードをアプリケーションに置かず(無しで)、対象サービス(twitterやgoogleアカウント)からデータ(アカウント情報など)を取ってこれる。※アクセストークンでやりとり
流れ
前提: 通常はSDK等に以下の挙動をするメソッドが準備されているケースがほとんどでそれを使ってアプリケーション側で実装していく。Auth0では以下の一連の流れをアプリケーション側に実装する必要がないことが特徴。JWTトークンを受け取った後の実装は当然必要。
①クライアント(サードパーティーアプリケーション)から認可サーバーへリソースサーバーのデータを使いたい旨をリクエスト
※クライアント(サードパーティーアプリケーションは認可サーバー側から見れば第三者なのでアプリケーション側をサードパーティーと呼ぶ。Auth0を使う場合は、Auth0自身がサードパーティーとなる
③許可すると認可サーバーへその旨を回答
④認可サーバーからクライアント(サードパーティーアプリケーション)へ許可された旨を通知するとともにアクセストークンを返す
⑤アクセストークンを持ってクライアント(サードパーティーアプリケーション)から実際にリソースサーバーにデータを取りに行く
- よくあるOAuthの認可画面
Auth0の場合の認可画面
-
ログインした時点で認可されることの同意も兼ねている模様(auth0.comと共有しますと書かれている)
-
googleやtwitterの認可サーバーからすると、Auth0はサードパーティーにあたり、ユーザーに出す上記の画面で同意を取った場合にリソースにアクセスして取得したデータをもとにAuth0にアカウント情報を保存しユーザー保存している。
-
googleアカウントでのログインをしない場合は、ユーザーから入力されたemail、パスワードをAuth0に保存して管理している。
JWTについてさらっと
jwtの構成
-
ヘッダ、ペイロード、署名の3つから成る。
- ヘッダー(Header) : ここには、JWTがどのようなアルゴリズムを用いて署名されているかを示す情報が含まれる。(例えば、HS256やRS256など)。ここは単なるBase64でのencodeなので機密情報などは入れないようにする。
- ペイロード(Payload) : ここには、クレームと呼ばれる情報が含まれる。これは、ユーザーIDや有効期限(exp: expiration time)などの情報を指す。ここは単なるBase64でのencodeなので機密情報などは入れないようにする。
- 署名(Signature) : ここがJWTの安全性を保証。署名はヘッダーとペイロードと秘密鍵を使って生成される。
-
それぞれは、Base64でエンコードされている
- ペイロードの中に実際のuserのidなどを格納してそれをdecodeして認証において使用する
- ここは単なるBase64エンコードなので
-
署名が正しいかの検証は公開鍵で行う
- 公開鍵暗号方式では、公開鍵は暗号化と署名の検証に使われ、誰でも利用可能で、一方、秘密鍵は復号化と署名の生成に使用され、これは秘密に保たれなければならない。
-
それぞれは、 . (ドット) で結合されている。
[ヘッダ].
[ペイロード].
[署名]
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjROckVVdE52NWMzRm5ab184OWRnMyJ9.eyJpc3MiOiJodHRwczovL2Rldi1ucDZobnktbC5qcC5hdXRoMC5jb20vIiwic3ViIjoiU05qRTdqZEg0RGgzbmtCb004NHFwUmlFMW1CSGJxcWFAY2xpZW50cyIsImF1ZCI6Imh0dHBzOi8vZGV2LW5wNmhueS1sLmpwLmF1dGgwLmNvbS9hcGkvdjIvIiwiaWF0IjoxNjQ5MDMxNDM5LCJleHAiOjE2NDkxMTc4MzksImF6cCI6IlNOakU3amRINERoM25rQm9NODRxcFJpRTFtQkhicXFhIiwiZ3R5IjoiY2xpZW50LWNyZWRlbnRpYWxzIn0.NIG9xThvcCeFMyOJX0X62sG_hcIJ0lfK5Y9fXgtqqzbxHl1wfDZQpRxSds06Yer9H8rpNewfa19QJfvuLIvL6fRbsoD1B9PANiaPB0QecTESgPx8lveDbpM_4D6dNI4kOkBhVltzJoCQkX1M1lxkUC_Soz8tCxvU7gwC-njLVH9NEpHMjTISr9Et3b0b3YAI-6-jwcwCxU6fAnH0Mp7w8Xzoz81TJN06CWwZ7M-R38E5BQxjPQ2Z6LEwyRcdmcmWWg5Cvaa44x0Boitb2pSULQ6ElQZhPZrAnHnuXRNV-vg-46V4nJMp6A1qgMrUkpJ3oVamXGb6YS4xdX2RdUk0Rw
gem jwt
今回使ったgemはこちらです。
https://github.com/jwt/ruby-jwt
色々READMEを見て試してみました。
- 指定アルゴリズムで発行されるhashによる署名なし(none)
irb(main):008:0> payload = { data: 'test' }
=> {:data=>"test"}
# シークレットはnil、署名は'none'でencodeする
irb(main):009:0> token = JWT.encode payload, nil, 'none'
=> "eyJhbGciOiJub25lIn0.eyJkYXRhIjoidGVzdCJ9."
irb(main):010:0> puts token
eyJhbGciOiJub25lIn0.eyJkYXRhIjoidGVzdCJ9.
=> nil
# シークレットはnil,シークレットなしのバリデーションfalseにして、decodeする
irb(main):011:0> decoded_token = JWT.decode token, nil, false
=> [{"data"=>"test"}, {"alg"=>"none"}]
- HMACで発行されるhashによる署名あり
irb(main):008:0> payload = { data: 'test' }
=> {:data=>"test"}
# 任意のシークレットを作成
irb(main):018:0> hmac_secret = 'my$ecretK3y'
=> "my$ecretK3y"
# シークレットと、アルゴリズムを用いてencodeする
irb(main):019:0> token = JWT.encode payload, hmac_secret, 'HS256'
=> "eyJhbGciOiJIUzI1NiJ9.eyJkYXRhIjoidGVzdCJ9.pNIWIL34Jo13LViZAJACzK6Yf0qnvT_BuwOxiMCPE-Y"
irb(main):020:0> puts token
eyJhbGciOiJIUzI1NiJ9.eyJkYXRhIjoidGVzdCJ9.pNIWIL34Jo13LViZAJACzK6Yf0qnvT_BuwOxiMCPE-Y
=> nil
# シークレットを用いてdecodeする
irb(main):021:0> decoded_token = JWT.decode token, hmac_secret, true, { algorithm: 'HS256' }
=> [{"data"=>"test"}, {"alg"=>"HS256"}]
- RSAで発行されるhashによる署名あり(今回はこれを採用)
# 秘密キーの発行
irb(main):022:0> rsa_private = OpenSSL::PKey::RSA.generate 2048
=> #<OpenSSL::PKey::RSA:0x00007f084a0d6318>
# 対になる公開キーの発行
irb(main):023:0> rsa_public = rsa_private.public_key
=> #<OpenSSL::PKey::RSA:0x00007f084be450e8>
# encodeは秘密キーを指定して行う
irb(main):024:0> token = JWT.encode payload, rsa_private, 'RS256'
=> "eyJhbGciOiJSUzI1NiJ9.eyJkYXRhIjoidGVzdCJ9.0xYFEz3F7MBZP7vytcnoifdopd6I0QavytCSqD0nWh3NCJZJ9eX-rCa4rPuxLvmxBh8HrM5U3TejJu7mYW2Pzkn2BbGJYu9coCbdQtEsPtI...
irb(main):025:0> puts token
eyJhbGciOiJSUzI1NiJ9.eyJkYXRhIjoidGVzdCJ9.0xYFEz3F7MBZP7vytcnoifdopd6I0QavytCSqD0nWh3NCJZJ9eX-rCa4rPuxLvmxBh8HrM5U3TejJu7mYW2Pzkn2BbGJYu9coCbdQtEsPtIjdHL6YuQQMtEka4y_krKVk-O1VhtBxvX69A4fp6r0q_RLMnSKm3MxDWUEBtA2O1detMjoEx4aPJYO95VXtjC5EsWigacq0eGC3tnB64oThMpvEceIMK9ODMJYm027_64u4xtHdBq2ZzXFBs39WZNXP6CaO-gCFye9F3iMTGGo3CCA1uNXpwQ7AfxFok7ysGeZmM4zxox4F1Ptp-E--SQ4VntfAOFcoxvRFkdY0BXq4A
=> nil
# decodeは公開キーを指定して行う
irb(main):026:0> decoded_token = JWT.decode token, rsa_public, true, { algorithm: 'RS256' }
=> [{"data"=>"test"}, {"alg"=>"RS256"}]
irb(main):027:0> puts decoded_token
{"data"=>"test"}
{"alg"=>"RS256"}
=> nil
リクエストの形式
- アプリ側からJWTtokenをheaderに入れて送信してもらう
- keyは
Authorization
- サーバー側では、
authenticate_with_http_token
を使い、headerのkeyがAuthorization
のもの値(token)を取り出すことができる。
curl -H 'Authorization: Bearer hogehugahogehuga' http://localhost/api/v1/cms/posts
{"status":200,"message":"Get Posts","data":{}}%
いかがでしたでしょうか??ご参考になれば幸いです!