Torihaji's Growth Diary

Little by little, no hurry.

Next.jsとRailsのアプリでdevise-token-authを使ってEメールの新規登録、ログインができるまで

はじめに

現在、HappinessChainの課題でXクローンを作ってます。

そこでdevise-token-authを使って認証周りを作っていくのですが、

毎回作っていてやり方を忘れるので、前にも書いた気がしますが書き残そうと思ってやってます。

ちなみに上からやっていけばできる、みたいな感じではないです。

この記事の終わりになればできるようになってるという感じです。

前提

frontendは立ち上げたら next.jsの画面が見えること、

backendは立ち上げたら赤いRailsのロゴがある画面が見えることを前提に進めます。

なお、corsの設定等はまだしてません。

新規登録

Rails

必要に応じて下記を参照しながら進めます。

devise_token_auth/SUMMARY.md at master · lynndylanhurley/devise_token_auth · GitHub

gemを入れる

gem "rack-cors"
gem "devise"
gem "devise-token-auth"

deviseのinstall

rails g devise:install

色々指示が飛ぶのでapiモードで必要なものだけ

development.rbに下記を設定

config.action_mailer.default_url_options = { host: 'localhost', port: 8000 }

=> rails g devise:installをしないとtoken-authのinstallをした後の諸々が失敗します。

devise-token-authのinstall

devise_token_auth/docs/config/README.md at master · lynndylanhurley/devise_token_auth · GitHub

rails generate devise-token-auth:install User /api/v1

=> 後に修正します。下の方がいいですね。
rails generate devise-token-auth:install User users

migrationファイルが作成されるので必要なものを入力してください。

今回はEメール認証をしたいのでconfirmableの部分はコメントアウト外してください。

その後に

rails db:migrate

corsの設定

devise_token_auth/docs/config/cors.md at master · lynndylanhurley/devise_token_auth · GitHub

# gemfile
gem 'rack-cors', :require => 'rack/cors'

# config/application.rb
module YourApp
  class Application < Rails::Application
    config.middleware.use Rack::Cors do
      allow do
        origins '*'
        resource '*',
          headers: :any,
          expose: ['access-token', 'expiry', 'token-type', 'uid', 'client'], <= ここは必須
          methods: [:get, :post, :options, :delete, :put]
      end
    end
  end
end

exposeのところはこのように書いてねとあるのでそうします。

corsを設定する理由 => corsというのはcross origin resource sharingというもので異なるオリジン間でのリソース共有の制限と許可をする仕組みです。

オリジンというのはスキーム+ホスト名+ポート番号の組み合わせからなるものです。

例えばhttps:://localhost:443 というやつですね。

httpsがスキーム、ホスト名がlocalhost、ポート番号が443です。

知らないサーバからのアクセスを制限したり許可したりすることで自分を守るためのセキュリティ機能です。

Eメール認証を作る

コントローラを作るのと開発環境用にletter_openerを入れようと思います。

まずは新規登録です。

パスを設定する

/api/v1/usersというパスに指定のデータをpostしたらデータが投げられるようにしたいです。

ということでroute.rbにその情報を書いていきましょう。

現状、routes.rbは下記のようになっているかと思います。

# frozen_string_literal: true

Rails.application.routes.draw do
  mount_devise_token_auth_for 'User', at: '/api/v1'
  resources :tasks
  # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

  # Defines the root path route ("/")
  # root "articles#index"
end

この状態で rails routesをするとおかしいことに気づきます。

POST  /api/v1(.:format)     devise_token_auth/registrations#create

色々おかしいですね。これはなぜか。

devise_token_auth/docs/usage/routes.md at master · lynndylanhurley/devise_token_auth · GitHub

公式ドキュメントを見ます。

mount_devise_token_auth_for 'User', at: '/api/v1' としたら 'User'のところには認証対象であるモデルのクラス名を、

'/api/v1/'にはパスにprefixをつけたいときに使うようですね。

ちなみにどちらも未指定だとエラー吐かれるみたいですね。

atの方はhogeでも'/'渡しても機能するそうですね。

ただ / だとなんかわかりにくいし、hogeだとダサいし。

なので指定のprefixつけるみたいですね。

でだ。やりたいことは '/api/v1/users'に指定データをPOSTしたらUserがcreateされるようなパスを作る、です。

なので

mount_devise_token_auth_for 'User', at: '/api/v1/users' と書けばいいですね。

POST  /api/v1/users(.:format)       devise_token_auth/registrations#create

ただnamespace使うこともできますね。そっちの方が後々のことを考えたら良いかと思います。

# frozen_string_literal: true

Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      mount_devise_token_auth_for 'User', at: '/users'
    end
  end
  resources :tasks
end

というかそもそも 最初から色々 パスが追加されていたけどそいつら何?と思った方は公式ドキュメントです。

devise_token_auth/docs/usage/README.md at master · lynndylanhurley/devise_token_auth · GitHub

ここに各パスがどういうものかどういう用途かが書いてあります。

ちなみにEメール認証は一番上のパスに飛ばせばいいらしいですね。必須情報も添えてあります。

あとで使いましょう。

登録用コントローラを作る

と言っても特にやることもないですね。

元々作られているコントローラに必須情報添えてpostすればuserが作られるので。

ということで試しに作ってみましょう。先ほどの公式ドキュメントの英文引用するとpathが / でmethodがPOSTのやつは

Email registration. Requires email, password, password_confirmation, and confirm_success_url params (this last one can be omitted if you have set config.default_confirm_success_url in config/initializers/devise_token_auth.rb). A verification email will be sent to the email address provided. Upon clicking the link in the confirmation email, the API will redirect to the URL specified in confirm_success_url. Accepted params can be customized using the devise_parameter_sanitizer system.

要は

{
   "email":  "hogehoge@mail.com",
    "password": "hogehoge",
    "password_confirmation": "hogehoge"
}

として今の場合なら"/api/v1/users"にPOSTすればいいみたいですね。

4つ目の"confirm_success_url"は "config/initializers/devise_token_auth.rb"に書いてなければ添える必要があるそうですが、

フロントでいちいち指定するのもかったるいのでrails側で設定しておきましょう。

次に確認メールを飛ばす必要があるらしいのでメールの設定をしていきましょう。

またconfirm_success_urlについては ログイン画面に飛ばせば良いと思うので http://localhost:3000/auth みたいにしときましょう。

さらに"config/initializers/devise_token_auth.rb"の末尾に”これ設定しないとメール飛ばさんよ"という記述があったので

これもtrueにしておきましょう。

config/initializers/devise_token_auth.rb
〜〜〜
  # By default DeviseTokenAuth will not send confirmation email, even when including
  # devise confirmable module. If you want to use devise confirmable module and
  # send email, set it to true. (This is a setting for compatibility)
  config.send_confirmation_email = true <= こいつ

  config.default_confirm_success_url = 'http://localhost:3000/auth'
end

あとは忘れていたapp/models/user.rbのconfirmableの設定を追記します。

# 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,
         :confirmable
  include DeviseTokenAuth::Concerns::User
end

ここからはletter_openerを入れます。

gem letter_opener を試してみる #Rails - Qiita

はい。これを元に色々入れて、initilizersをいじったので再起動させていざ実践。

いけましたね。ログを見るといけてますね。

{
 "email": "hoge@mail.com",
 "password": "hogehoge",
 "password_confirmation": "hogehoge"
}

を"http://localhost:8000/api/v1/users"にPOSTしたら

こんなメールが帰ってきて、このurlをクリックするとconfirm_success_urlのところに飛ぶみたいですね。

正確には"/api/v1/users/confirmation?~~~"にGETリクエストを送ってbackendで対象ユーザのconfirmed_at等がupdateされたあと

そこからリダイレクトされてconfirm_success_urlに飛ぶようです。

GET "/api/v1/users/confirmation?config=default&confirmation_token=[FILTERED]&redirect_url=http%3A%2F%2Flocalhost%3A3000%2Fauth"

ほー。あとはフロントの方作るだけですね。

これにてbackendの旅は終了です!

Next.js

Next.jsはpages routerで作ります

この画面を作りましょう。UIコンポーネントについてはshadcnを使っていきます。

使用するライブラリとしては axios、useSWR、react-hook-form、zod、react-toastifyです。

フロントについては作り方は特にないです笑

画面を作って、apiクライアント作ってpostすればいけると思います。

私はこんな感じで画面作りました!

export default function Auth() {
  const {
    isOpen: isOpenSignUp,
    setIsOpen: setIsOpenSignUp,
    open: openSignUp,
    close: closeSignUp,
    toggle: toggleSignUp,
  } = useDisclosure();
  return (
    <>
      <div className="flex flex-col h-screen">
        <div className="flex-1 flex">
          {/* ロゴ */}
          <div className="flex-1 flex justify-center items-center">
            <Image src="/logo-white.png" alt="logo" width={300} height={300} />
          </div>

          {/* 入力フォーム */}
          <div className="flex-1 p-4">
            <div className="flex flex-col p-4 h-full">
              <div className="my-10 text-6xl font-bold">
                すべての話題が、ここに。
              </div>
              <div className="text-4xl mb-5 font-bold">
                今すぐ参加しましょう
              </div>
              <div className="grow flex-1 flex flex-col gap-2 justify-between">
                <div className="flex flex-col gap-2">
                  <Button
                    variant="secondary"
                    className="w-[300px] rounded-full font-bold"
                  >
                    <Radius className="w-4 h-4" />
                    Google
                  </Button>
                  <Button
                    variant="secondary"
                    className="w-[300px] rounded-full font-bold"
                  >
                    <Apple className="w-4 h-4" />
                    Apple
                  </Button>
                  <Separator className="w-[300px] my-5 flex justify-center border-gray-500">
                    または
                  </Separator>
                  <Button
                    className="w-[300px] rounded-full bg-sky-500 font-bold hover:bg-sky-600"
                    onClick={openSignUp}
                  >
                    メールアドレスで登録
                  </Button>
                  <p className="text-sm text-gray-500 mb-3 w-[300px]">
                    アカウントを登録することにより、利用規約とプライバシーポリシー(Cookieの使用を含む)に同意したとみなされます。
                  </p>
                </div>
                <div className="flex flex-col mt-5">
                  <p className="text-sm text-gray-500">
                    アカウントをお持ちの方はこちら
                  </p>
                  <Button
                    variant="secondary"
                    className="w-[300px] rounded-full text-sky-500 font-bold"
                    onClick={openSignIn}
                  >
                    ログイン
                  </Button>
                </div>
              </div>
            </div>
          </div>
        </div>
        {/* フッター */}
        <div className="">
          <div className="font-sm text-gray-500">
            基本情報 Xアプリをダウンロード ヘルプセンター 利用規約
            プライバシーポリシー Cookieのポリシー アクセシビリティ 広告情報
            ブログ 採用情報 ブランドリソース 広告 マーケティング Xのビジネス活用
            開発者 プロフィール一覧 設定 © 2025 X Corp.
          </div>
        </div>
      </div>

      {/* ダイアログ */}
      <SignUpDialog
        isOpen={isOpenSignUp}
        setIsOpen={setIsOpenSignUp}
        onClose={closeSignUp}
      />

    </>
  );
}
type Props = {
  isOpen: boolean;
  setIsOpen: (isOpen: boolean) => void;
  onClose: () => void;
};

export const SignUpDialog = ({ isOpen, setIsOpen, onClose }: Props) => {
  const form = useForm<SignUpSchemaType>({
    resolver: zodResolver(SignUpSchema),
    defaultValues: {
      email: "",
      password: "",
      password_confirmation: "",
    },
  });

  const onSubmit: SubmitHandler<SignUpSchemaType> = async (data) => {
    try {
      const response = await signUp(data);
      toast.success("認証メールを送信しました");
      console.log(response);
    } catch (error) {
      console.log(error);
    }
  };

  return (
    <Dialog open={isOpen} onOpenChange={setIsOpen}>
      <DialogContent className="p-1 h-[80vh] overflow-y-auto">
        <div className="flex flex-col gap-2">
          {/* header */}
          <div className="flex justify-between items-center">
            <Button
              size="icon"
              variant="ghost"
              className="rounded-full p-1 "
              onClick={onClose}
            >
              <X className="w-10 h-10 text-foreground" />
            </Button>
            <div className="flex-1 flex justify-center">
              <Image src="/logo-black.png" alt="logo" width={30} height={30} />
            </div>
            <div></div>
          </div>
          {/* body */}
          <div className="grow flex flex-col px-10">
            <form
              onSubmit={form.handleSubmit(onSubmit)}
              className="grow flex flex-col gap-5 text-foreground"
            >
              <div className="text-3xl font-bold">アカウントを作成</div>
              <div className="grow flex flex-col justify-between gap-4">
                <div className="flex flex-col gap-8">
                  <div className="items-center gap-4">
                    <Label htmlFor="email" className="">
                      メールアドレス
                    </Label>
                    <Input
                      id="email"
                      {...form.register("email")}
                      className=""
                      placeholder="example@gmail.com"
                    />
                    {form.formState.errors.email && (
                      <p className="text-red-500">
                        {form.formState.errors.email?.message as string}
                      </p>
                    )}
                  </div>
                  <div className="items-center gap-4">
                    <Label htmlFor="password" className="">
                      パスワード
                    </Label>
                    <PasswordInput form={form} name="password" />
                  </div>
                  <div className="items-center gap-4">
                    <Label htmlFor="password_confirmation" className="">
                      確認用パスワード
                    </Label>
                    <PasswordInput form={form} name="password_confirmation" />
                  </div>
                  <div className="text-sm text-gray-500">
                    アカウントを登録することにより、利用規約とプライバシーポリシー(Cookieの使用を含む)に同意したとみなされます。
                  </div>
                </div>
                <div className="flex justify-center items-center mb-8">
                  <Button
                    className="w-[300px] rounded-full bg-sky-500 font-bold hover:bg-sky-600"
                    onClick={() => form.handleSubmit(onSubmit)}
                  >
                    アカウントを作成
                  </Button>
                </div>
              </div>
            </form>
          </div>
        </div>
      </DialogContent>
    </Dialog>
  );
};

ご参考までに!

ログイン

Rails

続いてログイン機能です。

devise_token_auth/docs/usage/README.md at master · lynndylanhurley/devise_token_auth · GitHub

公式ドキュメントを見ると下記の記載がありました。

/sign_in POST Email authentication. Requires email and password as params. This route will return a JSON representation of the User model on successful login along with the access-token and client in the header of the response.

/sign_inというパス、今回ならhttp://localhost:8000/api/v1/users/sign_inというパスに emailとpasswordをpostすれば良いそうです

でレスポンスヘッダにaccess-tokenとclientというものが添えられて帰ってくるらしいですね

やってみましょう。

ということで先ほど 登録した後の認証メールに記載されたurlをクリックしたuserでログインを行います。

ログインできました。

下側のレスポンスヘッダの部分を見てみると access-tokenとclient、expiry, uid欄に何やら値が追加されています。

毎リクエストごとにこれらを使う必要があるそうです。

devise_token_auth/docs/usage/controller_methods.md at master · lynndylanhurley/devise_token_auth · GitHub

では次にauthenticate_api_v1_user!とかいう特有のやつやったらいけるかの確認です。

下記のコントローラを作ります。

# frozen_string_literal: true

class Api::V1::HealthCheckController < ApplicationController
  before_action :authenticate_api_v1_user!
  def index
    render json: { message: 'OK', user: current_api_v1_user }, status: :ok
  end
end

route.rbはこんな

# frozen_string_literal: true

Rails.application.routes.draw do
  mount LetterOpenerWeb::Engine, at: '/letter_opener' if Rails.env.development?

  namespace :api do
    namespace :v1 do
      mount_devise_token_auth_for 'User', at: '/users'
      get 'health_check', to: 'health_check#index'
    end
  end
  resources :tasks
end

でやってみるとなんかエラーが出ました。

と。なんかエラーです。

ActionDispatch::Request::Session::DisabledSessionError (Your application has sessions disabled. To write to the session you must first configure a session store):

これはあれです。私もハマりました。

これは devise、元であるwardenはsessionを使う前提で動作しているが RailsAPIモードではセッションを扱う機能が除外されているからです。

そのため追加で設定する必要があります。

application.rbにこれを

    config.middleware.use ActionDispatch::Cookies
    config.middleware.use ActionDispatch::Session::CookieStore

application_controller.rbにこれを

  include ActionController::Cookies

追加してください。

また巷ではrack_session.rbとかいうものを作って擬似的にセッションを作って回避する?みたいなやり方もあるそうですが

こっちの方がRailsぽいし、なにぶん書いていて理解できるのでこっちにしましょう。

ということでリトライ。

行けました。

ということでbackendについては3行追加しただけですね。楽です。

フロント

ということですがこちらも作り方は特にありません。

modalを1つ作ればいいだけですね。

sign_up_modalをパクってください笑

少し変えればいけます。

終わりに

ということでおそらくdevise-token-authを使って認証をする際、最も詰まるのはbackendの設定かと思います。

ただ公式ドキュメント読んでたらいけます。

ちなみに私はこの道を3回通りました。

最初の1回が Rails x React(JavaScript)、2回目が Rails x Next.js(TypeScript)、そして3回目がこれです。

2回目までは今回と変わらない要件なのに deviseのコントローラ作っていたり、confirmation_contrllerとか作って

サーバの方でリダイレクトさせてました。まともに読めてなかったです、公式の方。

で読んだ結果、もう大丈夫になりました。基本のきのところですが。

昔より、「なぜこうする必要があるのか」ということが少しわかったような気がします。

ということで頑張っていきましょう。