【備忘録】Laravel + Vueだけの環境でログイン機能を実装する

## 背景
Nuxtを使用しないプロジェクトに参画するので、vue-routerやvuexの設定等を復習したい。



## 前提
[Dockerを使ってローカル環境構築済み](https://qiita.com/kaba_farm/items/4e07dd14c4e3848d0658)

## 手順
* APIルートにはwebミドルウェアを使用し、トークンを使った認証にする
* 認証機能はLaravelのデフォルトのものを使用する
* `login_id`と`password`で新規登録、ログイン、ログアウトできるところまでを実装
* cookieから取り出したCSRF-TOKENをajaxリクエストのヘッダーに付与する
* app.tsに、常に認証状態をvuexで保持するよう記述する
* コンポーネント化とcssによるスタイリングは割愛




## 認証機能はLaravelのデフォルトのものを使用する

### usersテーブルにlogin_idカラムを追加

```php:database/migrations/2014_10_12_000000_create_users_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateUsersTable extends Migration
{
  /**
   * Run the migrations.
   *
   * @return void
   */
  public function up()
  {
    Schema::create('users', function (Blueprint $table) {
      $table->id();
      $table->string('name');
      $table->string('login_id');  // 追加
      $table->string('email');  // unique()を削除
      $table->timestamp('email_verified_at')->nullable();
      $table->string('password');
      $table->rememberToken();
      $table->timestamps();
      $table->softDeletes();  // 追加
      $table->unique(['email', 'deleted_at'], 'users_email_deleted_at_unique');  // 追加
      $table->unique(['login_id', 'deleted_at'], 'users_login_id_deleted_at_unique');  // 追加
    });
  }
```

```php:app/Models/User.php

<?php

namespace App\Models;

use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\SoftDeletes;  // 追加

class User extends Authenticatable
{
  use Notifiable;
  use SoftDeletes;  // 追加

  /**
   * The attributes that are mass assignable.
   *
   * @var array
   */
  protected $fillable = [
    'name', 'email', 'login_id', 'password',
  ];

  protected $fillable = [
    'name', 'email', 'login_id', 'password',  // login_idを追加
  ];

// (中略)
```

```bash:/var/www/laravel
$ php artisan migrate:rollback
$ php artisan migrate
```

## APIルートにはwebミドルウェアを使用し、トークンを使った認証にする

```app/Providers/RouteServiceProvider.php
<?php


use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;

class RouteServiceProvider extends ServiceProvider
{

  (中略)
  
  protected function mapApiRoutes()
  {
    Route::prefix('api')
         ->middleware('web') // apiからwebに変更
         ->namespace($this->namespace)
         ->group(base_path('routes/api.php'));
  }

  (以下略)
```

### ルーティング

```php:routes/api.php
Route::group(['middleware' => 'api'], function () {

  Route::get('/current_user', function () {
    return Auth::user();
  })->name('currentUser');

  Route::namespace('Auth')->group(function() {
    Route::post('/register', 'RegisterController@register')->name('register');
    Route::post('/login', 'LoginController@login')->name('login');

    Route::middleware('auth')->group(function() {
      Route::post('/logout', 'LoginController@logout')->name('logout');
    });
  });
});
```

### 新規登録
```php:app/Http/Controllers/Auth/RegisterController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use App\Models\User;  // 追加
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Http\Request;  // 追加
use Illuminate\Validation\Rule;  // 追加

class RegisterController extends Controller
{
  // 中略

  protected function validator(array $data)
  {
    return Validator::make($data, [
      'name' => ['required', 'string', 'max:255'],
      'login_id' => ['required', 'string', 'max:255', Rule::unique('users', 'login_id')->whereNull('deleted_at')],  // 追加
      'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'login_id')->whereNull('deleted_at')],
      'password' => ['required', 'string', 'min:8', 'confirmed'],
    ]);
  }

  // (中略)

  protected function create(array $data)
  {
    return User::create([
      'name' => $data['name'],
      'login_id' => $data['login_id'],  // 追加
      'email' => $data['email'],
      'password' => Hash::make($data['password']),
    ]);
  }

  // (中略)

  // ↓追加
  /**
   * @param Request $request
   * @param User $user
   * @return User
   */
  protected function registered(Request $request, $user)
  {
    return $user;
  }
```

### ログイン／ログアウト

```php:app/Http/Controllers/Auth/LoginController.php

<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;  // 追加
use App\Models\User;  // 追加
use Illuminate\Support\Facades\Auth;  // 追加

class LoginController extends Controller
{
  // 中略

  /**
   * @return string
   */
  public function username()
  {
    return 'login_id';
  }

  /**
   * @param Request
   * @param User $user
   * @return User
   */
  protected function authenticated(Request $request, $user)
  {
    return $user;
  }

  /**
   * @param Request $request
   */
  protected function loggedOut(Request $request)
  {
    Auth::logout();
    $request->session()->regenerate();

    return response()->json();
  }

// (以下略)
```

### 認証時は`/home`ではなく、ユーザーを返す処理にリダイレクト
```php:app/Http/Middleware/RedirectIfAuthenticated.php
public function handle($request, Closure $next, $guard = null)
{
  if (Auth::guard($guard)->check()) {
    return redirect()->route('currentUser');  // 修正
  }

  return $next($request);
}
```

# フロント側の実装

```bash:~/workspace/myapp/laravel
$ touch resources/ts/util.ts
```

```ts:resources/ts/util.ts
export function getCookieValue (searchKey: string): string {
  if (typeof searchKey === 'undefined') {
    return ''
  }

  let val: string = ''

  document.cookie.split(';').forEach((cookie: string): void | string => {
    const [key, value] = cookie.split('=')
    if (key === searchKey) {
      return (val = value)
    }
  })

  return val
}

export const OK: number = 200
export const CREATED: number = 201
export const DELETED: number = 204
export const INTERNAL_SERVER_ERROR: number = 500
export const UNPROCESSABLE_ENTITY: number = 422
export const UNAUTHORIZED: number = 401
export const NOT_FOUND: number = 404
```

```ts:resources/ts/bootstrap.ts
import Axios, { AxiosStatic } from 'axios'
import { getCookieValue } from '@/util'

declare global {
  interface Window {
    axios: AxiosStatic
  }
  interface Element {
    content: string
  }
}

export default function bootstrap(): void {
  window.axios = Axios

  window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'

  window.axios.interceptors.request.use(config => {
    // クッキーからトークンを取り出してヘッダーに添付する
    config.headers['X-XSRF-TOKEN'] = getCookieValue('XSRF-TOKEN')

    return config
  })
}
```

```ts:resources/ts/app.ts
import Vue from 'vue'
import router from './router'
import App from './App.vue'
import store from './store'

// 追加
import bootstrap from './bootstrap'
bootstrap()

new Vue({
  el: '#app',
  router,
  store,
  template: '<App />'
})
```

```bash:~/workspace/myapp/laravel
$ mkdir -p resources/ts/types/Models
$ touch resources/ts/types/{Models/User,Auth,Error}.ts \
resources/ts/store/{auth,error}.ts
```

```ts:resources/ts/types/Models/User.ts
export type User = {
  id: number
  name: string
  [k: string]: any
} | null
```

```ts:resources/ts/types/Auth.ts
export type RegisterRequest = {
  name: string
  login_id: string
  email: string
  password: string
  password_confirmation: string
}

export type LoginRequest = {
  login_id: string
  password: string
}
```

```ts:resources/ts/types/Error.ts
export type ErrorMessages = null | {
  [k: string]: string[]
}
```

```ts:resources/ts/store/error.ts
import { ErrorMessages } from '@/types/Error'

export type State = {
  messages: ErrorMessages
  status: null | number
}

const state = {
  messages: null,
  status: null,
}

const getters = {
  messages: (state: State) => state.messages || [],
  status: (state: State) => state.status,
}

const mutations = {
  setErrors(state: State, data: State) {
    state.messages = data.messages
    state.status = data.status
  },
}

const actions = {}

export default {
  namespaced: true,
  state,
  getters,
  mutations,
  actions,
}
```

```ts:resources/ts/store/auth.ts
import { User } from '@/types/Models/User'
import { RegisterRequest, LoginRequest } from '@/types/Auth'
import { UNPROCESSABLE_ENTITY } from '@/util'
import router from '@/router'

export type State = {
  user: User
}

const state = {
  user: null,
}

const getters = {
  isLogin: (state: State): boolean => !!state.user,
  userId: (state: State): number | null => (state.user ? state.user.id : null),
  userName: (state: State): string => (state.user ? state.user.name : ''),
}

const mutations = {
  setUser(state: State, user: User): void {
    state.user = user
  },
}

const actions = {
  async register(context, data: RegisterRequest): Promise<void> {
    await window.axios
      .post('/api/register', data)
      .then((response) => {
        context.commit('setUser', response.data)
        context.commit(
          'error/setErrors',
          {
            messages: null,
            status: null,
          },
          { root: true }
        )
        router.push('/mypage')
      })
      .catch((err) => {
        if (err.response.status === UNPROCESSABLE_ENTITY) {
          context.commit(
            'error/setErrors',
            {
              messages: err.response.data.errors,
              status: err.response.status,
            },
            { root: true }
          )
        }
      })
  },

  async login(context, data: LoginRequest): Promise<void> {
    await window.axios
      .post('/api/login', data)
      .then((response) => {
        context.commit('setUser', response.data)
        context.commit(
          'error/setErrors',
          {
            messages: null,
            status: null,
          },
          { root: true }
        )
        router.push('/mypage')
      })
      .catch((err) => {
        if (err.response.status === UNPROCESSABLE_ENTITY) {
          context.commit(
            'error/setErrors',
            {
              messages: err.response.data.errors,
              status: err.response.status,
            },
            { root: true }
          )
        } else {
          context.commit('error/setErrors',
          {
            messages: err.response,
            status: err.response.status,
          },
          { root: true }
          )
        }
      })
  },

  async logout(context): Promise<void> {
    await window.axios
      .post('/api/logout')
      .then((response) => {
        context.commit('setUser', null)
        router.push('/login')
      })
      .catch((err) => {
        context.commit(
          'error/setErrors',
          {
            messages: err.response,
            status: err.response.status,
          },
          { root: true }
        )
      })
  },

  async currentUser(context): Promise<void> {
    await window.axios
      .get('/api/current_user')
      .then((response) => {
        const user = response.data || null
        context.commit('setUser', user)
      })
      .catch((err) => {
        context.commit(
          'error/setErrors',
          {
            messages: err.response.data.errors,
            status: err.response.status,
          },
          { root: true }
        )
      })
  },
}

export default {
  namespaced: true,
  state,
  getters,
  mutations,
  actions,
}
```

```ts:resources/ts/store/index.ts
import Vue from 'vue'
import Vuex from 'vuex'

import auth from './auth'
import error from './error'
Vue.use(Vuex)

const store = new Vuex.Store({
  modules: {
    auth,
    error
  },
})

export default store
```

```resources/ts/app.ts
import Vue from 'vue'
import router from './router'
import App from './App.vue'
import store from './store'

import bootstrap from './bootstrap'

bootstrap()

const createApp = async () => {
  await store.dispatch('auth/currentUser')
  new Vue({
    el: '#app',
    router,
    store,
    render: (h) => h(App),
  })
}

createApp()
```

```bash:~/workspace/myapp/laravel
$ mkdir resources/ts/layouts resources/ts/pages/mypage
$ touch resources/ts/layouts/{Default,MyPageLayout}.vue \
> resources/ts/pages/Register.vue \
> resources/ts/pages/mypage/Index.vue 
```

```ts:resources/ts/router.ts

// ↓レイアウト追加
import DefaultLayout from './layouts/Default.vue'
import MyPageLayout from './layouts/MyPageLayout.vue'

import Vue from 'vue'
import VueRouter from 'vue-router'
import Index from './pages/Index.vue'
import Login from './pages/Login.vue'

// ↓追加
import Register from './pages/Register.vue'
import MyPageIndex from './pages/mypage/Index.vue'

import store from './store'
Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    component: DefaultLayout,
    children: [
      {
        path: '/',
        component: Index,
      },
      {
        path: '/register',
        component: Register,
        beforeEnter (to, from, next) {
          if (store.getters['auth/isLogin']) {
            next('/')
          } else {
            next()
          }
        }
      },
      {
        path: '/login',
        component: Login,
        beforeEnter (to, from, next) {
          if (store.getters['auth/isLogin']) {
            next('/')
          } else {
            next()
          }
        }
      },
    ],
  },
  {
    path: '/mypage',
    component: MyPageLayout,
    beforeEnter (to, from, next) {
      if (!store.getters['auth/isLogin']) {
        next('/login')
      } else {
        next()
      }
    },
    children: [
      {
        path: '/',
        component: MyPageIndex,
      }
    ],
  },
]

const router = new VueRouter({
  mode: 'history',
  routes
})

export default router
```

```vue:resources/ts/App.vue
<template>
  <div>
    <router-view />
  </div>
</template>
<script lang="ts">
import { Vue, Component } from 'vue-property-decorator'

@Component

export default class App extends Vue {}
</script>
```

```vue:resources/ts/layouts/Default.vue
<template>
  <div>
    <header>
      <nav>
        <ul v-if="!isLogin">
          <li><router-link tag="a" to="/login">ログイン</router-link></li>
          <li><router-link tag="a" to="/register">会員登録</router-link></li>
        </ul>
        <ul v-else>
          <li @click="logout">ログアウト</li>
        </ul>
      </nav>
    </header>
    <main>
      <div class="container">
        <router-view />
      </div>
    </main>
  </div>
</template>
<script lang="ts">
import { Vue, Component } from 'vue-property-decorator'
import { mapGetters } from 'vuex'
@Component({
  computed: {
    ...mapGetters({
      isLogin: 'admin/isLogin'
    })
  }
})

export default class Default extends Vue {
  async logout(): Promise<void> {
    await this.$store.dispatch('admin/logout')
  }
}
</script>
```

```vue:resources/ts/layouts/MyPageLayout.vue
<template>
  <div>
    <header>
      <nav>
        <ul v-if="!isLogin">
          <li><router-link tag="a" to="/login">ログイン</router-link></li>
          <li><router-link tag="a" to="/register">会員登録</router-link></li>
        </ul>
        <ul v-else>
          <li @click="logout">ログアウト</li>
        </ul>
      </nav>
    </header>
    <main>
      <div class="container">
        <router-view :user="user" />
      </div>
    </main>
  </div>
</template>
<script lang="ts">
import { Vue, Component } from 'vue-property-decorator'
import { mapGetters } from 'vuex'
@Component({
  computed: {
    ...mapGetters({
      isLogin: 'auth/isLogin',
      user: 'auth/userInfo',
    })
  }
})

export default class MyPageLayout extends Vue {
  async logout(): Promise<void> {
    await this.$store.dispatch('auth/logout')
  }
}
</script>
```

```vue:resources/ts/pages/Login.vue
<template>
<div>
  <h1>ログイン</h1>
  <form @submit.prevent="login">
    <div>
      <input type="text" v-model="loginForm.login_id" placeholder="login_id" required>
    </div>
    <div>
      <input type="password" v-model="loginForm.password" placeholder="password" required>
    </div>
    <div>
      <button>ログイン</button>
    </div>
  </form>
</div>
</template>

<script lang="ts">
import { Vue, Component} from 'vue-property-decorator'
import { LoginRequest } from '@/types/Auth'

@Component
export default class Login extends Vue {

  loginForm: LoginRequest = {
    login_id: '',
    password: '',
  }

  async login(): Promise<void> {
    const submitData = new FormData()
    submitData.append('login_id', this.loginForm.login_id)
    submitData.append('password', this.loginForm.password)
    await this.$store.dispatch('auth/login', submitData)
  }

}
</script>
```

```vue:resources/ts/pages/Register.vue
<template>
<div>
  <h1>会員登録</h1>
  <form @submit.prevent="register">
    <div>
      <input type="text" v-model="registerForm.name" placeholder="name" required>
    </div>
    <div>
      <input type="email" v-model="registerForm.email" placeholder="mail" required>
    </div>
    <div>
      <input type="text" v-model="registerForm.login_id" placeholder="login_id" required>
    </div>
    <div>
      <input type="password" v-model="registerForm.password" placeholder="password" required>
    </div>
    <div>
      <input type="password" v-model="registerForm.password_confirmation" placeholder="password_confirmation" required>
    </div>
    <div>
      <button>会員登録</button>
    </div>
  </form>
</div>
</template>

<script lang="ts">
import { Vue, Component} from 'vue-property-decorator'
import { RegisterRequest } from '@/types/Auth'

@Component
export default class Register extends Vue {

  registerForm: RegisterRequest = {
    name: '',
    email: '',
    login_id: '',
    password: '',
    password_confirmation: '',
  }

  async register(): Promise<void> {
    const submitData = new FormData()
    submitData.append('name', this.registerForm.name)
    submitData.append('email', this.registerForm.email)
    submitData.append('login_id', this.registerForm.login_id)
    submitData.append('password', this.registerForm.password)
    submitData.append('password_confirmation', this.registerForm.password_confirmation)
    await this.$store.dispatch('auth/register', submitData)
  }

}
</script>
```

```vue:resources/ts/pages/mypage/Index.vue
<template>
<div>
  <p>{{ user.name }}さん、マイページへようこそ</p>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
import { User } from '@/types/Models/User'
@Component
export default class Index extends Vue {
  @Prop({ type: Object, required: true })
  user!: User
}
</script>
```