基于 OpenLDAP、React忘嫉、MUI 組件庫荤牍、Next.js、Serverless 等技術實現(xiàn)一個管理后臺模板(二)

上篇文章主要介紹了 OpenLDAP 的安裝庆冕、部署康吵,這篇文章會講一下如何基于 Next.js 搭建項目、項目主要用到了哪些依賴庫访递、前后端關鍵代碼實現(xiàn)晦嵌,分享一下如何通過 Next.js/MUI 等技術實現(xiàn)一個全棧管理后臺模板(API 輕量無狀態(tài)),并部署到 Vercel 云拷姿,整個過程還是比較容易的惭载,效果也很 Nice。

部署圖

deployment.png

圖中紅色部分屬于應用狀態(tài)响巢,主要包括 LDAP 服務和數(shù)據(jù)庫服務描滔。可以看到數(shù)據(jù)庫是虛線抵乓,因為這個項目僅僅是 Demo伴挚,暫時不涉及數(shù)據(jù)庫操作。實際中灾炭,需要自己購買數(shù)據(jù)庫服務或者自己搭建數(shù)據(jù)庫服務器茎芋。LDAP 服務搭建在了我自己的服務器上,代碼免費跑在 Vercel 云上蜈出,是無狀態(tài)的田弥。

在線體驗 Demo

代碼地址

在線體驗 Demo

  • 用戶名: huoyijie
  • 密碼:123456
signin.png
dashboard.png

接上篇文章

上篇文件已新建 Next.js (116k stars) 項目,接下來繼續(xù)搭建項目铡原,可參考 Next.js 文檔偷厦。

1.安裝配置 MUI (90k stars)

參考文檔

$ npm install @mui/material @emotion/react @emotion/styled
# font
$ npm install @fontsource/roboto
# icons
$ npm install @mui/icons-material

編輯 src/pages/_app.js 文件

// 全局配置,引入字體
import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
export default function App({ Component, pageProps }) {
  return (
    <>
      <Head>
        {/* Responsive meta tag */}
        <meta name="viewport" content="initial-scale=1, width=device-width" />
      </Head>
      {/* 可選燕刻,修復一些跨瀏覽器和設備的不一致性 */}
      <CssBaseline />
      <Component {...pageProps} />
    </>
  );
}

2.安裝 react-hook-form (38k stars)

一個流行的 react 表單庫只泼,幫助快速構建各種復雜的表單。

參考文檔

$ npm install react-hook-form

登錄頁面用到了表單:

import Avatar from '@mui/material/Avatar'
import Button from '@mui/material/Button'
import TextField from '@mui/material/TextField'
import FormControlLabel from '@mui/material/FormControlLabel'
import Checkbox from '@mui/material/Checkbox'
import Link from '@mui/material/Link'
import Grid from '@mui/material/Grid'
import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
import Typography from '@mui/material/Typography'
import LockOutlinedIcon from '@mui/icons-material/LockOutlined'

import { useState } from 'react'
import { useRouter } from 'next/router'

// 這里引入表單 hook
import { useForm, Controller } from 'react-hook-form'

import useMutation from './hooks/useMutation'
import useToken from './hooks/useToken'
import LayoutUnlogin from './LayoutUnlogin'
import FeedbackSnackbar from './FeedbackSnackbar'
import util from '@/lib/util'

export default function SignIn() {
  const router = useRouter()
  const [showFeedback, setShowFeedback] = useState(false)
  const token = useToken()

  const { trigger: grantToken, isMutating, error } = useMutation({ url: '/api/token/grant' })
  const [submitting, setSubmitting] = useState(false)
  const disabled = isMutating || submitting

  // 表單
  const { handleSubmit, control, formState: { errors } } = useForm()

  const onSubmit = async ({ username, password }) => {
    setSubmitting(true)

    try {
      const data = await grantToken({
        username,
        password,
      })
      token.set(data)
      setShowFeedback(true)
      await util.wait(1000)
      router.push('/')
    } catch (error) {
      setShowFeedback(true)
      setSubmitting(false)
    }
  }

  return (
    <LayoutUnlogin>
      <FeedbackSnackbar open={showFeedback} isError={!!error} message={!!error ? (error.message || '登錄失敗') : '登錄成功'} onClose={() => setShowFeedback(false)} />

      <Avatar sx={{ m: 1, bgcolor: 'secondary.main' }}>
        <LockOutlinedIcon />
      </Avatar>
      <Typography component="h1" variant="h5">
        登錄管理后臺
      </Typography>

      <Box component="form" onSubmit={handleSubmit(onSubmit)} noValidate sx={{ mt: 1 }}>
        {/* 注意 Controller */}
        <Controller
          name="username"
          control={control}
          rules={{
            required: '請輸入用戶名',
            minLength: {
              value: 6,
              message: '用戶名長度不能小于6'
            },
            maxLength: {
              value: 32,
              message: '用戶名長度不能大于32'
            },
          }}
          defaultValue=""
          render={({ field }) => (
            <TextField
              id="username"
              label="用戶名"
              error={!!errors.username}
              helperText={errors.username?.message}
              {...field}
              margin="normal"
              required
              fullWidth
              autoComplete="username"
              autoFocus
              disabled={disabled}
            />
          )}
        />
        <Controller
          name="password"
          control={control}
          rules={{
            required: '請輸入密碼',
            minLength: {
              value: 6,
              message: '密碼長度不能小于6',
            },
            maxLength: {
              value: 32,
              message: '密碼長度不能大于32'
            },
          }}
          defaultValue=""
          render={({ field }) => (
            <TextField
              id="password"
              type="password"
              label="密碼"
              error={!!errors.password}
              helperText={errors.password?.message}
              {...field}
              margin="normal"
              required
              fullWidth
              autoComplete="current-password"
              disabled={disabled}
            />
          )}
        />
        <Controller
          name="rememberMe"
          control={control}
          rules={{
            onChange: (e) => token.setRememberMe(e.target.value),
          }}
          render={({ field }) => (
            <FormControlLabel
              control={
                <Checkbox
                  id="rememberMe"
                  {...field}
                  checked={token.rememberMe}
                  disabled={disabled}
                />
              }
              label="記住我"
            />
          )}
        />
        <Button
          type="submit"
          fullWidth
          variant="contained"
          disabled={disabled}
          sx={{ mt: 3, mb: 2 }}
        >
          {isMutating ? (
            <CircularProgress size={24} />
          ) : (
            '登錄'
          )}
        </Button>
        <Grid container>
          <Grid item xs>
            <Link href="#" variant="body2">
              忘記密碼
            </Link>
          </Grid>
          <Grid item>
            <Typography component="span" variant="body2">
              沒有賬號卵洗?請聯(lián)系管理員
            </Typography>
          </Grid>
        </Grid>
      </Box>
    </LayoutUnlogin>
  )
}

3.安裝 @react-hookz/web (1.7k stars)

用戶在登錄成功后會把 token 寫入存儲请唱,勾選"記住我",寫入 localStorage,未勾選寫入 sessionStorage十绑。sessionStorage 中的內容在會話結束后會自動清理掉(如關閉網頁或退出瀏覽器)聚至。

剛開始嘗試使用 react-use(39k stars),但遇到了一個問題不能很好的實現(xiàn)本橙。在查看 issues 時扳躬,看到有開發(fā)者提到 @react-hookz/web 這個庫提供了參數(shù)可以解決這個問題,而我只需要用到存儲 hook甚亭,所以就用了這個庫贷币。

參考文檔

$ npm i @react-hookz/web

我封裝了 src/components/hooks/useToken 這個 hook 函數(shù)專門用來讀寫 token。

import { useLocalStorageValue, useSessionStorageValue } from '@react-hookz/web'

export default function useToken() {
  const rememberMe = useLocalStorageValue('remember_me', {
    defaultValue: false,
    initializeWithValue: false,
  })
  const localToken = useLocalStorageValue('token', {
    initializeWithValue: false,
  })
  const sessionToken = useSessionStorageValue('token', {
    initializeWithValue: false,
  })

  const token = rememberMe.value ? localToken : sessionToken

  return {
    // not undefined, but 'undefined'
    ready: (typeof token.value != 'undefined'),
    rememberMe: rememberMe.value || false,
    setRememberMe(value) {
      rememberMe.set(value)
    },
    value: token.value,
    set(data) {
      this.remove()
      token.set(data)
    },
    remove() {
      localToken.remove()
      sessionToken.remove()
    },
  }
}

4.安裝 jsonwebtoken

/api/token/grant 接口在處理用戶登錄時狂鞋,會連接 LDAP 服務器片择,對用戶進行認證,如果成功就會返回 jwt token骚揍,包含一個短期有效 access_token 和一個長期有效的 refresh_token,當 access_token 失效后可以通過后者獲取新的 token(刷新 token 的邏輯本次暫未實現(xiàn))啰挪。

我把 token 相關操作封裝在了 src/lib/api/util.js 文件中:

import crypto from 'crypto'
import jwt from 'jsonwebtoken'

const { SECRET_KEY, ACCESS_TOKEN_EXPIRES } = process.env

export default {
  newToken(username) {
    const accessToken = jwt.sign(
      { username },
      SECRET_KEY,
      { expiresIn: ACCESS_TOKEN_EXPIRES })

    const refreshToken = crypto.randomBytes(32).toString('base64url')

    return {
      access_token: accessToken, token_type: 'Bearer',
      expires_in: ACCESS_TOKEN_EXPIRES,
      refresh_token: refreshToken
    }
  },

  getToken(req) {
    const { authorization } = req.headers
    if (authorization) {
      return authorization.replace('Bearer ', '')
    }
  },

  verifyToken(accessToken) {
    return jwt.verify(accessToken, SECRET_KEY)
  },

  verifyMethod(req, method) {
    return req.method.toUpperCase() === method.toUpperCase()
  },

  wrapper(handler, method) {
    return async (req, res) => {
      const data = this.verifyMethod(req, method) ? await handler(req, res) : {
        statusCode: 405,
        code: 'MethodNotAllowed',
        message: '請求方法找不到',
      }

      res.status(data.statusCode || 200).json(data)
    }
  },
}

5.安裝 ldapjs

$ npm i ldapjs

/api/token/grant 接口收到登錄請求后會通過這個庫連接 LDAP 服務器信不,可以看看 src/pages/api/token/grant.js 文件

import ldap from 'ldapjs'
import util from '@/lib/api/util'
import handleUncaughtError from '@/lib/api/middleware/handleUncaughtError'
import post from '@/lib/api/middleware/post'

function bindAsync(client, userDN, password) {
  return new Promise((resolve, reject) => {
    client.bind(userDN, password, (err) => {
      if (err) {
        reject(err)
      } else {
        resolve()
      }
    })
  })
}

function unbindAsync(client) {
  return new Promise((resolve, reject) => {
    client.unbind((err) => {
      if (err) {
        reject(err)
      } else {
        resolve()
      }
    })
  })
}

async function ldapAuthenticate(username, password) {
  const ldapOptions = {
    // 通過環(huán)境變量配置 LDAP 服務器 URL
    url: process.env.LDAP_SERVER,
    tlsOptions: {
      rejectUnauthorized: false, // 禁用證書驗證
    },
  }

  const userDN = `uid=${username},ou=users,dc=huoyijie,dc=cn`

  const client = ldap.createClient(ldapOptions)

  try {
    await bindAsync(client, userDN, password)
    // Perform other LDAP operations if needed...
    await unbindAsync(client)
    return true
  } catch (err) {
    return false
  }
}

async function grant(req) {
  const { username, password } = req.body

  if (await ldapAuthenticate(username, password)) {
    return util.newToken(username)
  } else {
    return { statusCode: 400, code: 'BadRequest', message: '用戶名或密碼不正確' }
  }
}

export default handleUncaughtError(post(grant))

6.安裝 swr (28.6k stars)

$ npm i swr

參考文檔

我自己認為通過 swr 實現(xiàn) Data Fetching 是這個項目中非常值得一看的點。擺脫了以往在頁面生命周期函數(shù)或者 useEffect 中執(zhí)行 Data Fetching亡呵,提出了聲明式抽活、響應式的數(shù)據(jù)獲取方法,非常直觀清晰锰什。

請看下面這段示例代碼下硕,Profile 組件先是聲明請求 /api/user 接口,如果正在加載數(shù)據(jù)顯示 loading...汁胆,如果遇到錯誤顯示錯誤梭姓,如果數(shù)據(jù)獲取成功則顯示名字。

我們知道請求 api 接口是不可能立刻返回的嫩码,要等到服務器接收到請求并返回響應誉尖,但是下面這段代碼是很神奇的,會不斷根據(jù)數(shù)據(jù)獲取的最新狀態(tài)實時刷新頁面铸题。

import useSWR from 'swr'

function Profile() {
  const { data, error, isLoading } = useSWR('/api/user')

  if (isLoading) return <div>loading...</div>
  if (error) return <div>failed to load</div>
  return <div>hello {data.name}!</div>
}

對比一下傳統(tǒng)可能的寫法:

function Profile() {
  const [isLoading, setLoading] = useState()
  const [error, setError] = useState()
  const [data, setData] = useState()

  const getProfile = async () => {
    setLoading(true)
    try {
      const res = await fetch('/api/user')
      if (!res.ok) {
        setError('response was not ok')
      } else {
        setData(res.json())
      }
    } catch (e) {
      setError(e)
    } finally {
      setLoading(false)
    }
  }

  useEffect(() => {
    getProfile()
  }, [])

  if (error) return <div>failed to load</div>
  if (isLoading) return <div>loading...</div>
  return <div>hello {data.name}!</div>
}

除了上面提到的這點铡恕,swr 還實現(xiàn)了很多有價值的特性,值得我們學習一下丢间。

和 swr 相關的文件主要是:

  • src/lib/fetcher.js
  • src/components/hooks/useQuery
  • src/components/hooks/useMutation
  • src/components/AppSWRConfig

其中 AppSWRConfig 中配置了全局的 fetcher 函數(shù)和 Data Fetching 錯誤處理函數(shù)探熔。

7.安裝 recharts (21.6k stars)

一個基于 React 的 Charts 圖表庫。

參考文檔

$ npm i recharts

Dashboard 上顯示的圖表就是用這個庫生成的烘挫。

8.其他組件诀艰、工具函數(shù)

前端如 Layout、LayoutUnlogin、AppBar涡驮、Drawer 等等組件暗甥,后端如 src/lib/api/middleware 下面的擴展中間件函數(shù),實現(xiàn)了 token 校驗和全局錯誤捕獲處理等捉捅。

部署到 Vercel 云

打開 vercel.com 網站撤防,注冊會員(可通過 github 第三方登錄),登錄后要注意要選擇 Hobby 免費計劃棒口。

vercel-hobby.png
# 全局安裝 vercel cli
$ npm install -g vercel

# 會自動打開瀏覽器寄月,授權終端登錄 vercel 賬號
$ vercel login

進入到項目根目錄

# build
$ vercel build

# 部署到云
$ vercel --prebuilt

等待一會兒,控制臺會提示部署成功无牵,此時打開 vercel.com 網站漾肮,就可以看到剛剛的部署了。

vercel-deploy.png

可以看到 Environment 是 Preview茎毁,此時僅限開發(fā)人員可以打開克懊,可以測試一下看看功能是否正常。點擊 Visit 按鈕右邊下拉菜單七蜘,點擊 Promote to Production 可以發(fā)布到生產正式環(huán)境谭溉。

vercel-production.png

此時打開 https://ldap-auth.vercel.app/ 可以訪問網站了。

vercel-logs.png

如上圖橡卤,可以在網站控制臺上查詢 API 訪問日志扮念,會顯示 http 狀態(tài)碼,如果有異常錯誤碧库,右邊也會顯示具體錯誤信息柜与。

最后

本文主要從代碼實現(xiàn)角度粗略分享了一些主要內容,看完后建議可以打開項目代碼瀏覽一遍嵌灰,也可以嘗試在本地運行一下弄匕,不過要在電腦上安裝配置 OpenLDAP。

運行項目需要配置幾個環(huán)境變量伞鲫,在項目根目錄建立 .env.env.development 文件粘茄,并編輯內容如下:

# 本地安裝 openLDAP 可以不用開啟 TLS
LDAP_SERVER=ldap://127.0.0.1
# 如果要嘗試開啟 TLS,使用下面的配置
# LDAP_SERVER=ldaps://127.0.0.1

# 隨機生成的 uuid秕脓,作為生成 JWT Token 的密鑰
SECRET_KEY=fe8c1d970acd410c89f0d0148d3ebd0b

# access token 過期時間柒瓣,單位毫秒
ACCESS_TOKEN_EXPIRES=3600000

可以看到通過 Next.js 實現(xiàn)一個全棧項目(API 輕量無狀態(tài)),最后部署到 Vercel 云吠架,整個過程還是比較容易的芙贫,效果也很 Nice。當然一個應用不可能完全沒有狀態(tài)傍药,此時可以配合自己搭建數(shù)據(jù)庫服務器來存儲應用狀態(tài)磺平,這時會出現(xiàn) Vercel 云跨互聯(lián)網訪問其他數(shù)據(jù)庫服務器的情況魂仍,記得一定要開啟 TLS

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末拣挪,一起剝皮案震驚了整個濱河市擦酌,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌菠劝,老刑警劉巖赊舶,帶你破解...
    沈念sama閱讀 207,113評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異赶诊,居然都是意外死亡笼平,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,644評論 2 381
  • 文/潘曉璐 我一進店門舔痪,熙熙樓的掌柜王于貴愁眉苦臉地迎上來寓调,“玉大人,你說我怎么就攤上這事锄码《嵊ⅲ” “怎么了?”我有些...
    開封第一講書人閱讀 153,340評論 0 344
  • 文/不壞的土叔 我叫張陵巍耗,是天一觀的道長秋麸。 經常有香客問我,道長炬太,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,449評論 1 279
  • 正文 為了忘掉前任驯耻,我火速辦了婚禮亲族,結果婚禮上,老公的妹妹穿的比我還像新娘可缚。我一直安慰自己霎迫,他們只是感情好,可當我...
    茶點故事閱讀 64,445評論 5 374
  • 文/花漫 我一把揭開白布帘靡。 她就那樣靜靜地躺著知给,像睡著了一般。 火紅的嫁衣襯著肌膚如雪描姚。 梳的紋絲不亂的頭發(fā)上涩赢,一...
    開封第一講書人閱讀 49,166評論 1 284
  • 那天,我揣著相機與錄音轩勘,去河邊找鬼筒扒。 笑死,一個胖子當著我的面吹牛绊寻,可吹牛的內容都是我干的花墩。 我是一名探鬼主播悬秉,決...
    沈念sama閱讀 38,442評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼冰蘑!你這毒婦竟也來了和泌?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 37,105評論 0 261
  • 序言:老撾萬榮一對情侶失蹤祠肥,失蹤者是張志新(化名)和其女友劉穎武氓,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體搪柑,經...
    沈念sama閱讀 43,601評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡聋丝,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 36,066評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了工碾。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片弱睦。...
    茶點故事閱讀 38,161評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖渊额,靈堂內的尸體忽然破棺而出况木,到底是詐尸還是另有隱情,我是刑警寧澤旬迹,帶...
    沈念sama閱讀 33,792評論 4 323
  • 正文 年R本政府宣布火惊,位于F島的核電站,受9級特大地震影響奔垦,放射性物質發(fā)生泄漏屹耐。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,351評論 3 307
  • 文/蒙蒙 一椿猎、第九天 我趴在偏房一處隱蔽的房頂上張望惶岭。 院中可真熱鬧,春花似錦犯眠、人聲如沸按灶。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,352評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽鸯旁。三九已至,卻和暖如春量蕊,著一層夾襖步出監(jiān)牢的瞬間铺罢,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,584評論 1 261
  • 我被黑心中介騙來泰國打工危融, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留畏铆,地道東北人。 一個月前我還...
    沈念sama閱讀 45,618評論 2 355
  • 正文 我出身青樓吉殃,卻偏偏與公主長得像辞居,于是被迫代替她去往敵國和親楷怒。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 42,916評論 2 344

推薦閱讀更多精彩內容