Docusaurus 文章密码保护功能实现
替代方案对比
在动手实现之前,先了解有哪些可选方案,选择最适合你的:
| 方案 | 安全性 | 实现复杂度 | 是否依赖平台 | 适用场景 |
|---|---|---|---|---|
| 本文方案(客户端 hash) | ★☆☆☆☆(防君子) | 低 | 否 | 个人站点、非敏感内容 |
| Cloudflare Access / Vercel Password | ★★★☆☆ | 低 | 是 | 已使用对应平台、需要账号级保护 |
| 构建时 AES 加密(remark 插件) | ★★★★☆ | 高 | 否 | 需要真保护、可接受额外复杂度 |
docusaurus-plugin-password-protect | ★★☆☆☆ | 低 | 否 | 不想手写、需求与插件匹配 |
实现思路
密码校验流程
为什么存 hash 不存明文
密码会打进客户端 JS bundle 和 git 仓库。存 SHA-256 hash 避免密码直接暴露。不过 hash 仅防止"一眼看到密码",不能防 hash 碰撞或彩虹表攻击——这个场景下够用。
sessionStorage 解锁粒度
以 hash 为 key 存解锁状态。同一会话内,相同 hash 的文章解锁一次即可,不重复输入。关闭浏览器标签页后解锁状态自动清除。
实现步骤
1. 配置全局默认密码
在 docusaurus.config.js 中新增 customFields.globalPasswordHash:
docusaurus.config.js
customFields: {
// 全局默认密码的 SHA-256 hash
// 修改密码:echo -n "你的密码" | shasum -a 256
globalPasswordHash: "057ba03d6c44104863dc7361fe4578965d1887360f90a0895882e58a6248fc86",
},
生成 hash:
echo -n "changeme" | shasum -a 256
# 输出: 057ba03d6c44104863dc7361fe4578965d1887360f90a0895882e58a6248fc86
2. Swizzle DocItem/Content 组件
Docusaurus 的 swizzle 机制允许覆盖主题组件。这里 eject DocItem/Content——正文渲染的入口组件。
npx docusaurus swizzle @docusaurus/theme-classic DocItem/Content --eject
备注
交互式命令在非 tty 环境下无法选择语言和确认。可以手动创建文件到 src/theme/DocItem/Content/index.js,Docusaurus 会自动识别 swizzled 组件。
3. 密码保护组件
新建 src/components/PasswordProtect.js:
src/components/PasswordProtect.js
import React, {useState, useCallback} from 'react';
const styles = {
container: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '200px',
padding: '2rem',
textAlign: 'center',
},
title: {
fontSize: '1.5rem',
fontWeight: 600,
marginBottom: '0.5rem',
},
hint: {
fontSize: '0.9rem',
opacity: 0.7,
marginBottom: '1.5rem',
},
inputRow: {
display: 'flex',
gap: '0.5rem',
width: '100%',
maxWidth: '360px',
},
input: {
flex: 1,
padding: '0.5rem 0.75rem',
fontSize: '1rem',
border: '1px solid var(--ifm-color-emphasis-300)',
borderRadius: '4px',
background: 'var(--ifm-background-color)',
color: 'var(--ifm-font-color-base)',
outline: 'none',
},
button: {
padding: '0.5rem 1.25rem',
fontSize: '1rem',
border: 'none',
borderRadius: '4px',
background: 'var(--ifm-color-primary)',
color: '#fff',
cursor: 'pointer',
whiteSpace: 'nowrap',
},
error: {
color: 'var(--ifm-color-danger)',
fontSize: '0.85rem',
marginTop: '0.75rem',
},
};
async function sha256(text) {
const encoder = new TextEncoder();
const data = encoder.encode(text);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}
function getUnlockedHash(hash) {
try {
return sessionStorage.getItem(`galaxy-unlocked-${hash}`) === '1';
} catch {
return false;
}
}
function setUnlockedHash(hash) {
try {
sessionStorage.setItem(`galaxy-unlocked-${hash}`, '1');
} catch {
// sessionStorage 不可用时静默失败
}
}
export default function PasswordProtect({passwordHash, children}) {
const [unlocked, setUnlocked] = useState(() => getUnlockedHash(passwordHash));
const [inputValue, setInputValue] = useState('');
const [error, setError] = useState('');
const handleUnlock = useCallback(
async (e) => {
e.preventDefault();
const inputHash = await sha256(inputValue);
if (inputHash === passwordHash) {
setUnlockedHash(passwordHash);
setUnlocked(true);
setError('');
} else {
setError('密码错误,请重试');
}
},
[inputValue, passwordHash],
);
if (unlocked) {
return <>{children}</>;
}
return (
<div style={styles.container}>
<p style={styles.title}>🔒 此文章需要密码访问</p>
<p style={styles.hint}>请输入密码以查看正文内容</p>
<form onSubmit={handleUnlock} style={styles.inputRow}>
<input
type="password"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="请输入密码"
style={styles.input}
autoFocus
/>
<button type="submit" style={styles.button}>
解锁
</button>
</form>
{error && <p style={styles.error}>{error}</p>}
</div>
);
}