简易的 Next.js 用户认证系统

2022年1月20日

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

12345678910import 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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172import 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

1234567891011import jwt from 'jsonwebtoken'const JWT_SECRET = process.env.JWT_SECRETexport 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

1234567891011121314151617181920import type { NextApiRequest, NextApiResponse } from 'next'import cookie from 'cookie'import { decode } 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

1234567891011import 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

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859import { 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') {            window.removeEventListener('message', messageHandler)            setUser(data)            resolve(data)          }        } catch (e) { }      }      window.addEventListener('message', messageHandler)    })  }, [])  const logout = useCallback(async () => {    await fetch(`/api/logout`)    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 文件

123456789101112import '../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 做处理。

评论

0 条讨论

匿名评论需填写昵称、邮箱,并通过人机验证。邮箱不会公开展示,提交后需审核通过才会显示。

还没有评论,来抢沙发吧。