92 lines
7.5 KiB
TypeScript
92 lines
7.5 KiB
TypeScript
import { mkdirSync, writeFileSync, existsSync, readFileSync, renameSync, chmodSync, chownSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import assert from 'node:assert/strict';
|
|
import QRCode from 'qrcode';
|
|
import { authConfig, newAuthConfig, signSession, totp, isLoginRedirect } from '../src/lib/auth';
|
|
import { db } from '../src/lib/db';
|
|
|
|
async function main() {
|
|
const command = process.argv[2];
|
|
const file = process.env.AUTH_CONFIG_FILE;
|
|
if (!file) throw Error('Set AUTH_CONFIG_FILE before running the server authentication tool');
|
|
if (command === 'init' || command === 'rotate') {
|
|
if (command === 'rotate' && !process.argv.includes('--confirm')) throw Error('Rotation invalidates all authenticators and sessions; pass --confirm to proceed');
|
|
if (command === 'init' && existsSync(file)) { authConfig(); console.log('Authentication configuration already exists; preserved'); return; }
|
|
mkdirSync(dirname(file),{recursive:true,mode:0o700});
|
|
const temporary = file + '.' + process.pid + '.tmp';
|
|
writeFileSync(temporary,JSON.stringify(newAuthConfig())+'\n',{mode:0o600,flag:'wx'});
|
|
if (process.getuid?.() === 0) { chownSync(dirname(file),1000,1000); chownSync(temporary,1000,1000); }
|
|
chmodSync(dirname(file),0o700); renameSync(temporary,file);
|
|
console.log('Authentication configuration saved privately. Use the qr command in an SSH terminal to bind.'); return;
|
|
}
|
|
const config = authConfig(); if (!config) throw Error('Authentication is not configured');
|
|
if (command === 'qr') {
|
|
if (!process.stdout.isTTY) throw Error('QR display requires an interactive SSH terminal (ssh -t, docker compose exec without -T). Never send QR output to CI logs.');
|
|
const uri = 'otpauth://totp/PROMPT:shared?'+new URLSearchParams({secret:config.secret,issuer:'PROMPT',algorithm:'SHA1',digits:'6',period:'30'});
|
|
console.log('Private binding QR — everyone scanning this uses the same authenticator key.');
|
|
console.log(await QRCode.toString(uri,{type:'terminal',small:true,errorCorrectionLevel:'M'}));
|
|
return;
|
|
}
|
|
if (command === 'token') {
|
|
if (!process.stdout.isTTY) throw Error('Tokens may only be displayed in an interactive SSH terminal');
|
|
console.log(signSession(config,undefined,3600)); return;
|
|
}
|
|
const base = process.env.AUTH_CHECK_BASE_URL || 'http://127.0.0.1:3000';
|
|
if (command === 'run') {
|
|
const script = process.argv[3]; if (!script) throw Error('Provide a server-side script path');
|
|
const original = globalThis.fetch; const token = signSession(config,undefined,600);
|
|
// Only attach the credential to the local application, never to remote fetches.
|
|
globalThis.fetch = (input,init) => {
|
|
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
if (url.origin === new URL(base).origin) { const headers = new Headers(init?.headers || (input instanceof Request ? input.headers : undefined)); headers.set('Authorization','Bearer '+token); return original(input,{...init,headers}); }
|
|
return original(input,init);
|
|
};
|
|
await import(pathToFileURL(resolve(script)).href); return;
|
|
}
|
|
if (command === 'verify' || command === 'exercise') {
|
|
const request = (path:string,init:RequestInit={}) => fetch(base+path,{...init,redirect:'manual'});
|
|
const buildId = readFileSync('.next/BUILD_ID','utf8').trim();
|
|
for(const path of ['/','/workspace','/documents','/account'])assert.equal((await request(path)).status,200,'Public shell '+path);
|
|
for(const path of ['/favicon.ico','/brand/logo-light.svg','/brand/logo-dark.svg','/brand/icon-dark.svg','/apple-touch-icon.png']) assert.equal((await request(path)).status,200,'Public brand asset '+path);
|
|
const studio=await request('/studio');assert.equal(studio.status,307);assert.equal(studio.headers.get('location'),'/workspace');
|
|
for(const [from,to] of [['/studio/graphic','/graphic'],['/studio/infinite_canvas','/infinite_canvas']]){const r=await request(from);assert.equal(r.status,308);assert.equal(r.headers.get('location'),to)}
|
|
assert.equal((await request('/account')).status,200,'Public account page');
|
|
const homeData = await request(`/_next/data/${buildId}/index.json`);
|
|
assert.ok([200,404].includes(homeData.status),'Public homepage data is readable or absent for static pages');
|
|
assert.ok(!isLoginRedirect(homeData.headers.get('location') || homeData.headers.get('x-nextjs-redirect'),base));
|
|
assert.equal((await request('/api/posts',{method:'POST',headers:{Cookie:'wiki_account='+ 'a'.repeat(64)},body:'{}'})).status,401,'Account cookies never grant admin rights');
|
|
for (const path of ['/library/import','/skills/shadow-puppet/egret-example.png','/studio/layouts/ref-01',`/_next/data/${buildId}/library/import.json`]) {
|
|
const response = await request(path); assert.equal(response.status,307,path); const destination = response.headers.get('location') || response.headers.get('x-nextjs-redirect'); assert.ok(isLoginRedirect(destination,base),path+' must redirect to login: '+destination);
|
|
}
|
|
for (const path of ['/api/feed','/api/upload','/studio/api/export/test','/api/media/missing']) assert.equal((await request(path)).status,401,path);
|
|
assert.equal((await request('/api/posts',{method:'POST',body:'{}'})).status,401);
|
|
assert.equal((await request('/api/feed',{headers:{'x-middleware-subrequest':'proxy:proxy:proxy:proxy:proxy'}})).status,401);
|
|
const page = await request('/login'); assert.equal(page.status,200); const html = await page.text(); assert.ok(html.includes('输入动态码')); assert.ok(!html.includes(config.secret));
|
|
assert.equal((await request('/api/health')).status,200);
|
|
const headers = {Authorization:'Bearer '+signSession(config,undefined,600)};
|
|
assert.equal((await request('/api/feed',{headers})).status,200);
|
|
assert.equal((await request('/api/feed',{headers:{Authorization:headers.Authorization+'x'}})).status,401);
|
|
if (command === 'exercise') {
|
|
const origin = new URL(base).origin;
|
|
const login = (code:string,customOrigin=origin) => request('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json',Origin:customOrigin},body:JSON.stringify({code})});
|
|
assert.equal((await login('000000','https://untrusted.example')).status,403);
|
|
const code = totp(config.secret,Math.floor(Date.now()/30000));
|
|
const results = await Promise.all([login(code),login(code)]);
|
|
assert.deepEqual(results.map(r=>r.status).sort(),[200,401],'exactly one concurrent use succeeds');
|
|
const cookie = results.find(r=>r.status===200)!.headers.get('set-cookie')!;
|
|
assert.match(cookie,/HttpOnly/); assert.match(cookie,/SameSite=Lax/); assert.match(cookie,/Max-Age=604800/);
|
|
const session = cookie.split(';')[0];
|
|
assert.equal((await request('/api/feed',{headers:{Cookie:session}})).status,200);
|
|
assert.equal((await request('/api/posts',{method:'POST',headers:{Cookie:session,Origin:'https://untrusted.example'}})).status,403);
|
|
const logout = await request('/api/auth/logout',{method:'POST',headers:{Cookie:session,Origin:origin}}); assert.equal(logout.status,200); assert.match(logout.headers.get('set-cookie')!,/Max-Age=0/);
|
|
let status = 0; for (let i=0;i<21;i++) status=(await login('invalid')).status; assert.equal(status,429);
|
|
// Durable throttling remains even after this test process exits.
|
|
assert.equal((await login(code)).status,429);
|
|
}
|
|
console.log('Dynamic-code authentication '+command+' passed (no secrets displayed)'); return;
|
|
}
|
|
throw Error('Usage: auth-cli.cjs init | qr | rotate --confirm | token | run <script> | verify | exercise');
|
|
}
|
|
main().catch(error=>{console.error(error instanceof Error?error.message:'Authentication command failed');process.exitCode=1;}).finally(async()=>{if (process.env.DATABASE_URL) await db().end().catch(()=>{});});
|