はじめに
Rails6(API-mode)+Vue3のSPAを開発する環境ができた(前記事)ので、早速作り始める。
アプリの目的は決めてないが、とりあえず、ログイン認証機能を作ろう。
事前調査
以下の記事を作ってくれていた、先達たちに感謝。これらの情報があって私は自前のアプリを作り、この記事を書くことができています。謝意を表します。
API(Rails側)の設定
先達の記事に従って、次の手順ですすめた。
devise と devise_token_auth の install
railsのGemfileに次のとおり編集する。(ファイルの位置は前Docker環境構築記事参照)
# "jbuilder" の gem コメントアウトを解除
gem 'jbuilder', '~> 2.7'
#(以下を追記)
gem 'devise'
gem 'devise_token_auth'
rails api のコンテナのシェルにアタッチして、インストールコマンドを実行。
api-container $ rails g devise:install
api-container $ rails g devise_token_auth:install User auth
deviseのinitializerファイルやlocaleファイルが作成された。
作成されたmigrationファイルを修正する。
#(途中部分、 ## Rememberable と ## Confirmable の間に、以下挿入)
# 追加
## Trackable
t.integer :sign_in_count, default: 0, null: false
t.datetime :current_sign_in_at
t.datetime :last_sign_in_at
t.string :current_sign_in_ip
t.string :last_sign_in_ip
作成されたモデルのファイルuser.rbを修正する。(devise の項目に :trackable を追加)
# frozen_string_literal: true
class User < ActiveRecord::Base
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable, :trackable
include DeviseTokenAuth::Concerns::User
end
作成されたconfig/environmentsのファイルdevelopment.rbを修正する。
Rails.application.configure do
#(中略)
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
#(後略)
end
Dockerであれば、(ビルドファイルの中に、bundle install コマンドを含んでいるので)ここでいったんコンテナを終了させて、再度ビルドする。 rails の bundle install コマンドを実行して設定を反映することが必要。
あとは、先達の記録にしたがって、テストユーザーを作ってみる。
railsのコンテナのシェルにアタッチして、コマンドを実行してみる。
root@...:/app# rails c
irb(main):001:0>User.create!(name: 'テストユーザー', email: 'test-user+1@example.com', password: 'password')
(RailsってSQLを意識したりしなくてもデータの書き込みできちゃうんだ。フレームワークがきちんと考えられているんだなぁ、と今更ながら感心)
ちゃんとユーザーができてるか、先達と同じようにcurlで確認。
curl -D - localhost:3000/auth/sign_in -X POST -d '{"email":"test-user+1@example.com", "password":"password"}' -H "content-type:application/json"
結果は、先達と同じように 200 OKをヘッダにもち、ユーザーのデータがJSONで 返ってきた。・・・ちょっと待て。いままで行ってきた作業の中で、「/auth/sign_in」なんてアドレスのアクション、作ってたか?deviseとやらが、勝手に作ってるのか。すげーな、gemとrailsフレームワーク。
pgadmin4でも、テーブル、データが確認できた。(前回、docker-composeでインストールしておいたので、localhost:8005からアクセスできる)
続けて、「tokenの検証」を行ってみた。さっきのcurlに表示されていた情報を使う。コピペがめんどい(笑
$ curl -D - -H "access-token:取得したtoken" -H "client:取得したclient" -H "expiry:取得したexpiry" -H "uid:取得したuid" -H "content-type:application/json" localhost:3000/auth/validate_token
これも、先達と同じように成功。
さて、次だが・・・scaffoldってなんだ?
何?アプリケーションに必要なファイルを自動生成してくれる?だと?おいおい、凄すぎるじゃないか。(なるほど、これがあるから、みんなRails使うんだな、きっと)
よし、それでは早速(railsのコンテナのシェルにアタッチして・・・)
root@...:/app# rails g scaffold Post title:string body:text user:references
は、はじめてのs..schadfol..scaffoldです。(←緊張した割には、1秒もかからずにできた。MVCの関連ファイル7つ作成が秒殺かよ。ほかのフレームワークで手作業で製作してるのがバカバカしいな)
あとは、先達が示している修正を施したあとで、posts機能のテストを試してみた。うまくいった。(テスト初回は、postcontrollerの最下段にある、"params.require"を削除、書き換えする作業を漏らしていて、動作しないであせったけど)
すごいぜ、rails。
フロント(vue側)の設定
この先達に倣う。
Axiosをインストール。(frontのコンテナーのシェルにアタッチ)
# npm install axios
なお、srcフォルダにファイルを自在に作成できるよう、全ユーザにsrcフォルダの編集権限を許可しておく。
# chmod 766 src -r
vue-routerなるものをセットアップする必要がある。
# npm install vue-router@4
frontフォルダ直下に、.env.developmentファイルを作成。内容は以下のように記述。
VITE_API_BASE=http://localhost:3000
このファイルの内容は、環境に合わせてのなかで、 import.meta.env から読み込むことができる。development環境は、 .env.development、 production環境は、 .env.production とファイル名を指定するらしい。
ちなみに、これは、「API」のアドレス、つまりrailsのアドレスを指定するのだ。まちがってもfrontのベースアドレスではないぞ!(←実は、一回間違えてあとでログイン機能が働かなくて涙目になった。ここ重要。いや、涙目が重要なんじゃなくて、APIのアドレスを登録する、ってことが重要。)
Vite環境では、.env なファイルもサーバー再起動なしで読み込む。すげぇ。
App.vueにてテストしてみる。
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
console.log(import.meta.env.VITE_API_BASE)
</script>
(後略)
これで、 localhost:8110 にアクセスしたブラウザで、Consoleを見てみると・・・
先達いわく、この情報を取得する関数を作って利用すれば、環境に応じた設定(例えばサーバー名(FQDN)とか)を切り替えるときに便利っぽい。
import axios from 'axios'
export default axios.create({
baseURL: import.meta.env.VITE_API_BASE
})
オリジナリティあふれる(?)おまけ。
<template>
<div>
hajimete no vue
</div>
<div>
rails to vue
</div>
</template>
<template>
<img alt="Vue logo" src="./assets/vue.svg" />
<div id="nav">
<router-link to="/">Hello World</router-link> |
<router-link to="/login">Login</router-link> |
<router-link to="/about">About</router-link>
</div>
<router-view />
</template>
<script lang="ts">
export default {
name: 'App',
components: {}
}
</script>
### ログイン画面づくり。
ひきつづきログイン画面を作る(先達のソースに、ちょっとだけ、追加。追加部分は、ログインに成功したあと、ページ遷移する(router.push)を書いたところ。今回はテストなのでとりあえず、"/"へルーティングしている。ログインしたら画面が遷移したほうがいいかな、と思って。)
<template>
<div>
<label for="email">
Email
</label>
<input v-model="email" id="Email" type="text" placeholder="Email">
</div>
<div>
<label for="password">
Password
</label>
<input v-model="password" id="password" type="password" placeholder="******************">
</div>
<button @click="handleLogin()">
Sign In
</button>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs } from 'vue'
import { login } from '../api/auth'
import router from '../router'
export default defineComponent({
name: 'Home',
setup () {
const formData = reactive({
email: '',
password: ''
})
return {
...toRefs(formData),
handleLogin: async () => {
await login(formData.email, formData.password)
.then((res) => {
if (res?.status === 200) {
console.log(res)
router.push('/')
} else {
alert('メールアドレスかパスワードが間違っています。')
}
})
.catch(() => {
alert('ログインに失敗しました。')
})
}
}
}
})
</script>
routerの設定。
router.tsファイルを新規作成、main.tsへのRouter使用設定。
import * as vueRouter from 'vue-router'
import HelloWorld from './components/HelloWorld.vue'
import Login from './components/Login.vue'
const routes = [
{
path: '/',
name: 'HelloWorld',
component: HelloWorld
},
{
path: '/login',
name: 'Login',
component: Login
},
{
path: '/about',
name: 'About',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ './components/About.vue')
}
]
const router = vueRouter.createRouter({
history: vueRouter.createWebHistory(),
routes,
});
export default router;
import { createApp } from 'vue'
import './style.css'
import App from './Index.vue'
import router from './router'
createApp(App)
.use(router)
.mount('#app')
ここまでで、一段落。
次に「ログイン・ログアウトAPIを叩く準備」に進む。
先達の記述に「LocalStorageに認証用のtokenを保存することには云々」とあるのが気になるところ。とりあえず、今はそのまま従うことにしてここは別の機会に追求しよう。
LocalStorageに保存する実装。
import { AuthHeaders } from '@/types/auth'
export const getAuthDataFromStorage = (): AuthHeaders => {
return {
'access-token': localStorage.getItem('access-token'),
'client': localStorage.getItem('client'),
'expiry': localStorage.getItem('expiry'),
'uid': localStorage.getItem('uid'),
'Content-Type': 'application/json'
}
}
export const setAuthDataFromResponse = (authData: AuthHeaders): void => {
if (authData['access-token'] && authData['client'] && authData['uid'] && authData['expiry']) {
localStorage.setItem('access-token', authData['access-token'])
localStorage.setItem('client', authData['client'])
localStorage.setItem('uid', authData['uid'])
localStorage.setItem('expiry', authData['expiry'])
}
}
export const removeAuthDataFromStorage = (): void => {
localStorage.removeItem('access-token')
localStorage.removeItem('client')
localStorage.removeItem('uid')
localStorage.removeItem('expiry')
}
export type AuthHeaders = {
'access-token': string | null;
'uid': string | null;
'client': string | null;
'expiry': string | null;
'Content-Type': string;
}
APIを叩く関数。(しかし、なぜAPIは「たたく」んだろう?オス、ひく、きっくするとかではないのだな・・・)
import Client from '@/api/client'
import { User } from '@/types/user'
import {
getAuthDataFromStorage,
removeAuthDataFromStorage,
setAuthDataFromResponse
} from '@/utils/auth-data'
import { AxiosResponse, AxiosError } from 'axios'
export const login = async (email: string, password: string) => {
return await Client.post<User>('/auth/sign_in', { email, password })
.then((res: AxiosResponse<User>) => {
setAuthDataFromResponse(res.headers)
return res
})
.catch((err: AxiosError) => {
return err.response
})
}
export const logout = async () => {
return await Client.delete('/auth/sign_out', { headers: getAuthDataFromStorage() })
.then(() => {
removeAuthDataFromStorage()
})
}
export type User = {
allow_password_change: boolean;
email: string;
id: string;
image: string | null;
nickname: string;
provider: string;
uid: string;
}
ここまでやって画面を確認すると、こんな感じ。
よかった。先達のご指導どおりに(見た目は)できてそう・・・。ここまでで6時間ぐらいかかったよ。(笑)
ログイン動作の確認
ここも引き続き、先達のご指導どおり・・・。consoleを開いておいて、login操作画面で、Email/Passwordを入力して「Sign In」をオス。ちゃんと、コンソールに情報が表示されたYO!
・・・だが、localstorageに、情報が記録されてない。
調べてみると、 axiosの responseでヘッダーが取得できてない。というか、ヘッダーが空? ブラウザのデバッグコンソールには表示されるのに?
・・・CORs絡みの問題か。Exposeするヘッダーを明示するんだな。ってことは、Railsの(API側の)設定を見てみるか。あ、先達の操作でちゃんと設定が書いてあるわ・・・なるほど。ここのexposeってこういう意味だったのか。
よし。これでlocalstorageにデータが保存されるようになったぞ。
ログアウト画面作り
先達に従い、Home.vueを作成する。
<template>
<button @click="handleLogout()">Logout</button>
</template>
<script lang='ts'>
import { defineComponent } from 'vue'
import { logout } from '../api/auth'
import router from '../router'
export default defineComponent({
name: 'Home',
setup () {
const handleLogout = () => {
logout().then(() => {
router.push('/login')
})
}
return {
handleLogout
}
}
})
</script>
router.tsも書き換えて、アクセスできるようにする。
import * as vueRouter from 'vue-router'
import Home from './components/Home.vue'
import HelloWorld from './components/HelloWorld.vue'
import Login from './components/Login.vue'
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/login',
name: 'Login',
component: Login
},
{
path: '/hello',
name: 'HelloWorld',
component: HelloWorld
},
{
path: '/about',
name: 'About',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ './components/About.vue')
}
]
const router = vueRouter.createRouter({
history: vueRouter.createWebHistory(),
routes,
});
export default router;
ログイン画面(Login.vue)で、ログイン成功時に遷移する先を、"/hello"に変えておく。
ログアウト動作の確認
home画面で、logoutボタンをおすと、localstorageの情報が消えることを確認。
NavigationGuard
先達の手順通りに進める。
Guardのためのファイル編集
先達と違って私はViteで環境を構築したのでrouterファイルの編集は次のファイル。
// 認証が必要なURLにはrequiresAuthを付与し、
meta: { requiresAuth: true }
// 認証が不要なURLにはrequiresNotAuthを付与します。
meta: { requiresNotAuth: true }
// (例)
{
path: '/login',
name: 'Login',
component: Login,
meta: { requiresNotAuth: true } // 追加
},
はたして、認証があってもなくてもアクセスできるURLはどうするの?と思ったんだが、どうやら先達はそれは扱ってない(後述)。とりあえず、「認証要」「認証不要」の2種類、どちらかにルーティング項目をグループ分けする。
先達の示すとおり「authorizeToken関数の実装」、「validateToken関数の実装」と進める。私の場合、authGuard.tsファイルの置き場は ./front/src/utils/ とした。(後は、authrizeTokenで、requiresNotAuthにマッチしたときのnext(遷移先)のアドレスを変えたぐらい)
上述2つのファイルを編集したら、router.tsを編集する。routerに対してbeforeEachでauthorizeToken関数をコールする処理を追加。
import NewPost from '@/views/NewPost.vue'
import { authorizeToken } from './authGuard' // 追加
// 省略
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
})
router.beforeEach(authorizeToken) // 追加
動作確認と考察
おお、ログインしてないときには「ログインが必要な」URLに入ろうとするとログイン画面に飛ばされるぞ。逆にログインしてしまうとログインが不要だったページにはいけなくなってる。
これは、1)router.beforeEachでユーザーがアクセしようとするURLへのルーティングの都度authorizeToken関数を実行している、2)authorizeToken関数は、routeレコードのmetaデータ「requiresNotAuth」「requiresAuth」を識別して進んでよいかどうか判定している、という仕組みで作動しているんだな。
・・・ということは、authorizeToken関数を次のように書き換えて、routeの設定をいじれば、認証があってもなくても見られるURLにできるはずだ。先に生じた疑問については、この道筋で処理を考えてつくれば解決できそう。
(書き換え、というか書き加え。 if 文の最後の else 節。 else で何も判定せずにnext()している)
import { validateToken } from '../api/auth'
import { NavigationGuardNext, RouteLocationNormalized, RouteRecordNormalized } from 'vue-router'
export const authorizeToken = (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
if (to.matched.some((record: RouteRecordNormalized) => record.meta.requiresAuth)) {
validateToken()
.then(() => {
next()
})
.catch(() => {
next({ path: '/login' })
})
} else if (to.matched.some((record: RouteRecordNormalized) => record.meta.requiresNotAuth)) {
validateToken()
.then(() => {
next({ path: '/hello' })
})
.catch(() => {
next()
})
} else {
next()
}
}
// 認証されているときに表示するURLにはrequiresAuthを付与し、
meta: { requiresAuth: true }
// 認証されているときには表示させないURLにはrequiresNotAuthを付与します。
meta: { requiresNotAuth: true }
// 認証されていてもされていなくても表示するURLには何も付与しません。
// (例)
{
path: '/login',
name: 'Login',
component: Login,
meta: { requiresNotAuth: true } // 追加
},
{
path: '/about',
name: 'About',
component: About // meta なし
},
うん、これでよさげだ。動作確認してみたところ、想定通りの動作になった。
初めてながら調べて作っていって合計12時間ぐらいかかっているな。コピーすれば使いまわせそうな機能だし、このプロジェクトのファイルは、足場(scaffold)テンプレートとしてとっておこうかな。
まとめ
Rails6(api-mode)とvue3フロントエンドで、ログイン認証とその認証情報によって画面の表示を切り替える機能をつくった。あとは、アプリの中身をつくっていったり、ログインを他のSNS(FacebookとかGoogleとか)のアカウントを使えるようにしてみたりしてみよう。