Terakhir diperbarui: 2024-05-17

Penulis: Tim PBF 2024

Authentication di ReactJS dengan Next.js

Pada codelab ini Anda akan mempelajari tentang authentication di ReactJS dengan Next.js.

Pengetahun yang Anda harus miliki

Sebelum memulai codelab ini, sebaiknya Anda memiliki pengetahuan dasar tentang:

Apa yang Anda akan pelajari

Apa yang Anda perlu persiapkan

Otentikasi (authentication) adalah bagian penting dari banyak aplikasi web saat ini. Begitulah cara sistem memeriksa apakah pengguna memang sesuai dengan yang sebenarnya atau bukan.

Situs web yang aman sering kali menggunakan berbagai cara untuk memeriksa identitas pengguna. Misalnya, setelah memasukkan nama pengguna dan kata sandi, situs mungkin mengirimkan kode verifikasi ke perangkat Anda atau menggunakan aplikasi eksternal seperti Google Authenticator. Otentikasi 2 faktor (2FA) ini membantu meningkatkan keamanan. Meskipun seseorang mengetahui kata sandi Anda, mereka tidak dapat mengakses akun Anda tanpa token unik Anda.

Authentication vs. Authorization

Dalam pengembangan web, otentikasi dan otorisasi mempunyai peran yang berbeda:

Jadi, autentikasi memeriksa siapa Anda, dan otorisasi menentukan apa yang dapat Anda lakukan atau akses dalam aplikasi.

Pada praktikum ini, Anda dapat melanjutkan project dari Codelab #09 sebelumnya. Silakan lakukan langkah-langkah praktikum berikut ini.

Langkah 1: Buat halaman Login

Buat route baru dengan nama /login dan paste kode berikut:

/app/login/page.tsx

import AcmeLogo from '@/app/ui/acme-logo';
import LoginForm from '@/app/ui/login-form';
 
export default function LoginPage() {
  return (
    <main className="flex items-center justify-center md:h-screen">
      <div className="relative mx-auto flex w-full max-w-[400px] flex-col space-y-2.5 p-4 md:-mt-32">
        <div className="flex h-20 w-full items-end rounded-lg bg-blue-500 p-3 md:h-36">
          <div className="w-32 text-white md:w-36">
            <AcmeLogo />
          </div>
        </div>
        <LoginForm />
      </div>
    </main>
  );
}

Anda akan melihat error pada impor LoginForm dan AcmeLogo, yang akan Anda perbarui nanti di akhir praktikum ini.

NextAuth.js

Kita akan menggunakan NextAuth.js untuk menambahkan otentikasi ke aplikasi Anda. NextAuth.js mengabstraksi sebagian besar kerumitan yang terlibat dalam pengelolaan sesi, proses masuk dan keluar, serta aspek autentikasi lainnya. Meskipun Anda dapat menerapkan fitur-fitur ini secara manual, prosesnya dapat memakan waktu dan rawan kesalahan. NextAuth.js menyederhanakan proses, memberikan solusi terpadu untuk autentikasi di aplikasi Next.js.

Langkah 2: Konfigurasi NextAuth.js

Install NextAuth versi terakhir dengan cara running perintah berikut di terminal:

npm i --save next-auth@beta

Langkah 3: Generate secret key

Selanjutnya, kita generate secret key. Ini digunakan untuk enkripsi cookies, memastikan keamanan terhadap session pengguna. Anda dapat menjalankan perintah berikut.

openssl rand -base64 32

Jika di komputer Anda belum terpasang openssl, Anda dapat menggunakan versi online di https://generate-random.org/encryption-key-generator

Kemudian tambahkan baris kode berikut pada file .env dan paste secret key tersebut.

AUTH_SECRET="your-secret-key"

Langkah 4: Tambah auth.config

Buat file auth.config.ts pada root project Anda. File ini berisi konfigurasi untuk NextAuth.js.

/auth.config.ts

import type { NextAuthConfig } from 'next-auth';
 
export const authConfig = {
  pages: {
    signIn: '/login',
  },
};

Anda dapat menggunakan opsi pages untuk menentukan route custom seperti login, logout, dan halaman error. Hal ini tidak wajib, tetapi dengan menambahkan route pada signIn: '/login' ke dalam opsi pages, pengguna akan diarahkan ke halaman login yang dibuat custom, bukan halaman default NextAuth.js.

Langkah 5: Melindungi route dengan Middleware

Selanjutnya, tambahkan logic untuk melindungi route Anda. Ini akan mencegah user mengakses halaman beranda kita kecuali user telah melakukan login.

/auth.config.ts

import type { NextAuthConfig } from 'next-auth';
 
export const authConfig = {
  pages: {
    signIn: '/login',
  },
  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user;
      const isOnDashboard = nextUrl.pathname.startsWith('/');
      if (isOnDashboard) {
        if (isLoggedIn) return true;
        return false; // Redirect unauthenticated users to login page
      } else if (isLoggedIn) {
        return Response.redirect(new URL('/', nextUrl));
      }
      return true;
    },
  },
  providers: [], // Add providers with an empty array for now
} satisfies NextAuthConfig;

Callback authorized digunakan untuk memverifikasi apakah permintaan diotorisasi untuk mengakses halaman melalui Next.js Middleware. Itu dipanggil sebelum permintaan diselesaikan, dan menerima objek dengan properti auth dan request. Properti auth berisi sesi pengguna, dan properti request berisi permintaan masuk.

Bagian providers adalah sebuah larik tempat Anda mencantumkan opsi login yang berbeda. Untuk saat ini, bagian larik ini biarkan kosong untuk memenuhi konfigurasi NextAuth. Anda akan mempelajarinya lebih lanjut setelah langkah ini.

Selanjutnya, Anda perlu mengimpor objek authConfig ke dalam file Middleware. Di root proyek Anda, buat file bernama middleware.ts dan ketik kode seperti berikut:

/middleware.ts

import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
 
export default NextAuth(authConfig).auth;
 
export const config = {
  // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};

Di sini Anda menginisialisasi NextAuth.js dengan objek authConfig dan mengekspor properti auth. Anda juga menggunakan opsi matcher dari Middleware untuk menentukan bahwa opsi tersebut harus dijalankan pada jalur tertentu.

Keuntungan menggunakan Middleware adalah rute yang dilindungi tidak akan mulai dirender sampai Middleware memverifikasi otentikasi, sehingga dapat meningkatkan keamanan dan kinerja aplikasi Anda.

Langkah 6: Password hashing

Merupakan praktik yang baik untuk meng-hash kata sandi sebelum menyimpannya ke dalam database. Hashing mengubah kata sandi menjadi serangkaian karakter dengan panjang tetap, yang tampak acak, memberikan lapisan keamanan bahkan jika data pengguna terekspos.

Di file src/seeder/seed.js pada project codelab sebelumnya, Anda menggunakan paket bernama bcrypt untuk meng-hash kata sandi pengguna sebelum menyimpannya ke database. Anda akan menggunakannya lagi nanti pada praktikum ini untuk membandingkan bahwa kata sandi yang dimasukkan oleh pengguna cocok dengan yang ada di database. Namun, Anda perlu membuat file terpisah untuk paket bcrypt. Ini karena bcrypt bergantung pada API Node.js yang tidak tersedia di Middleware Next.js.

Buat file baru bernama auth.ts pada root project dengan berisi kode berikut:

import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
 
export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
});

Langkah 7: Tambahkan Provider Credentials

Selanjutnya, Anda perlu menambahkan providers untuk NextAuth.js. Providers adalah larik tempat Anda mencantumkan opsi masuk yang berbeda seperti Google atau GitHub. Untuk praktikum ini, kami akan fokus menggunakan Credentials provider saja.

Credentials provider memungkinkan pengguna untuk masuk dengan nama pengguna dan kata sandi. Tambahkan kode pada file auth.ts di baris 3 dan 7 seperti berikut.

Langkah 8: Tambahkan fungsi sign in

Install dependensi yang dibutuhkan dengan perintah berikut di terminal.

npm i --save zod

Anda dapat menggunakan fungsi authorize untuk menangani logika otentikasi. Mirip dengan Server Actions, Anda dapat menggunakan library zod untuk memvalidasi penulisan email dan kata sandi sebelum memeriksa apakah pengguna ada di database. Tambahkan kode pada baris 4 dan baris 9-15 seperti berikut:

Setelah memvalidasi kredensial, buat fungsi getUser baru yang dapat melakukan query pengguna dari database.

Lalu, panggil bcrypt.compare untuk memeriksa apakah kata sandinya cocok:

Terakhir, jika kata sandi cocok, kembalikan pengguna, jika tidak, kembalikan nilai null untuk mencegah pengguna masuk.

Melanjutkan dari praktikum sebelumnya, sekarang Anda perlu persiapkan untuk tampilan form login dan fungsi sign in. Silakan lakukan langkah-langkah berikut ini.

Langkah 1: Tambah fungsi actions authenticate

Sekarang Anda perlu menghubungkan logika autentikasi dengan formulir login Anda. Di file src/model/actions.tsx Anda, buat action baru yang disebut authenticate. Actions ini harus mengimpor fungsi signIn dari file auth.ts:

import { signIn } from '@/auth';
import { AuthError } from 'next-auth';
 
// ...
 
export async function authenticate(
  prevState: string | undefined,
  formData: FormData,
) {
  try {
    await signIn('credentials', formData);
  } catch (error) {
    if (error instanceof AuthError) {
      switch (error.type) {
        case 'CredentialsSignin':
          return 'Invalid credentials.';
        default:
          return 'Something went wrong.';
      }
    }
    throw error;
  }
}

Jika terjadi error 'CredentialsSignin', Anda dapat menampilkan pesan error yang sesuai. Anda dapat mempelajari error NextAuth.js dengan akses dokumentasi.

Langkah 2: Buat file login-form.tsx

Berikutnya, buatlah file baru pada path src/app/components/molecules/login-form.tsx, Anda dapat menggunakan useFormState React untuk memanggil action server dan menangani error pada login form, dan menggunakan useFormStatus untuk menangani state pada login form:

'use client';

import { lusitana } from '@/app/components/atoms/fonts';
import {
    AtSymbolIcon,
    KeyIcon,
    ExclamationCircleIcon,
} from '@heroicons/react/24/outline';
import { ArrowRightIcon } from '@heroicons/react/20/solid';
import { Button } from '@/app/components/atoms/button';
import { useFormState, useFormStatus } from 'react-dom';
import { authenticate } from '@/model/actions';

export default function LoginForm() {
    const [errorMessage, dispatch] = useFormState(authenticate, undefined);

    return (
        <form action={dispatch} className="space-y-3">
            <div className="flex-1 rounded-lg bg-gray-50 px-6 pb-4 pt-8">
                <h1 className={`${lusitana.className} mb-3 text-2xl`}>
                    Please log in to continue.
                </h1>
                <div className="w-full">
                    <div>
                        <label
                            className="mb-3 mt-5 block text-xs font-medium text-gray-900"
                            htmlFor="email"
                        >
                            Email
                        </label>
                        <div className="relative">
                            <input
                                className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
                                id="email"
                                type="email"
                                name="email"
                                placeholder="Enter your email address"
                                required
                            />
                            <AtSymbolIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
                        </div>
                    </div>
                    <div className="mt-4">
                        <label
                            className="mb-3 mt-5 block text-xs font-medium text-gray-900"
                            htmlFor="password"
                        >
                            Password
                        </label>
                        <div className="relative">
                            <input
                                className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
                                id="password"
                                type="password"
                                name="password"
                                placeholder="Enter password"
                                required
                                minLength={6}
                            />
                            <KeyIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
                        </div>
                    </div>
                </div>
                <LoginButton />
                <div
                    className="flex h-8 items-end space-x-1"
                    aria-live="polite"
                    aria-atomic="true"
                >
                    {errorMessage && (
                        <>
                            <ExclamationCircleIcon className="h-5 w-5 text-red-500" />
                            <p className="text-sm text-red-500">{errorMessage}</p>
                        </>
                    )}
                </div>
            </div>
        </form>
    );
}

function LoginButton() {
    const { pending } = useFormStatus();

    return (
        <Button className="mt-4 w-full" aria-disabled={pending}>
            Log in <ArrowRightIcon className="ml-auto h-5 w-5 text-gray-50" />
        </Button>
    );
}

Buat komponen atom button dengan kode berikut:

src\app\components\atoms\button.tsx

import clsx from 'clsx';

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
    children: React.ReactNode;
}

export function Button({ children, className, ...rest }: ButtonProps) {
    return (
        <button
            {...rest}
            className={clsx(
                'flex h-10 items-center rounded-lg bg-blue-500 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 active:bg-blue-600 aria-disabled:cursor-not-allowed aria-disabled:opacity-50',
                className,
            )}
        >
            {children}
        </button>
    );
}

Pada praktikum ini, Anda akan menambahkan fungsi Logout, silakan lakukan langkah-langkah berikut ini.

Langkah 1: Buat komponen atom sidenav

Untuk menambahkan fungsionalitas logout ke SideNav, panggil fungsi signOut dari auth.ts di elemen form Anda seperti kode berikut:

src\app\components\atoms\sidenav.tsx

import Link from 'next/link';
import NavLinks from './nav-links';
import AcmeLogo from './acme-logo';
import { PowerIcon } from '@heroicons/react/24/outline';
import { signOut } from '../../../../auth';

export default function SideNav() {
  return (
    <div className="flex h-full flex-col px-3 py-4 md:px-2">
      <Link
        className="mb-2 flex h-20 items-end justify-start rounded-md bg-blue-600 p-4 md:h-40"
        href="/"
      >
        <div className="w-32 text-white md:w-40">
          <AcmeLogo />
        </div>
      </Link>
      <div className="flex grow flex-row justify-between space-x-2 md:flex-col md:space-x-0 md:space-y-2">
        <NavLinks />
        <div className="hidden h-auto w-full grow rounded-md bg-gray-50 md:block"></div>
        <form
          action={async () => {
            'use server';
            await signOut();
          }}
        >
          <button className="flex h-[48px] w-full grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3">
            <PowerIcon className="w-6" />
            <div className="hidden md:block">Sign Out</div>
          </button>
        </form>
      </div>
    </div>
  );
}

Langkah 2: Buat komponen atom nav-links

Selanjutnya buat komponen atom nav-links agar kode di langkah 1 tidak error.

src\app\components\atoms\nav-links.tsx

'use client';

import {
    UserGroupIcon,
    HomeIcon,
    DocumentDuplicateIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import clsx from 'clsx';

// Map of links to display in the side navigation.
// Depending on the size of the application, this would be stored in a database.
const links = [
    { name: 'Home', href: '/', icon: HomeIcon },
    {
        name: 'Invoices',
        href: '/invoices',
        icon: DocumentDuplicateIcon,
    },
    { name: 'Customers', href: '/customers', icon: UserGroupIcon },
];

export default function NavLinks() {
    const pathname = usePathname();

    return (
        <>
            {links.map((link) => {
                const LinkIcon = link.icon;
                return (
                    <Link
                        key={link.name}
                        href={link.href}
                        className={clsx(
                            'flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3',
                            {
                                'bg-sky-100 text-blue-600': pathname === link.href,
                            },
                        )}
                    >
                        <LinkIcon className="w-6" />
                        <p className="hidden md:block">{link.name}</p>
                    </Link>
                );
            })}
        </>
    );
}

Langkah 3: Buat komponen atom acme-logo

Langkah berikutnya buatlah komponen atom acme-logo dengan kode berikut:

src\app\components\atoms\acme-logo.tsx

import { GlobeAltIcon } from '@heroicons/react/24/outline';
import { lusitana } from './fonts';

export default function AcmeLogo() {
  return (
    <div
      className={`${lusitana.className} flex flex-row items-center leading-none text-white`}
    >
      <GlobeAltIcon className="h-12 w-12 rotate-[15deg]" />
      <p className="text-[44px] ">Acme</p>
    </div>
  );
}

Langkah 4: Eksperimen

Sekarang, cobalah. Anda harus dapat masuk dan keluar dari aplikasi Anda menggunakan kredensial berikut:

Tambahkan fungsi authentication dan authorization pada project kelompok UAS Anda !

Selamat, Anda telah berhasil menyelesaikan codelab ini. Semoga mendapatkan ilmu yang bermanfaat.

Referensi