上篇文章主要介紹了 OpenLDAP 的安裝庆冕、部署康吵,這篇文章會講一下如何基于 Next.js 搭建項目、項目主要用到了哪些依賴庫访递、前后端關鍵代碼實現(xiàn)晦嵌,分享一下如何通過 Next.js/MUI 等技術實現(xiàn)一個全棧管理后臺模板(API 輕量無狀態(tài)),并部署到 Vercel 云拷姿,整個過程還是比較容易的惭载,效果也很 Nice。
部署圖
圖中紅色部分屬于應用狀態(tài)响巢,主要包括 LDAP 服務和數(shù)據(jù)庫服務描滔。可以看到數(shù)據(jù)庫是虛線抵乓,因為這個項目僅僅是 Demo伴挚,暫時不涉及數(shù)據(jù)庫操作。實際中灾炭,需要自己購買數(shù)據(jù)庫服務或者自己搭建數(shù)據(jù)庫服務器茎芋。LDAP 服務搭建在了我自己的服務器上,代碼免費跑在 Vercel 云上蜈出,是無狀態(tài)的田弥。
在線體驗 Demo
- 用戶名: huoyijie
- 密碼:123456
接上篇文章
上篇文件已新建 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 cli
$ npm install -g vercel
# 會自動打開瀏覽器寄月,授權終端登錄 vercel 賬號
$ vercel login
進入到項目根目錄
# build
$ vercel build
# 部署到云
$ vercel --prebuilt
等待一會兒,控制臺會提示部署成功无牵,此時打開 vercel.com 網站漾肮,就可以看到剛剛的部署了。
可以看到 Environment 是 Preview茎毁,此時僅限開發(fā)人員可以打開克懊,可以測試一下看看功能是否正常。點擊 Visit 按鈕右邊下拉菜單七蜘,點擊 Promote to Production
可以發(fā)布到生產正式環(huán)境谭溉。
此時打開 https://ldap-auth.vercel.app/ 可以訪問網站了。
如上圖橡卤,可以在網站控制臺上查詢 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。