Node.js 接入 Google OAuth2(Google 认证/登录)

529 阅读5分钟

运行 Node.js Google OAuth2 项目

  1. 从 github.com/wpcodevo/go… 下载或克隆 Node.js Google OAuth 项目,然后在 IDE 中打开源代码。
  2. 在根目录下的控制台运行,更改 Git 分支。尽管如此,请随意使用 master 分支。git checkout google-oauth2-nodejs
  3. 通过运行 或 来安装项目的依赖项。yarn``yarn install
  4. 复制文件并将复制的文件重命名为 。example.env``.env
  5. 按照“获取 Google OAuth2 凭证”部分从 Google API 控制台获取 OAuth2 客户端 ID 和密钥。
  6. 将 Google OAuth2 凭据添加到文件中。.env
  7. 通过运行 将 Prisma 架构推送到 SQLite 数据库。npx prisma db push
  8. 运行 以启动 Express HTTP 服务器。yarn start
  9. 设置 React 应用程序以与 Node.js API 交互。

使用 React.js 应用程序运行 Node.js API

有关如何在 React 应用程序中设置 Google OAuth2 流程的完整指南,请参阅文章“如何在 React.js 中实现 Google OAuth2”。但是,您可以按照以下步骤在几分钟内启动应用程序。

  1. 从 github.com/wpcodevo/go… 下载或克隆 React Google OAuth2 项目,并在代码编辑器中打开该项目。
  2. 运行 或 安装必要的依赖项。yarn``yarn install
  3. 复制文件并将复制的文件重命名为 。example.env``.env.local
  4. 将 OAuth2 客户端 ID 和客户端密钥添加到文件中。.env.local
  5. 运行 以启动 Vite 开发服务器。yarn dev
  6. 从 React 应用程序与 Node.js API 交互。

设置 Node.js 项目

首先,导航到系统上的方便位置并创建项目目录。对于本教程的 seeve,您可以将 project 命名为 。完成后,导航到该文件夹并使用 Yarn 初始化 Node.js 项目。google-oauth2-nodejs

mkdir google-oauth2-nodejs
cd google-oauth2-nodejs
yarn init -y

这将创建一个包含Node.js应用程序初始设置的文件。完成后,运行以下命令来安装项目所需的依赖项。package.json

yarn add @prisma/client axios cookie-parser cors dotenv express jsonwebtoken qs zod
yarn add -D typescript ts-node-dev prisma morgan @types/qs @types/node @types/morgan @types/jsonwebtoken @types/express @types/cors @types/cookie-parser
  • @prisma/client– 一个自动生成的查询生成器,支持类型安全的数据库访问。
  • axios– 用于浏览器和Node.js的基于 Promise 的 HTTP 客户端。
  • cookie-parser– 用于解析 HTTP 请求 cookie 的中间件。
  • cors– Node.js CORS 中间件。
  • dotenv– 从文件加载环境变量。.env
  • express– Node.js 的 Web 框架。
  • jsonwebtoken– JavaScript 项目的 JSON Web 令牌实现。
  • qs– 用于解析和字符串化查询参数的库。
  • zod– 架构验证库。
  • typescript– 一种用于 JavaScript 开发的语言。
  • ts-node-dev– 编译 TypeScript 文件并在所需文件更改时重新启动服务器。
  • prisma– 一个 CLI 工具,允许您从命令行与您的 Prisma 项目进行交互。
  • morgan– 用于 Node.js 的 HTTP 请求中间件记录器

安装完成后,在 IDE 或文本编辑器中打开项目。接下来,在根目录中创建一个文件,并添加以下 TypeScript 配置。tsconfig.json

tsconfig.json

{
  "compilerOptions": {
    "target": "es2016",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "strictPropertyInitialization": false,
    "skipLibCheck": true
  }
}

现在,让我们通过构建一个基本的 Express.js 服务器来使用简单的 JSON 对象进行响应。为此,请在根目录中创建一个 src 文件夹。在 src 文件夹中,创建一个文件并添加以下代码。app.ts

来源 / app.ts

import express, { Request, Response } from "express";

const app = express();

app.get("/api/healthChecker", (req: Request, res: Response) => {
  res.status(200).json({
    status: "success",
    message: "Implement Google OAuth2 in Node.js",
  });
});

const port = 8000;
app.listen(port, () => {
  console.log(`✅ Server started on port: ${port}`);
});

在上面,我们创建了一个 Express 服务器,它侦听端口 8000 并有一个路由 ,该路由返回 200 OK 状态代码和一个 JSON 对象。/api/healthChecker

打开文件并添加以下脚本:package.json

package.json

{
  "scripts": {
    "start": "ts-node-dev --respawn --transpile-only src/app.ts",
    "db:migrate": "npx prisma migrate dev --name 'users' --create-only",
    "db:generate": "npx prisma generate",
    "db:push": "npx prisma db push"
  }
}
  • start– 此脚本将启动 Express HTTP 服务器,并在所需文件更改时热重新加载服务器。
  • db:migrate– 此脚本将创建 Prisma 迁移文件而不应用它。
  • db:generate– 此脚本将在 node_modules 文件夹中生成 Prisma 客户端。
  • db:push– 此脚本会将 Prisma 迁移文件推送到数据库。

现在运行以启动 Express 服务器并向  . 在几毫秒内,您应该会看到 JSON 对象。yarn start``http://localhost:8000/api/healthchecker

测试 Node.js Google OAuth2 项目的健康检查器路由

获取 Google OAuth2 凭证

  1. 转到 Google Cloud Console console.cloud.google.com/ 并选择现有项目或创建新项目。

    如果您尚未创建项目,可以通过单击“NEW PROJECT” 按钮创建一个项目。
    在 Google Cloud API 仪表板上选择一个项目或创建一个新项目

  2. 在下一个屏幕上,输入项目名称并单击 “CREATE”  按钮。
    在 Google Console API 仪表板上创建新项目

  3. 在短时间内,将创建项目,并提示您从通知中选择新创建的项目。
    从通知中单击新创建的项目

  4. 选择项目后,点击左侧边栏中的 “OAuth 同意屏幕” 菜单,然后在下一个屏幕的 “用户类型” 下选择 “外部”。
    在 User type 下选择 External 并单击 Create
    然后,单击“创建”按钮。

  5. 在“OAuth 同意屏幕”选项卡上,输入应用程序信息并向下滚动到“应用程序域”部分。
    提供同意筛选凭证第 1 部分
    在“App domain”部分,输入应用程序主页、隐私政策和服务条款 URL。
    提供同意筛选凭证(第 2 部分)
    之后,在 “开发者联系信息” 部分输入您的电子邮件,然后单击 “保存并继续” 按钮。

  6. 在“范围”选项卡上,单击“添加或删除范围”按钮,然后选择 和 。完成后,滚动到底部并单击“更新”按钮。userinfo.email``userinfo.profile
    选择范围

  7. 点击 “Test users” 选项卡上的 “ADD USERS”  按钮。当应用程序处于沙盒模式时,仅允许这些用户使用其 Google 帐户对其进行测试。

    提供测试用户的电子邮件,然后单击“ADD”按钮。
    添加 test 用户
    单击“保存并继续”按钮以保留更改。在 “Summary” 选项卡上,浏览提供的信息,然后单击 “BACK TO DASHBOARD” 按钮。

  8. 此时,我们已经提供了同意屏幕所需的信息。现在,让我们创建 OAuth 客户端 ID 和密钥。为此,请单击左侧边栏中的“凭据”菜单,然后单击“创建凭据”按钮。

    从可用选项中选择“OAuth 客户端 ID”。
    选择 OAuth 客户端 ID

  9. 在“创建 OAuth 客户端 ID”屏幕上,选择“Web 应用程序”作为应用程序类型,并为客户端 ID 提供名称,

    然后输入授权重定向 URI,然后单击底部的“创建”按钮。http://localhost:8000/api/sessions/oauth/google
    提供 OAuth 凭证

  10. 生成 OAuth 客户端 ID 和密钥后,打开文件并添加它们。.env

打开文件并添加以下环境变量。不要忘记将 OAuth 客户端 ID 和客户端密钥添加到占位符中。.env

.env

DATABASE_URL="file:./dev.db"

GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
GOOGLE_OAUTH_REDIRECT=http://localhost:8000/api/sessions/oauth/google

NODE_ENV=development
JWT_SECRET=my_ultra_secure_secret
TOKEN_EXPIRES_IN=60
FRONTEND_ORIGIN=http://localhost:3000

使用 Prisma 设置数据库

现在,让我们设置一个数据库来存储用户的帐户信息。为此,我们将使用 Prisma ORM 来访问和更改 SQLite 数据库中的数据。

随意使用任何 Prisma 支持的数据库。执行 Prisma init 命令初始化工程中的 Prisma。

npx prisma init --datasource-provider sqlite

这将创建一个包含 Prisma 架构文件的新目录。打开文件和以下 Prisma 模型。prisma``prisma/schema.prisma

prisma/schema.prisma

model User {
  id       String  @id @default(uuid())
  name     String
  email    String  @unique
  password String
  role     String  @default("user")
  photo    String  @default("default.png")
  verified Boolean @default(false)
  provider String  @default("local")

  createdAt DateTime
  updatedAt DateTime @updatedAt

  @@map(name: "users")
}

Prisma 模型有两个主要用途:

  • 它表示基础数据库中的表
  • 用作生成的 Prisma Client 的基础。

运行 Prisma migrate 命令创建 SQL 迁移文件,生成 Prisma Client,并将迁移推送到数据库。

npx prisma migrate dev --name init

现在,让我们创建一个 Prisma 客户端的实例和一个函数来检查数据库连接池是否已成功建立。在 src 目录中创建一个 utils 文件夹。在 src/utils 文件夹中,创建一个文件并添加以下代码片段。prisma.ts

来源/utils/prisma.ts

import { PrismaClient } from "@prisma/client";

export const prisma = new PrismaClient();

async function connectDB() {
  try {
    await prisma.$connect();
    console.log("🚀 Database connected successfully");
  } catch (error) {
    console.log(error);
    process.exit(1);
  } finally {
    await prisma.$disconnect();
  }
}

export default connectDB;

创建验证架构

为了防止用户在请求正文中发送垃圾值,让我们创建验证架构和中间件,以根据架构中定义的规则验证请求正文。

在 src 目录中创建一个 schema 文件夹。在 src/schema/  文件夹中,创建一个文件并添加以下架构定义。user.schema.ts

来源/架构/user.schema.ts

import { object, string, TypeOf } from 'zod';

export const createUserSchema = object({
  body: object({
    name: string({ required_error: 'Name is required' }),
    email: string({ required_error: 'Email is required' }).email(
      'Invalid email'
    ),
    password: string({ required_error: 'Password is required' })
      .min(8, 'Password must be more than 8 characters')
      .max(32, 'Password must be less than 32 characters'),
    passwordConfirm: string({ required_error: 'Please confirm your password' }),
  }).refine((data) => data.password === data.passwordConfirm, {
    path: ['passwordConfirm'],
    message: 'Passwords do not match',
  }),
});

export const loginUserSchema = object({
  body: object({
    email: string({ required_error: 'Email is required' }).email(
      'Invalid email or password'
    ),
    password: string({ required_error: 'Password is required' }).min(
      8,
      'Invalid email or password'
    ),
  }),
});

export type CreateUserInput = TypeOf<typeof createUserSchema>['body'];
export type LoginUserInput = TypeOf<typeof loginUserSchema>['body'];

现在,让我们创建一个验证中间件,将其添加到请求中间件管道中,以验证传入的请求正文并将适当的验证错误返回给客户端。

为此,请创建一个文件并添加以下代码:src/middleware/validate.ts

src/middleware/validate.ts

import { NextFunction, Request, Response } from "express";
import { AnyZodObject, ZodError } from "zod";

export const validate =
  (schema: AnyZodObject) =>
  (req: Request, res: Response, next: NextFunction) => {
    try {
      schema.parse({
        params: req.params,
        query: req.query,
        body: req.body,
      });

      next();
    } catch (err: any) {
      if (err instanceof ZodError) {
        return res.status(400).json({
          status: "fail",
          error: err.errors,
        });
      }
      next(err);
    }
  };

此路由中间件将接受 Zod 架构作为参数,解析请求正文并根据架构中定义的规则验证字段,如果违反了任何规则,则返回验证错误。

获取 Google OAuth 访问令牌和用户信息

如果你能走到这一步,我为你感到骄傲。现在,是时候使用 OAuth 客户端 ID 和客户端密钥从 Google OAuth2 API 获取访问令牌了。如果是国内用户,会存在代理的问题,这个时候就可以使用代理服务器来请求google服务了

安装代理插件

yarn add https-proxy-agent

首先,创建一个文件并添加以下导入和常量。src/services/session.service.ts

src/services/session.service.ts

import axios from "axios";
import qs from "qs";
import { HttpsProxyAgent } from 'https-proxy-agent';

const GOOGLE_OAUTH_CLIENT_ID = process.env.GOOGLE_OAUTH_CLIENT_ID as unknown as string;
const GOOGLE_OAUTH_CLIENT_SECRET = process.env.GOOGLE_OAUTH_CLIENT_SECRET as unknown as string;
const GOOGLE_OAUTH_REDIRECT = process.env.GOOGLE_OAUTH_REDIRECT as unknown as string;

创建代理服务

const httpsAgent = new HttpsProxyAgent('你的代理服务器ip');

获取 OAuth 访问令牌

要获取 OAuth 访问令牌,我们将创建一个函数,该函数将使用该软件包向 Google OAuth 令牌端点发出 POST 请求,并使用 OAuth 客户端 ID 和密钥检索访问令牌。axios

src/services/session.service.ts

interface GoogleOauthToken {
  access_token: string;
  id_token: string;
  expires_in: number;
  refresh_token: string;
  token_type: string;
  scope: string;
}

export const getGoogleOauthToken = async ({
  code,
}: {
  code: string;
}): Promise<GoogleOauthToken> => {
  const rootURl = "https://oauth2.googleapis.com/token";

  const options = {
    code,
    client_id: GOOGLE_OAUTH_CLIENT_ID,
    client_secret: GOOGLE_OAUTH_CLIENT_SECRET,
    redirect_uri: GOOGLE_OAUTH_REDIRECT,
    grant_type: "authorization_code",
  };
  try {
    const { data } = await axios.post<GoogleOauthToken>(
      rootURl,
      qs.stringify(options),
      {
        httpsAgent,
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
      }
    );

    return data;
  } catch (err: any) {
    console.log("Failed to fetch Google Oauth Tokens");
    throw new Error(err);
  }
};
  • client_id– 客户端 ID 是我们在 Google Cloud Console 上创建项目时发给我们的唯一标识符。
  • client_secret– Google OAuth2 API 将使用客户端密钥来证明应用程序的身份。
  • redirect_uri– 重定向 URI,也称为回调 URL,是 OAuth2 服务器在授予或拒绝用户访问所请求权限后将用户重定向到的 URL。
  • grant_type– 该代码(也称为授权代码)是 OAuth2 服务器在用户授予对其资源的访问权限时颁发的短期代码。我们将使用此代码从 OAuth2 服务器获取访问令牌。

获取 Google 帐户用户

现在我们已经能够获取访问令牌,让我们创建一个函数来使用访问令牌检索用户的账户信息。

src/services/session.service.ts

interface GoogleUserResult {
  id: string;
  email: string;
  verified_email: boolean;
  name: string;
  given_name: string;
  family_name: string;
  picture: string;
  locale: string;
}

export async function getGoogleUser({
  id_token,
  access_token,
}: {
  id_token: string;
  access_token: string;
}): Promise<GoogleUserResult> {
  try {
    const { data } = await axios.get<GoogleUserResult>(
      `https://www.googleapis.com/oauth2/v1/userinfo?alt=json&access_token=${access_token}`,
      {
        httpsAgent,
        headers: {
          Authorization: `Bearer ${id_token}`,
        },
      }
    );

    return data;
  } catch (err: any) {
    console.log(err);
    throw Error(err);
  }
}

此函数将使用 Axios 向 Google OAuth2 的端点发出 GET 请求,以检索用户的公开个人资料信息。为此,该函数会将访问令牌作为查询参数添加到 URL,并将访问令牌作为 Bearer 添加到 Authorization 标头。/v1/userinfo``id_token

在 Node.js 中实施 Google OAuth2

糟糕,相当多的配置和代码。此时,我们现在可以实现 Node.js API 的身份验证方面了。为此,我们将创建中间件函数,当客户端或前端应用程序向服务器发出请求时,Express 会将请求委托给这些函数。

首先,在 src 目录中创建一个 controllers 文件夹。然后,在该文件夹中创建一个文件并添加以下代码。auth.controller.ts``controllers

src/controllers/auth.controller.ts

import { NextFunction, Request, Response } from "express";
import { CreateUserInput, LoginUserInput } from "../schema/user.schema";
import {
  getGoogleOauthToken,
  getGoogleUser,
} from "../services/session.service";
import { prisma } from "../utils/prisma";
import jwt from "jsonwebtoken";

export function exclude<User, Key extends keyof User>(
  user: User,
  keys: Key[]
): Omit<User, Key> {
  for (let key of keys) {
    delete user[key];
  }
  return user;
}

该函数将允许我们从 Prisma 返回的记录中省略敏感字段,例如密码。exclude

注册用户路由处理程序

当向端点发出 POST 请求时,Express 路由器将调用此路由中间件函数来注册新用户。/api/auth/register

当 Express 将请求转发到此函数时,它将使用请求正文中提供的数据创建一个新的用户对象,并使用 Prisma 客户端将用户保存到数据库。

如果数据库中已存在具有该电子邮件的用户,则将向客户端发送 409 Conflict 响应。但是,如果操作成功,该函数将在响应中返回 201 状态代码和新创建的用户。

src/controllers/auth.controller.ts

export const registerHandler = async (
  req: Request<{}, {}, CreateUserInput>,
  res: Response,
  next: NextFunction
) => {
  try {
    const user = await prisma.user.create({
      data: {
        name: req.body.name,
        email: req.body.email,
        password: req.body.password,
        createdAt: new Date(),
      },
    });

    res.status(201).json({
      status: "success",
      data: {
        user: exclude(user, ["password"]),
      },
    });
  } catch (err: any) {
    if (err.code === "P2002") {
      return res.status(409).json({
        status: "fail",
        message: "Email already exist",
      });
    }
    next(err);
  }
};

登录用户路由处理程序

当向终端节点发出 POST 请求时,将调用此中间件函数以将用户登录到 API。当请求到达此路由处理程序时,它将使用 Prisma 客户端检查数据库中是否存在具有该电子邮件的用户。/api/auth/login

如果存在用户,将生成 JSON Web 令牌,并将其作为仅限 HTTP 的 Cookie 发送到客户端。注意: 为了简化项目,我们将跳过其他身份验证方法,如密码哈希、会话存储等。

src/controllers/auth.controller.ts

export const loginHandler = async (
  req: Request<{}, {}, LoginUserInput>,
  res: Response,
  next: NextFunction
) => {
  try {
    const user = await prisma.user.findUnique({
      where: { email: req.body.email },
    });

    if (!user) {
      return res.status(401).json({
        status: "fail",
        message: "Invalid email or password",
      });
    }

    if (user.provider === "Google") {
      return res.status(401).json({
        status: "fail",
        message: `Use ${user.provider} OAuth2 instead`,
      });
    }

    const TOKEN_EXPIRES_IN = process.env.TOKEN_EXPIRES_IN as unknown as number;
    const TOKEN_SECRET = process.env.JWT_SECRET as unknown as string;
    const token = jwt.sign({ sub: user.id }, TOKEN_SECRET, {
      expiresIn: `${TOKEN_EXPIRES_IN}m`,
    });

    res.cookie("token", token, {
      expires: new Date(Date.now() + TOKEN_EXPIRES_IN * 60 * 1000),
    });

    res.status(200).json({
      status: "success",
    });
  } catch (err: any) {
    next(err);
  }
};

注销用户路由处理程序

现在我们已经能够注册和登录用户,让我们创建一个 Express 将用于注销用户的路由函数。为此,我们将发送一个过期的 Cookie 来删除用户浏览器或 API 客户端中的现有 Cookie。

src/controllers/auth.controller.ts

export const logoutHandler = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    res.cookie("token", "", { maxAge: -1 });
    res.status(200).json({ status: "success" });
  } catch (err: any) {
    next(err);
  }
};

使用 Google OAuth2 路由处理程序进行身份验证

src/controllers/auth.controller.ts

export const googleOauthHandler = async (req: Request, res: Response) => {
  const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN as unknown as string;

  try {
    const code = req.query.code as string;
    const pathUrl = (req.query.state as string) || "/";

    if (!code) {
      return res.status(401).json({
        status: "fail",
        message: "Authorization code not provided!",
      });
    }

    const { id_token, access_token } = await getGoogleOauthToken({ code });

    const { name, verified_email, email, picture } = await getGoogleUser({
      id_token,
      access_token,
    });

    if (!verified_email) {
      return res.status(403).json({
        status: "fail",
        message: "Google account not verified",
      });
    }

    const user = await prisma.user.upsert({
      where: { email },
      create: {
        createdAt: new Date(),
        name,
        email,
        photo: picture,
        password: "",
        verified: true,
        provider: "Google",
      },
      update: { name, email, photo: picture, provider: "Google" },
    });

    if (!user) return res.redirect(`${FRONTEND_ORIGIN}/oauth/error`);

    const TOKEN_EXPIRES_IN = process.env.TOKEN_EXPIRES_IN as unknown as number;
    const TOKEN_SECRET = process.env.JWT_SECRET as unknown as string;
    const token = jwt.sign({ sub: user.id }, TOKEN_SECRET, {
      expiresIn: `${TOKEN_EXPIRES_IN}m`,
    });

    res.cookie("token", token, {
      expires: new Date(Date.now() + TOKEN_EXPIRES_IN * 60 * 1000),
    });

    res.redirect(`${FRONTEND_ORIGIN}${pathUrl}`);
  } catch (err: any) {
    console.log("Failed to authorize Google User", err);
    return res.redirect(`${FRONTEND_ORIGIN}/oauth/error`);
  }
};

完成路由处理程序

src/controllers/auth.controller.ts

import { NextFunction, Request, Response } from "express";
import { CreateUserInput, LoginUserInput } from "../schema/user.schema";
import {
  getGoogleOauthToken,
  getGoogleUser,
} from "../services/session.service";
import { prisma } from "../utils/prisma";
import jwt from "jsonwebtoken";

export function exclude<User, Key extends keyof User>(
  user: User,
  keys: Key[]
): Omit<User, Key> {
  for (let key of keys) {
    delete user[key];
  }
  return user;
}

export const registerHandler = async (
  req: Request<{}, {}, CreateUserInput>,
  res: Response,
  next: NextFunction
) => {
  try {
    const user = await prisma.user.create({
      data: {
        name: req.body.name,
        email: req.body.email,
        password: req.body.password,
        createdAt: new Date(),
      },
    });

    res.status(201).json({
      status: "success",
      data: {
        user: exclude(user, ["password"]),
      },
    });
  } catch (err: any) {
    if (err.code === "P2002") {
      return res.status(409).json({
        status: "fail",
        message: "Email already exist",
      });
    }
    next(err);
  }
};

export const loginHandler = async (
  req: Request<{}, {}, LoginUserInput>,
  res: Response,
  next: NextFunction
) => {
  try {
    const user = await prisma.user.findUnique({
      where: { email: req.body.email },
    });

    if (!user) {
      return res.status(401).json({
        status: "fail",
        message: "Invalid email or password",
      });
    }

    if (user.provider === "Google") {
      return res.status(401).json({
        status: "fail",
        message: `Use ${user.provider} OAuth2 instead`,
      });
    }

    const TOKEN_EXPIRES_IN = process.env.TOKEN_EXPIRES_IN as unknown as number;
    const TOKEN_SECRET = process.env.JWT_SECRET as unknown as string;
    const token = jwt.sign({ sub: user.id }, TOKEN_SECRET, {
      expiresIn: `${TOKEN_EXPIRES_IN}m`,
    });

    res.cookie("token", token, {
      expires: new Date(Date.now() + TOKEN_EXPIRES_IN * 60 * 1000),
    });

    res.status(200).json({
      status: "success",
    });
  } catch (err: any) {
    next(err);
  }
};

export const logoutHandler = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    res.cookie("token", "", { maxAge: -1 });
    res.status(200).json({ status: "success" });
  } catch (err: any) {
    next(err);
  }
};

export const googleOauthHandler = async (req: Request, res: Response) => {
  const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN as unknown as string;

  try {
    const code = req.query.code as string;
    const pathUrl = (req.query.state as string) || "/";

    if (!code) {
      return res.status(401).json({
        status: "fail",
        message: "Authorization code not provided!",
      });
    }

    const { id_token, access_token } = await getGoogleOauthToken({ code });

    const { name, verified_email, email, picture } = await getGoogleUser({
      id_token,
      access_token,
    });

    if (!verified_email) {
      return res.status(403).json({
        status: "fail",
        message: "Google account not verified",
      });
    }

    const user = await prisma.user.upsert({
      where: { email },
      create: {
        createdAt: new Date(),
        name,
        email,
        photo: picture,
        password: "",
        verified: true,
        provider: "Google",
      },
      update: { name, email, photo: picture, provider: "Google" },
    });

    if (!user) return res.redirect(`${FRONTEND_ORIGIN}/oauth/error`);

    const TOKEN_EXPIRES_IN = process.env.TOKEN_EXPIRES_IN as unknown as number;
    const TOKEN_SECRET = process.env.JWT_SECRET as unknown as string;
    const token = jwt.sign({ sub: user.id }, TOKEN_SECRET, {
      expiresIn: `${TOKEN_EXPIRES_IN}m`,
    });

    res.cookie("token", token, {
      expires: new Date(Date.now() + TOKEN_EXPIRES_IN * 60 * 1000),
    });

    res.redirect(`${FRONTEND_ORIGIN}${pathUrl}`);
  } catch (err: any) {
    console.log("Failed to authorize Google User", err);
    return res.redirect(`${FRONTEND_ORIGIN}/oauth/error`);
  }
};

创建用户路由处理程序

让我们创建一个路由处理程序,当向终端节点发出 GET 请求时,将调用该处理程序以返回经过身份验证的用户的信息。此路由中间件将受到保护,只有具有有效 JWT 的用户才能访问它。/api/users/me

src/controllers/user.controller.ts

import { NextFunction, Request, Response } from "express";

export const getMeHandler = (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const user = res.locals.user;
    res.status(200).json({
      status: "success",
      data: {
        user,
      },
    });
  } catch (err: any) {
    next(err);
  }
};

创建身份验证守卫

在这里,您将创建一个 Express 中间件函数,该函数将通过检查请求标头或 Cookies 对象中的有效令牌来验证用户。

身份验证中间件

中间件函数将检查请求中是否存在 “Authorization” 标头,该标头应包含 JSON Web 令牌 (JWT)。如果标头不存在,它将检查 Cookies 对象,如果请求中未包含令牌,则将向客户端发送 401 Unauthorized 响应。

但是,如果令牌存在于 Authorization 标头或 Cookies 对象中,则该函数将通过调用库提供的方法来验证令牌。jwt.verify()``jsonwebtoken

src/middleware/deserializeUser.ts

import { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";
import { exclude } from "../controllers/auth.controller";
import { prisma } from "../utils/prisma";

export const deserializeUser = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    let token;
    if (
      req.headers.authorization &&
      req.headers.authorization.startsWith("Bearer")
    ) {
      token = req.headers.authorization.split(" ")[1];
    } else if (req.cookies.token) {
      token = req.cookies.token;
    }

    if (!token) {
      return res.status(401).json({
        status: "fail",
        message: "You are not logged in",
      });
    }

    const JWT_SECRET = process.env.JWT_SECRET as unknown as string;
    const decoded = jwt.verify(token, JWT_SECRET);

    if (!decoded) {
      return res.status(401).json({
        status: "fail",
        message: "Invalid token or user doesn't exist",
      });
    }

    const user = await prisma.user.findUnique({
      where: { id: String(decoded.sub) },
    });

    if (!user) {
      return res.status(401).json({
        status: "fail",
        message: "User with that token no longer exist",
      });
    }

    res.locals.user = exclude(user, ["password"]);

    next();
  } catch (err: any) {
    next(err);
  }
};

如果令牌有效,则将提取有效负载,并进行查询以检查属于该令牌的用户是否仍存在于数据库中。如果用户存在,则数据库返回的记录将作为 response 对象添加,并将请求传递给下一个中间件。res.locals.user

需要用户中间件

然后,此中间件将检查响应以查看对象是否具有用户记录。如果未定义该属性,则会向客户端发送 401 Unauthorized 响应。res.locals``res.locals.user

src/middleware/requireUser.ts

import { NextFunction, Request, Response } from "express";

export const requireUser = (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const user = res.locals.user;
    if (!user) {
      return res.status(401).json({
        status: "fail",
        message: "Invalid token or session has expired",
      });
    }

    next();
  } catch (err: any) {
    next(err);
  }
};

创建 API 路由

现在让我们创建 Express 路由器来调用路由中间件功能。第一个路由器将具有以下端点:

  • /register– 当向 发出 POST 请求时,此路由将调用函数。registerHandler``/api/auth/register
  • /login– 当向终端节点发出 POST 请求时,此路由将触发函数。loginHandler``/api/auth/login
  • /logout– 当向终端节点发出 GET 请求时,此路由将触发函数。logoutHandler``/api/auth/logout

在 src 目录中创建一个 routes 文件夹。在 src/routes/  文件夹中,创建一个文件并添加以下代码。auth.route.ts

src/routes/auth.route.ts

import express from "express";
import {
  loginHandler,
  logoutHandler,
  registerHandler,
} from "../controllers/auth.controller";
import { deserializeUser } from "../middleware/deserializeUser";
import { requireUser } from "../middleware/requireUser";
import { validate } from "../middleware/validate";
import { createUserSchema, loginUserSchema } from "../schema/user.schema";

const router = express.Router();

router.post("/register", validate(createUserSchema), registerHandler);
router.post("/login", validate(loginUserSchema), loginHandler);
router.get("/logout", deserializeUser, requireUser, logoutHandler);

export default router;

第二个路由器将只有一个端点,当 Oauth2 服务器将用户重定向到 Express 服务器时,将触发该端点。因此,创建一个文件并添加以下代码。/oauth/google``session.route.ts

src/routes/session.route.ts

import express from "express";
import { googleOauthHandler } from "../controllers/auth.controller";

const router = express.Router();

router.get("/oauth/google", googleOauthHandler);

export default router;

最后一个路由器还将有一个 endpoint,当向 endpoint 发出 GET 请求时,将触发该 endpoint。/me``/api/users/me

src/routes/user.route.ts

import express from "express";
import { getMeHandler } from "../controllers/user.controller";
import { deserializeUser } from "../middleware/deserializeUser";
import { requireUser } from "../middleware/requireUser";

const router = express.Router();

router.use(deserializeUser, requireUser);

router.get("/me", getMeHandler);

export default router;

设置 CORS 并注册 API 路由器

最后,让我们将路由器添加到 Express 应用程序,并将服务器配置为接受来自特定来源的跨域请求。为此,请打开该文件,并将其内容替换为以下代码。src/app.ts

来源 / app.ts

require("dotenv").config();
import path from "path";
import express, { NextFunction, Request, Response } from "express";
import morgan from "morgan";
import cors from "cors";
import cookieParser from "cookie-parser";
import userRouter from "./routes/user.route";
import authRouter from "./routes/auth.route";
import sessionRouter from "./routes/session.route";
import connectDB from "./utils/prisma";

const app = express();

app.use(express.json({ limit: "10kb" }));
app.use(cookieParser());
if (process.env.NODE_ENV === "development") app.use(morgan("dev"));
app.use("/api/images", express.static(path.join(__dirname, "../public")));

const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN as unknown as string;
app.use(
  cors({
    credentials: true,
    origin: [FRONTEND_ORIGIN],
  })
);

app.use("/api/users", userRouter);
app.use("/api/auth", authRouter);
app.use("/api/sessions", sessionRouter);

app.get("/api/healthChecker", (req: Request, res: Response) => {
  res.status(200).json({
    status: "success",
    message: "Implement OAuth in Node.js",
  });
});

// UnKnown Routes
app.all("*", (req: Request, res: Response, next: NextFunction) => {
  const err = new Error(`Route ${req.originalUrl} not found`) as any;
  err.statusCode = 404;
  next(err);
});

app.use((err: any, req: Request, res: Response, next: NextFunction) => {
  err.status = err.status || "error";
  err.statusCode = err.statusCode || 500;

  res.status(err.statusCode).json({
    status: err.status,
    message: err.message,
  });
});

const port = 8000;
app.listen(port, () => {
  console.log(`✅ Server started on port: ${port}`);
  connectDB();
});

现在,您可以通过运行 来再次启动 Express HTTP 服务器。yarn start

结论

我们完成了!您可以在 GitHub 上找到 Node.js Google OAuth2 项目的源代码。

在本文中,我们使用 TypeScript 和 Node.js 从头开始实现了 Google OAuth 流。该 API 具有所有必需的功能,例如,注册用户、将用户登录到 API、使用 Google OAuth 进行身份验证以及将用户从应用程序中注销。