基于Next14+Auth5实现Github、Google、Gitee平台授权登录和邮箱密码登录

背景

后面打算自己做一个独立产品,产品需要用到服务端渲染,而我比较擅长React,所以最近在学Next14。

刚开始学的时候,因为自己英文不好,中文文档又特别少,踩了不少坑。后面买了冴羽大佬的Next小册跟着学习,很快就上手了,小册质量很高,个人觉得很适合新手入门。

学完之后肯定是要实战的,这样才能把知识转化为自己的东西。所以使用next-auth库实现Github、Google、Gitee平台授权登录和账号密码登录来练练手,实现的过程中,也遇到了一些坑,下面给大家分享一下。

题外话

我前面有篇文章说tRPC和Next开发全栈应用很爽,那是我没用过Next的server action,在我写完这个登录demo后,才发现server action太香了,个人感觉tRPC在做前后端分离的后台管理项目还是挺有优势的,Next14中使用tRPC有些鸡肋了,因为server action已经足够好用了。

到网上搜索了一下,国外也有人讨论这个问题,这个帖子有很多人评论,大家可以看看。

image.png

image.png

原帖地址:www.reddit.com/r/nextjs/co…

需要挂代理才能访问

创建Next项目

使用下面命令创建项目,全部使用默认选项就行了。

npx create-next-app@latest

创建成功后,使用npm run dev启动项目,访问http://localhost:3000/,能看到下面页面表示启动成功。

image.png

引入shadcn-ui

ui框架使用最近很火的shadcn-ui

pnpm dlx shadcn-ui@latest init
✔ Which style would you like to use? › New York
✔ Which color would you like to use as base color? › Stone
✔ Would you like to use CSS variables for colors? … no / yes

引入Button组件测试一下

pnpm dlx shadcn-ui@latest add button

改造app/page.tsx文件

import { Button } from '@/components/ui/button';

export default function Home() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <Button>button</Button>
    </main>
  );
}

如下图,显示出按钮

image.png

黑色的按钮不好看,shadch-ui支持在线自定义主题,访问自定义主题页面, 选择一个主题,然后复制代码。

image.png

替换app/global.css中颜色变量,可以看到按钮颜色变了

image.png

image.png

在/app/component/ui目录下可以看到button组件,这就是shadcn库和别的ui库的区别,shadcn会把组件下载到本地,让使用者更方便的拓展。

image.png

image.png

引入next-auth

安装依赖

pnpm add next-auth@5.0.0-beta.4

在src目录下创建auth.ts文件

// src/auth.ts
import NextAuth from 'next-auth';

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [],
});

src/app目录下创建api/auth/[...nextauth]/route.ts文件

// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";

export const { GET, POST } = handlers

实现github授权登录

到github注册授权应用

第一步

image.png

第二步

image.png

第三步

image.png

第四步

image.png

第五步

image.png

因为需要本地调试回调地址先写成http://localhost:3000http://localhost:3000/api/auth/callback/github

第六步

image.png

第七步

image.png

记住clientId和clientSecret,马上要用。

创建.env文件

AUTH_GITHUB_ID=刚才的clientId
AUTH_GITHUB_SECRET=刚才的clientSecre
AUTH_SECRET=用于散列令牌、签署 cookie 和生成加密密钥的随机字符串。

AUTH_SECRET可以使用npx auth secret命令生成

改造auth.ts文件,引入github provider

// src/auth.ts
import NextAuth from 'next-auth';
import Github from 'next-auth/providers/github';

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [Github],
})

使用form action登录github

// src/app/page.tsx
import { signIn } from '@/auth';
import { Button } from '@/components/ui/button';

export default function Home() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <form
        action={async () => {
          'use server';
          // 登录完成后,重定向到user页面
          await signIn('github', { redirectTo: '/user' });
        }}
      >
        <Button>github登录</Button>
      </form>
    </main>
  );
}

有些人可能使用form action的时候,会报这个错,这是因为react版本太低了,form不支持绑定函数,需要升级react版本到18.2.0,react-dom到最新版本。

image.png

image.png

安装完,可能还会报错,重启一下vscode就行了。

image.png

这里的use server别忘记了,不然也会报错。

创建user页面

// src/app/user/page.tsx
import { auth } from '@/auth';

export default async function UserPage() {

  // 从session中获取登录信息
  const session = await auth();

  return (
    <div>
      {session?.user ? (
        <p>{JSON.stringify(session.user)}</p>
      ) : (
        <p>未登录</p>
      )}
    </div>
  )
}

注意

本地调试需要开代理并且还是全局代理或增强模式才能正常使用,如果没有梯子改本地hosts也可以。

sudo vi /etc/hosts

把下面两行文本添加到/etc/hosts文件后面

20.205.243.168 api.github.com

140.82.121.3 github.com

加了这个后,不开代理也能快速访问github。

如果上面两个ip也能墙了,可以访问这个地址,寻找那些不超时的ip

image.png

效果展示

Kapture 2024-01-29 at 22.10.05.gif

OAuth 2.0 授权流程

看完上面代码,大家是不是觉得github授权登录怎么那么简单,其实github授权登录流程还是挺复杂的,只是auth库帮我们实现了一些接口而已。

github是基于oAuth2授权登录流程,下面给大家说一下oAuth2授权登录流程。

OAuth 2.0 授权流程一般包括以下步骤:

  1. 用户访问应用:用户尝试登录或使用需要授权的应用。

  2. 应用请求授权:应用将用户重定向到授权服务器,并附带以下信息:客户端 ID(识别应用)、重定向 URI(成功授权后返回的地址)、响应类型(通常为 “code”,表示使用授权码流程)、范围(应用要求的权限列表)。

  3. 用户登录并认证:在授权服务器上,用户会被要求登录并确认应用请求的权限。

  4. 用户授权应用:用户在授权服务器上同意授予应用权限。

  5. 应用接收授权码:授权服务器将用户重定向回应用,同时在查询字符串中附带授权码。

  6. 应用请求访问令牌:应用通过后台服务向授权服务器发送包含授权码的请求,以换取访问令牌。同时还需要提供客户端 ID 和 客户端密钥。

  7. 应用接收访问令牌:授权服务器验证请求后,如果一切符合要求,就会发送访问令牌回应用。

  8. 应用使用访问令牌来访问保护资源:具有访问令牌的应用可以调用 API,访问用户数据或执行其他操作。

下面以github授权为例,给大家看一下具体授权过程

  1. 当前用户点击github登录按钮时,会跳转到github.com/login/oauth… 地址,并在query上携带下面参数
{
  // 需要获取的信息,这里是用户信息和邮箱
  scope: "read:user user:email",
  // 设置返回的数据是code
  response_type: "code",
  // 在github上申请的client_id
  client_id: "XXXXXXX",
  // 客户端生成的一种随机值(如一个随机字符串),通常在向授权服务器发出授权请求时发送。该值在转换前和转换后都需要保存,因为在后续的令牌交换过程中需要使用。主要用来防止授权码被截获并利用。
  code_challenge:  "XXXXXXX",
  // 这是对 `code_challenge` 进行转换的方法,可以是 "plain" 或 "S256"
  //  "plain": 对应的 `code_challenge` 就是原始的随机字符串。
  //  "S256": 对应的 `code_challenge` 是原始随机字符串进行 SHA256 散列并进行 URL 安全的 Base64 编码后的结果。
  code_challenge_method: "plain",
  // 授权完成后,重定向地址
  redirect_uri: "http://localhost:3000/api/auth/callback/github"
}
  1. 上一步授权成功后,会重定向上面配置的地址http://localhost:3000/api/auth/callback/github?code=8d4691082f7956265a78 , 并带上code。

  2. http://localhost:3000/api/auth/callback/github 接口里拿到code,调用github的 github.com/login/oauth… 接口获取token。

// 客户端的 ID
client_id:"XXXXX"
// 客户端的密钥
client_secret:"XXXX",
// 授权码
code:"8d4691082f7956265a78",
// 前面code_challenge没加密前的随机字符串
code_verifier: 'XXXXX'
  1. 上一步拿到token后,调用api.github.com/user 接口获取用户信息,把上一步获取到的token放到请求头里。

基于上面流程,我用koa库实现了一下。

const Koa = require('koa');
const Router = require('koa-router');
const axios = require('axios');
const crypto = require('crypto');

const app = new Koa();
const router = new Router();

// 你的 GitHub OAuth 应用配置
const CLIENT_ID = 'XXXX';
const CLIENT_SECRET = 'XXXX';
const redirect_uri = `http://localhost:3000/api/auth/callback/github`;

function generateRandomString() {
  return crypto.randomBytes(64).toString('hex');
}

function sha256(buffer) {
  return crypto.createHash('sha256').update(buffer).digest();
}

function base64URLEncode(str) {
  return str.toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

router.get('/login', ctx => {
  const code_verifier = generateRandomString();
  const code_challenge = base64URLEncode(sha256(code_verifier));
  ctx.cookies.set('code_verifier', code_verifier);

  const dataStr = (new Date()).valueOf();
  var path = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&scope=user&state=${dataStr}&redirect_uri=${redirect_uri}&code_challenge=${code_challenge}&code_challenge_method=S256`;
  ctx.redirect(path);
});

router.get('/api/auth/callback/github', async ctx => {
  const code = ctx.query.code;
  const code_verifier = ctx.cookies.get('code_verifier');
  const params = {
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
    code: code,
    code_verifier: code_verifier
  };
  
  let res = await axios.post('https://github.com/login/oauth/access_token', params);
  console.log(res ,'res');
  
  const access_token = decodeURIComponent(res.data.split('&')[0].split('=')[1]);
  res = await axios({
    url: 'https://api.github.com/user',
    headers: {
      Authorization: `Bearer ${access_token}`
    },
    method: 'POST',
  });
  ctx.body = res.data;
})

app.use(router.routes());

app.listen(3000);

Google登录

创建google应用

第一步

访问console.cloud.google.com/welcome 地址,创建项目

第二步

image.png

第三步

image.png

第四步

image.png

第五步

image.png

第六步

image.png

第七步

image.png

第八步

image.png

第九步

image.png

第十步

把自己的账号添加为测试人员

image.png

在.env文件中添加环境变量

GOOGLE_CLIENT_ID=上面CLIENT_ID
GOOGLE_CLIENT_SECRET=上面CLIENT_SECRET

改造auth.ts文件,添加google provider

import NextAuth from 'next-auth';
import Github from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Github,
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET
    })
  ],
})

添加google登录按钮

image.png

效果展示

image.png

image.png

image.png

注意

这个必须要开全局代理才能使用,配置hosts不行,我试了很多ip都不行,有知道可以用的ip,评论区可以告知一下。

gitee授权登录

创建gitee授权应用

第一步

image.png

第二步

image.png

第三步

image.png

第四步

image.png

第五步

image.png

配置.env

GITEE_CLIENT_ID=上面的CLIENT_ID
GITEE_CLIENT_SECRET=上面的CLIENT_SECRET

自定义gitee prvider

auth是不支持gitee的,需要我们自定义provider,gitee授权登录使用的是标准的oAuth2协议,所以我们把github的provider复制出来改一下就行了。


// src/providers/gitee.ts

/**
 * @module providers/gitee
 */

export default function Gitee(
  config: any
): any {
  const baseUrl = 'https://gitee.com';
  const apiBaseUrl = 'https://gitee.com/api/v5';

  return {
    id: "gitee",
    name: "Gitee",
    type: "oauth",
    authorization: {
      url: `${baseUrl}/oauth/authorize`,
      params: { scope: '' },
    },
    token: {
      url: `${baseUrl}/oauth/token`,
      params: {
        grant_type: 'authorization_code',
      }
    },
    userinfo: {
      url: `${apiBaseUrl}/user`,
      async request({ tokens, provider }: any) {
        const profile = await fetch(provider.userinfo?.url as URL, {
          headers: {
            Authorization: `Bearer ${tokens.access_token}`,
            "User-Agent": "authjs",
          },
        }).then(async (res) => await res.json())

        if (!profile.email) {
          const res = await fetch(`${apiBaseUrl}/user/emails`, {
            headers: {
              Authorization: `Bearer ${tokens.access_token}`,
              "User-Agent": "authjs",
            },
          })

          if (res.ok) {
            const emails: any[] = await res.json()
            profile.email = (emails.find((e) => e.primary) ?? emails[0]).email
          }
        }

        return profile
      },
    },
    profile(profile: any) {
      return {
        id: profile.id.toString(),
        name: profile.name ?? profile.login,
        email: profile.email,
        image: profile.avatar_url,
      }
    },
    options: config,
  }
}

改造auth.ts文件,引入gitee provider

import NextAuth from 'next-auth';
import Github from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import Gitee from './providers/gitee';

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Github,
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
    Gitee({
      clientId: process.env.GITEE_CLIENT_ID,
      clientSecret: process.env.GITEE_CLIENT_SECRET,
    }),
  ],
})

添加gitee登录按钮

image.png

效果演示

点击gitee登录,可以正常获取gitee用户信息

image.png

注意

gitee因为是国内的,不要开代理也可以正常使用。

引入prisma,把用户登录信息持久化到数据库

上面我们实现了授权登录,但是哪些用户登录了,我们并不知道,所以需要把用户信息持久话到数据库中。数据库我这边使用mysql,数据库操作库使用prisma。

安装mysql

我本地使用Docker Desktop启动mysql服务。具体教程可以看下我以前的文章

安装prisma

prisma也是最近比较热门的一个orm库,主要数据库迁移比较简单,使用也比较简单。

当然你也可以使用其他orm库,auth.js主流orm库都支持。

安装依赖

pnpm add prisma -D
pnpm add  @auth/prisma-adapter

生成prisma配置

npx prisma init --datasource-provider mysql

修改.env文件,设置数据库连接

image.png

设置auth需要的模型

把下面模型配置复制到prisma/schema.prisma文件中

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

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])
}

把模型生成到数据库中

npx prisma migrate dev --name init

生成prisma客户端

npx prisma generate

改造auth.ts文件,引入适配器

image.png

// src/auth.ts
import { PrismaAdapter } from '@auth/prisma-adapter';
import { PrismaClient } from '@prisma/client';
import NextAuth from 'next-auth';
import Github from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import Gitee from './providers/gitee';

const prisma = new PrismaClient();

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    Github,
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
    Gitee({
      clientId: process.env.GITEE_CLIENT_ID,
      clientSecret: process.env.GITEE_CLIENT_SECRET,
    }),
  ],
})

这里如果引入@prisma/client报错,重新执行一下npx prisma generate,执行完如果还报错,重启一下vscode。

测试一下

使用google授权登录一下,使用npx prisma studio命令启动一个数据库客户端,可以在线查看数据库表数据,也可以使用数据库连接工具查看。

因为刚才登录了一下,已经有了一条数据。

image.png

这里注意一下,如果没有退出登录,直接在用gitee登录一下,即使两个邮箱不一样,也不会生成两个用户,而是给当前用户添加一个账号。

image.png

我没退出登录又登录了一次gitee,就变成这样了,下面我们实现一下退出登录功能。

实现退出登录

改造/user/page.tsx,添加退出登录功能


// src/app/user/page.tsx
import { auth, signOut } from '@/auth';
import { Button } from '@/components/ui/button';

export default async function UserPage() {

  // 从session中获取登录信息
  const session = await auth();

  return (
    <div>
      {session?.user ? (
        <>
          <p>{JSON.stringify(session.user)}</p>
          <form action={async () => {
            'use server';
            // 退出登录后,重定向首页
            await signOut({ redirectTo: '/' });
          }}>
            <Button>退出登录</Button>
          </form>
        </>
      ) : (
        <p>未登录</p>
      )}
    </div>
  )
}

登录完github,然后退出登录,登录gitee,这时候就会生成两条用户数据了。

image.png

自定义session数据

image.png

可以看到从session中取出来的用户信息没有id,但是前端很多地方可能需要用到用户id,这就需要自定义session返回数据了。

image.png

改造auth.ts文件,先设置生成session策略为jwt,然后再callbacks中添加jwt和session方法,在session方法中可以自定义session里的内容。

这里有个坑,网上很多教程是这样写的。

image.png

我试了一下,user一直是空,根本没法用,可以从token的sub字段中取到useId。

再次登录一下,发现id正常返回了

image.png

处理登录异常情况

授权登录如果有报错,系统会默认重定向到/api/auth/signin内置页面,我们想重定向自己的页面,可以在auth.ts中配置。

image.png

我现在使用google授权登录,因为邮箱和github是同一个,而用户邮箱不能重复,所以会报错,报错后会重定向到首页,并带上错误码。

image.png

服务端组件中可以直接从searchParams取url上的参数,错误码对应的错误详细信息可以到官网查看

image.png

image.png

实现邮箱密码登录

登录页面

shadcn-ui有form组件,不过需要配合zod和react-hook-form一起使用。

安装form和input组件

npx shadcn-ui@latest add form input

安装react-hook-form和zod

pnpm add react-hook-form zod

代码实现

在app文件夹下创建auth文件夹,后面登录相关的页面都放在这个文件夹下。在auth文件夹下创建login/page.tsx文件,再把下面代码复制进去,下面代码是shadcn提供的实例代码。

"use client"

import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"

import { Button } from "@/components/ui/button"
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"

const formSchema = z.object({
  username: z.string().min(2, {
    message: "Username must be at least 2 characters.",
  }),
})

export default function LoginPage() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      username: "",
    },
  })

  // 2. Define a submit handler.
  function onSubmit(values: z.infer<typeof formSchema>) {
    // Do something with the form values.
    // ✅ This will be type-safe and validated.
    console.log(values)
  }

  return (
    <div className='flex h-screen justify-center items-center'>
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 w-[400px]">
          <FormField
            control={form.control}
            name="username"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Username</FormLabel>
                <FormControl>
                  <Input placeholder="shadcn" {...field} />
                </FormControl>
                <FormDescription>
                  This is your public display name.
                </FormDescription>
                <FormMessage />
              </FormItem>
            )}
          />
          <Button type="submit">Submit</Button>
        </form>
      </Form>
    </div>
  )
}

访问http://localhost:3000/auth/login

image.png

改造页面

前几天看到一个炫酷的登录页面,我在码上掘金上实现了一下,大家可以看看效果。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

把这个引入到项目中,并封装成组件

image.png

// src/components/colorful-card/index.tsx

import React from 'react';

import './index.css';

export default function ColorfulCard({ children }: { children: React.ReactElement }) {
  return (
    <div className="colorful">
      <div className="box bg-[#17171a]">
        <div className="box-mask bg-[#28292d]" />
      </div>
      <div className="content bg-[#28292d]">
        {children}
      </div>
    </div>
  )
}

// src/components/colorful-card/index.css

.colorful {
  position: relative;
}

.colorful .box {
  border-radius: 8px;
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  overflow: hidden;
}

.colorful .box::before {
  content: '';
  position: absolute;
  top: -50%;
  left: -50%;
  bottom: 50%;
  right: 50%;
  transform-origin: bottom right;
  background: linear-gradient(0deg, transparent, #6960EC, #6960EC);
  animation: animate 6s linear infinite;
}

.colorful .box::after {
  content: '';
  position: absolute;
  top: -50%;
  left: -50%;
  bottom: 50%;
  right: 50%;
  background: linear-gradient(0deg, transparent, #6960EC, #6960EC);
  transform-origin: bottom right;
  animation: animate 6s linear infinite;
  animation-delay: -3s;
}


@keyframes animate {
  0% {
    transform: rotate(0deg);
  }

  100% {
    transform: rotate(360deg);
  }
}

.colorful .box-mask {
  position: absolute;
  z-index: 10;
  inset: 3px;
  border-radius: 8px;
}

.colorful .content {
  border-radius: 8px;
  position: relative;
  z-index: 11;
  margin: 3px;
}

因为这个适合在暗色主题,所以我们给主题切换成暗色,创建theme-provider.tsx,需要先安装next-themes

pnpm add next-themes
// src/components/providers/theme-provider.tsx
"use client"

import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

改造layout.tsx文件,设置主题为dark。

import { ThemeProvider } from '@/components/providers/theme-provider';
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className="bg-[#23242a]">
        <ThemeProvider
          attribute="class"
          defaultTheme="dark"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

启动项目,控制台会有报错

image.png

给html添加这个属性就行了

image.png

改造auth/login/page.tsx代码,把上面写的ColorfulCard组件引入进去

"use client"

import { IconGiteefillround } from '@/assets/icons/gitee-fill-round'

import { Button } from "@/components/ui/button"
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"

import { Separator } from '@/components/ui/separator'
import { GithubOutlined, GoogleOutlined } from '@ant-design/icons'
import Link from 'next/link'

import ColorfulCard from '@/components/colorful-card'

const loginFormSchema = z.object({
  email: z.string().email({
    message: "无效的邮箱格式",
  }),
  password: z.string().min(1, {
    message: "不能为空",
  }),
})

export type loginFormSchemaType = z.infer<typeof loginFormSchema>;

export default function LoginPage() {
  const form = useForm<loginFormSchemaType>({
    resolver: zodResolver(loginFormSchema),
    defaultValues: {
      email: '',
      password: '',
    }
  })

  async function onSubmit(values: loginFormSchemaType) {
    console.log(values);
  }

  return (
    <div className='py-[100px] flex justify-center'>
      <ColorfulCard>
        <Form {...form}>
          <div>
            <form
              onSubmit={form.handleSubmit(onSubmit)}
              className="p-[20px] w-[420px]"
            >
              <div className='flex justify-center my-[20px]'>
                <h1 className='text-2xl font-bold text-[#6960EC]'>登录</h1>
              </div>
              <FormField
                control={form.control}
                name="email"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>邮箱</FormLabel>
                    <FormControl>
                      <Input {...field} />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />
              <FormField
                control={form.control}
                name="password"
                render={({ field }) => (
                  <FormItem className='mt-[20px]'>
                    <FormLabel>密码</FormLabel>
                    <FormControl>
                      <Input type='password' {...field} />
                    </FormControl>
                  </FormItem>
                )}
              />
              <div className='flex justify-between mt-[20px]'>
                <Button size='lg' className='w-full' type="submit">
                  登录
                </Button>
              </div>
              <Separator className="my-4 bg-[#666]" />
              <div className='flex flex-col gap-3'>
                <div className='flex gap-2'>
                  <Button
                    size='lg'
                    variant='secondary'
                    type="button"
                    className='px-0 flex-1 flex justify-center gap-1'
                  >
                    <GithubOutlined />GitHub登录
                  </Button>
                  <Button
                    size='lg'
                    variant='secondary'
                    type="button"
                    className='px-0 flex-1 flex justify-center gap-1'
                  >
                    <GoogleOutlined /> Google登录
                  </Button>
                  <Button
                    size='lg'
                    variant='secondary'
                    type="button"
                    className='px-0 flex-1 flex justify-center gap-1'
                  >
                    <IconGiteefillround /> Gitee登录
                  </Button>
                </div>
                <Link href="/auth/register">
                  <Button size='lg' variant='link' className='w-full mt-[12px]' type='button'>
                    还没有账号?注册新用户
                  </Button>
                </Link>
              </div>
            </form>
          </div>
        </Form>
      </ColorfulCard>
    </div>
  )
}

这里图标使用的是antd的,也可以使用我以前写的插件使用iconfont里的图标。

image.png

颜色搭配不太好,修改一下按钮和input组件的颜色,可以修改global.css里的颜色变量,修改dark模式下的颜色变量。

.dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    --card: 222.2 84% 4.9%;
    --card-foreground: 210 40% 98%;
    --popover: 222.2 84% 4.9%;
    --popover-foreground: 210 40% 98%;
    --primary: 244 79% 65%;
    --primary-foreground: 222.2 47.4% 100%;
    --secondary: 221.2 70.2% 50%;
    --secondary-foreground: 210 40% 98%;
    --muted: 217.2 32.6% 17.5%;
    --muted-foreground: 215 20.2% 65.1%;
    --accent: 217.2 32.6% 17.5%;
    --accent-foreground: 210 40% 98%;
    --destructive: 0 62.8% 30.6%;
    --destructive-foreground: 210 40% 98%;
    --border: 217.2 32.6% 17.5%;
    --input: 0 0% 40%;
    --ring: 244 79% 65%;
  }

这样看起来,好看了一些

image.png

登录功能

因为login/page.tsx是客户端组件,不能直接定义server action,所以我们单独定义一个action.ts文件放在login文件夹下。

实现三方授权登录

actiont.ts实现

// src/app/auth/login/action.ts
'use server';

import { signIn } from '@/auth';

export const loginWithGithub = async () => {
  await signIn('github', {
    redirectTo: '/user',
  });
};

export const loginWithGoogle = async () => {
  await signIn('google', {
    redirectTo: '/user',
  });
};

export const loginWithGitee = async () => {
  await signIn('gitee', {
    redirectTo: '/user',
  });
};

按钮点击事件调用server action方法

image.png

实现邮箱密码登录

先给用户模型添加password字段

image.png

添加完成后,执行下面命令同步到数据库

npx prisma db push
npx prisma generate

在action.ts中添加自定义凭证登录

...
export const loginWithCredentials = async (
  credentials: LoginFormSchemaType
): Promise<void | {error?: string}> => {
  try {
    await signIn('credentials', {
      ...credentials,
      redirectTo: '/user',
    });
  } catch (error) {
    if (error instanceof AuthError) {
      return {
        error: '用户名或密码错误',
      };
    }

    // 这里一定要抛出异常,不然成功登录后不会重定向
    throw error;
  }
};

如果登录失败,我们用shadcn的Toast组件把错误消息弹出来,安装toast组件

npx shadcn-ui@latest add toast

把Toaster组件放到layout.tsx中

image.png

在表单的onSubmit事件中调用loginWithCredentials方法,并把当前输入的邮箱和密码传过来,如果error有值说明登录失败,把错误消息弹出来。

image.png

改造auth.ts文件,添加Credentials登录,并且实现登录校。如果返回null,表示登录失败。prisma实例后面会在多个地方使用,这里给prisma单独放到一个文件里然后导出。

// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client';

export const prisma = new PrismaClient();

image.png

如果user中没有password属性,重启一下vscode就行了。

image.png

手动添加一个用户

image.png

登录失败的情况

image.png

登录成功的情况

image.png

实现注册功能

注册页面实现

和登录页面差不多,只是多了个字段。要注意的是,在客户端组件中跳转路由使用useRouter方法。

// src/app/auth/register/page.tsx

"use client"

import { Button } from "@/components/ui/button"
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { zodResolver } from "@hookform/resolvers/zod"
import { ReloadIcon } from '@radix-ui/react-icons'
import { useForm } from "react-hook-form"
import * as z from "zod"

import ColorfulCard from '@/components/colorful-card'
import { useToast } from '@/components/ui/use-toast'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { register } from './action'

const registerFormSchema = z.object({
  username: z.string().min(1, {
    message: "不能为空",
  }),
  password: z.string().min(1, {
    message: "不能为空",
  }),
  email: z.string().email({ message: "无效的邮箱格式" }),
})


export type RegisterFormSchemaType = z.infer<typeof registerFormSchema>;


export default function RegisterPage() {

  const [loading, setLoading] = useState(false);
  const { toast } = useToast();
  const router = useRouter();

  const form = useForm<RegisterFormSchemaType>({
    resolver: zodResolver(registerFormSchema),
    defaultValues: {
      email: '',
      username: '',
      password: '',
    },
  })

  // 2. Define a submit handler.
  async function onSubmit(values: RegisterFormSchemaType) {
    setLoading(true);
    const result = await register(values);

    if (result?.error) {
      toast({
        title: '注册失败',
        description: result.error,
        variant: 'destructive',
      });
    } else {
      // 注册成功,跳到登录页面
      router.push('/auth/login');
    }
    setLoading(false);
  }

  return (
    <div className='py-[100px] flex justify-center'>
      <ColorfulCard>
        <Form {...form}>
          <div>
            <form
              onSubmit={form.handleSubmit(onSubmit)}
              className="p-[20px] w-[420px]"
            >
              <div className='flex justify-center my-[20px]'>
                <h1 className='text-2xl font-bold text-[#6960EC]'>注册</h1>
              </div>
              <FormField
                control={form.control}
                name="username"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>用户名</FormLabel>
                    <FormControl>
                      <Input {...field} />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />
              <FormField
                control={form.control}
                name="email"
                render={({ field }) => (
                  <FormItem className='mt-[20px]'>
                    <FormLabel>邮箱</FormLabel>
                    <FormControl>
                      <Input {...field} />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />
              <FormField
                control={form.control}
                name="password"
                render={({ field }) => (
                  <FormItem className='mt-[20px]'>
                    <FormLabel>密码</FormLabel>
                    <FormControl>
                      <Input type='password' {...field} />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />
              <div className='flex justify-between mt-[30px]'>
                <Button disabled={loading} size='lg' className='w-full' type="submit">
                  {loading && <ReloadIcon className="mr-2 h-4 w-4 animate-spin" />}
                  注册
                </Button>
              </div>
              <div className='flex flex-col gap-3'>
                <Link href="/auth/login">
                  <Button disabled={loading} size='lg' variant='link' className='w-full mt-[12px]' type='button'>
                    已有账号?返回登录
                  </Button>
                </Link>
              </div>
            </form>
          </div>
        </Form>
      </ColorfulCard>
    </div>
  )
}

实现注册方法

需要安装bcrypt库,对密码进行加盐处理。

pnpm add bcrypt
pnpm add @types/bcrypt -D
// src/app/auth/register/action.ts

'use server';

import { prisma } from '@/lib/prisma';
import bcrypt from 'bcrypt';
import { RegisterFormSchemaType } from './page';

export const register = async (data: RegisterFormSchemaType) => {
  const existUser = await prisma.user.findUnique({
    where: {
      email: data.email,
    },
  });

  if (existUser) {
    return {
      error: '当前邮箱已存在!',
    };
  }

  // 给密码加盐,密码明文存数据库不安全
  const hashedPassword = await bcrypt.hash(data.password, 10);

  await prisma.user.create({
    data: {
      name: data.username,
      password: hashedPassword,
      email: data.email,
    },
  });

};

测试一下,可以看到密码已经加密了

image.png

image.png

改造登录校验方法,因为数据库中密码已经加密了,不能使用用户输入的密码直接对比。

image.png

使用刚才注册的邮箱登录

image.png

实现邮箱验证

为了验证用户注册时的邮箱是不是真实的邮箱,一般网站会在用户注册成功之后,给当前注册邮箱发送一条激活当前账号的邮件,邮件中有一个链接,用户点击链接可以激活邮箱。下面我们来实现一下这个功能。

开始之前需要有一个邮箱服务器给用户发送邮箱,可以使用自己的邮箱当邮箱服务器,可以看下我这篇文章开启邮箱服务。

安装nodemailer依赖

pnpm add nodemailer
pnpm add @types/nodemailer -D

配置邮箱环境变量

在.env中添加邮箱配置

image.png

MAIL_USER:表示邮箱账号

MAIL_PASS:表示上面开启服务时的密钥,不是登录密码。

封装公共邮箱发送方法

// src/lib/email.ts
import * as nodemailer from 'nodemailer';

export interface MailInfo {
  // 目标邮箱
  to: string;
  // 标题
  subject: string;
  // 文本
  text?: string;
  // 富文本,如果文本和富文本同时设置,富文本生效。
  html?: string;
}

export const sendEmail = async (mailInfo: MailInfo) => {
  const transporter = nodemailer.createTransport({
    host: process.env.MAIL_HOST,
    port: +(process.env.MAIL_PORT || 465),
    secure: true,
    auth: {
      user: process.env.MAIL_USER,
      pass: process.env.MAIL_PASS,
    },
  });

  // 定义transport对象并发送邮件
  const info = await transporter.sendMail({
    from: `next-auth-demo <${process.env.MAIL_USER}>`,
    ...mailInfo,
  });

  return info;
};

注册成功后,发送邮件

随机token使用uuid生成的,所以需要安装uuid依赖

pnpm add uuid
pnpm add @types/uuid -D

image.png

image.png

添加一个注册成功后显示消息的页面

注册成功后,因为需要等用户激活邮箱,所以不能直接跳转到登录页面,停留在当前页面也不太好,所以我们单独做一个页面。

// src/app/auth/register/result/page.tsx
import { IconQingzhu } from '@/assets/icons/qingzhu'
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from '@/components/ui/button'
import Link from 'next/link'


export default function RegisterResultPage() {
  return (
    <div className='mt-[20px] px-[100px]'>
      <Alert>
        <AlertTitle className='flex items-center'>
          <IconQingzhu className='text-green-500' />
          注册成功!
        </AlertTitle>
        <AlertDescription>
          您的验证邮件已发送,请前往验证。
          <Link href="/auth/login">
            <Button color="green" variant="link">
              已验证?返回登录
            </Button>
          </Link>
        </AlertDescription>
      </Alert>
    </div>
  )
}

image.png

添加激活页面

用户点击激活链接,会跳转到系统中激活页面,在激活页面中拿到token,通过token拿到用户邮箱,通过用户邮箱拿到用户信息,然后把用户信息中emailVerified属性设置为当前时间,表示为已激活。

// src/app/auth/activate/page.tsx
'use client'

import { IconQingzhu } from '@/assets/icons/qingzhu'
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from '@/components/ui/button'
import { ExclamationCircleOutlined, LoadingOutlined } from '@ant-design/icons'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { activateUser } from './action'

export default function ActivatePage() {

  const params = useSearchParams();
  const token = params.get('token');

  const [error, setError] = useState('');
  const [success, setSucess] = useState('');

  const activate = useCallback(() => {

    setError('');
    setSucess('');

    if (!token) {
      return;
    }

    activateUser(token).then((result) => {
      if (result?.error) {
        setError(result.error);
      }
      if (result?.success) {
        setSucess(result.success);
      }
    });
  }, [token])

  useEffect(() => {
    activate();
  }, [activate]);

  if (!error && !success) {
    return (
      <div className='mt-[20px] px-[100px]'>
        <Alert>
          <AlertTitle className='flex items-center gap-2'>
            <LoadingOutlined />
            激活中...
          </AlertTitle>
        </Alert>
      </div>
    )
  }

  if (success) {
    return (
      <div className='mt-[20px] px-[100px]'>
        <Alert>
          <AlertTitle className='flex items-center'>
            <IconQingzhu className='text-green-500' />
            {success}
          </AlertTitle>
          <AlertDescription>
            <Link href="/auth/login">
              <Button color="green" variant="link">
                返回登录
              </Button>
            </Link>
          </AlertDescription>
        </Alert>
      </div>
    )
  }

  return (
    <div className='mt-[20px] px-[100px]'>
      <Alert>
        <AlertDescription className='flex items-center gap-2'>
          <ExclamationCircleOutlined className='text-red-700' />
          {error}
        </AlertDescription>
      </Alert>
    </div>
  )
}
// src/app/auth/activate/action.ts
'use server';

import { prisma } from '@/lib/prisma';

export const activateUser = async (token: string) => {
  const verificationToken = await prisma.verificationToken.findUnique({
    where: {
      token,
    },
  });


  if (!verificationToken || verificationToken.expires < new Date()) {
    return {
      error: '当前连接已失效',
    };
  }

  const user = await prisma.user.findUnique({
    where: {
      email: verificationToken.identifier,
    },
  });

  if (!user) {
    return {
      error: '激活失败,请联系管理员',
    };
  }

  await prisma.verificationToken.delete({
    where: {
      token: verificationToken.token,
    },
  });

  await prisma.user.update({
    where: {
      id: user.id,
    },
    data: {
      emailVerified: new Date(),
    },
  });

  return {
    success: '激活成功',
  };
};

改造登录方法,用户没有激活不让登录

image.png

效果展示

注册

image.png

注册完成后

image.png

没有激活直接登录

image.png

收到的邮件

image.png

激活成功

image.png

再次登录就可以登录成功了

image.png

中间件

需求分析

上面我们实现了登录功能,现在想一下,如果用户没有登录的情况下,访问/user页面时系统给重定向到登录页面。

这个需求最简单的实现方案是在user页面,判断是否登录,没有登录就给跳转到登录页面,但是以后有很多页面都需要登录才能访问,不是要写很多遍吗,所以这个需求我们使用中间件来实现。

具体实现

在src目录下创建middleware.ts文件,我开始看的有个教程说在项目根目录下创建,试了一下,根本不会执行,放到src目录下可以正常运行。

// src/middleware.ts
import NextAuth from 'next-auth';

const {auth} = NextAuth({
  providers: [],
});

export default auth((req) => {
  const isLoggedIn = !!req.auth?.user;

  // 没有登录,并且访问的页面不是以auth开头的,则重定向到登录页
  if (!isLoggedIn && !req.nextUrl.pathname.startsWith('/auth')) {
    return Response.redirect(new URL('/auth/login', req.nextUrl));
  } else if (isLoggedIn && req.nextUrl.pathname.startsWith('/auth')) {
    // 已经登录并且访问的页面是以auth开头的,则重定向到用户页,不需要重新登录了
    return Response.redirect(new URL('/user', req.nextUrl));
  }
});

// 排除掉一些接口和静态资源
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

再加一个需求,当用户访问根页面时重定向到用户信息页面,这个可以在根目录下的next.config.mjs中配置重定向。

/** @type {import('next').NextConfig} */
const nextConfig = {
  redirects: async () => {
    return [{
      source: "/",
      destination: "/user",
      permanent: true,
    }];
  },
};

export default nextConfig;

客户端组件中获取session

以前在Next Pages模式下客户端组件可以使用SessionProvider和useSession方法获取session,但是在app router模式下,不建议这样使用了,那我们就想在客户端组件中使用session怎么办,可以在服务器组件中获取session,然后把值当成属性传给客户端组件。

image.png

image.png

服务器组件

import { auth } from '@/auth';
import Client from './client';

export default async function TestPage() {
  const session = await auth();

  return (
    <Client session={session} />
  )
}

客户端组件

'use client'

export default function Client({
  session
}: {
  session: any
}) {

  return (
    <div>{session?.user && (
      <p>{JSON.stringify(session.user)}</p>
    )}</div>
  )
}

项目部署

解决build报错问题

部署之前本地先运行一下npm run build,build一下试试,因为有些报错npm run dev能正常运行,build的时候会报错。

image.png

比如现在这个报错,刚才我们在开发环境能正常运行,build报错了。报错的意思是src/app/auth/activate/page.tsx页面中使用useSearchParams必须要用Suspense包起来。

src/app/auth/activate/page.tsx文件里的内容拆到另外一个文件中,然后在page文件中把ActivateClient组件用Suspense包起来。

import { Suspense } from 'react'
import ActivateClient from './client'

export default function ActivatePage() {
  return (
    <Suspense>
      <ActivateClient />
    </Suspense>
  )
}

docker部署

我这里部署使用了github action和docker compose方案,这套部署方案细节可以看下我以前写的一篇文章

docker部署官方给了一个demo仓库,把仓库中Dockerfile复制到本地,在build前添加一行生成prisma客户端,其他都不用改。

image.png

然后修改next.config.mjs配置文件

image.png

在项目根目录下创建.github/workflows/docker-publish.yml文件

name: Docker

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.

on:
  push:
    branches: ['main']
    # Publish semver tags as releases.
    tags: ['v*.*.*']

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      # This is used to complete the identity challenge
      # with sigstore/fulcio when running outside of PRs.
      id-token: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v3

      # Workaround: https://github.com/docker/build-push-action/issues/461
      - name: Setup Docker buildx
        uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf

      - name: Cache Docker layers
        uses: actions/cache@v2
        with:
          path: /tmp/.buildx-cache
          key: ${{ runner.os }}-buildx-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-buildx-

      # Login against a Docker registry except on PR
      # https://github.com/docker/login-action
      - name: Log into registry ${{ env.REGISTRY }}
        if: github.event_name != 'pull_request'
        uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # Extract metadata (tags, labels) for Docker
      # https://github.com/docker/metadata-action
      - name: Extract Docker metadata
        id: meta
        uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      # Build and push Docker image with Buildx (don't push on PR)
      # https://github.com/d˜ocker/build-push-action
      - name: Build and push Docker image
        id: build-and-push
        uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=local,src=/tmp/.buildx-cache
          cache-to: type=local,dest=/tmp/.buildx-cache-new

      - name: Move cache
        run: |
          rm -rf /tmp/.buildx-cache
          mv /tmp/.buildx-cache-new /tmp/.buildx-cache
          

因为.env文件存放我们的密钥信息,所以把这个文件忽略掉,不要上传到仓库。

image.png

在根目录添加.env.production文件,配置服务的域名,线上环境授权登录不能使用ip和端口号,必须使用域名。

AUTH_URL=https://auth.fluxyadmin.cn

服务器上使用docker-compose部署,docker-compose文件如下

version: '3.7'
services:
  auth:
    image: ghcr.io/dbfu/next-auth-demo:main
    container_name: next
    restart: unless-stopped
    environment:
      DATABASE_URL:
      AUTH_GITHUB_ID: 
      AUTH_GITHUB_SECRET: 
      AUTH_SECRET: 
      GOOGLE_CLIENT_ID: 
      GOOGLE_CLIENT_SECRET: 
      GITEE_CLIENT_ID: 
      GITEE_CLIENT_SECRET: 
      MAIL_HOST: 
      MAIL_PORT: 
      MAIL_USER: 
      MAIL_PASS: 
    networks:
      - app_subnet
    ports:
      - 5001:5001
    extra_hosts:
      - "github.com:140.82.121.3"
      - "api.github.com:20.205.243.168"

把本地的环境变量复制到docker-componse文件中。因为我的云服务器是腾讯云,国内云服务是不能访问github的,所以需要配置hosts。国内服务器没办法访问google,所以没办法使用google授权登录。除非给服务器开梯子,或者买国外服务器。

更改授权登录地址

前面我们授权地址配的是本地地址,服务发布成功后,把地址改成域名。

image.png

image.png

最后

到此,从开发到部署整个流程结束了,大家可以使用下面地址去体验一下,不支持google登录。

demo体验地址:auth.fluxyadmin.cn/auth/login

仓库地址:github.com/dbfu/next-a…

下篇准备分享一个next实战项目,敬请期待。

  • 59
    点赞
  • 56
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值