はじめに
本記事はAPIをRailsのAPIモードで開発し、フロント側をVue.js 3で開発して、認証基盤にdevise_token_authを用いてトークンベースの認証機能付きのSPAを作るチュートリアルの2要素ログイン編の記事になります。
前回: Rails APIモード + devise_token_auth + Vue.js 3 で認証機能付きのSPAを作る(2要素認証設定編)
次回: Rails APIモード + devise_token_auth + Vue.js 3 で認証機能付きのSPAを作る(provide/inject編)
Rails側
sessions_contorllerの修正
2要素認証を実現させるために、devise_token_authのsessions_controllerをオーバーライドする形を取りたいと思います。
以下のコマンドを実行してください。
$ mkdir app/controllers/devise_token_auth/
$ touch app/controllers/devise_token_auth/sessions_controller.rb
作成したファイルを以下のように修正します。
module DeviseTokenAuth
class SessionsController < DeviseTokenAuth::ApplicationController
before_action :set_user_by_token, only: [:destroy]
after_action :reset_session, only: [:destroy]
def new
render_new_error
end
def create
# Check
field = (resource_params.keys.map(&:to_sym) & resource_class.authentication_keys).first
@resource = nil
if field
q_value = get_case_insensitive_field_from_resource_params(field)
@resource = find_resource(field, q_value)
end
if @resource && valid_params?(field, q_value) && (!@resource.respond_to?(:active_for_authentication?) || @resource.active_for_authentication?)
valid_password = @resource.valid_password?(resource_params[:password])
if (@resource.respond_to?(:valid_for_authentication?) && !@resource.valid_for_authentication? { valid_password }) || !valid_password
return render_create_error_bad_credentials
end
@token = @resource.create_token
@resource.save
sign_in(:user, @resource, store: false, bypass: false)
yield @resource if block_given?
render_create_success
elsif @resource && !(!@resource.respond_to?(:active_for_authentication?) || @resource.active_for_authentication?)
if @resource.respond_to?(:locked_at) && @resource.locked_at
render_create_error_account_locked
else
render_create_error_not_confirmed
end
else
render_create_error_bad_credentials
end
end
def destroy
# remove auth instance variables so that after_action does not run
user = remove_instance_variable(:@resource) if @resource
client = @token.client
@token.clear!
if user && client && user.tokens[client]
user.tokens.delete(client)
user.save!
yield user if block_given?
render_destroy_success
else
render_destroy_error
end
end
protected
def valid_params?(key, val)
resource_params[:password] && key && val
end
def get_auth_params
auth_key = nil
auth_val = nil
# iterate thru allowed auth keys, use first found
resource_class.authentication_keys.each do |k|
if resource_params[k]
auth_val = resource_params[k]
auth_key = k
break
end
end
# honor devise configuration for case_insensitive_keys
if resource_class.case_insensitive_keys.include?(auth_key)
auth_val.downcase!
end
{ key: auth_key, val: auth_val }
end
def render_new_error
render_error(405, I18n.t('devise_token_auth.sessions.not_supported'))
end
def render_create_success
render json: {
data: resource_data(resource_json: @resource.token_validation_response)
}
end
def render_create_error_not_confirmed
render_error(401, I18n.t('devise_token_auth.sessions.not_confirmed', email: @resource.email))
end
def render_create_error_account_locked
render_error(401, I18n.t('devise.mailer.unlock_instructions.account_lock_msg'))
end
def render_create_error_bad_credentials
render_error(401, I18n.t('devise_token_auth.sessions.bad_credentials'))
end
def render_destroy_success
render json: {
success:true
}, status: 200
end
def render_destroy_error
render_error(404, I18n.t('devise_token_auth.sessions.user_not_found'))
end
private
def resource_params
params.permit(*params_for_resource(:sign_in))
end
end
end
次に、オーバーライドしたcontrollerのアクションが呼ばれるようにroutingを修正します。
# config/routes.rb
mount_devise_token_auth_for 'User', at: 'auth', controllers: {
sessions: 'devise_token_auth/sessions'
}
次に、application_controller.rbでログイン時に許可するパラメータを絞る実装を行います。
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
include DeviseTokenAuth::Concerns::SetUserByToken
before_action :configure_permitted_parameters, if: :devise_controller?
protected
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_in, keys: [:email, :password, :otp_code])
end
end
次に、先ほど作成したsessions_controllerをゴリゴリ修正していきます。
def create
# Check
field = (resource_params.keys.map(&:to_sym) & resource_class.authentication_keys).first
@resource = nil
if field
q_value = get_case_insensitive_field_from_resource_params(field)
@resource = find_resource(field, q_value)
end
if @resource && valid_params?(field, q_value) && (!@resource.respond_to?(:active_for_authentication?) || @resource.active_for_authentication?)
valid_password = @resource.valid_password?(resource_params[:password])
if (@resource.respond_to?(:valid_for_authentication?) && !@resource.valid_for_authentication? { valid_password }) || !valid_password
return render_create_error_bad_credentials
end
# 2要素認証機能が有効な場合
if @resource.otp_required_for_login
# FE側からotp_codeが送られていて、かつそのotp_codeで認証した結果、認証に成功した場合
if resource_params[:otp_code].present? && @resource.validate_and_consume_otp!(resource_params[:otp_code])
@token = @resource.create_token
@resource.save
sign_in(:user, @resource, store: false, bypass: false)
yield @resource if block_given?
return render_create_success
else
# FE側からotp_codeが送られていないか、認証に失敗した場合は、例外をレンダリング
return render_create_error_need_to_two_factor_auth
end
end
@token = @resource.create_token
@resource.save
sign_in(:user, @resource, store: false, bypass: false)
yield @resource if block_given?
render_create_success
elsif @resource && !(!@resource.respond_to?(:active_for_authentication?) || @resource.active_for_authentication?)
if @resource.respond_to?(:locked_at) && @resource.locked_at
render_create_error_account_locked
else
render_create_error_not_confirmed
end
else
render_create_error_bad_credentials
end
end
# もろもろ省略
protected
# 2要素認証が必要または2要素認証に失敗した場合にレンダリングされる
def render_create_error_need_to_two_factor_auth
render_error(401, I18n.t('devise_token_auth.sessions.bad_credentials'), { two_factor_auth: true })
end
private
def resource_params
params.permit(*params_for_resource(:sign_in))
end
end
end
render_create_error_need_to_two_factor_authは、第三引数に「2要素認証が有効化されている」ことを表すデータを入れています。
dataのtwo_factor_authがtrueだった場合は、FE側で2要素認証のコードを入力するフォームを表示するために使います。
返り値の修正
これで2要素認証そのものの実装は完了ですが、このままだと返り値に本来は含まれて欲しくない値まで返却されてしまうため、app/models/user.rbにログイン後の返り値を絞り込むメソッドを定義します。
class User < ApplicationRecord
TOKEN_VALIDATION_RESPONSE_ATTRS = [
:otp_required_for_login,
:id,
:provider,
:email,
:uid,
:name,
:nickname,
:image
]
# 省略
def token_validation_response
self.as_json(only: TOKEN_VALIDATION_RESPONSE_ATTRS)
end
end
DeviseTokenAuth::Concerns::Userをincludeすると定義されるtoken_validation_responseをオーバーライドしています。jsonとして返却したい値をsymbolを含んだ配列として定義し、as_jsonメソッドのonlyオプションに定数を渡すことで、定数に含まれているsymbolのattrのみが返却されるようにしています。
これでサーバーサイド側の準備は完了です。
Vue.js側
src/views/Login.vueの修正
ログインのAPIを叩いて、2要素認証が必要な場合は認証コードを表示して入力して表示できるところまで実装したいと思います。
以下のようにsrc/views/Login.vueを修正します。
# src/views/Login.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">
Login
</h2>
<div v-if="isTwoFactorAuth" 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">
OtpCode
</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 OtpCode">
</div>
<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="handleLogin"
>
Login
</button>
</div>
</div>
<div v-else 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">
Email
</div>
</div>
<input class="w-full text-lg py-2 border-b border-gray-300 focus:outline-none focus:border-indigo-500" v-model="email" type="email" placeholder="Enter your Email">
</div>
<div class="mt-8">
<div class="flex justify-between items-center">
<div class="text-sm font-bold text-gray-700 tracking-wide">
Password
</div>
</div>
<input class="w-full text-lg py-2 border-b border-gray-300 focus:outline-none focus:border-indigo-500" v-model="password" type="password" placeholder="Enter your password">
</div>
<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="handleLogin"
>
Login
</button>
</div>
</div>
</div>
</template>
<script lang="ts">
/* eslint-disable @typescript-eslint/camelcase */
import { defineComponent, reactive, ref, toRefs } from 'vue'
import { login } from '@/api/auth'
import router from '@/router'
export default defineComponent({
name: 'Home',
setup () {
const formData = reactive({
email: '',
password: '',
otp_code: ''
})
const isTwoFactorAuth = ref(false)
const handleLogin = async () => {
await login(formData.email, formData.password, formData.otp_code)
.then((res) => {
if (res?.data.two_factor_auth) {
isTwoFactorAuth.value = true
} else {
if (res?.status === 200) {
router.push('/')
} else {
alert('メールアドレスかパスワードが間違っています。')
}
}
})
.catch(() => {
alert('ログインに失敗しました。')
})
}
return {
...toRefs(formData),
isTwoFactorAuth,
handleLogin
}
}
})
</script>
requiredTwoFactorAuthをリアクティブな値として定義し、ログインのAPIを叩いた結果、two_factor_authがtrue(= 2要素認証が有効になっている場合)の場合は、requiredTwoFactorAuthのvalueをtrueにし、template側で認証コードの入力フォームを表示するようにしています。
認証を行う関数の修正
合わせて、src/api/auth.tsのlogin関数の引数を修正します。
export const login = async (email: string, password: string, otp_code: string) => {
return await Client.post('/auth/sign_in', { email, password, otp_code })
.then((res: AxiosResponse<User>) => {
setAuthDataFromResponse(res.headers)
return res
})
.catch((err: AxiosError) => {
return err.response
})
}
新たなパラメータとしてotp_codeを受け取れるように修正しています。
これで準備完了です。
動作確認
すでに2要素認証が有効になっているユーザーで検証してみます。
ログインページへアクセス
Email & passwordが正しい場合、認証コードが入力できるフォームが表示される
入力した認証コードが正しい場合、トップ画面に遷移する。
返り値は以下のような値になります。
また、LocalStorageにもaccess-token等が格納されている
これで動作確認完了です。
おわりに
エラーハンドリングやフォームのバリデーション等を全く考慮できていませんが、最低限の実装ができました。
次はprovide/injectを使って認証状態を管理し、NavBarのボタンの出し分けを実装します。