見出し画像

【アプリ開発日記30週目】Django×Nextjs+グーグル認証+そのアカウントでPOSTする

 タイトルの通りすごくわがままな内容。けど、実装するまでに案外まとまった記事がなかったこともあり、ざっくりとした流れをまとめました!

※ 細かいコードなどは触れず、なるべく簡潔に、全体の流れがなんとなくわかることを目的にしています。途中で参考にさせていただいた記事を掲載しているので、実際のコードはそちらを参考にしていただければ幸いです!

 今回は日記というより、テクニカル多め。

【2022/11/12 追記】
コードをGitHubで公開しました。わかりにくいところなどがあればご連絡いただけるとありがたいです!

1,Next.js(下準備)

 この部分は今までの内容と重なり、分かりやすいサイトもたくさんあるので、さっと次に進んでいきます。

 「Django Nextjs」で調べると、UdemyやYouTube、Qiita、Zennなどで1から丁寧に説明されているチュートリアルがたくさん紹介されています。

 初めての方はブログ系やtodoアプリを入り口に3~5個ほど、1回目:見ながら/2回目:自分でもう一度、を目安に実際に作ってみてください。

今回は、DjangoとNext.jsでフォルダを分けて作成しました。

実際に行う内容は以下の通りです。

  • npx create-next-app {アプリ名}

  • npm install sass axios(sassはお好みで)

以下、tailwindcss(お好み)

  • npm install -D tailwindcss postcss autoprefixer

  • npx tailwindcss init -p

  • tailwind.config.jsファイルを書き換え

  • styles/globals.cssを書き換え

特にお気に入りのチュートリアル(Udemy ※有料)

 また、これからAPIとOAuth2.0(オーアース)に触れていきますが、「聞いたことはあるけど…」「自信ない…」という方は、ぜひ一度下の記事をご覧ください。本当の本当におすすめです!

2,Django REST API(下準備)

 このパートも短め。下準備として必要な工程だけ並べます。

  • 仮想環境作成:python -m venv venv

  • 仮想環境に入る:venv\Scripts\activate

  • pip install django django-cors-headers djangorestframework

  • pip install djangorestframework-simplejwt PyJWT dj-rest-auth django-allauth

  • プロジェクト作成:django-admin startproject testproject .

  • アプリ作成:python manage.py startapp {1つ目のアプリ名}

  • models.pyにPostなど、カスタムユーザー以外を作成

  • 併せてserializers.py、views.py、admin.py、urls.py作成・編集(※まだmigrateはしない)

  • settings.py編集(あとでまとめてでも大丈夫です…が、初めての追記箇所が複数あるため、最初は分けておくと今何をやっているか、あとで迷いにくくなると思います。私の最終版を一番下に載せておきます)

migrateして管理画面やAPIの確認をしても問題ないですが、このあと2つ目のアプリでカスタムユーザーを作るため、データベースを削除する必要があります。適当なところで次に移ってください!

続いてAPI部分。

  • 2つ目のアプリ作成(python manage.py startapp accounts)

  • models.pyにカスタムユーザーとそのmanagerを作成。今回はGoogle認証のため「image_url」(アイコン画像)も作成します。普段と異なるため、私が実際に認証と連携したときのコードを一番下にのせておきます

  • 併せてserializers.py、views.py、admin.py、urls.py作成・編集

  • makemigrations、migrate、createsuperuser

  • models.PostなどでAPI動作確認

  • settings.py編集

ここまでは単体のnext.jsとdjango apiを作っているだけで、認証とは何の関係もありません。異なる点がインストールする認証用ライブラリとsettings.py。settings.pyも一番下に載せているので、下記のチュートリアル通りやったけど分からん…!という場合は参照にしてみてください。

3,+グーグル認証

 いよいよ本題です。グーグルやTwitter、GitHubのアカウントでログインする方法をまとめて「ソーシャル認証」というそう。

 正直、今まではDjangoやJWT認証だけでも手いっぱいだったので「本当に必要なの…?」という気もしてしまいますが、今までの自分を振り返っても「グーグルでサインアップ」などの選択肢があればふと登録してしまう、なんてことも珍しくないはず。パスワードをメモする必要もないし……

 そこで、今回は「next-auth」という認証ライブラリを使っていきます。おすすめのポイントは

  • next.jsに簡単に実装できる(慣れれば10分前後)

  • グーグルを始め、多様なソーシャルログインに対応

やはりこの2点だと思います。参考資料が多いかというと「探しながら公式ドキュメントもメインのところは読む必要がある」くらいでしょうか。実装のみならともかく、後半で触れるデータベースとの接続(初回ログイン時にユーザー情報が保存されるなど)も含めると、これだ!という資料やエラーの解決を探すのも一苦労になるかもしれません。(私は何度もつまずきました、、、)

 参考にさせていただいたメインの記事を掲載します!

公式ドキュメント

Getting Startedの中の「Getting Started」全般、Providerの中の「Google」には最低限目を通しておくと、必要なコードものっているためあとで便利です。またuseContextなどと同様、

  • 「useSessionは<SessionPrivider>タグの中に入ってないと使えないよ」

ということと、pages/_app.js、useSessionを定義・ログイン/ログアウト表示をするコンポーネント1つ、「pages/api/auth/[…nextauth].js」の3ファイルを主に操作することを頭に入れておくと、全体像がつかみやすいかもしれないです。それでも最初は慣れにくいかもしれませんが……

他、Django+Next.jsにグーグル認証を実装するチュートリアルに

(グーグル認証の導入で一番お世話になりました!が、POST処理までは触れられていないのでそこから先は自力で進む必要もあります、、、POST実装までに落とし穴にたくさんハマったので、POST処理までたどり着く!というのがこの記事のきっかけでもあります)

公式ドキュメントをわかりやすく書いてくれているサイトに

要所を押さえたテンポのいい動画に(ざっくり流れつかむ用)

公式の、サインインしたときに返ってくるjsonを自分のアカウントで見れる(殺風景だけどすごく便利!)

が特におすすめです!

 初めて自分のサイトでもグーグル認証できたときは感動します。

 いきなり制作中のアプリに実装というのもごちゃごちゃになりかねないので(私は別のJWT認証と混ざって1から作り直しになりました笑)、最初は新たに「create-next-app」をやって、Django Api抜き・フロントエンドのみでなるべくシンプルな実装を試してみることを強くお勧めします…!

上記サイトでも触れられていますが、next.jsのプロジェクト直下(.gitignoreなどと同層)に「.env.local」を作成・以下のように記載することを忘れないでください。

NEXTAUTH_URL=http://127.0.0.1:3000/
DJANGO_URL=http://127.0.0.1:8000/
NEXT_PUBLIC_RESTAPI_URL=http://127.0.0.1:8000/
GOOGLE_CLIENT_ID={クライアントID}
GOOGLE_CLIENT_SECRET={クライアントシークレット}

※DJANGO_URLとNEXT_PUBLIC_RESTAPI_URL同じじゃん、となりますが、[…nextauth].js内のクライアントサイドでは「NEXT_PUBLIC_」から始まる環境変数でないと読み取れないという落とし穴があります。そのため、分けておくのが無難です。(だいぶハマりました)

 最後に、Google Cloud Consoleで取得したクライエントID・シークレットをDjango管理画面の「Social applications」にも追加→入力・保存(プロバイダーはGoogle、名前は自由。一番下のサイトをexample.comを選択し右へ移動させる。keyは空欄でOK)すれば完了です。

 このあとデータベースとの連携も行うため、私の場合の[…nextauth].jsのコードも載せておきます。あくまで参考までに…!

import NextAuth from "next-auth"
import GoogleProvider from "next-auth/providers/google"
import axios from 'axios'

export default NextAuth({
  providers: [
    GoogleProvider({
        clientId: process.env.GOOGLE_CLIENT_ID,
        clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
    // ...add more providers here
    ],
    callbacks: { // https://next-auth.js.org/configuration/callbacks
        // signIn : 
        async signIn({ user, account, profile, email, credentials }) {
            //console.log('signIn: ',user, account, profile, email, credentials);
            if (account.provider === "google") {
                //const { accessToken, idToken } = account;
                const accessToken = account.access_token;
                const idToken = account.id_token;
                //console.log('token: ',accessToken, idToken);

                try {
                    const response = await axios.post( // ※axios : DRFのAPIをたたくために使用。fetchでも可
                        `${process.env.DJANGO_URL}api/social/login/google/`, // djangoのaccounts/urls.pyで勝手に設定しているだけなので、公式に/social/login/などはない
                        {
                            access_token: accessToken, // Django apiに入るためのアクセストークンを取得
                            id_token: idToken,
                        }
                    )
                    // レスポンスからアクセストークンを取得
                    const { access_token } = response.data;
                    const { userId } = response.data.user;
                    user.accessToken = access_token;
                    user.userId = userId;
                    account.userId = userId;
                    //console.log(account);
                    //console.log('access_token: ',access_token);
                    return true;
                } catch (error) {
                    // エラーの時はDjangoのコンソールやrestAPIサイトでアクセストークンなどを入力して試すと割とはっきりした原因が返ってくる
                    //console.log('access_token: miss',error);
                    return true // Do different verification for other providers that don't have `email_verified`
                }
            }
            return false;
        },
        // jwtとsessionを組み合わせて、ログインしたユーザーの情報を取得できる(3,useSessionで取り出し)
        async jwt({ token, account }) {
            // サインイン直後にトークンへのOAuth access_tokenを永続化
            //console.log('jwt:',token,account);
            if (account) {
                token.accessToken = account.access_token // 1,account → tokenにデータを移す(最初に↑のsignInでresponse→accountに移す)
                token.userId = account.userId
            }
            return token
        },
        async session({ session, token, user }) {
            // プロバイダーからクライエント側にユーザー情報を送信
            // user: データベースを作成するとデータベースから取得したuserデータが含まれるように
            //console.log('user:',session,token,user);
            session.accessToken = token.accessToken // 2,token → sessionにデータを移す
            session.userId = token.userId
            return session
        }
    }
})

コールバックはざっくりいうと「追加処理」です。このコードではサインインと同時にユーザーIDやアクセストークンをセッションに保存しています。コールバック内の「signIn」「jwt」「session」の使い方は公式ドキュメントの「callback」に分かりやすくまとまっています!

 ちなみに「process.env.」はクライエントIDを取得したときに作成した「.env.local」内の環境変数をとってきてくれます。一度サーバーを切り、再起動させないと反映されないので注意してください! あとにも先にも落とし穴だらけ。

 この時点でグーグル認証自体は完成しているはずです!

4,Next.jsからグーグル認証したユーザー名義でPOSTする

 これが、本当に鬼門でした。完成した処理はシンプルだったので、いかに自分がチュートリアルに日和ってきたかが分かります、、、ちゃんと公式ドキュメントに目を通しましょう。

 この部分は形も今までをベースにしているため、完成形をご覧になった方が分かりやすいかもしれません。ので、index.js(フォームを表示するページ)とフォームコンポーネントの結論をそのまま載せておきます。

 特に、今回はカスタムユーザーの外部キーでデータをPOSTしているので、POSTする際も「user={id}」のようにしてください。

POSTできたデータ。ユーザーがグーグルアカウントになってる!

以下、POST通信で書き加えた部分です!

_app.js(SessionProviderの中にuseSession(セッション情報を取り出せるnext-authの機能)が入るようにするのが肝

import '../styles/globals.css'
import { SessionProvider } from 'next-auth/react'
import StateContextProvider from '../context/StateContext'

function MyApp({ Component, pageProps: { session, ...pageProps } }) {
  return (
    <SessionProvider session={session}>
      <StateContextProvider>
        <Component {...pageProps} />
      </StateContextProvider>
    </SessionProvider>
  )
}

export default MyApp

index.js(一部)

import { useSession } from 'next-auth/react'

export default function Page() {

    const { data: session } = useSession(); // sessionからデータ取得
    let userId;
    if (session) userId = session.userId;
    
    return (
        <TextForm userId={userId} data={data} />
    )
}

TextForm.js

import { useSession } from "next-auth/react";
import { useState } from "react";
import styles from '../styles/Home.module.scss'


export default function TextForm({ userId=userId,data=data }) {
    const [text, setText] = useState('');
    const { data: session } = useSession();

    const create = async (e) => {
        e.preventDefault();
        const now = new Date();
        await fetch(`${process.env.NEXT_PUBLIC_RESTAPI_URL}api/notes/`, {
            method: 'POST',
            body: JSON.stringify({ user:userId, index:data.index, phase:data.phase, note:text, created_at: now.toLocaleString().replace('/','-').replace('/','-') }),
            headers: {
                'Content-Type': 'application/json',
            },
        }).catch((error) => {
            console.log('error in TextForm create',error);
        });
        setText('');
    };
   
    return (
        <div>
            <form onSubmit={create} className='flex items-end mb-6'>
                <div className="w-full mr-3">
                    <input
                        className="h-10 px-2 py-1 bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
                        type='text'
                        value={text}
                        onChange={(e) => 
                            setText(e.target.value)
                        }
                    />
                </div>
                
                <div>
                    <button type="submit" className={`ml-2 h-10 text-lg font-bold w-20 px-1 py-1 pl-3 rounded uppercase ${styles.dialogue_btn_color}`}>
                        {'送信!'}
                    </button>
                </div>
            </form>
        </div>
    );
}

2022/12/20 追記:axios版(fetchの代替手段)

--- post.js ---

import axios from "axios";

// Django APIサーバーURL
const SERVERURL = 'http://127.0.0.1:8000/'

export async function axiosPost(url, data) {
  if (!data['user']) {
    console.log(`userIdがなかったため、保存できませんでした\n${data}`);
    return;
  }
  return new Promise(async (resolve, reject) => {
    await axios.post(`${SERVERURL}api/${url}/`, data)
    .then(res => {
      if (res.status != 201) console.log(`例外発生時の処理 : ${res.status}`);
      console.log(res.data);
      resolve(res.data);
    })
    .catch(err => {
      console.log(err.response);
      reject(err.response); // エラー時に実行
    });
  });
}
--- form.js ---

(前後は省略)

const createBook = async (e) => {
  e.preventDefault();
  const now = new Date();
  const now_str = now.toLocaleString().replace('/','-').replace('/','-'); 

  const data = { user: userId, title: title, desc: desc, status: status, tags: [1,2], updated_at: now_str, created_at: now_str };
  axiosPost('book-post', data);
};

 以上でグーグルアカウントでログインし、そのユーザーでデータを保存できるようになります。おつかれさまでした!

おわりに

 ソーシャル認証できるようになると、一気にユーザーのログイン系のハードルが下がるような気がします。それに名前や画像を登録せずとも半自動でプロフィール画面なども生成されるので、一度作ってしまえば非常に重宝する機能になるかもしれません。

 最後までご覧いただき、ありがとうございました!


APIエラーチートシート(今回用)

APIで原因不明のエラー(400など)が起きたときは 

  • とりあえずDjango rest frameworkサイトでAPIを投げてみる(ありがたいことに、エラーの詳細が表示されることも度々あります!)

  • Django/Nextjsの各コンソールを確かめる

  • fetch内に間違いがないか確認(今回の「create_at」みたいに、id以外は抜けているとエラーになっている可能性があるので特に注意)

  • CORSエラーはsettings.pyで除外しているため起きないはず。もしこのエラーが出たらsetting.pyを確認してみる

  • CSRFエラーはnext-authではデフォルトでcsrfトークンをつけてくれているため起きないはず。csrfエラーが出てきたときは「:3000」とすべきところを「:8000」としていないか確認(主に.env.local。nextjsのサーバーを再起動させないと環境変数は反映されないので、再起動も試したいところ)

おまけ:実際に使用したコード一部

settings.py追加内容


INSTALLED_APPS = [
    '{一つ目のアプリ名。'testapp' など}',
    'rest_framework',
    'corsheaders',
    'django.contrib.sites',
    'rest_framework.authtoken',
    'accounts',
    'dj_rest_auth',
    'dj_rest_auth.registration',
    'allauth',
    'allauth.account',
    'allauth.socialaccount',
    'allauth.socialaccount.providers.google',
]
# allauth Googleプロバイダー設定
# https://django-allauth.readthedocs.io/en/latest/providers.html#google
SOCIALACCOUNT_PROVIDERS={
    'google': {
        'SCOPE': [
            'profile',
            'email',
        ],
        'AUTH_PARAMS': {
            'access_type': 'online',
        }
    }
}
# allauth設定
# https://django-allauth.readthedocs.io/en/latest/configuration.html
SOCIALACCOUNT_EMAIL_VERIFICATION="none"
SOCIALACCOUNT_EMAIL_REQUIRED=False
# dj_rest_auth設定
# https://dj-rest-auth.readthedocs.io/en/latest/installation.html
SITE_ID=1
# https://dj-rest-auth.readthedocs.io/en/latest/configuration.html
REST_USE_JWT=True
REST_AUTH_SERIALIZERS={
    'USER_DETAILS_SERIALIZER': 'accounts.serializers.UserSerializer'
}
# REST Framework設定
REST_FRAMEWORK={
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated'
    ],
    'DEFAULT_AUTHENTICATION_CLASSES': (
        "rest_framework.authentication.BasicAuthentication",
        "rest_framework.authentication.SessionAuthentication",
        "dj_rest_auth.utils.JWTCookieAuthentication",
    ),
}
# Simple JWT設定
# https://django-rest-framework-simplejwt.readthedocs.io/en/latest/settings.html
SIMPLE_JWT={
    'AUTH_HEADER_TYPES': ('JWT',),
    'ACCESS_TOKEN_LIFETIME': timedelta(days=7),
    'ROTATE_REFRESH_TOKENS': True,
    'BLACKLIST_AFTER_ROTATION': True,
    'UPDATE_LAST_LOGIN': True,
    "USER_ID_FIELD": "userId",
    "USER_ID_CLAIM": "user_id",
}
# 認証モデル設定
AUTH_USER_MODEL='accounts.CustomUser'


MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware', # 追加(一番上)
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

CORS_ORIGIN_WHITELIST=[
    "http://localhost:3000",
    "http://127.0.0.1:3000",
]

accounts/models.py(カスタムユーザー)

from django.db import models
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
from uuid import uuid4
from django.utils import timezone


class CustomUserManager(BaseUserManager):
	
	use_in_migrations = True

	def _create_user(self, request_data, password, **extra_fields):
		if not request_data['email']:
			raise ValueError('emailを入力してください')
		if not request_data['username']:
			raise ValueError('usernameを入力してください')
		email = self.normalize_email(request_data['email'])
		user = self.model(
			username=request_data['username'],
			email=email,
			image_url=request_data['image_url'],
			**extra_fields
		)
		user.set_password(password)
		user.save(using=self.db)
		return user

	def create_user(self, request_data, password=None, **extra_fields):
		extra_fields.setdefault('is_staff', False)
		extra_fields.setdefault('is_superuser', False)
		return self._create_user(request_data, password, **extra_fields)
	
	def create_superuser(self, username, email, password, **extra_fields):
		extra_fields.setdefault('is_staff', True)
		extra_fields.setdefault('is_superuser', True)
		if extra_fields.get('is_staff') is not True:
			raise ValueError('staffがTrueではないです')
		if extra_fields.get('is_superuser') is not True:
			raise ValueError('is_superuserがTrueではないです')
		if not email:
			raise ValueError('emailを入力してください')
		if not username:
			raise ValueError('usernameを入力してください')
		email = self.normalize_email(email)
		user = self.model(username=username, email=email, **extra_fields)
		user.set_password(password)
		user.save(using=self.db)
		return user


class CustomUser(AbstractBaseUser, PermissionsMixin):
    userId = models.CharField(max_length=255, default=uuid4, primary_key=True, editable=False)
    username = models.CharField('名前', max_length=255, unique=True)
    email = models.EmailField('メールアドレス', unique=True)
    image_url = models.URLField('imageUrl', blank=True, max_length=200)
    date_joined = models.DateTimeField('date_joined', default=timezone.now)

    is_staff = models.BooleanField(default=False)
    is_active = models.BooleanField(default=True)

    objects = CustomUserManager()

    USERNAME_FIELD = 'username'
    EMAIL_FIELD = 'email'
    REQUIRED_FIELDS = ['email']

    def __str__(self):
        return 'id: '+self.userId+' '+self.email

accounts/admin.py(カスタムユーザー)

from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
from django.utils.translation import gettext_lazy as _
from .models import CustomUser

class CustomUserChangeForm(UserChangeForm):
	class Meta:
		model = CustomUser
		fields = '__all__'

class CustomUserCreationForm(UserCreationForm):
	class Meta:
		model = CustomUser
		fields = ('username', 'email',)

class CustomUserAdmin(UserAdmin):
	fieldsets = (
		(None, {'fields': ('username', 'email', 'password', 'image_url')}),
		(_('Permissions'), {
			'fields': (
				'is_active',
				'is_staff',
				'is_superuser',
				'groups',
				'user_permissions'
			)
		}),
		(_('Important dates'), {'fields': ('last_login', 'date_joined')}),
	)

	add_fieldsets = (
		(None, {
			'classes': ('wide',),
			'fields': ('username', 'email', 'password1', 'password2'),
		}),
	)

	change_form = CustomUserChangeForm
	add_form = CustomUserCreationForm
	list_display = ('username', 'email', 'is_staff')
	list_filter = ('is_staff', 'is_superuser', 'is_active', 'groups')
	search_fields = ('email',)
	ordering = ('email',)


admin.site.register(CustomUser, CustomUserAdmin)


この記事が気に入ったらサポートをしてみませんか?