简易的 Nextjs 用户认证系统

Next.js 提供了接口路由功能,可以用来添加一些简单的接口,我们可以利用 Github Oauth + JWT 实现一个简易的 Next.js 用户认证系统。(如果的你的网站只是使用 next.js 生成静态网站,然后部署到 Static hosting,例如 Github Pages,则无法运行 Next.js 接口路由功能)

Github 登录

首先我们需要创建一个用于登录的 Github Oauth App,点击这里创建,其中 Authorization callback URL 请填写 http://localhost:3000/api/oauth/github/callback (上线后请创建一个新的应用填写线上的地址)

Github New Oauth App

获取到 Client id 和 Client Secret 后写入到本地的环境变量配置文件 .env.local, 对应的 key 为 GITHUB_CLIENT_ID, GITHUB_SECRET

跳转用户到 Github 授权

Oauth 认证的第一步,需要跳转用户到对应的第三方网站进行应用授权,我们使用一个固定的链接,直接跳转到 Github 授权。

首先在环境变量文件中添加 NEXT_PUBLIC_SITE_URL 用于区别开发和线上的访问链接,本地的值为 http://localhost:3000, 然后创建文件 /pages/api/connect/github.ts

import type { NextApiRequest, NextApiResponse } from 'next'

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const redirectURL = `${process.env.NEXT_PUBLIC_SITE_URL}/api/oauth/github/callback`
  const url = `https://github.com/login/oauth/authorize?client_id=${process.env.GITHUB_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectURL)}&scope=user`
  res.redirect(url)
}

这样用户访问 /api/connect/github 就会跳转到 Github 授权页面

获取 Github 用户信息

Oauth 后面的步骤就是用户完成授权后,会跳转回我们在应用注册时填写的 Authorization callback URL,在页面 URL 中会加上 code 参数,使用 code 参数获取到 access_token, 然后利用 access_token 访问接口数据。 关于 Github Oauth 详细文档请参考 Authorizing OAuth Apps

创建 /pages/api/oauth/github/callback.ts

import type { NextApiRequest, NextApiResponse } from 'next'
import axios from 'axios'
import cookie from 'cookie'
import { encode } from '../../../../lib/jwt'
import { User } from '../../../../types/user'

type GithubProfile = {
  login: string
  email: string
  blog?: string
  avatar_url: string
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { code } = req.query
  if (!code) {
    res.status(401).json({"message": "bad request"})
    return
  }
  // 使用 code 获取 access_token
  const resp = await axios.post<{access_token: string}>(`https://github.com/login/oauth/access_token`, undefined, {
    params: {
      code,
      client_id: process.env.GITHUB_CLIENT_ID,
      client_secret: process.env.GITHUB_SECRET,
    },
    headers: {
      'Accept': 'application/json'
    }
  })
  const token = resp.data.access_token
  if (!token) {
    res.status(500).json(resp.data)
    return
  }
  // 使用 access_token 获取用户数据
  const userRes = await axios.get<GithubProfile>('https://api.github.com/user', {
    headers: {
      Authorization: `token ${token}`
    }
  })
  const user: User = {
    name: userRes.data.login,
    email: userRes.data.email,
    avatar: userRes.data.avatar_url,
    url: userRes.data.blog
  }
  // 通过 JWT 加密用户数据
  const userToken = encode(user)
  // 将加密后的数据写入 cookie
  const userCookie = cookie.serialize('user', userToken, { httpOnly: true, maxAge: 60 * 60 * 24 * 365, path: '/' })
  res.status(200).setHeader('Set-Cookie', userCookie).write(setupHTML(user))
  res.end()
}

function setupHTML(user: User) {
  return `
    <html>
      <head>
        <script>
          (function() {
            window.opener.postMessage(JSON.stringify({...${JSON.stringify(user)}, type: 'user'}))
            window.close()
          })()
        </script>
      </head>
    </html>
  `
}

我们使用新窗口完成 Github 认证,在获取到 Github 用户数据后,将数据加密后存储在 cookie 中,同时在请求响应中通过 postMessage 把用户数据传递到当前页面,完成后自动关闭认证页面,这样在不刷新页面的情况下完成用户认证。

JWT 加密

由于我们并不使用数据库存储用户数据,而是直接将用户数据存储在 cookie 中,所以我们需要做一层加密,最常见的方式就是使用 JWT(JSON Web Tokens)

首先在环境变量文件中添加 JWT_SECRET 加密密码,然后创建 /lib/jwt.ts

import jwt from 'jsonwebtoken'

const JWT_SECRET = process.env.JWT_SECRET

export function encode(data: string | object) {
  return jwt.sign(data, JWT_SECRET)
}

export function decode<T>(token: string) {
  return jwt.verify(token, JWT_SECRET) as T
}

用户认证

用户验证

添加接口读取 cookie 中用户数据,创建 /pages/api/user.ts

import type { NextApiRequest, NextApiResponse } from 'next'
import cookie from 'cookie'
import { decode, encode } from '../../lib/jwt'

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const userToken = req.cookies.user
  if (!userToken) {
    res.status(401).json({"message": "unauthorized"})
    return
  }
  try {
    const user = decode(userToken)
    res.status(200).json(user)
  } catch (e) {
    res.status(401).json({"message": "unauthorized"})
  }
}

用户退出

添加接口清除用户 cookie 即可退出用户登录,创建 /pages/api/logout.ts

import type { NextApiRequest, NextApiResponse } from 'next'
import cookie from 'cookie'

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const userCookie = cookie.serialize('user', '', { httpOnly: true, expires: new Date(), path: '/' })
  res.status(200).setHeader('Set-Cookie', userCookie)
  res.json({"message": "ok"})
}

用户上下文(React Context)

完成后端接口后,我们创建上下文处理用户认证,创建文件 /context/auth/provider.tsx

import { createContext, PropsWithChildren, useCallback, useEffect, useState } from "react"
import { User } from "../../types/user"

export type AuthContext = {
  user?: User
  login: () => Promise<User>
  logout: () => Promise<void>
}

export const AuthProviderContext = createContext<AuthContext>({
  login: () => Promise.reject(),
  logout: () => Promise.reject(),
})

export function AuthContextProvider({ children }: PropsWithChildren<{}>) {
  const [user, setUser] = useState<User>()

  const login = useCallback(() => {
    const loginWindow = window.open('/api/connect/github', 'loginWindow', 'height=500,width=500')
    return new Promise<User>((resolve, reject) => {
      if (!loginWindow) return reject()
      const messageHandler = (e: MessageEvent) => {
        try {
          const data = JSON.parse(e.data)
          if (data.type === 'user') {
            setUser(data)
            resolve(data)
          }
        } catch (e) { }
      }
     window.addEventListener('message', messageHandler)
    })
  }, [])

  const logout = useCallback(async () => {
    await fetch(`/api/signout`)
    setUser(undefined)
  }, [])

  useEffect(() => {
    fetch(`/api/user`)
      .then(res => {
        if (res.status !== 200) {
          return Promise.reject(res.status)
        }
        return res.json()
      })
      .then(user => setUser(user))
      .catch(() => {})
  }, [])

  return (
    <AuthProviderContext.Provider value={{ user, login, logout}}>
      { children }
    </AuthProviderContext.Provider>
  )

}

更新 /pages/_app.tsx 文件

import '../styles/globals.css'
import type { AppProps } from 'next/app'

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <AuthContextProvider>
      <Component {...pageProps} />
    </AuthContextProvider>
  )
}

export default MyApp

这样我们在任意页面和组件使用上下文,获取当前登录用户,调用登录、退出方法。

在登录环节可以把 Github 换成其他任何第三方,或者使用 passport.js 做处理。

nextjs

评论

还没有评论,快来抢沙发吧!

添加评论