Files
AriWeb-Suite/lib/session.mjs

65 lines
5.1 KiB
JavaScript

/**
* lib/session.mjs — DB 세션(기기 목록·개별/전체 종료 §3.2)과 CSRF.
*
* 쿠키에는 무작위 토큰만 두고 서버는 sha256 해시로 찾는다(DB 가 유출돼도 세션을 위조할 수 없다).
* kind: 'customer'(고객 포털) | 'operator'(운영자 콘솔) | 'setup'. 종류가 다른 세션으로는 상대편 화면을 열 수 없다(§25).
* CSRF: 세션마다 무작위 csrf 값을 두고 변경 요청은 `X-CSRF` 헤더로 보낸다(상수 시간 비교).
* 재인증(§25 고위험 작업): reauth_at 을 두고 requireRecentAuth(maxAgeMs) 로 검사한다.
*/
import { randomToken, sha256, safeEqual, newId } from './crypto.mjs';
import { nowIso, parseJson, toJson } from './db.mjs';
export const MIGRATIONS = [{ id: 'lib_sessions_0001', sql: `
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, user_id TEXT NOT NULL, kind TEXT NOT NULL,
csrf TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, reauth_at TEXT,
ip TEXT, ua TEXT, label TEXT, revoked_at TEXT, data_json TEXT
);
CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_id, kind);` }];
const COOKIE = { customer: 'aw_session', operator: 'aw_ops', setup: 'aw_setup' };
export const cookieName = (kind) => COOKIE[kind] || 'aw_session';
/** 세션 만들기. 반환 { id, token(원문, 쿠키용), csrf, expiresAt } */
export function createSession(store, { userId, kind = 'customer', ip = '', ua = '', days = 30, data = null, reauth = true }) {
const token = randomToken(32); const id = newId('s_'); const now = nowIso();
const expiresAt = new Date(Date.now() + days * 86400000).toISOString(); const csrf = randomToken(24);
store.run('INSERT INTO sessions(id, token_hash, user_id, kind, csrf, created_at, expires_at, last_seen_at, reauth_at, ip, ua, label, data_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)',
id, sha256(token), userId, kind, csrf, now, expiresAt, now, reauth ? now : null, ip, String(ua || '').slice(0, 200), deviceLabel(ua), toJson(data));
return { id, token, csrf, expiresAt };
}
/** 토큰(쿠키 값)으로 세션을 찾는다. 만료·종료된 세션은 null. 찾으면 last_seen 을 갱신(1분에 한 번만). */
export function resolveSession(store, token, kind) {
if (!token) return null;
const s = store.get('SELECT * FROM sessions WHERE token_hash=? AND kind=?', sha256(token), kind);
if (!s || s.revoked_at || Date.parse(s.expires_at) < Date.now()) return null;
if (Date.now() - Date.parse(s.last_seen_at) > 60000) store.run('UPDATE sessions SET last_seen_at=? WHERE id=?', nowIso(), s.id);
return { ...s, data: parseJson(s.data_json, {}) };
}
/** 세션 데이터(현재 그룹 등) 갱신 */
export function updateSessionData(store, id, patch) { const s = store.get('SELECT data_json FROM sessions WHERE id=?', id); const d = { ...parseJson(s?.data_json, {}), ...patch }; store.run('UPDATE sessions SET data_json=? WHERE id=?', toJson(d), id); return d; }
/** 재인증 기록(고위험 작업 전 비밀번호 재확인) */
export function markReauth(store, id) { store.run('UPDATE sessions SET reauth_at=? WHERE id=?', nowIso(), id); }
/** 최근 재인증 여부 */
export function recentlyAuthed(session, maxAgeMs = 10 * 60 * 1000) { return !!(session && session.reauth_at && Date.now() - Date.parse(session.reauth_at) < maxAgeMs); }
/** 개별 세션 종료 */
export function revokeSession(store, id, userId) { return store.run('UPDATE sessions SET revoked_at=? WHERE id=? AND user_id=? AND revoked_at IS NULL', nowIso(), id, userId).changes; }
/** 다른 모든 세션 종료(현재 세션 제외 가능). kind 를 주면 그 종류만 */
export function revokeOtherSessions(store, userId, keepId = null, kind = null) {
return store.run(`UPDATE sessions SET revoked_at=? WHERE user_id=? AND revoked_at IS NULL ${keepId ? 'AND id<>?' : ''} ${kind ? 'AND kind=?' : ''}`, ...[nowIso(), userId, ...(keepId ? [keepId] : []), ...(kind ? [kind] : [])]).changes;
}
/** 사용자의 활성 세션 목록(기기 화면용, 토큰 없음) */
export function listSessions(store, userId, kind) {
return store.all('SELECT id, created_at, last_seen_at, expires_at, ip, label FROM sessions WHERE user_id=? AND kind=? AND revoked_at IS NULL AND expires_at>? ORDER BY last_seen_at DESC', userId, kind, nowIso());
}
/** CSRF 검사 */
export function csrfOk(session, token) { return !!(session && token && safeEqual(session.csrf, token)); }
/** UA 에서 기기 표시 이름 (대략) */
function deviceLabel(ua) {
const s = String(ua || ''); const os = /Windows/.test(s) ? 'Windows' : /Android/.test(s) ? 'Android' : /iPhone|iPad/.test(s) ? 'iOS' : /Mac OS/.test(s) ? 'macOS' : /Linux/.test(s) ? 'Linux' : '기기';
const br = /Edg\//.test(s) ? 'Edge' : /Chrome\//.test(s) ? 'Chrome' : /Safari\//.test(s) ? 'Safari' : /Firefox\//.test(s) ? 'Firefox' : '브라우저';
return `${os} · ${br}`;
}
/** 만료·종료된 세션 정리(주기 작업) */
export function purgeSessions(store, olderThanDays = 7) { const cut = new Date(Date.now() - olderThanDays * 86400000).toISOString(); return store.run('DELETE FROM sessions WHERE expires_at<? OR (revoked_at IS NOT NULL AND revoked_at<?)', cut, cut).changes; }