使用 Next-auth 給 Next-js 應用添加鑑權與認證

前言

在系統中要實現身份驗證是一件比較麻煩的事情,比如集成郵箱登錄,手機號登錄,以及其他第三方登錄等,但是有了 NextAuth.js[1],一切就變得簡單。正如官網說的添加身份驗證,只要幾分鐘就可以實現。在上一篇文章中,我們使用 prisma 和 Next.js,創建了一個視頻網站,但我們還沒有實現用戶的註冊與登錄,本文將繼續開發視頻網站,實現郵箱登錄、 Github 授權登錄,以及密碼登錄。那麼,一起來看看吧!

文中涉及代碼全部託管在 GitHub 倉庫 [2] 中。

Next.js 應用接入 NextAuth

NextAuth.js 是 Next.js 應用程序的完整開源身份驗證解決方案,專門爲 Next.js 設計,NextAuth 的特點:

  1. 靈活且易於使用,支持 OAuth1.0 OAuth2.0 和 OpenId 鏈接;

  2. 靈活數據管理,可以不使用數據庫,也可以選擇使用 MySQL, MariaDB, Postgres, SQL Server, MongoDB 以及 SQLite。

  3. 默認安全,默認 Cookie 機制,可開啓 JSON Web Token;

  4. NextAuth 推進無密碼的登錄機制

  5. 支持 serverless 部署

安裝

首先我們使用 yarn 安裝 NextAuth.js

yarn add next-auth

授權 api

要通過 NextAuth.js 獲得授權, 需要先創建一個pages/api/auth/[...nextauth].ts 文件,它包含了所有全局 NextAuth.js 配置。

import NextAuth from "next-auth"

import GithubProvider from "next-auth/providers/github"



export const authOptions = {

  // 在 providers 中配置更多授權服務

  providers: [

    GithubProvider({

      clientId: process.env.GITHUB_ID,

      clientSecret: process.env.GITHUB_SECRET,

    }),

    // ...add more providers here

  ],

}



export default NextAuth(authOptions)

我們先添加一種授權登錄方式,首先是使用 GITHUB 登錄

Github 授權流程

Github 授權流程

我之前使用過 Nodejs 集成 Github OAuth 流程,大致要分爲以上 6 個步驟,需要寫不少代碼和接口,但使用了 Next-auth.js, 就可以非常輕鬆的集成到我們的應用中,幾乎不用寫代碼。

註冊 GitHub OAuth Application

環境變量可以在 Github 開發者中申請,點擊註冊一個新 OAuth Application:

註冊 GitHub OAuth Application

回調地址填http://localhost:3000/api/auth/callback/github

地址可以先填開發環境地址,待上前線前可以修改爲正式域名地址,或者開發環境和生產環境單獨申請。

複製 GITHUB_ID

註冊成功過後,在頁面上覆制 Client IDClient secrets.env 文件中

GITHUB_ID=你註冊的 GITHUB_ID

GITHUB_SECRET=你註冊的 GITHUB_SECRET

配置 pages/_app.ts

爲了讓所有頁面能夠獲取到 Session, 我們需要在 pages/_app.ts 外層加SessionProvider

import { SessionProvider } from "next-auth/react"

export default function App({

  Component,

  pageProps: { session, ...pageProps },

}) {

  return (

    <SessionProvider session={session}>

      <Component {...pageProps} />

    </SessionProvider>

  )

}

客戶端獲取登錄信息

然後我們就可以創建一個登錄組件components/login-btn.tsx

import { useSession, signIn, signOut } from "next-auth/react"



export default function Component() {

  const { data: session } = useSession()

  if (session) {

    return (

    <>

       <span class>session.user.email</span>

        <button onClick={() => signOut()}>登出</button>

      </>

    )

  }

  return (

      <button onClick={() => signIn()}>登錄</button>

  )

}

在首頁引用登錄組件,就可以使用 GITHUB 來登錄了,一起看來看看效果吧。

GITHUB 授權登錄成功

注意:有時候會因爲網絡問題, GitHub 無法登錄。我們可以設置 NextAuthOptionsdebugtrue,會在控制檯看到以下錯誤信息:

GITHUB 授權登錄超時

原因是訪問 GitHub 需要代理,需要將代理設置爲全局模式,並且設置請求 timeout 時間,將超時時間延長。

GithubProvider({

  clientId: process.env.GITHUB_ID,

  clientSecret: process.env.GITHUB_SECRET,

  httpOptions: {

    timeout: 50000,

  },

}),

登錄成功後,我們看下頁面打印出來的數據,包含 GitHub 登錄賬戶的基本信息。

通過控制檯我們可以發現,useSession 其實就是訪問了http://localhost:3000/api/auth/session接口獲取信息,這部分是在客戶端實現的,那麼在服務端可以獲取到用戶授權信息嗎?

SSR 頁面獲取登錄信息

回到我們要開發的視頻網站,還缺少個人視頻管理頁面,這個頁面必須是當前登錄用戶才能訪問,沒授權,是不能訪問的。

新建pages/me.tsx,用於用戶管理自己的視頻。

import { authOptions } from "@/pages/api/auth/[...nextauth]";

import { unstable_getServerSession } from "next-auth/next";



export default function Page() {

  return <div>個人中心</div>;

}



export async function getServerSideProps(context) {

  const session = await unstable_getServerSession(

    context.req,

    context.res,

    authOptions

  );



  if (!session) {

    return {

      redirect: {

        destination: "/",

        permanent: false,

      },

    };

  }



  return {

    props: {

      session,

    },

  };

}

此時訪問 http://localhost:3000/me 若沒有授權登錄,則將自動跳轉到首頁。

看打印出的session值,其中沒有 Userid,而我們的視頻表關聯的是 UserId ,因此我們需要將用戶的授權信息同步到我們的數據表中。

Prisma 適配

next-auth.js 爲 prisma 提供了適配器,我們只需要按官網給出的步驟依次執行

  1. 安裝 prisma 適配器
yarn add @next-auth/prisma-adapter
  1. 在 NextAuth.js 配置 prisma 適配器
import NextAuth, { NextAuthOptions } from "next-auth";

import EmailProvider from "next-auth/providers/email";

import GithubProvider from "next-auth/providers/github";

import prisma from "@/lib/prisma";

+ import { PrismaAdapter } from "@next-auth/prisma-adapter";



export const authOptions: NextAuthOptions = {

  //debug: true,

+  adapter: PrismaAdapter(prisma),

  providers: [

    // OAuth authentication providers...

    GithubProvider({

      clientId: process.env.GITHUB_ID,

      clientSecret: process.env.GITHUB_SECRET,

    }),

  ],

+  callbacks: {

+    session: async ({ session, token, user }) => {

+      if (session?.user) {

+        session.user.id = user.id;

+      }

+      return session;

+    },

+  },

};



export default NextAuth(authOptions);

添加 callbacks 函數,將 user id 賦值給 session 中的 user id,方便後面接口中可以直接獲取用戶 id

  1. 添加 prisma Schema 中添加模型
model Account {

  id                 String  @id @default(cuid())

  userId             String

  type               String

  provider           String

  providerAccountId  String

  refresh_token      String?  @db.Text

  access_token       String?  @db.Text

  expires_at         Int?

  token_type         String?

  scope              String?

  id_token           String?  @db.Text

  session_state      String?



  user User @relation(fields: [userId], references: [id], onDelete: Cascade)



  @@unique([provider, providerAccountId])

}



model Session {

  id           String   @id @default(cuid())

  sessionToken String   @unique

  userId       String

  expires      DateTime

  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)

}



model User {

  id            String    @id @default(cuid())

  name          String?

  email         String?   @unique

  emailVerified DateTime?

  image         String?

  accounts      Account[]

  sessions      Session[]

}



model VerificationToken {

  identifier String

  token      String   @unique

  expires    DateTime



  @@unique([identifier, token])

}

當我們將這些模型粘貼到 Schema 後,會看到 VSCode 中有錯誤提示

Prisma Schema 錯誤提示

原因是我們之前設計的用戶表 idInt 類型,跟當前的 Sring 類型不匹配,解決辦法是將 Int 改成 String,最好的做法是所有表中的 id 類型改成統一。

  1. 遷移 Schema,生成表
npx prisma migrate dev

執行完成後,我們刷新頁面,重新登錄頁面,來看下效果

session 包含 userId

session 中已經有了 id,這裏我測試了下,將我 Github 默認郵箱改成另一個,也不會影響註冊用戶表中的信息,因爲 Account 表中的唯一值是provider + providerAccountId

服務端渲染我的視頻

session 中可以獲取 userId,那麼我們就可以在 getServerSideProps 獲取當前用戶的視頻了。

export async function getServerSideProps(context) {

  const session = await unstable_getServerSession(

    context.req,

    context.res,

    authOptions

  );



  if (!session) {

    return {

      redirect: {

        destination: "/",

        permanent: false,

      },

    };

  }



  const data = await prisma.video.findMany({

    where: {

      authorId: session.user.id,

    },

    include: { author: true },

  });



  return {

    props: {

      session,

      data: makeSerializable(data),

    },

  };

}

這裏有個問題,當我們獲取 user.id 的時候, typescript 會提示錯誤,因爲默認的 User 類型中是不包含 id

TS 校驗提示

所以我們需要重寫下 next-auth 中 Session 的接口,新建 types/next-auth.d.ts 輸入以下代碼,就可以繼承默認的 Session TS 類型接口了

import NextAuth, { DefaultSession } from "next-auth";



declare module "next-auth" {

  interface Session {

    user: {

      id: string;

    } & DefaultSession["user"];

  }

}

添加完成後,在頁面中使用 useSession, unstable_getServerSession 等獲取到的 Session 不會 TS 類型報錯了。

郵箱授權登錄

有了 Github 授權登錄,並且關聯了數據庫,那要加上郵箱授權登錄,便是輕而易舉。

首先安裝 nodemailer,用於 Node.js 發送郵件

yarn add nodemailer

然後在 pages/api/auth/[...nextauth].ts引入並且配置 EmailProvider

import EmailProvider from "next-auth/providers/email";



export const authOptions: NextAuthOptions = {

  //debug: true,

  adapter: PrismaAdapter(prisma),

  providers: [

    EmailProvider({

      server: process.env.EMAIL_SERVER,

      from: process.env.EMAIL_FROM,

      //maxAge: 24 * 60 * 60, // 設置郵箱鏈接失效時間,默認24小時

    }),

    // OAuth authentication providers...

    GithubProvider({

      clientId: process.env.GITHUB_ID,

      clientSecret: process.env.GITHUB_SECRET,

    }),

  ],

  // ...

}

然後在 .env 文件中配置環境變量

EMAIL_SERVER=smtp://username:password@smtp.example.com:587

EMAIL_FROM=NextAuth <noreply@example.com>

這裏的 EMAIL_SERVER 中的 username 就是發件郵箱的賬號,而 password 並不是郵箱密碼,需要在郵箱設置中開啓,這裏我以 163 郵箱爲例

163 郵箱設置

登錄郵箱後,在郵箱設置中開啓 POP3/SMTP/IMAP 服務,點擊開啓,這裏會需要短信驗證,驗證會有一個授權密碼,這個授權碼就是 password, 最後面的服務地址和端口需要根據你最終選擇的 POP3/SMTP/IMAP 服務來配置,下圖是 126 郵箱的服務器配置。

126 郵箱服務器信息

配置完成後刷新瀏覽器就可以使用郵箱來完成登錄了,登錄的郵箱賬號不能是發送郵件服務的賬號,比如我設置的是發送郵件服務是 163 郵箱,那我註冊的時候使用 QQ 郵箱。

使用郵箱登錄界面

點擊 “sign in with Email” 後,你就會收到如下郵件,在郵箱中點擊鏈接,便會自動授權登錄成功。

收到默認郵件模板

登錄成功後的,Session 中的信息跟我 Github 賬號登錄的信息是一致的,因爲在數據庫中,郵箱地址是唯一值。

郵箱登錄成功

更改郵件模板

有些同學會說,發送的郵件主題太醜了,我們可以定製嗎?

放心,Next-auth 幫我們考慮到了 , EmailProvider 支持自定義模板,我們需要配置 sendVerificationRequest 函數

import EmailProvider from "next-auth/providers/email";

...

providers: [

  EmailProvider({

    server: process.env.EMAIL_SERVER,

    from: process.env.EMAIL_FROM,

    sendVerificationRequest({

      identifier: email,

      url,

      provider: { server, from },

    }) {

      /* your function */

    },

  }),

]

郵件模板函數可能會很大,可以將 sendVerificationRequest 提取爲單獨文件,然後再引入;

import { createTransport } from "nodemailer";

import { SendVerificationRequestParams } from "next-auth/providers/email";

import { Theme } from "next-auth";



export async function sendVerificationRequest(

  params: SendVerificationRequestParams

) {

  const { identifier, url, provider, theme } = params;

  const { host } = new URL(url);

  const transport = createTransport(provider.server);

  const result = await transport.sendMail({

    to: identifier,

    from: provider.from,

    subject: `${host} 註冊認證`,

    text: text({ url, host }),

    html: html({ url, host, theme }),

  });

  const failed = result.rejected.concat(result.pending).filter(Boolean);

  if (failed.length) {

    throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`);

  }

}



/**

 *使用HTML body 代替正文內容

 */

function html(params: { url: string; host: string; theme: Theme }) {

  const { url, host, theme } = params;

  //由於使用

  const escapedHost = host.replace(/\./g, "​.");



  return `

<body>

  <div>歡迎註冊${escapedHost},點擊<a href="${url}" target="_blank">登錄</a></div>

</body>

`;

}



/** 不支持HTML 的郵件客戶端會顯示下面的文本信息 */

function text({ url, host }: { url: string; host: string }) {

  return `歡迎註冊 ${host}\n點擊${url}登錄\n\n`;

}

當然這裏我簡化了模板代碼, 在真實場景中,我們也可以替換 HTML 文件來實現。

密碼登錄

密碼登錄 Next-auth 是不鼓勵使用的,因爲與密碼相關的固有安全風險以及與支持用戶名和密碼具有額外複雜性。

使用密碼登錄需要使用 CredentialsProvider

import NextAuth, { NextAuthOptions } from "next-auth";

import CredentialsProvider from "next-auth/providers/credentials";

import prisma from "@/lib/prisma";

import { PrismaAdapter } from "@next-auth/prisma-adapter";



export const authOptions: NextAuthOptions = {

  //debug: true,

  adapter: PrismaAdapter(prisma),

  providers: [

    CredentialsProvider({

      // 登錄按鈕顯示 (e.g. "Sign in with Credentials")

      name: "Credentials",

      // credentials 用於配置登錄頁面的表單

      credentials: {

       email: {

          label: "郵箱",

          type: "text",

          placeholder: "請輸入郵箱",

        },

        password: {

          label: "密碼",

          type: "password",

          placeholder: "請輸入密碼",

        },

      },

      async authorize(credentials, req) {

        console.log(credentials);

        // TODO

        // const maybeUser= await prisma.user.findFirst({where:{

        //   email: credentials.email,

        //  }})



        // 根據 credentials 我們查詢數據庫中的信息

        const user = {

          id: "1",

          name: "xiaoma",

          email: "xiaoma@example.com",

        };



        if (user) {

          // 返回的對象將保存才JWT 的用戶屬性中

          return user;

        } else {

          // 如果返回null,則會顯示一個錯誤,建議用戶檢查其詳細信息。

          return null;

          // 跳轉到錯誤頁面,並且攜帶錯誤信息 http://localhost:3000/api/auth/error?error=用戶名或密碼錯誤

          //throw new Error("用戶名或密碼錯誤");

        }

      },

    }),

  ],

  session: {

    strategy: "jwt",

  },

  jwt: {

    secret: "test",

  },

  callbacks: {

    async jwt({ token, user, account, profile, isNewUser }) {

      if (user) {

        token.id = user.id;

      }

      return token;

    },

    session: async ({ session, token, user }) => {

      if (session?.user && token) {

        session.user.id = token.id as string;

      }

      return session;

    },

  },

};



export default NextAuth(authOptions);

上面代碼中,我們首先需要開啓 JWT 模式,在 authorize 方法中我們可以根據用戶所填的表單信息進行數據庫查詢,由於我們的數據庫中沒有密碼字段,所以上面的代碼中直接返回了一個固定 user 信息,那真實的流程應該是:郵箱登錄——> 設置密碼——> 密碼登錄

實現效果:

密碼登錄界面

自定義登錄頁面

有同學會說,這個頁面怎麼這麼醜,既有中文也有英文呢?顯然在國內是不合適的, Next-auth 幫我們考慮到了,它支持配置自定義頁面。

pages/api/auth/[...nextauth].ts 添加 pages 參數就可以實現自定義

pages: {

    signIn: '/auth/login',

},

自定義界面 ,可配置 signInsignOuterrorverifyRequestnewUser,在這裏,我們只配置登錄頁面。

登錄頁面的 dom 結構可以參考默認的 dom 結構, 直接複製出來就可以了。

查看默認登錄界面 dom

我們可以看到 form 表單中,有個默認的隱藏域,提交了 csrfToken 的值,那麼這個值該如何獲取呢?

import { getCsrfToken } from "next-auth/react"



export default function SignIn({ csrfToken }) {

  return (

    <form method="post" action="/api/auth/signin/email">

      <input  defaultValue={csrfToken} />

      <label>

        Email address

        <input type="email"  />

      </label>

      <button type="submit">Sign in with Email</button>

    </form>

  )

}



export async function getServerSideProps(context) {

  const csrfToken = await getCsrfToken(context)

  return {

    props: { csrfToken },

  }

}

csrfToken 可以通過導出的 getCsrfToken 方法獲取,並且賦值給隱藏域 csrfToken,在提交表單的時候,就會自動提交該值。

最後我們來看下實現效果:

自定義登錄頁面

是不是有國內 App 的風格了呢?這裏我使用了 @chakra-ui/react 實現代碼也很簡單,這裏就不貼了,感興趣的小夥伴可以直接看我的 github。

還有些小夥伴會問,登錄頁面能否能做成彈窗呢?當然也可以。

import { signIn } from "next-auth/react";



export default function Login() {

  return (

      <button

        onClick={() =>

          signIn("credentials", {

            email: "xiaoma@example.com",

            password: "1234",

          })

        }

      >

        登錄

      </button>

  );

}

界面我們可以完全自定義,寫成一個組件,只需要調用內置的 signIn 方法即可,它會幫我們自動添加 csrfToken 值。

小結

思考:國內 app 使用手機短信驗證登錄已經成爲主流,結合前面的文章,我們該如何修改表,使用哪個 providers 來實現?相信你已經有了答案。

本文通過 NextAuth.js, 給我們的視頻網站實現了郵箱登錄、 Github 授權登錄,以及密碼登錄。你學會了嗎?若對你有幫助,記得幫我點贊。

後續

接下來我將繼續分享 Next.js 相關的實戰文章,歡迎各位關注我的《Next.js 全棧開發實戰》 專欄。

你對哪塊內容比較感興趣呢?歡迎在評論區留言,感謝您的閱讀。

[1]Next-auth.js: https://next-auth.js.org/

[2]GitHub 倉庫代碼: https://github.com/maqi1520/next-prisma-video-app

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Juu2nh9K3jDjaKkkpw4brw