はじめに
本記事はAPIをRailsのAPIモードで開発し、フロント側をVue.js 3で開発して、認証基盤にdevise_token_authを用いてトークンベースの認証機能付きのSPAを作るチュートリアルの2要素認証設定編の記事になります。
前回: Rails APIモード + devise_token_auth + Vue.js 3 で認証機能付きのSPAを作る(RequestSpec編)
次回: Rails APIモード + devise_token_auth + Vue.js 3 で認証機能付きのSPAを作る(2要素ログイン編)
Rails側
Railsで2要素認証を行う場合、既にdeviseが導入されているなら、devise-two-factorを使うとそれほど工数をかけずに2要素認証を導入できるかと思いますので、今回はこのGemを用いて実装します。
devise-two-factorはGemの内部の実装で、rotpというTOTP(Time-based One-time Password)での認証を提供するGemを使っているので、deviseを使っていない場合はrotp単体で使うといいかもしれません。
devise-two-factorの導入
まずはGemのインストールから
インストール
# Gemfile
gem 'devise-two-factor'
でbundle install
$ bundle
bundle installが終了したら、devise-two-factorの機能を使うのに必要なカラムの生成とモデルの書き換えを行ってくれるコマンドを実行します。
カラムの生成とモデルの書き換えコマンドの実行
$ bundle exec rails generate devise_two_factor User ENCRYPTION_KEY
上記コマンドを実行すると、Userモデルに以下のような変更が加わります。
class User < ActiveRecord::Base
devise :two_factor_authenticatable,
:otp_secret_encryption_key => ENV['ENCRYPTION_KEY']
# database_authenticatableが削除される
devise :registerable, :recoverable, :rememberable, :trackable, :validatable
end
さらに、2要素認証に必要なマイグレーションファイルも自動生成されます。
class AddDeviseTwoFactorToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :encrypted_otp_secret, :string
add_column :users, :encrypted_otp_secret_iv, :string
add_column :users, :encrypted_otp_secret_salt, :string
add_column :users, :consumed_timestep, :integer
add_column :users, :otp_required_for_login, :boolean
end
end
変更内容が確認できたら以下コマンドを実行
$ rails db:migrate
環境変数の設定
先ほど追加したENCRYPTION_KEYの管理方法はアプリケーションコードにベタ書き以外はご自身のお好きな方法で良いかと思いますが、今回はRails付属のcredentialsで管理したいと思います。
ENCRTPTION_KEYの値は適当にSecureRandom.hex(32)等で生成したものを割り当てることにします。
$ rails c
$ SecureRandom.hex(32)
=> "ランダムな文字列"
以下コマンドを実行
$ EDITOR=vi rails credentials:edit
# aws:
# access_key_id: 123
# secret_access_key: 345
# ここから追加
# devise_two_factor encryption_key
devise_two_factor:
ENCRYPTION_KEY: "ランダムな文字列を貼り付けてね"
# ここまで
# Used as the base secret for all MessageVerifiers in Rails, including the one protecting cookies.
secret_key_base: d56...
追加し終えたら、escキーを押してから :wq を押すと保存ができます。
追加した環境変数をきちんと参照できるか確かめてみます。
$ Rails.application.credentials.devise_two_factor[:ENCRYPTION_KEY]
=> "hogefugahogefuga..."
参照できることが確認できました。
先ほど自動で追加されたUserモデルの記述を変更します。
class User < ActiveRecord::Base
# ENV['ENCRYPTION_KEY'] から以下のように変更
devise :two_factor_authenticatable,
:otp_secret_encryption_key => Rails.application.credentials.devise_two_factor[:ENCRYPTION_KEY]
devise :registerable, :recoverable, :rememberable, :trackable, :validatable
end
これで導入は完了です。
2要素認証設定ページの設定
次に2要素認証設定ページのRails側の実装を行います。
Routing
2要素認証の設定画面のルーティングを定義します。
scope format: 'json' do
# ここから追加
namespace :users do
resource :account, only: :show
end
# ここまで
resources :users do
resources :posts
end
end
Controller
Controllerは、Users::AccountsControllerを定義します。
class Users::AccountsController < ApplicationController
before_action :authenticate_user!
def show; end
end
Views
views/users/accounts/show.json.jbuilderを定義します。
json.extract! current_user, :id, :allow_password_change, :email, :nickname, :provider, :uid, :otp_required_for_login
Vue.js側で、2要素認証ログインが有効かどうかを表すotp_required_for_loginの値を使って、表示の出し分けをする予定です。
2要素認証の有効化
Routing
2要素認証有効化のルーティングを定義します。
namespace :users do
resource :account, only: :show
resource :two_factor_auth, only: [:show, :create] # 追加
end
Controller
- Users::TwoFactorAuthsController
createアクション内でパラメータの有無で2つのことを行います。
- OTPのSecretをUserに保存しつつ、QRコードのuriを返却する
- QRコードを読み取って入力された6桁の認証コードを認証する
# app/controllers/users/two_factor_auths_controller.rb
class Users::TwoFactorAuthsController < ApplicationController
before_action :authenticate_user!
def show; end
def create
if permitted_otp_code[:otp_code]
current_user.validate_and_consume_otp!(permitted_otp_code[:otp_code])
current_user.otp_required_for_login = true
current_user.save!
else
unless current_user.otp_secret
current_user.otp_secret = User.generate_otp_secret(32)
current_user.save!
end
end
render :show
end
def permitted_otp_code
params.permit(:otp_code)
end
end
views
# app/views/users/two_factor_auths/show.json.jbuilder
json.otp_uri current_user.otp_provisioning_uri("service_name", issuer: "company_name")
これでRails側の実装は終了です。
Vue.js側
次にVue.js側で2要素認証設定画面作成とQRコードの表示、2要素認証を有効化するAPIを叩く関数の作成等を行います。
2要素認証の有効化画面
ルーティングの設定
URLは、/accountとします。
src/router/index.tsを編集します。
import NewPost from '@/views/NewPost.vue'
import Account from '@/views/Account.vue' // 追加
{
path: '/posts/:id',
name: 'PostDetail',
component: PostDetail,
meta: { requiresAuth: true }
},
// ここから追加
{
path: '/account',
name: 'Account',
component: Account,
meta: { requiresAuth: true }
}
// ここまで
Accountコンポーネントは後ほど実装しますので、エラーが出ていても一旦無視でOKです。
コンポーネントの作成
次に、アカウント設定画面のコンポーネントの作成を行います。
$ touch src/views/Account.vue
作成されたファイルを以下のように修正してください。
<template>
<div class="mt-16 px-16 mx-auto xl:max-w-3xl">
<h2 class="text-center text-4xl text-indigo-900 font-display font-semibold lg:text-left xl:text-5xl
xl:text-bold">
アカウント
</h2>
<div class="mt-12">
<div class="mt-8">
<div class="flex justify-between items-center">
<div class="text-sm font-bold text-gray-700 tracking-wide">
2要素認証
</div>
</div>
<p v-if='account.otp_required_for_login'>設定済み</p>
<p v-else>設定する</p>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue'
import { getAccount } from '@/api/user'
import { User } from '@/types/user'
export default defineComponent({
name: 'Account',
setup () {
const account = ref({} as User)
const onGetAccount = async () => {
await getAccount()
.then((res) => {
account.value = res.data
})
}
onMounted(() => {
onGetAccount()
})
return {
account
}
}
})
</script>
まずはアカウント情報を取得するAPIを叩いて、2要素認証が有効かどうかをv-ifで判定して表示の出し分けをするところまで実装しています。
次に、User型のプロパティの追加をします。
export type User = {
allow_password_change: boolean;
email: string;
id: string;
image: string | null;
nickname: string;
provider: string;
uid: string;
otp_required_for_login: boolean; // 追加
}
2要素認証が有効かどうかを表すotp_required_for_loginプロパティを新たに追加しています。
次に、ユーザー情報を取得する関数を定義します。
APIを叩く関数の実装
$ touch src/api/user.ts
作成したファイルを以下のように編集します。
import Client from '@/api/client'
import {
getAuthDataFromStorage,
setAuthDataFromResponse
} from '@/utils/auth-data'
import { AxiosResponse } from 'axios'
import { User } from '@/types/user'
export const getAccount = async () => {
return await Client.get('/users/account', { headers: getAuthDataFromStorage() })
.then((res: AxiosResponse<User>) => {
setAuthDataFromResponse(res.headers)
return res
})
.catch((err) => {
return err.response
})
}
先ほどRails側で定義した/users/accountのエンドポイントを叩く関数を定義しました。
返り値はこんな内容になっています。
{
"id": 1,
"allow_password_change": false,
"email": "test-user+1@example.com",
"nickname": "テスト",
"provider": "email",
"uid": "test-user+1@example.com",
"otp_required_for_login": false
}
次に、アカウント画面へのリンクボタンをNavBarに設置します。
NavBarの修正
// src/component/NavBar.vue
<template>
<nav class="bg-gray-800">
<div class="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8">
<div class="relative flex items-center justify-between h-16">
<div class="flex-1 flex items-center justify-center sm:items-stretch sm:justify-start">
<div class="flex-shrink-0 flex items-center">
</div>
<div class="hidden sm:block sm:ml-6">
<div class="flex space-x-4">
<button @click='movePosts()' class="bg-gray-900 text-white px-3 py-2 rounded-md text-sm font-medium">Top</button>
</div>
</div>
</div>
// ここから追加
<div class="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
<div class="ml-3 relative">
<div>
<button class="bg-gray-800 flex text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white" id="user-menu" aria-haspopup="true">
<button @click='moveAccount()' class="bg-gray-900 text-white px-3 py-2 rounded-md text-sm font-medium">MyPage</button>
</button>
</div>
</div>
</div>
// ここまで
<div class="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
<div class="ml-3 relative">
<div>
<button @click='moveNewPost()' class="bg-gray-900 text-white px-3 py-2 rounded-md text-sm font-medium">create Post</button>
</div>
</div>
</div>
</div>
</div>
</nav>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import router from '@/router'
export default defineComponent({
name: 'NavBar',
setup () {
const movePosts = () => {
router.push('/posts')
}
const moveNewPost = () => {
router.push('/posts/new')
}
// ここから追加
const moveAccount = () => {
router.push('/account')
}
// ここまで
return {
movePosts,
moveNewPost,
moveAccount // 追加
}
}
})
</script>
一度問題なく/users/accountが表示できるか試してみましょう。
/users/accountにアクセスして上記のような表示になっていれば一旦OKです。
QRコード表示の準備
QRコードの表示には、vue-qrcodeというライブラリを使います。
以下のコマンドを実行してください。
$ npm install vue@next qrcode @chenfengyuan/vue-qrcode@next
次にmain.tsを以下のように修正します。
import { createApp } from 'vue'
import App from '@/App.vue'
import router from '@/router'
import '@/assets/styles/tailwind.css'
import VueQrcode from '@chenfengyuan/vue-qrcode' // 追加
const app = createApp(App).use(router)
// ここから追加
if (VueQrcode.name) {
app.component(VueQrcode.name, VueQrcode)
}
// ここまで
app.mount('#app')
if文で条件分岐を挟んでいるのは、VueQrcodeTypeのnameプロパティがoptionalなため、app.component関数の第一引数でstringが渡されることが定義されているのに、string | undefined な値を渡せないよ!とeslintに怒られてしまうためです、、、
// component関数の型定義
export declare interface App<HostElement = any> {
version: string;
config: AppConfig;
use(plugin: Plugin_2, ...options: any[]): this;
mixin(mixin: ComponentOptions): this;
component(name: string): Component | undefined;
component(name: string, component: Component): this;
これでComponent内で、
<vue-qrcode :value="qrCode" />
のように使うことで、QRコードの表示が可能になります。
QRコードのuriを取得する
次にRails側で定義したQRコードのuriを取得するAPIを叩く関数を定義し、QRコードを表示させるところまで実装してみます。
// src/api/user.ts
/* eslint-disable @typescript-eslint/camelcase */ // camelcaseを使えと怒られる場合は追記してください
import Client from '@/api/client'
import {
getAuthDataFromStorage,
setAuthDataFromResponse
} from '@/utils/auth-data'
import { AxiosResponse } from 'axios'
import { User } from '@/types/user'
import { CurrentOtp } from '@/types/current_otp' // 追加
export const getAccount = async () => {
return await Client.get('/users/account', { headers: getAuthDataFromStorage() })
.then((res: AxiosResponse<User>) => {
setAuthDataFromResponse(res.headers)
return res
})
.catch((err) => {
return err.response
})
}
// ここから追加
export const createQrCode = async () => {
return await Client.post('/users/two_factor_auth', {}, { headers: getAuthDataFromStorage() })
.then((res: AxiosResponse<CurrentOtp>) => {
setAuthDataFromResponse(res.headers)
return res
})
.catch((err) => {
return err.response
})
}
// ここまで
Rails側に定義した/users/two_factor_authのエンドポイントをpostリクエストで叩く関数を定義しています。返り値は下記のようになります。
{
"otp_uri": "otpauth://totp/company_name:service_name?secret=YOURSECRET&issuer=company_name"
}
次にCurrentOtp型の定義を行います。
$ touch src/types/current_otp.ts
作成されたファイルを以下のように修正します。
export type CurrentOtp = {
otp_uri: string;
}
次に、Account.vueでQRコードを表示する修正を行います。
QRコードの表示
src/views/Account.vueを以下のように編集します。
<template>
<div class="mt-16 px-16 mx-auto xl:max-w-3xl">
<h2 class="text-center text-4xl text-indigo-900 font-display font-semibold lg:text-left xl:text-5xl
xl:text-bold">
アカウント
</h2>
<div class="mt-12">
<div class="mt-8">
<div class="flex justify-between items-center">
<div class="text-sm font-bold text-gray-700 tracking-wide">
2要素認証設定
</div>
</div>
<p v-if='account.otp_required_for_login'>設定済み</p>
<p v-else><button @click='getQrCode()'>設定する</button></p> // 追加
// ここから追加
<div v-if="qrCode">
<vue-qrcode :value="qrCode" />
</div>
// ここまで
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue'
import { getAccount, createQrCode } from '@/api/user' // createQrCodeを追加
import { User } from '@/types/user'
export default defineComponent({
name: 'Account',
setup () {
const account = ref({} as User)
const qrCode = ref('') // 追加
const onGetAccount = async () => {
await getAccount()
.then((res) => {
account.value = res.data
})
}
// ここから追加
const getQrCode = async () => {
await createQrCode()
.then((res) => {
qrCode.value = res.data.otp_uri
})
}
// ここまで
onMounted(() => {
onGetAccount()
})
return {
account,
getQrCode, // 追加
qrCode // 追加
}
}
})
</script>
「設定する」ボタンの押下をトリガーに、QRコードを生成するAPIを叩いて、refで定義したリアクティブな値に代入し、templateにQRコードを描画するようにしています。
ここで一旦動作確認をしてみます。
添付画像のようにQRコードが表示されていればOKです。(画像は一部加工しています。)
6桁の認証コードを入力して2要素認証を有効化する
次に、2要素認証アプリ(私はGoogleAuthenticatorを使いました)でQRコードを読み取り、表示された6桁の認証コードを入力して、2要素認証を有効化するところまでを実装していきます。
Account.vueを以下のように修正してください。
<template>
<div class="mt-16 px-16 mx-auto xl:max-w-3xl">
<h2 class="text-center text-4xl text-indigo-900 font-display font-semibold lg:text-left xl:text-5xl
xl:text-bold">
アカウント
</h2>
<div class="mt-12">
<div class="mt-8">
<div class="flex justify-between items-center">
<div class="text-sm font-bold text-gray-700 tracking-wide">
2要素認証設定
</div>
</div>
<p v-if='account.otp_required_for_login'>設定済み</p>
<p v-else><button @click='getQrCode()'>設定する</button></p>
<div v-if="qrCode">
<vue-qrcode :value="qrCode" />
// ここから追加
<div class="mt-8">
<div class="flex justify-between items-center">
<div class="text-sm font-bold text-gray-700 tracking-wide">
認証コード
</div>
</div>
<input class="w-full text-lg py-2 border-b border-gray-300 focus:outline-none focus:border-indigo-500" v-model="otp_code" type="password" placeholder="Enter your otp code">
<div class="mt-10">
<button class="bg-indigo-500 text-gray-100 p-4 w-full rounded-full tracking-wide
font-semibold font-display focus:outline-none focus:shadow-outline
hover:bg-indigo-600 shadow-lg"
@click="handleVerifyOtpCode()"
>
認証する
</button>
</div>
</div>
// ここまで
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, reactive, ref, toRefs } from 'vue' // reactiveとtoRefsを追加
import { getAccount, createQrCode, verifyOtpCode } from '@/api/user' // verifyOtpCodeを追加
import { User } from '@/types/user'
import router from '@/router' // 追加
export default defineComponent({
name: 'Account',
setup () {
const account = ref({} as User)
const qrCode = ref('')
// ここから追加
const formData = reactive({
// eslint-disable-next-line @typescript-eslint/camelcase
otp_code: ''
})
// ここまで
const onGetAccount = async () => {
await getAccount()
.then((res) => {
account.value = res.data
})
}
const getQrCode = async () => {
if (account.value.otp_required_for_login) {
return ''
} else {
await createQrCode()
.then((res) => {
qrCode.value = res.data.otp_uri
})
}
}
// ここから追加
const handleVerifyOtpCode = async () => {
await verifyOtpCode(formData.otp_code)
.then(() => {
router.push('/')
alert('2要素認証の設定に成功しました。')
})
}
// ここまで
onMounted(() => {
onGetAccount()
})
return {
account,
getQrCode,
qrCode,
...toRefs(formData), // 追加
handleVerifyOtpCode // 追加
}
}
})
</script>
認証コード入力用のフォームと、その入力された認証コードをパラメータとして、APIを叩く関数を呼び出すボタンを定義しています。
次に、src/api/user.tsを編集します。
export const createQrCode = async () => {
return await Client.post('/users/two_factor_auth', {}, { headers: getAuthDataFromStorage() })
.then((res: AxiosResponse<CurrentOtp>) => {
setAuthDataFromResponse(res.headers)
return res
})
.catch((err) => {
return err.response
})
}
// ここから追加
export const verifyOtpCode = async (otp_code: string) => {
return await Client.post('/users/two_factor_auth', { otp_code }, { headers: getAuthDataFromStorage() })
.then((res: AxiosResponse<CurrentOtp>) => {
setAuthDataFromResponse(res.headers)
return res
})
.catch((err) => {
return err.response
})
}
// ここまで
認証コードをパラメーターに取ってRails側で定義した認証コードの検証を行うAPIを叩く関数を定義しています。
以下の添付画像のようにフォームとボタンが表示されていればOKです。
これで準備完了です。一通り動作確認してみます。
動作確認
- /accountにアクセスし、「設定する」のボタンを押す。QRコードが表示されればOK
- 通信内容を確認すると、two_factor_authのエンドポイントに向けてリクエストが走ったことがわかる
- GoogleAuthenticatorでQRコードを読み取って、6桁のコードを入力し、認証に成功するとアラートが表示される
これで2要素認証の有効化までは実装できました。
おわりに
簡易的な実装ですが、自前で2要素認証の有効化機能まで実装ができました。
次回は2要素ログインを実装します。(クソださデザインをなんとかしたくなってきた、、、)