📗

既存サービスのユーザーとLINE公式アカウントのユーザーを紐付ける実装例 by Remix

2025/02/10に公開

前書き

運営しているサービスについて、LINE公式アカウント(以下、公式アカウント)をユーザー訴求などで利用することがあるかと思います。
そのような既にリリースしているサービスのユーザー情報と、関連する公式アカウントに登録しているLINEユーザーを紐付けるための実装について、公式のガイドラインに従って解説していきます。

公式アカウントとは?

https://entry.line.biz/start/jp/

実装ガイドライン

https://developers.line.biz/ja/docs/messaging-api/linking-accounts/

※記載の内容は執筆時点(2025/02/10)のものとなります。

前提

  • 公式アカウントのMessaging APIという機能を使用します。
  • Messagin APIで提供してくれるものは公式アカウントのユーザーと既存サービスのユーザーを安全に紐付ける導線となります。そのため、連携済みの定義、及び連携処理については自前で設計、実装が必要となります。

https://developers.line.biz/ja/docs/messaging-api/

要件

ここでの実装例の要件を記します。
※あくまで一例であり、詳細な各要件はお持ちのサービスに合わせて設計してください。

  • LINEユーザー連携済みの定義
    • userテーブルのlineIdカラムにLINEユーザーIDが格納されている
  • 連携実行のトリガー
    • ユーザーが公式アカウントを追加した時
  • DBテーブル定義
    • user: ユーザーの管理
    • user_line_nonce: 発行するnonceの管理
    • session: ログインセッションの管理
  • 実装画面
    • ログイン画面: /login
    • TOP画面: /
    • 連携画面: /link
  • 実装API
    • Webhook API: /webhook POST

準備

  • Messaging APIを有効にしたLINE公式アカウントの作成
    • Webhook URLの設定が必要です。(実装 > 1 の実施時に設定でも問題ありません。)
    • ローカル環境で動かす場合にはngrokなどを使用して、外部にアドレスを公開して設定する必要があります。

実装

公式記載のシーケンスはこちらになります。
こちらを、幾つかのセクションに区切って解説、実装例を記していきます。

引用: https://developers.line.biz/ja/docs/messaging-api/linking-accounts/#account-link-sequence

※実装例はRemix公式のSDKを使用して書かれています。
※userデータは格納済みの前提での記載となります。

1. 連携トークンの発行と連携URLの生成

実装するもの

  • Webhook API

実装例

Webhook API

app/routes/webhook.ts
import {
  LINE_SIGNATURE_HTTP_HEADER_NAME,
  type WebhookEvent,
  validateSignature,
} from "@line/bot-sdk";
import { type ActionFunctionArgs, json } from "@remix-run/node";
import { getMessagingApiClient } from "~/lib/lineBot";
import { prisma } from "~/lib/prisma";

export const action = async ({ request }: ActionFunctionArgs) => {
  if (request.method !== "POST") {
    return json({ message: "Method not allowed" }, 405);
  }

  // 1. リクエストの検証
  const signature = request.headers.get(LINE_SIGNATURE_HTTP_HEADER_NAME) || "";
  const body = await request.clone().text();
  if (
    !validateSignature(body, process.env.LINE_CHANNEL_SECRET || "", signature)
  ) {
    return json({ message: "Invalid signature" }, 403);
  }

  // 2. イベントの処理
  const payload = await request.json();
  const events = payload.events as WebhookEvent[];
  for (const event of events) {
    switch (event.type) {
      case "follow": {
        try {
          const client = getMessagingApiClient();
          if (event.source.type !== "user") {
            break;
          }
          // 3. 連携トークンの発行
          const linkTokenResponse = await client.issueLinkToken(
            event.source.userId,
          );
          // 4. 公式アカウントへ連携画面のURLを送信
          await client.replyMessage({
            replyToken: event.replyToken,
            messages: [
              {
                type: "text",
                text: "友達追加ありがとうございます!\nアカウントを連携してください!",
              },
              {
                type: "template",
                altText: "アカウントを連携する",
                template: {
                  type: "buttons",
                  text: "アカウントを連携する",
                  actions: [
                    {
                      type: "uri",
                      label: "アカウントを連携する",
                      uri: `${process.env.FRONTEND_URL}/link?linkToken=${linkTokenResponse.linkToken}`,
                    },
                  ],
                },
              },
            ],
          });
        } catch (e) {
          // biome-ignore lint/suspicious/noExplicitAny: <explanation>
          console.error((e as any).status);
          // biome-ignore lint/suspicious/noExplicitAny: <explanation>
          console.error((e as any).body);
        }
        break;
      }
     // -- 省略 --
      }
    }
  }

  return json({ success: true }, 200);
};

解説

    1. 処理を行う前に、本当にWebhookからのリクエストであるか検証を行います。
    1. Webhookのイベントを処理します。ここでは公式アカウント追加時に連携処理が実行されるように実装しています。
    1. 連携トークンを発行します。
    1. 連携ページへのURLを公式アカウントへ送信します。その際、発行したトークンをクエリパラメータとして設定しています。

※連携トークンの発行には以下のエンドポイントを使用しますが、ここではjsのsdkの関数を使って呼び出しています。

https://developers.line.biz/ja/reference/messaging-api/#issue-link-token

2. 連携画面へのアクセス

1で送信したURLへアクセスされた後の処理を実装します。

実装するもの

  • 連携画面

実装例

連携画面

app/routes/link.tsx
import crypto from "node:crypto";
import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
import { prisma } from "~/lib/prisma";
import { getSession } from "~/lib/session";

export async function loader({ request }: LoaderFunctionArgs) {
  const linkToken = new URL(request.url).searchParams.get("linkToken");
  if (!linkToken) {
    return redirect("/", { status: 400 });
  }

  const session = await getSession(request.headers.get("Cookie"));

  // 1. 認証状態の検証
  if (!session.has("userId")) {
    return redirect(
      `/login?r=${process.env.FRONTEND_URL}/link?linkToken=${linkToken}`,
    );
  }

  try {
    const nonce = getNonce();
    // 2. 生成したnonceを保持
    await prisma.user_line_nonce.upsert({
      where: {
        userId: session.get("userId") as number,
      },
      update: {
        // 1000 * 60 * 60 = 1 hour
        expiresAt: new Date(Date.now() + 1000 * 60 * 60),
        nonce,
      },
      create: {
        userId: session.get("userId") as number,
        nonce,
        expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24),
      },
    });

    // 3. 連携実行
    const linkUrl = `https://access.line.me/dialog/bot/accountLink?linkToken=${linkToken}&nonce=${nonce}`;
    return redirect(linkUrl);
  } catch (e) {
    console.error(e);
    return redirect("/", { status: 500 });
  }
}

/**
 * セキュアなランダム生成関数を使う。
 * 少なくとも128ビット(16バイト)以上にする。
 * Base64エンコードする。
 */
const getNonce = () => {
  return crypto.randomBytes(16).toString("base64");
};

解説

    1. 認証状態を検証し、未認証であればログイン画面へリダイレクトさせます。
    1. nonceを生成し、それを認証ユーザーと紐付け可能な形で保持します。
    1. 連携トークンとnonceをクエリパラメータに加え、LINE側で提供している連携URLへリダイレクトさせます。

3. 連携成功後の処理

連携が成功した後の処理を実装します。

実装するもの

  • Webhook API
    • 1で実装したものに連携イベントの処理を追加します。

実装例

app/routes/webhook.ts
export const action = async ({ request }: ActionFunctionArgs) => {
  // 省略

      case "accountLink": {
        try {
          // 1. 成功可否を検証
          if (event.link.result !== "ok") {
            throw new Error("Invalid nonce");
          }
          // 2. nonceを検証し、紐付けたユーザー情報を取得
          const userLineNonce = await prisma.user_line_nonce.findFirst({
            where: {
              nonce: event.link.nonce,
              expiresAt: {
                gt: new Date(), // 有効期限が過ぎていないか
              },
            },
          });
          if (!userLineNonce) {
            throw new Error("Invalid nonce");
          }

          // 3. LINEユーザーIDを保存する
          await prisma.user.update({
            where: {
              id: userLineNonce.userId,
            },
            data: {
              lineId: event.source.userId,
            },
          });

          const client = getMessagingApiClient();
          await client.replyMessage({
            replyToken: event.replyToken,
            messages: [
              { type: "text", text: "アカウント連携が完了しました!" },
            ],
          });
        } catch (e) {
          const client = getMessagingApiClient();
          await client.replyMessage({
            replyToken: event.replyToken,
            messages: [
              {
                type: "text",
                text: "アカウント連携に失敗しました。再度お試しください。",
              },
            ],
          });
        }
        break;
      }

// 省略
}

解説

    1. パラメータに成功可否が含まれているので、それを元に結果を検証します。
    1. 2で発行したnonceがパラメータに含まれているので、それを元にuserテーブルのレコードを見つけます。
    1. 該当のレコードのカラムに、パラメータに含まれているLINEユーザーIDを格納します。

LINE側での連携判定が成功した場合には、以下のような画面が表示されます。

完成

  • 公式アカウントの追加からのフローを実施し、最終的にLINEユーザーIDがDBに格納されていれば成功です。
  • なお、実際に運用する場合には連携解除の処理と、連携解除が可能な旨をユーザーに事前通知する必要がありますが、特記事項などはないためここでは割愛しております。

余談

以下が全体の実装サンプルとなります。

https://github.com/yu-ta-9/line-messaging-api-demo

後書き

公式のドキュメントを読んだだけでは分かりづらいと思う箇所が多々あり、実装例を交えて解説を記してみました。
皆さんの実装の一助となれば幸いです。

Discussion