Ariweb Suite 0.1.1 기반: 공통 라이브러리(db·http·세션·작업 엔진·할당/예약·알림·감사·보관소), 포털 뼈대, Webmaker/AriMail 도입 소스와 manifest
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
9ca4cee341
commit
c7aea8d419
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
// ============================================================================
|
||||
// 초기 설정 페이지 인증 (v1.5.11, 요구 2)
|
||||
//
|
||||
// 1.5.7 에서는 접속 토큰·세션 쿠키 처리가 setup-server.js(별도 포트) 안에만 있었다. 1.5.11 부터는 본 서비스(server.js)도
|
||||
// 같은 설정 페이지를 /setup 에서 "관리자 로그인 없이 토큰으로" 열어야 하므로(설치 직후 첫 화면이 /setup 으로 넘어온다 — 관리자
|
||||
// 비밀번호를 아직 모를 수 있다) 인증 부분을 이 모듈로 떼어 두 서버가 같이 쓴다.
|
||||
//
|
||||
// - 접속 토큰: data/setup.token (0600, 32자 base64url). 없으면 만든다. 설치 스크립트가 `node scripts/config.js setup-url` 로 읽어
|
||||
// http://<서버 IP>:8795/?token=… 를 안내한다. 운영자는 서버의 파일에서도 볼 수 있다(= 서버 접근 권한이 곧 설정 권한).
|
||||
// - 세션: 쿠키 wm_setup = base64url("<who>|<만료 ms>") + "." + HMAC(session.secret 에서 파생한 키). 12시간.
|
||||
// who 는 'token' 또는 'admin:<아이디>'. 본 서비스에서는 관리자 세션 쿠키(wh_session, role=admin)도 같은 권한으로 본다.
|
||||
// - 로그인 시도 제한: IP 당 15분 10회 (토큰·비밀번호 대입 방지). 공인망에 열리는 포트이므로 꼭 둔다.
|
||||
//
|
||||
// 쓰는 쪽: setup-server.js (standalone), server.js (/api/setup/*, /setup)
|
||||
// ============================================================================
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
|
||||
const TOKEN_FILE = path.join(DATA_ROOT, 'setup.token');
|
||||
const COOKIE = 'wm_setup';
|
||||
const SESSION_HOURS = 12;
|
||||
|
||||
/** 접속 토큰: 없으면 만든다 (scripts/config.js setup-token 과 같은 규칙) */
|
||||
function token() {
|
||||
try { const t = fs.readFileSync(TOKEN_FILE, 'utf8').trim(); if (t.length >= 24) return t; } catch (_) {}
|
||||
fs.mkdirSync(DATA_ROOT, { recursive: true });
|
||||
const t = crypto.randomBytes(24).toString('base64url');
|
||||
fs.writeFileSync(TOKEN_FILE, t + '\n', { mode: 0o600 }); try { fs.chmodSync(TOKEN_FILE, 0o600); } catch (_) {}
|
||||
return t;
|
||||
}
|
||||
/** 세션 서명 키: data/session.secret(본 서비스 세션과 같은 파일)에 용도 문자열을 섞어 파생 — 설정 세션과 서비스 세션의 서명이 서로 호환되지 않게 */
|
||||
function signKey() {
|
||||
let s = ''; try { s = fs.readFileSync(path.join(DATA_ROOT, 'session.secret'), 'utf8').trim(); } catch (_) {}
|
||||
if (!s) { s = crypto.randomBytes(32).toString('hex'); try { fs.mkdirSync(DATA_ROOT, { recursive: true }); fs.writeFileSync(path.join(DATA_ROOT, 'session.secret'), s, { mode: 0o600 }); } catch (_) {} }
|
||||
return crypto.createHash('sha256').update('setup-session:' + s).digest();
|
||||
}
|
||||
/** 세션 값 만들기 (who: 'token' | 'admin:<id>') */
|
||||
function makeSession(who) {
|
||||
const exp = Date.now() + SESSION_HOURS * 3600 * 1000; const body = `${who}|${exp}`;
|
||||
const sig = crypto.createHmac('sha256', signKey()).update(body).digest('base64url');
|
||||
return Buffer.from(body).toString('base64url') + '.' + sig;
|
||||
}
|
||||
function sessionCookie(who, secure) { return `${COOKIE}=${makeSession(who)}; Path=/; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}; Max-Age=${SESSION_HOURS * 3600}`; }
|
||||
function clearCookie() { return `${COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`; }
|
||||
/** 요청의 설정 세션 쿠키 검증 → { who } 또는 null */
|
||||
function readSession(req) {
|
||||
const m = new RegExp(`(?:^|;\\s*)${COOKIE}=([^;]+)`).exec(req.headers.cookie || ''); if (!m) return null;
|
||||
const [b64, sig] = m[1].split('.'); if (!b64 || !sig) return null;
|
||||
const body = Buffer.from(b64, 'base64url').toString(); const want = crypto.createHmac('sha256', signKey()).update(body).digest('base64url');
|
||||
if (sig.length !== want.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) return null;
|
||||
const i = body.lastIndexOf('|'); if (i < 0) return null; const who = body.slice(0, i); const exp = Number(body.slice(i + 1));
|
||||
if (!(exp > Date.now())) return null; return { who };
|
||||
}
|
||||
|
||||
// ---- 로그인 시도 제한 ----
|
||||
const attempts = new Map();
|
||||
function limited(ip) { const now = Date.now(); const a = (attempts.get(ip) || []).filter((t) => now - t < 900000); attempts.set(ip, a); return a.length >= 10; }
|
||||
function hit(ip) { const a = attempts.get(ip) || []; a.push(Date.now()); attempts.set(ip, a); }
|
||||
|
||||
/**
|
||||
* 로그인 처리: { token } 또는 { id, password }(관리자 계정만).
|
||||
* @returns {{status:number, body:object, cookie?:string}}
|
||||
*/
|
||||
function login(body, ip, secure) {
|
||||
if (limited(ip)) return { status: 429, body: { error: '로그인 시도가 너무 많습니다. 15분 뒤 다시 시도하세요' } };
|
||||
const b = body || {}; let who = null;
|
||||
if (typeof b.token === 'string' && b.token.trim()) {
|
||||
// v1.6.31 (요구 6, 검토 S7): 설치 점검이 끝나(비밀번호를 바꾼 관리자가 있음) 운영 중인 서버에서는 접속 토큰으로 들어올 수 없다 — 관리자 로그인만.
|
||||
// 설치 스크립트가 알려 준 토큰 주소는 "최초 설정" 전용이며, 그 뒤에는 토큰 파일이 남아 있어도 쓰이지 않는다.
|
||||
try { const st = require('./auth').adminState(); if (st.usable) { hit(ip); return { status: 403, body: { error: '최초 설정이 끝난 서버입니다. 접속 토큰은 더 쓸 수 없으니 관리자 계정으로 로그인하세요' } }; } } catch (_) {}
|
||||
const t = token(); const given = b.token.trim();
|
||||
if (given.length === t.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(t))) who = 'token';
|
||||
if (!who) { hit(ip); return { status: 401, body: { error: '접속 토큰이 맞지 않습니다 (서버의 data/setup.token 파일 내용)' } }; }
|
||||
} else if (b.id) {
|
||||
if (require('./ratelimit').limited(`setup-login:${String(b.id).toLowerCase()}`, 10, 900000)) return { status: 429, body: { error: '이 계정의 로그인 시도가 너무 많습니다. 15분 뒤 다시 시도하세요' } };
|
||||
try {
|
||||
const auth = require('./auth'); const u = auth.login({ id: String(b.id), password: String(b.password || '') });
|
||||
if (u.role !== 'admin') { hit(ip); return { status: 403, body: { error: '관리자 계정만 들어올 수 있습니다' } }; }
|
||||
who = `admin:${u.id}`;
|
||||
} catch (e) { hit(ip); return { status: 401, body: { error: e.message || '아이디 또는 비밀번호가 맞지 않습니다' } }; }
|
||||
} else { hit(ip); return { status: 400, body: { error: '접속 토큰 또는 관리자 아이디·비밀번호를 보내세요' } }; }
|
||||
return { status: 200, body: { ok: true, who }, cookie: sessionCookie(who, secure) };
|
||||
}
|
||||
|
||||
/** v1.6.31 설정 세션용 CSRF 토큰 (who 에 묶인 HMAC) */
|
||||
function csrfOf(who) { return crypto.createHmac('sha256', signKey()).update('csrf|' + String(who)).digest('base64url'); }
|
||||
function csrfOk(who, tok) { if (!who || !tok) return false; const a = Buffer.from(csrfOf(who)); const b = Buffer.from(String(tok)); return a.length === b.length && crypto.timingSafeEqual(a, b); }
|
||||
module.exports = { csrfOf, csrfOk, TOKEN_FILE, COOKIE, token, makeSession, sessionCookie, clearCookie, readSession, login, limited, hit };
|
||||
Reference in New Issue
Block a user