LIFFアプリをFirebaseで安全に認証する

# 概要
LIFFアプリでクライアントと自分のサーバ間で安全に認証するにはアクセストークンやIDトークンをサーバに送って認証する必要があります。（詳細は[こちら](https://developers.line.biz/ja/docs/line-login/secure-login-process/))

今回はアクセストークンを使った認証でかつFirebaseのカスタム認証を使用することで安全な認証を行いたいと思います。

今回のコードは[GitHub](https://github.com/Arahabica/line-firebase-auth-sample)に置いています。

# その前に
LIFFを使うと簡単にユーザ情報を取得できますが、そのユーザ情報をサーバに送ってそのまま信頼してしまうと、なりすましやその他の攻撃に対して脆弱になってしまいます。

<img width="712" alt="スクリーンショット 2020-08-09 7.29.20.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30931/010bbd7c-44b2-d7ce-0b7d-b30e0d619d5e.png">
↑(LINE 公式ページより引用)

安全のためにアクセストークンを使いましょう。

# 全体の流れ
今回はさらにFirebaseも使いたいのでクライアント、LINE、Firebaseの3者が登場します。

1. クライアントでLINE認証 (LINEアプリ内)
2. LINEのアクセストークンを取得 (LINEアプリ内)
3. LINEのアクセストークンをFirebase Functionsに投げる(LINEアプリ内)
4. LINEのアクセストークンの有効性を検証(Firebase Functions内)
5. LINEのアクセストークンを利用してユーザプロフィール取得(Firebase Functions内) 
6. LINEのユーザIDをIDとしたFirebase Authenticationのカスタムトークンを生成(Firebase Functions内)
7. Firebaseのカスタムトークンをクライアントに返却(Firebase Functions内)
8. 取得したFirebaseのカスタムトークンを利用してFirebase認証(LINEアプリ内)

LINEの公式ページに載っている下記の流れとだいたい同じです。
<img width="722" alt="スクリーンショット 2020-08-09 8.16.12.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30931/63b80a99-b1e1-6457-567a-619434ec6e9c.png">

ただ、今回はFirebaseを使っているので、6,7,8番の処理が追加されている形です。
それでは、それぞれの項目をコードにしていきます。

### 1. クライアントでLINE認証 (LINEアプリ内)
LINEアプリ内でLIFFを開いた場合は認証不要です。
liff.initでliffを初期化するだけでOKです。
外部ブラウザで開いてかつ未認証のときはliff.loginを呼んでログイン画面にリダイレクトさせます。

```js
const liffId = 'YOUR_LIFF_ID'
await liff.init({liffId})
  .catch((err) => {
    console.error('LIFFの初期化失敗。\n' + err)
  })
if (!liff.isLoggedIn()) {
  await liff.login()
  return
}
```

### 2. LINEのアクセストークンを取得 (LINEアプリ内)
liff.getAccessTokenを使うだけです。

```js
const accessToken = liff.getAccessToken()
```

### 3. LINEのアクセストークンをFirebase Functionsに投げる(LINEアプリ内)
Firebase Functionsのloginというfunctionにアクセストークンを投げています。

```js
const functions = firebase.app().functions('asia-northeast1')
const login = functions.httpsCallable('login')
const result = await login({accessToken})
```

### 4. LINEのアクセストークンの有効性を検証(Firebase functions内)
ここからはサーバサイド(Firebase Functions)です。
LINE Social APIの「アクセストークンの有効性を検証する」APIを使って、有効性を検証しています。
参考) https://developers.line.biz/ja/reference/social-api/#verify-access-token

```js:login.js
const axiosInstance = axios.create({
  baseURL: 'https://api.line.me',
  responseType: 'json'
})
const verifyToken = async accessToken => {
  const response = await axiosInstance.get('/oauth2/v2.1/verify', { params: { access_token: accessToken }} )
  if (response.status !== 200) {
    console.error(response.data.error_description)
    throw new Error(response.data.error)
  }
  // チャネルIDをチェック
  if (response.data.client_id !== channelId) {
    throw new Error('client_id does not match.')
  }
  //アクセストークンの有効期限
  if (response.data.expires_in < 0) {
    throw new Error('access token is expired.')
  }
}
```

### 5. LINEのアクセストークンを利用してユーザプロフィール取得(Firebase Functions内) 
LINE Social APIの「ユーザープロフィールを取得する」APIを使って、ユーザIDを含むプロフィールを取得します。
LIFFのScopeの設定にProfileを有効にしておく必要があります。
参考) https://developers.line.biz/ja/reference/social-api/#get-user-profile

```js:login.js
const axiosInstance = axios.create({
  baseURL: 'https://api.line.me',
  responseType: 'json'
})
const getProfile = async (accessToken) => {
  const response = await axiosInstance.get('/v2/profile', {
    headers: {
      'Authorization': `Bearer ${accessToken}`
    },
    data: {}
  })
  if (response.status !== 200) {
    console.error(response.data.error_description)
    throw new Error(response.data.error)
  }
  return response.data
}
```



### 6. LINEのユーザIDをIDとしたFirebase Authenticationのカスタムトークンを生成(Firebase Functions内)

5までの時点でLINE側の処理は大体終わりました。
クライアントが送ったアクセストークンは正しく、クライアントのユーザIDもわかったので、次にFirebaseにそのユーザIDを伝えましょう。

LINEのユーザIDを用いてFirebase Authenticationのカスタムトークンを生成します。

```js:login.js
const token = await admin.auth().createCustomToken(profile.userId)
```

### 7. Firebaseのカスタムトークンをクライアントに返却(Firebase functions内)
6で取得したトークンをクライアントに返却します。

```js:login.js
functions
  .region('asia-northeast1')
  .https.onCall(async data => {
    ...中略...
    const token = await admin.auth().createCustomToken(profile.userId)
    return { token }
  })
```

### 8. 取得したFirebaseのカスタムトークンを利用してFirebase認証(LINEアプリ内)

```js
const result = await login({accessToken})
if (result.data.error) {
  console.error(result.data.error)
} else {
  const res = await auth().signInWithCustomToken(result.data.token)
  this.user = res.user
}
```
##　全体のコード
下記に全体のコードを置いています。
これまで触れていなかった、ログイン後に再度アクセスしたときのための処理も入れてあります。

#### クライアントサイド
クライアントサイドはVue.jsを使用していますが、Vue.js特有の機能はさほど使ってません。分からない人はmounted関数の中だけ見て下さい。

```js:App.vue
<template lang="pug">
  div
    div(v-if="user")
      div 認証済み
      pre {{JSON.stringify(user, null, '  ')}}
    div(v-else)
      | 未認証
</template>
<script>
import firebase from 'firebase/app'
import 'firebase/auth'
import 'firebase/functions'

const liffId = 'YOUR_LIFF_ID'
const firebaseConfig = {
  apiKey: "YOUR_API_KEY",
  authDomain: "YOUR_AUTH_DOMAIN",
  projectId: "YOUR_PROJECT_ID",
}

firebase.initializeApp(firebaseConfig)

const auth = firebase.auth
const functions = firebase.app().functions('asia-northeast1')

export default {
  data() {
    return {user: null}
  },
  async mounted() {
    // 1. LIFFの初期化
    const liff = window.liff
    await liff.init({liffId})
      .catch((err) => {
        window.alert('LIFFの初期化失敗。\n' + err)
      })
    // 2. LINEに未認証の場合、ログイン画面にリダイレクト
    if (!liff.isLoggedIn()) {
      await liff.login()
      return
    }
    // 3. firebaseの認証情報を取得
    auth().onAuthStateChanged(async user => {
      if (user) {
        // 3.1 firebaseにログイン済みの場合、ユーザー情報を取得し、終了
        this.user = user
      } else {
        // 3.2 firebaseにログインしていない場合
        // 3.2.1 LIFF APIを利用して、LINEのアクセストークンを取得
        const accessToken = liff.getAccessToken()
        // 3.2.3 LINEのIDトークンをfirebase functionsに投げて、firebaseのカスタム認証用トークンを取得
        const login = functions.httpsCallable('login')
        const result = await login({accessToken})
        if (result.data.error) {
          console.error(result.data.error)
        } else {
          // 3.2.4 firebaseの認証用トークンを利用してカスタム認証
          const res = await auth().signInWithCustomToken(result.data.token)
          this.user = res.user
        }
      }
    })
  }
}
</script>
```


#### サーバサイド (Firebase Functions)

```js
const admin = require("firebase-admin")
const functions = require('firebase-functions')
const axios = require('axios')
// サービス アカウント JSON ファイル
const serviceAccount = require('./config/serviceAccountKey.json')
const channelId = 'YOUR_CHANNEL_ID'

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount)
})

const axiosInstance = axios.create({
  baseURL: 'https://api.line.me',
  responseType: 'json'
})

// 渡されたLINEトークンが正しいものかを検証
const verifyToken = async accessToken => {
  const response = await axiosInstance.get('/oauth2/v2.1/verify', { params: { access_token: accessToken }} )
  if (response.status !== 200) {
    console.error(response.data.error_description)
    throw new Error(response.data.error)
  }
  // チャネルIDをチェック
  if (response.data.client_id !== channelId) {
    throw new Error('client_id does not match.')
  }
  //アクセストークンの有効期限
  if (response.data.expires_in < 0) {
    throw new Error('access token is expired.')
  }
}

const getProfile = async (accessToken) => {
  const response = await axiosInstance.get('/v2/profile', {
    headers: {
      'Authorization': `Bearer ${accessToken}`
    },
    data: {}
  })
  if (response.status !== 200) {
    console.error(response.data.error_description)
    throw new Error(response.data.error)
  }
  return response.data
}

exports.login = functions
  .region('asia-northeast1')
  .https.onCall(async data => {
    const { accessToken } = data
    try {
      // LINEのアクセストークンが正しいか検証
      await verifyToken(accessToken)
      // アクセストークンを利用してプロフィール取得
      const profile = await getProfile(accessToken)
      // LINEのuserIdを利用してfirebaseのカスタム認証トークンを発行
      const token = await admin.auth().createCustomToken(profile.userId)
      return { token }
    } catch(e) {
      console.error(JSON.stringify(e, null, '  '))
      return { error: e.message }
    }
  })
```