Next.js + SupabaseでSMS認証を作成する方法

前回はNext.jsとSupabaseを使って、認証付きチャットアプリを作成しました。
ここまでくれば、もう一つのアプリとして成り立つレベルです。

しかしながら、サービスとしてローンチするにはまだまだ問題があります。
人が集まれば集まる程、Botが投稿をし始めたり、違法なことを書き込んだりする人が出てきます。
そのようなユーザーへの対応は基本運営が行わなければなりませんが、手動で削除やBAN等を行っているのでは、貴重なリソースやコストがどんどん浪費されてしまいます。

そこで今回は、以前作成した認証機能のリポジトリに『SMS認証機能』を追加してみましょう。
これにより、Botの登録を防ぎ、責任ある書き込みを促すことが出来ます。
更にはユーザーが簡単・安全にログインできるという効果もあるため、非常に有用な機能です。

目次

Supabase側の設定

①:Supabaseプロジェクトの作成

Supabaseにログイン(登録していなければアカウント登録から)して、
トップページの「New Project」を押します。

すると、下記の様な画面が表示されます。

適当なプロジェクト名とデータベースのパスワードを入れて新しいプロジェクトを作成しましょう。
※Regionはできれば自分の住んでいる地域の近くがいいです。私は日本にしました。

Next.js事前準備

①:認証機能のリポジトリをクローン

今回はこちらの記事で紹介している認証機能(Supabase Auth)を元に実装を進めたいので、
まずはこちらの記事内にあるgithubのリンク から`git clone`してリポジトリを持ってきてください。

②:起動確認

Supabase Authが正しく動くかをまず確認しましょう。
クローンしたプロジェクトの直下に`.env.local`を新規作成し、
内容を下記の通り入力します

# Update these with your Supabase details from your project settings > API
# https://app.supabase.com/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL={ここにProject URLを入れる}
NEXT_PUBLIC_SUPABASE_ANON_KEY={ここにProject API Anon Keyを入れる}

それぞれここに〜〜入れると書かれている部分に
Supabaseのダッシュボードの`Project Settings`の`API`から必要な情報をコピーします。

コピーが終わったら、

npm install

を実行した上で

npm run dev

を実行します。

下記の画面が表示されるか確認してください。

Sign Upで登録したメールアドレスでLoginを行い、Profileページが表示されたらOKです。

Twilio設定

※すでにTwilioに登録済みの方は対応不要です。

今回SMS認証機能を提供するために、CpaaSであるTwilioを利用します。
https://www.twilio.com/ja-jp
上記サイトにアクセスし、右上の`無料トライアルボタン`からアカウント登録しましょう。

登録してはじめに表示されるダッシュボード画面で`Get a trial phone number`をクリックし、無料で使える電話番号を取得します。
電話番号を取得すると、ダッシュボード画面の`Account Info`にも電話番号が追加されます。

こちらの情報をSupabaseのダッシュボードの`Authentication`→`Providers`→`Phone`に入れていきます。
Account SIDとAuth Tokenはそのまま同じ値を入れ、Message Service SIDは先程取得した電話番号を入力します。

これで保存すればSMS認証をするための下準備はOKです。
ここからNext.js側での実装に移ります。

Next.js側の実装

既存のサインインフォームに追加すると分かりづらくなるため、SMS認証用のフォームを別で作成します。

事前準備

Twilioに送る電話番号には国際コードが必要になりますが、
わざわざそのために国際コード用のセレクタを作成するのも面倒なのでライブラリを利用します。
https://www.npmjs.com/package/react-international-phone

npm i react-international-phone

components/modal/modalType.ts

分岐させるためにSMS認証用のTypeを追加します。

export enum ModalType {
  SignIn = 1,
  SignUp = 2,
  SMSAuth = 3
}

components/modal/smsAuthForm.tsx

SMS認証向けのフォームです。

"use client"
import { useState } from "react";
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
import { PhoneInput } from 'react-international-phone';
import 'react-international-phone/style.css';

export default function SMSAuthForm() {
  const supabase = createClientComponentClient();
  const [phoneNumber, setPhoneNumber] = useState('');
  const [otp, setOtp] = useState('');

  const handleSendOtp = async () => {
    try {
      const { error } = await supabase.auth.signInWithOtp({ phone: phoneNumber });
      if (error) throw error;
    } catch (error: any) {
      console.error('Error sending OTP:', error.message);
    }
  };

  return (
    <>
      <form action="/auth/smsLogin" method="post" className="space-y-4">
        <div>
          <PhoneInput
            name="phone"
            defaultCountry="jp"
            value={phoneNumber}
            onChange={(e) => setPhoneNumber(e)}
          />
          <button className="bg-white hover:bg-gray-100 text-gray-800 font-semibold py-1 px-4 border border-gray-400 rounded shadow block mt-2" type="button" onClick={handleSendOtp}>Send OTP</button>
        </div>

        <div>
          <input
            className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
            type="text"
            placeholder="Enter OTP"
            name="otp"
            value={otp}
            onChange={(e) => setOtp(e.target.value)}
          />
          <button className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded px-4 py-1 text-center mt-2" type="submit">Verify OTP</button>
        </div>
      </form>
    </>
  );
}

ここでサインイン処理を行います。
実行が成功すると設定した電話番号に対してワンタイムパスワードが送られます。

const handleSendOtp = async () => {
    try {
      const { error } = await supabase.auth.signInWithOtp({ phone: phoneNumber });
      if (error) throw error;
    } catch (error: any) {
      console.error('Error sending OTP:', error.message);
    }
  };

app/auth/smsLogin/route.ts

smsAuthForm.tsxのフォームからPOSTされるとこちらに来てワンタイムパスワードによる認証が行われます。

import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'

export async function POST(request: Request) {
    const requestUrl = new URL(request.url)
    const formData = await request.formData()
    const phone = String(formData.get('phone'))
    const otp = String(formData.get('otp'))
    const supabase = createRouteHandlerClient({ cookies })

    const { data: { session }, error } = await supabase.auth.verifyOtp({ phone: phone, token: otp, type: "sms" });

    if (error) {
      throw error
    }

    return NextResponse.redirect(requestUrl.origin + '/profile', {
        status: 301,
    })
}

その他実装

components/modalCore.tsx

import { useState } from 'react';
import { ModalType } from './modal/modalType';
import SignUpForm from './modal/signupForm';
import SignInForm from './modal/signinForm';
import SMSAuthForm from './modal/smsAuthForm';

interface Props {
  modalType: ModalType;
}

const ModalCore = ({ modalType }: Props) => {
  const [showModal, setShowModal] = useState(false);
  let title = "";
  let headerButton = "";
  let formElement = <p>フォームを読み込めませんでした。</p>;
  switch (modalType) {
    case ModalType.SignIn:
      title = "ログインフォーム";
      headerButton = "Login";
      formElement = <SignInForm showModal={setShowModal}></SignInForm>;
      break;

    case ModalType.SignUp:
      title = "ユーザ登録フォーム";
      headerButton = "Sign Up";
      formElement = <SignUpForm showModal={setShowModal}></SignUpForm>;
      break;

    case ModalType.SMSAuth:
      title = "SMS認証フォーム";
      headerButton = "SMS認証";
      formElement = <SMSAuthForm></SMSAuthForm>;
  }
  return (
    <>
      <button
        className="text-gray-600 hover:text-blue-600"
        type="button"
        onClick={() => setShowModal(true)}
      >
        {headerButton}
      </button>
      {showModal ? (
        <>
          <div className="overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 max-h-full bg-black-rgba">
            <div className="m-auto relative p-4 w-full max-w-md max-h-full">
              <div className="relative bg-white rounded-lg shadow">
                <div className="flex items-center justify-between p-4 md:p-5 border-b rounded-t">
                  <h3 className="text-xl font-semibold text-gray-900">
                    {title}
                  </h3>
                  <button
                    type="button"
                    className="end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center"
                    data-modal-hide="authentication-modal"
                    onClick={() => setShowModal(false)}
                  >
                    <svg
                      className="w-3 h-3"
                      aria-hidden="true"
                      xmlns="http://www.w3.org/2000/svg"
                      fill="none"
                      viewBox="0 0 14 14"
                    >
                      <path
                        stroke="currentColor"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        strokeWidth="2"
                        d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
                      />
                    </svg>
                    <span className="sr-only">モーダルを閉じる</span>
                  </button>
                </div>
                <div className="p-4 md:p-5">{formElement}</div>
              </div>
            </div>
          </div>
        </>
      ) : null}
    </>
  );
};

export default ModalCore;

追加されているのはこの部分です。

import SMSAuthForm from './modal/smsAuthForm';

~~~~~~~

switch (modalType) {

    ~~~~~~~~

    case ModalType.SMSAuth:
      title = "SMS認証フォーム";
      headerButton = "SMS認証";
      formElement = <SMSAuthForm></SMSAuthForm>;
  }

components/navigation.tsx

'use client';
import type { Session } from '@supabase/auth-helpers-nextjs';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import ModalCore from './modalCore';
import { ModalType } from './modal/modalType';
const Navigation = ({ session }: { session: Session | null }) => {
  const pathname = usePathname();
  const router = useRouter();
  if (session === null && pathname?.includes('/profile')) {
    router.push('/');
  }
  return (
    <header>
      <div className="flex items-center justify-between px-4 py-2 bg-white shadow-md">
        <nav className="hidden md:flex space-x-4">
          <div>
            <Link className="text-gray-600 hover:text-blue-600" href="/">
              Home
            </Link>
          </div>
          {session ? (
            <div>
              <Link
                className="text-gray-600 hover:text-blue-600"
                href="/profile"
              >
                Profile
              </Link>
            </div>
          ) : (
            <>
              <div>
                <ModalCore modalType={ModalType.SignIn}></ModalCore>
              </div>
              <div>
                <ModalCore modalType={ModalType.SignUp}></ModalCore>
              </div>
              <div>
                <ModalCore modalType={ModalType.SMSAuth}></ModalCore>
              </div>
            </>
          )}
        </nav>
      </div>
    </header>
  )
}

export default Navigation

こちらはSignUpの下にSMS認証のモーダルを追加しています

<div>
    <ModalCore modalType={ModalType.SMSAuth}></ModalCore>
</div>

動作確認

※動かないときは…

Twilioのトライアルアカウントでは認証された電話番号しか利用することができません
Twilioのダッシュボード→`Phone Numbers`→`Manage`→`Verified Caller IDs`に電話番号を追加しましょう。
※もし認証されているのにエラーが発生する場合は再度追加し直すと解消されることがあります。

ワンタイムパスワード発行

`SMS認証`ボタンを押し、上記認証フォームの電話番号を入力、その後`Send OTP`でワンタイムパスワードを発行します。

電話番号に送信されたワンタイムパスワード

発行されたワンタイムパスワードを入力して`Verify OTP`をクリックしてProfileページにアクセスできたら成功です。

ログインに成功すると、Supabaseのダッシュボード→`Authentication`に認証した電話番号が表示されます。

これでSMS認証の作成が確認できました!

その他参考資料など

今回のgithubはこちらになります。

またTodoONada株式会社では、チャットシステム以外にも、ストレージを利用した画像アプリやTodoアプリ等の作成方法についてご紹介しています。
ぜひこちらもご覧ください!

お問合せ&各種リンク

presented by

  • システム開発、アプリ開発
  • マッチングアプリ開発
  • インフラ構築支援等、なんでもご相談ください。
よかったらシェアしてね!
  • URLをコピーしました!
目次