82 lines
6.1 KiB
TypeScript
82 lines
6.1 KiB
TypeScript
import { mkdirSync, writeFileSync, existsSync, 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 } 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/waterfall-wiki:shared?'+new URLSearchParams({secret:config.secret,issuer:'waterfall-wiki',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'});
|
|
for (const path of ['/','/library/import','/skills/shadow-puppet/egret-example.png','/studio','/_next/data/auth-test/index.json']) {
|
|
const response = await request(path); assert.equal(response.status,307,path); assert.match(response.headers.get('location') || '',/\/login\?/);
|
|
}
|
|
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(()=>{});});
|