diff --git a/GIT_EDITING.md b/GIT_EDITING.md index 13df40b..ab972c0 100644 --- a/GIT_EDITING.md +++ b/GIT_EDITING.md @@ -1,6 +1,6 @@ # 使用 Git 编辑文档 -登录 Wiki,在一篇文档的“更多 → Git 提交”中获取仓库地址并生成 Git 专用令牌。每篇文档对应一个 HTTPS Git 仓库,只有 `main` 分支与 `page.md` 文件。用户名可任意填写,密码使用令牌;不要把令牌写入仓库或远程 URL。令牌只显示一次,30 天后失效,可随时撤销或更换。 +登录 Wiki,在一篇文档的“更多 → Git 提交”中获取仓库地址并生成 通用 API 令牌。每篇文档对应一个 HTTPS Git 仓库,只有 `main` 分支与 `page.md` 文件。用户名可任意填写,密码使用令牌;不要把令牌写入仓库或远程 URL。令牌只显示一次,默认 30 天有效,可选择 7/30/90/365 天、自定义到期时间或永不过期,也可随时撤销或更换。个人设置和 `/tokens` 页面也能管理令牌。 ```sh git clone https://www.nodeloc.wiki/git/pages/<文档ID>.git @@ -23,3 +23,10 @@ git push origin main 私有页面要求相应权限,删除的页面停止提供仓库。隐藏、永久清除或已物理删除的修订会截断下载的历史,Git 对象不能通过公共媒体接口读取。已经下载到用户电脑上的内容无法远程撤回。 开发验证:`npm run test:git` 使用真实 Git CLI 验证 clone、push、网页修改后的 pull、拒绝 force/delete、权限撤销、私有访问与隐藏修订边界。 + + +## 通用 API 和 MCP + +Git、Wiki API 和 MCP 共用同一个个人令牌。API 请求使用 `Authorization: Bearer <令牌>`;Git 仍使用 HTTP Basic,令牌作为密码。现有 `git_` 令牌继续有效,新生成的令牌使用 `wiki_` 前缀。权限每次请求按当前账号与文档 ACL 检查,封禁、撤销和到期立即生效。 + +`GET/POST/DELETE /api/me/api-token` 用于网页登录后的管理,原 `/api/me/git-token` 保留兼容。POST JSON 可指定 `expires_at`(Unix 秒,必须为未来时间),`0` 或 `null` 表示永不过期,不传则为 30 天。令牌本身不能创建或管理令牌;生成新令牌会替换旧令牌。服务端仅保存 SHA-256 摘要。 diff --git a/packages/wiki-shared/src/i18n/en.json b/packages/wiki-shared/src/i18n/en.json index 08fc260..d5bd31e 100644 --- a/packages/wiki-shared/src/i18n/en.json +++ b/packages/wiki-shared/src/i18n/en.json @@ -3477,17 +3477,24 @@ "policies.footer.terms": "Terms of service", "policies.footer.privacy": "Privacy policy", "git.title": "Git editing", - "git.intro": "Each page has its own repository containing page.md. Use any username and your Git token as the password.", + "git.intro": "Each page has its own repository containing page.md. Use any username and your personal API token as the password.", "git.limits": "Only linear fast-forward updates to main are supported. History replacement, branch deletion, tags and merge commits are rejected. Limit: 40 commits and 4 MiB per push; page.md at most 512 KiB. Initial import includes up to 20 revisions; fetch includes up to 100 visible commits. Hidden or purged revisions form a shallow boundary. A batch of Git commits creates one Wiki revision.", - "git.token": "Git access token", + "git.token": "Personal API token", "git.generate": "Generate / rotate token", "git.revoke": "Revoke token", "git.clone": "Clone and push", "git.commands": "After editing page.md:", "git.back": "Back to Wiki", - "git.tokenHint": "The token is shown once and expires in 30 days. Rotating or revoking immediately invalidates the previous token. Page permissions and ACLs still apply. Git author fields are not verified identities.", + "git.tokenHint": "Shown once. Choose an expiration; rotation or revocation invalidates the previous token immediately. Tokens use your current account permissions and page ACLs. Git author fields are not verified identities.", "git.updated": "Token updated. Open Git from a page’s More menu to get its repository URL.", "git.failed": "Operation failed. Sign in again and retry.", "git.confirm": "Generating a new token immediately invalidates the old token. Continue?", - "permissions.key.git:push": "Push through Git" + "permissions.key.git:push": "Push through Git", + "tokens.title": "Personal API token", + "tokens.expiry": "Lifetime (days)", + "tokens.customExpiry": "Custom expiration", + "tokens.never": "Never expires", + "tokens.apiHint": "Use the same token as your Git password or send Authorization: Bearer TOKEN to the Wiki API and MCP. Keep it private.", + "tokens.invalidExpiry": "Choose a future expiration time.", + "tokens.sessionRequired": "Sign in through the browser to manage tokens." } diff --git a/packages/wiki-shared/src/i18n/source.json b/packages/wiki-shared/src/i18n/source.json index ce7be2f..f22d8f8 100644 --- a/packages/wiki-shared/src/i18n/source.json +++ b/packages/wiki-shared/src/i18n/source.json @@ -3477,17 +3477,24 @@ "policies.footer.terms": "服务条款", "policies.footer.privacy": "隐私政策", "git.title": "Git 편집", - "git.intro": "각 문서는 page.md 파일을 포함한 독립 저장소를 가집니다. 사용자 이름은 임의로 입력하고 Git 토큰을 비밀번호로 사용하세요.", + "git.intro": "각 문서는 page.md를 포함한 독립 저장소입니다. 사용자 이름은 임의로 입력하고 개인 API 토큰을 비밀번호로 사용하세요.", "git.limits": "main 브랜치의 선형 빨리 감기만 지원합니다. 이력 덮어쓰기, 브랜치 삭제, 태그 및 병합 커밋은 거부합니다. 푸시당 40개 커밋, 4 MiB, 본문은 512 KiB까지 가능합니다. 최초 가져오기는 최대 20개 리비전, 조회는 최대 100개 공개 커밋이며 숨김·영구 삭제 리비전에서 이력이 잘립니다. Git 커밋 묶음은 하나의 위키 리비전으로 기록됩니다.", - "git.token": "Git 전용 토큰", + "git.token": "개인 API 토큰", "git.generate": "토큰 생성 / 교체", "git.revoke": "토큰 폐기", "git.clone": "복제 및 푸시", "git.commands": "page.md를 편집한 후:", "git.back": "위키로 돌아가기", - "git.tokenHint": "토큰은 한 번만 표시되며 30일 후 만료됩니다. 교체나 폐기 시 이전 토큰은 즉시 무효화됩니다. 문서 권한과 ACL이 적용되며 Git 작성자 정보는 검증된 신원이 아닙니다.", + "git.tokenHint": "토큰은 한 번만 표시됩니다. 만료일을 선택할 수 있으며 교체나 폐기 시 이전 토큰은 즉시 무효화됩니다. 현재 계정 권한과 문서 ACL이 적용됩니다. Git 작성자 정보는 검증된 신원이 아닙니다.", "git.updated": "토큰이 업데이트되었습니다. 문서의 더보기 메뉴에서 Git을 열어 저장소 주소를 확인하세요.", "git.failed": "작업에 실패했습니다. 다시 로그인한 후 재시도하세요.", "git.confirm": "새 토큰을 생성하면 이전 토큰이 즉시 무효화됩니다. 계속할까요?", - "permissions.key.git:push": "Git으로 제출" + "permissions.key.git:push": "Git으로 제출", + "tokens.title": "개인 API 토큰", + "tokens.expiry": "유효 기간 (일)", + "tokens.customExpiry": "사용자 지정 만료 시간", + "tokens.never": "만료 없음", + "tokens.apiHint": "같은 토큰을 Git 비밀번호로 사용하거나 Authorization: Bearer TOKEN 헤더로 Wiki API와 MCP를 호출하세요. 안전하게 보관하세요.", + "tokens.invalidExpiry": "미래 만료 시간을 선택하세요.", + "tokens.sessionRequired": "토큰 관리는 브라우저 로그인이 필요합니다." } diff --git a/packages/wiki-shared/src/i18n/zh-CN.json b/packages/wiki-shared/src/i18n/zh-CN.json index d404a73..08408b9 100644 --- a/packages/wiki-shared/src/i18n/zh-CN.json +++ b/packages/wiki-shared/src/i18n/zh-CN.json @@ -3477,17 +3477,24 @@ "policies.footer.terms": "服务条款", "policies.footer.privacy": "隐私政策", "git.title": "Git 提交", - "git.intro": "每篇文档拥有独立仓库,文件为 page.md。用户名可任意填写,密码填写下方生成的 Git 令牌。", + "git.intro": "每篇文档拥有独立仓库,文件为 page.md。用户名可任意填写,密码填写下方生成的通用 API 令牌。", "git.limits": "仅支持 main 分支的线性快进提交。禁止覆盖历史、删除分支、标签及合并提交。每次推送最多 40 个提交、4 MiB;正文最大 512 KiB。初次导入最多 20 条修订,每次读取最多 100 个可见提交;隐藏或清除的修订会截断历史。批量 Git 提交对应一条 Wiki 修订。", - "git.token": "Git 专用令牌", + "git.token": "通用 API 令牌", "git.generate": "生成 / 更换令牌", "git.revoke": "撤销令牌", "git.clone": "克隆与提交", "git.commands": "编辑 page.md 后执行:", "git.back": "返回 Wiki", - "git.tokenHint": "令牌只显示一次,30 天后过期。更换或撤销后旧令牌立即失效。提交仍受文档编辑权限和 ACL 限制;Git 作者字段不代表已验证的用户身份。", + "git.tokenHint": "令牌只显示一次,可自选有效期,更换或撤销后旧令牌立即失效。令牌拥有当前账号的操作权限,仍受文档 ACL 限制;Git 作者字段不代表已验证的身份。", "git.updated": "令牌已更新。请从文档的“更多”菜单进入 Git,获取该文档的仓库地址。", "git.failed": "操作失败,请重新登录后重试。", "git.confirm": "生成新令牌会使旧令牌立即失效,继续?", - "permissions.key.git:push": "通过 Git 提交" + "permissions.key.git:push": "通过 Git 提交", + "tokens.title": "通用 API 令牌", + "tokens.expiry": "有效期(天)", + "tokens.customExpiry": "自定义到期时间", + "tokens.never": "永不过期", + "tokens.apiHint": "同一令牌可用作 Git 密码,也可通过 Authorization: Bearer TOKEN 调用 Wiki API 和 MCP。请妥善保管。", + "tokens.invalidExpiry": "请选择未来的到期时间。", + "tokens.sessionRequired": "请通过网页登录管理令牌。" } diff --git a/src/client/pages/mypage.ts b/src/client/pages/mypage.ts index 9c0eb20..90f9aa2 100644 --- a/src/client/pages/mypage.ts +++ b/src/client/pages/mypage.ts @@ -1167,6 +1167,12 @@ document.addEventListener('DOMContentLoaded', async () => { const listEl = document.getElementById('mcpClientsList'); if (!section || !listEl) return; + const tokenContainer = document.getElementById('mcpApiKeyContainer'); + if (tokenContainer && !document.getElementById('personalApiTokenLink')) { + const link = document.createElement('a'); link.id = 'personalApiTokenLink'; + link.href = '/tokens'; link.className = 'btn btn-outline-wiki mb-3'; + link.textContent = ui('tokens.title'); tokenContainer.before(link); + } // 위키 MCP 엔드포인트 URL 및 API 키 JSON 스니펫 세팅 (origin + /api/mcp) const wikiEndpointEl = document.getElementById('wikiMcpEndpointUrl'); if (wikiEndpointEl) wikiEndpointEl.textContent = window.location.origin + '/api/mcp'; diff --git a/src/index.ts b/src/index.ts index 1edc502..ba142d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,21 +52,21 @@ app.use('*', localeMiddleware); // Secure Headers app.use('*', secureHeaders()); +// RBAC 초기화 및 세션 미들웨어 (모든 요청에서 유저 정보를 주입) +app.use('*', rbacMiddleware); +app.use('*', sessionMiddleware); // CSRF 보호 (GET/HEAD/OPTIONS 제외) // MCP / OAuth 토큰 엔드포인트는 외부 서비스(Claude 등)에서 호출하므로 CSRF 제외. // /oauth/authorize 는 위키 도메인의 동의 폼에서 POST 되므로 CSRF 적용 (Origin 자동 검증). app.use('*', (c, next) => { const path = c.req.path; + if (path.startsWith('/api/') && c.get('apiTokenAuthenticated') && !['/api/me/api-token','/api/me/git-token','/api/me/mcp-api-key'].includes(path)) return next(); if (/^\/git\/pages\/[1-9][0-9]*\.git\/git-(upload|receive)-pack$/.test(path)) return next(); if (path === '/api/mcp' || path.startsWith('/api/mcp/')) return next(); if (path === '/oauth/token' || path === '/oauth/register' || path === '/oauth/revoke') return next(); return csrf()(c, next); }); -// RBAC 초기화 및 세션 미들웨어 (모든 요청에서 유저 정보를 주입) -app.use('*', rbacMiddleware); -app.use('*', sessionMiddleware); - // ── closed 위키에서 banned 유저의 접근 제한 ── // WIKI_VISIBILITY=closed 인 환경의 banned 사용자는 다음 세 슬러그(=wrangler.toml 환경변수) // 와 인증·정적 자산 경로만 허용한다 — 차단된 사용자가 위키 본 콘텐츠를 우회 열람하지 못하도록. diff --git a/src/middleware/session.ts b/src/middleware/session.ts index 159f237..7206591 100644 --- a/src/middleware/session.ts +++ b/src/middleware/session.ts @@ -1,3 +1,4 @@ +import {authenticatePersonalToken} from '../utils/personalTokens'; import { ui } from '../i18n/server'; import { createMiddleware } from 'hono/factory'; import { getCookie } from 'hono/cookie'; @@ -28,6 +29,13 @@ export const rbacMiddleware = createMiddleware(async (c, next) => { const SESSION_CACHE_TTL = 1800; // KV 캐시 TTL: 30분 export const sessionMiddleware = createMiddleware(async (c, next) => { + const authorization = c.req.header('Authorization') || ''; + if (c.req.path.startsWith('/api/') && /^Bearer (?:wiki|git)_/i.test(authorization)) { + const user = await authenticatePersonalToken(c.env, authorization.slice(7).trim()); + if (!user) return c.json({ error: 'Invalid or expired API token' }, 401, { 'Cache-Control': 'no-store' }); + c.set('user', user); c.set('apiTokenAuthenticated', true); + return next(); + } const sessionId = getCookie(c, 'wiki_session'); if (!sessionId) { @@ -176,3 +184,9 @@ export function requirePermission(permission: string) { return next(); }); } + +/** Credential management is reserved for browser sessions, never personal bearer tokens. */ +export const requireBrowserSession = createMiddleware(async (c, next) => { + if (c.get('apiTokenAuthenticated')) return c.json({error:ui('tokens.sessionRequired')},403); + return next(); +}); diff --git a/src/routes/auth/index.ts b/src/routes/auth/index.ts index 738f32f..fdb1845 100644 --- a/src/routes/auth/index.ts +++ b/src/routes/auth/index.ts @@ -1,3 +1,4 @@ +import { requireBrowserSession } from '../../middleware/session'; import { PERMISSION_KEYS } from '../../utils/permissionGroups'; import { ui } from '../../i18n/server'; import { Hono } from 'hono'; @@ -1192,6 +1193,10 @@ auth.delete('/api/me/account', requireAuth, async (c) => { if (!err?.message?.includes('no such table')) throw err; } + // Revoke personal API/Git credentials on account deletion. + try { await db.prepare('DELETE FROM git_tokens WHERE user_id = ?').bind(user.id).run(); } + catch (err: any) { if (!err?.message?.includes('no such table')) throw err; } + // 5. KV 세션 캐시 무효화 (현재 세션) const sessionId = getCookie(c, 'wiki_session'); if (sessionId) { @@ -1208,7 +1213,7 @@ auth.delete('/api/me/account', requireAuth, async (c) => { * GET /api/me/mcp-api-key * 현재 로그인한 사용자의 MCP API 키 정보 조회 */ -auth.get('/api/me/mcp-api-key', requireAuth, async (c) => { +auth.get('/api/me/mcp-api-key', requireAuth, requireBrowserSession, async (c) => { const user = c.get('user')!; const db = c.env.DB; try { @@ -1228,7 +1233,7 @@ auth.get('/api/me/mcp-api-key', requireAuth, async (c) => { * POST /api/me/mcp-api-key * MCP API 키 생성 또는 갱신 (30일 고정 수명) */ -auth.post('/api/me/mcp-api-key', requireAuth, async (c) => { +auth.post('/api/me/mcp-api-key', requireAuth, requireBrowserSession, async (c) => { const user = c.get('user')!; const db = c.env.DB; @@ -1267,7 +1272,7 @@ auth.post('/api/me/mcp-api-key', requireAuth, async (c) => { * DELETE /api/me/mcp-api-key * MCP API 키 삭제 */ -auth.delete('/api/me/mcp-api-key', requireAuth, async (c) => { +auth.delete('/api/me/mcp-api-key', requireAuth, requireBrowserSession, async (c) => { const user = c.get('user')!; const db = c.env.DB; diff --git a/src/routes/git.ts b/src/routes/git.ts index 5f48acb..34f2051 100644 --- a/src/routes/git.ts +++ b/src/routes/git.ts @@ -1,9 +1,9 @@ import { Hono } from 'hono'; import { Buffer } from 'node:buffer'; import * as git from 'isomorphic-git'; -import type { Env, User } from '../types'; -import { requireAuth } from '../middleware/session'; -import { isSuperAdmin } from '../utils/auth'; +import type { Env } from '../types'; +import { requireAuth, requireBrowserSession } from '../middleware/session'; +import { authenticatePersonalToken, ensurePersonalTokens, personalTokenExpiry } from '../utils/personalTokens'; import { ObjectStore, newCommits } from '../git/objectStore'; import { advertisement, parsePackets, parseUpdate, packet, status, OID } from '../git/protocol'; import { acquire, release, ensureGit, page, synchronize, recordHead } from '../git/storage'; @@ -22,20 +22,27 @@ async function boundedBody(request: Request, limit: number) { return Buffer.concat(chunks); } // Browser token management uses the normal session and CSRF protection. Passwords are one-time output. -routes.get('/api/me/git-token', requireAuth, async c => { - await ensureGit(c.env.DB); +const tokenPaths = ['/api/me/api-token', '/api/me/git-token']; +routes.on('GET', tokenPaths, requireAuth, requireBrowserSession, async c => { + await ensurePersonalTokens(c.env.DB); const token = await c.env.DB.prepare('SELECT masked_token, expires_at FROM git_tokens WHERE user_id = ?').bind(c.get('user')!.id).first(); return c.json({ token }, 200, { 'Cache-Control': 'no-store' }); }); -routes.post('/api/me/git-token', requireAuth, async c => { - await ensureGit(c.env.DB); - const raw = `git_${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex')}`; - const expires = Math.floor(Date.now() / 1000) + 30 * 86400; +routes.on('POST', tokenPaths, requireAuth, requireBrowserSession, async c => { + await ensurePersonalTokens(c.env.DB); + let expires: number; + try { + const text = await c.req.text(); + const body = text ? JSON.parse(text) : {}; + if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Invalid body'); + expires = personalTokenExpiry(body.expires_at); + } catch { return c.json({ error: ui('tokens.invalidExpiry') }, 400); } + const raw = `wiki_${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex')}`; await c.env.DB.prepare('INSERT OR REPLACE INTO git_tokens (user_id, token_hash, masked_token, expires_at, created_at) VALUES (?, ?, ?, ?, unixepoch())') .bind(c.get('user')!.id, await hash(raw), raw.slice(0, 8) + '…' + raw.slice(-4), expires).run(); return c.json({ token: raw, expires_at: expires }, 200, { 'Cache-Control': 'no-store' }); }); -routes.delete('/api/me/git-token', requireAuth, async c => { await ensureGit(c.env.DB); await c.env.DB.prepare('DELETE FROM git_tokens WHERE user_id = ?').bind(c.get('user')!.id).run(); return c.json({ success: true }); }); +routes.on('DELETE', tokenPaths, requireAuth, requireBrowserSession, async c => { await ensurePersonalTokens(c.env.DB); await c.env.DB.prepare('DELETE FROM git_tokens WHERE user_id = ?').bind(c.get('user')!.id).run(); return c.json({ success: true }); }); // Basic credentials are intentionally restricted to the Git transport, never accepted for browser APIs. routes.use('/git/pages/*', async (c, next) => { const authorization = c.req.header('Authorization') || ''; @@ -43,13 +50,8 @@ routes.use('/git/pages/*', async (c, next) => { let token: string; try { const decoded = Buffer.from(authorization.slice(6), 'base64').toString('utf8'); token = decoded.slice(decoded.indexOf(':') + 1); } catch { return challenge(); } - if (!/^git_[a-f0-9]{64}$/.test(token)) return challenge(); - await ensureGit(c.env.DB); - const user = await c.env.DB.prepare(`SELECT u.* FROM users u JOIN git_tokens t ON t.user_id = u.id WHERE t.token_hash = ? AND t.expires_at > unixepoch()`) - .bind(await hash(token)).first(); - if (!user || user.role === 'deleted' || (user.banned_until && user.banned_until > Math.floor(Date.now() / 1000))) return challenge(); - if (isSuperAdmin(user.email, c.env)) user.role = 'super_admin'; - else if (user.role === 'banned') { if (!user.banned_until) return challenge(); user.role = 'user'; } + const user = await authenticatePersonalToken(c.env, token); + if (!user) return challenge(); c.set('user', user); await next(); }); async function accessible(c: any) { @@ -156,16 +158,20 @@ routes.post('/git/pages/:repository/git-receive-pack', async c => { finally { if (lease) await release(c.env.DB, current.id, lease); } }); const escape = (s: string) => s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!)); -routes.get('/git', requireAuth, c => { +routes.on('GET', ['/git', '/tokens'], requireAuth, c => { const id = c.req.query('page'); const url = id && /^[1-9][0-9]*$/.test(id) ? `${new URL(c.req.url).origin}/git/pages/${id}.git` : ''; - const texts = Object.fromEntries(['title', 'intro', 'token', 'generate', 'revoke', 'clone', 'commands', 'limits', 'back', 'tokenHint', 'updated', 'failed', 'confirm'].map(key => [key, ui(`git.${key}`)])); - return c.html(`${escape(texts.title)}
${escape(texts.back)}

${escape(texts.title)}

${escape(texts.intro)}

${escape(texts.limits)}

${escape(texts.token)}

${escape(texts.tokenHint)}

${escape(texts.clone)}

${url ? `
git clone ${escape(url)}\ncd ${id}\n# ${escape(texts.commands)}\ngit add page.md\ngit commit -m "Update page"\ngit pull --rebase\ngit push origin main
` : `

${escape(texts.updated)}

`}
`); }); export default routes; diff --git a/src/routes/wiki.ts b/src/routes/wiki.ts index daab07a..dfa2d2d 100644 --- a/src/routes/wiki.ts +++ b/src/routes/wiki.ts @@ -2073,7 +2073,7 @@ wiki.put('/w/:slug', requireAuth, async (c) => { } // Turnstile 검증 - if (c.env.TURNSTILE_SECRET_KEY && !c.get('gitAuthenticated')) { + if (c.env.TURNSTILE_SECRET_KEY && !c.get('gitAuthenticated') && !c.get('apiTokenAuthenticated')) { const token = body.turnstileToken; if (!token) { return c.json({ error: ui("m_e6b962a8eaff5533") }, 403); diff --git a/src/shared/sitePolicies.ts b/src/shared/sitePolicies.ts index d787c06..4552bc0 100644 --- a/src/shared/sitePolicies.ts +++ b/src/shared/sitePolicies.ts @@ -55,7 +55,7 @@ export const DEFAULT_SITE_POLICIES = { ## 政策更新与联系 本政策更新将在本页公布。有关个人信息处理的问题,请通过站点管理员公开的联系方式联系本站。 ## Git 编辑 -Git 专用令牌仅保存不可逆摘要及有效期,默认有效期为 30 天,可以撤销或更换。Git 推送记录认证的 Wiki 账号;提交中的作者名字和邮箱由用户填写,不代表已验证的身份。用户下载到本地的 Git 内容无法由本站远程撤回,请勿在正文或提交信息中包含敏感资料。 +通用 API 令牌仅保存不可逆摘要及有效期,默认有效期为 30 天,用户可以自定义到期时间、选择永不过期、撤销或更换。令牌可用于 Git、Wiki API 和 MCP。Git 推送记录认证的 Wiki 账号;提交中的作者名字和邮箱由用户填写,不代表已验证的身份。用户下载到本地的 Git 内容无法由本站远程撤回,请勿在正文或提交信息中包含敏感资料。 ` }, } as const; diff --git a/src/types.ts b/src/types.ts index ae3fb89..321cca0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -85,6 +85,7 @@ export type Env = { }; Variables: { gitAuthenticated?: boolean; + apiTokenAuthenticated?: boolean; gitTargetPageId?: number; user: User | null; rbac?: any; // To be defined or used as a helper diff --git a/src/utils/mcpAuth.ts b/src/utils/mcpAuth.ts index 5bb0155..69d218d 100644 --- a/src/utils/mcpAuth.ts +++ b/src/utils/mcpAuth.ts @@ -1,3 +1,4 @@ +import {authenticatePersonalToken,isPersonalToken} from './personalTokens'; import { ui } from '../i18n/server'; import type { Context } from 'hono'; import type { Env, User } from '../types'; @@ -55,6 +56,11 @@ export async function resolveBearerAuth(c: Context): Promise>(); +export function ensurePersonalTokens(db: D1Database) { + let pending = ready.get(db); + if (!pending) { + pending = db.prepare('CREATE TABLE IF NOT EXISTS git_tokens (user_id INTEGER PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, masked_token TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL)').run().then(() => {}); + ready.set(db, pending); pending.catch(() => ready.delete(db)); + } + return pending; +} +export const isPersonalToken = (token: string) => /^(wiki|git)_[a-f0-9]{64}$/.test(token); +export async function authenticatePersonalToken(env: Env['Bindings'], token: string): Promise { + if (!isPersonalToken(token)) return null; + await ensurePersonalTokens(env.DB); + const now = Math.floor(Date.now() / 1000); + const user = await env.DB.prepare(`SELECT u.* FROM users u JOIN git_tokens t ON t.user_id=u.id WHERE t.token_hash=? AND (t.expires_at=0 OR t.expires_at>?)`).bind(await sha256Hex(token),now).first(); + if (!user || user.role==='deleted' || (user.banned_until && user.banned_until>now) || (user.role==='banned' && !user.banned_until)) return null; + if (isSuperAdmin(user.email,env)) user.role='super_admin'; + else if (user.role==='banned') user.role='user'; + return user; +} +/** Unix seconds; null/0 explicitly means no expiry, omission retains the 30-day default. */ +export function personalTokenExpiry(value: unknown, now = Math.floor(Date.now()/1000)): number { + if (value===undefined) return now+30*86400; + if (value===null || value===0) return 0; + if (typeof value!=='number' || !Number.isSafeInteger(value) || value<=now || value>253402300799) throw new Error('Invalid expiration'); + return value; +} diff --git a/tests/git.test.ts b/tests/git.test.ts index c0e95a0..1461afa 100644 --- a/tests/git.test.ts +++ b/tests/git.test.ts @@ -57,7 +57,7 @@ test('real Git clone, web pull, push, rejected force/delete, revoked permissions const pending: Promise[] = [], ctx: any = { waitUntil: (p: Promise) => pending.push(p), passThroughOnException() {} }; const tokenResponse = await app.request('/api/me/git-token', { method: 'POST' }, env, ctx); const token = (await tokenResponse.json() as any).token; - assert.match(token, /^git_/); + assert.match(token, /^wiki_/); const server = createServer(async (req, res) => { try { const chunks = []; for await (const chunk of req) chunks.push(chunk); diff --git a/tests/services.test.ts b/tests/services.test.ts index d26e961..df0133e 100644 --- a/tests/services.test.ts +++ b/tests/services.test.ts @@ -1,3 +1,4 @@ +import worker from '../src/index'; import {findPrefixRuleEditAcl,evaluateEditAcl} from '../src/utils/editAcl'; import {test} from 'node:test'; import assert from 'node:assert/strict'; @@ -61,3 +62,52 @@ test('Wiki prefix defaults deny ordinary editors and allow administrators', asyn assert.equal((await evaluateEditAcl(db,acl!,{id:1,role:'admin'} as any,null,0,true)).allowed,true); assert.equal(await findPrefixRuleEditAcl(db,'WikiX/普通页面'),null); }); + +test('personal token expiration validates input and keeps backward compatibility', async () => { + const {personalTokenExpiry}=await import('../src/utils/personalTokens'); + assert.equal(personalTokenExpiry(undefined,100),100+30*86400); + assert.equal(personalTokenExpiry(150,100),150);assert.equal(personalTokenExpiry(0,100),0);assert.equal(personalTokenExpiry(null,100),0); + for(const value of [99,100,-1,150.1,'150',NaN,Infinity,253402300800])assert.throws(()=>personalTokenExpiry(value,100)); +}); + +test('personal tokens authenticate API and MCP, honor current roles and expire/revoke immediately', async () => { + const {readFile}=await import('node:fs/promises'); + const {authenticatePersonalToken}=await import('../src/utils/personalTokens'); + const {resolveBearerAuth}=await import('../src/utils/mcpAuth'); + const {sha256Hex}=await import('../src/utils/oauth'); + const sql=new DatabaseSync(':memory:');sql.exec(await readFile('migrations/schema.sql','utf8')); + const db:any={prepare(query:string){let params:any[]=[];const s={bind(...args:any[]){params=args;return s},async first(){return sql.prepare(query).get(...params)||null},async all(){return{results:sql.prepare(query).all(...params)}},async run(){const r=sql.prepare(query).run(...params);return{meta:{changes:Number(r.changes),last_row_id:Number(r.lastInsertRowid)}}}};return s},async batch(items:any[]){sql.exec('BEGIN');try{const result=[];for(const item of items)result.push(await item.run());sql.exec('COMMIT');return result}catch(error){sql.exec('ROLLBACK');throw error}}}; + sql.exec("INSERT INTO users(id,provider,uid,email,name,role) VALUES(1,'nodeloc','1','token@example.com','API editor','user');INSERT INTO pages(id,slug,content,version) VALUES(1,'Public','hello',1)"); + const env:any={DB:db,KV:{get:async()=>null,put:async()=>{},delete:async()=>{}},MEDIA:{put:async()=>{},get:async()=>null},WIKI_NAME:'Test Wiki',WIKI_VISIBILITY:'open',MCP_MODE:'open',SUPER_ADMIN_EMAILS:'',ENABLED_EXTENSIONS:'',EDIT_REQUEST_ENABLED:'false',ASSETS:{fetch:async()=>new Response('',{status:404})}}; + const waits:Promise[]=[];const ctx:any={waitUntil:(p:Promise)=>waits.push(p),passThroughOnException(){}}; + (globalThis as any).caches={default:{match:async()=>undefined,put:async()=>{},delete:async()=>true}}; + const now=Math.floor(Date.now()/1000); + sql.prepare('INSERT INTO sessions(id,user_id,expires_at) VALUES(?,?,?)').run('browser',1,now+3600); + const browser={Cookie:'wiki_session=browser',Origin:'https://example.com','Content-Type':'application/json'}; + const call=(path:string,init:any={})=>worker.fetch(new Request('https://example.com'+path,init),env,ctx); + const tokenPage=await call('/tokens',{headers:browser});assert.equal(tokenPage.status,200); + const html=await tokenPage.text();assert.ok(html.includes('datetime-local'));assert.ok(html.includes('value="never"')); + const {Script}=await import('node:vm');new Script(html.match(/