Extend authenticated session activity

This commit is contained in:
2026-09-09 16:58:08 +09:00
parent 5f3ac952ed
commit 20e0902adc
7 changed files with 118 additions and 7 deletions
+7 -1
View File
@@ -67,10 +67,14 @@ node .\server\bootstrap-admin.js --email=your-email@example.com
1. 최초 방문자는 로그인 화면에서 사용 신청으로 이동해 이메일·비밀번호·표시 이름을 등록한다.
2. 서비스 운영자는 별도 `/admin` 페이지에서 승인 대기 사용자를 확인하고 이용을 승인한다. 개발 주소는 `http://localhost:5173/admin`이며, 운영 환경에서는 서비스 주소 뒤에 `/admin`을 붙인다.
3. 승인된 사용자가 다시 로그인하면 내 팀에서 새 팀을 만들거나 기존 팀 이름을 검색할 수 있다. 서비스 이용 승인만으로 기존 팀에 자동 가입되지 않는다.
3. 승인된 사용자가 로그인하면 소속 팀이 있는 경우 **소속 팀**, 없는 경우 **팀 검색** 화면으로 진입한다. **소속 팀 | 팀 검색** 토글로 두 화면을 전환하며, 새 팀 만들기도 이용할 수 있다. 서비스 이용 승인만으로 기존 팀에 자동 가입되지 않는다.
4. 기존 팀을 검색해 가입을 신청하면 팀 소유자의 승인을 기다린다.
5. 팀 소유자는 팀원 관리에서 가입 신청을 승인하고 열람자 또는 편집자 권한을 부여한다. 이후에도 팀원별 권한을 변경할 수 있다.
운영자는 팀 선택·검색·팀원 관리·전술 목록 화면 우측 하단의 **운영자 승인 페이지** FAB로 가입 승인 페이지에 접근한다. 로그인 전과 승인 페이지, 전술 편집 화면에서는 FAB를 표시하지 않는다. 기존 `/admin` 직접 접근과 운영자 권한 검사는 유지한다.
2026-09-09 UX 검증: 운영 DB와 분리된 메모리 서버에서 PC 1280×900 및 모바일 390×844 화면을 확인했다. 소속 팀 유무에 따른 로그인·세션 복원 분기, 토글 클릭·방향키 전환, 팀 검색·가입 요청, 팀원 관리 복귀, 운영자 FAB의 승인 페이지 진입·새로고침·복귀, 로그아웃 및 일반 계정의 FAB 숨김과 `/admin` 접근 제한을 확인했다. 최종 전체 테스트 119개가 통과했다.
열람자는 팀 전술을 조회할 수 있고 서버에 저장할 수 없다. 편집자와 소유자는 팀 전술을 서버에 저장할 수 있다. 소유자 권한 이전은 지원하지 않는다. 서비스 운영자 권한과 팀 소유자 권한은 별개이며, 운영자도 다른 팀에 자동으로 참여하지 않는다.
가입 신청은 MongoDB `team_join_requests`에 저장한다. 서버 시작 시 필요한 컬렉션 인덱스를 준비하며, 기존 계정·팀·전술을 삭제하거나 다시 만들 필요는 없다. 코드 반영 후에는 서버를 재시작하고 운영 화면은 다시 빌드한다.
@@ -98,6 +102,8 @@ npm run server
Fastify가 `dist` 화면과 API를 제공한다. 실제 도메인을 허용 출처로 설정하고 HTTPS 프록시 뒤에서 실행한다. 운영 세션 쿠키는 Secure를 사용한다.
로그인 세션은 1시간 동안 유지되며, 인증된 조회·저장 요청 및 전술판의 클릭·드래그·키보드 등 사용자 입력에 따라 서버 만료 시각과 쿠키 유효기간이 연장된다. 로컬 입력의 활동 알림은 요청 수를 제한해 전송하고, 입력 없이 화면만 열어 두면 주기적으로 연장하지 않는다. 만료된 세션은 활동 알림으로 복구되지 않으며 다시 로그인해야 한다. 이전 7일 세션에 활동 기록이 없으면 로그인 시각을 기준으로 1시간 만료를 적용한다.
휴대폰의 HTTP LAN 화면 접속만으로 OS 파일 공유 조건이 충족되지는 않는다. 카카오톡으로 MP4 파일을 직접 공유하는 실기기 검증에는 지원 브라우저와 신뢰할 수 있는 HTTPS가 필요하다.
## 검증
+20 -3
View File
@@ -7,7 +7,7 @@ import fastifyStatic from '@fastify/static';
import { isValidPlay } from '../src/playRepository.js';
import { createMongoStore, DEFAULT_MONGODB_DB, DEFAULT_MONGODB_URI } from './db.js';
import { isDevelopmentOrigin, loadConfig } from './config.js';
import { hashPassword, hashToken, newSessionToken, sessionExpiry, SESSION_COOKIE, verifyPassword, validEmail } from './security.js';
import { hashPassword, hashToken, newSessionToken, sessionExpiry, SESSION_COOKIE, SESSION_SECONDS, verifyPassword, validEmail } from './security.js';
const here = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(here, '..');
@@ -23,6 +23,13 @@ function errorPayload(code, message) { return { error: { code, message } }; }
function fail(reply, status, code, message) { return reply.code(status).send(errorPayload(code, message)); }
function parsePositiveId(value) { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value); }
function isProtectedConfigPath(url) { return /(?:^|\/)\.config\.json(?:\.[^/]*)?$/.test(String(url).split('?')[0]); }
function isRenewableApiRequest(request, allowedOrigins, mode) {
const url = String(request.url).split('?')[0];
if (!url.startsWith('/api/') || url === '/api/health') return false;
if (url === '/api/auth/login' || url === '/api/auth/register' || url === '/api/auth/logout' || url === '/api/auth/me') return false;
if (request.headers.origin && !requestOriginAllowed(request, allowedOrigins, mode)) return false;
return true;
}
function requestOriginAllowed(request, allowedOrigins, mode) {
const origin = request.headers.origin;
if (!origin) return true;
@@ -57,9 +64,17 @@ export async function buildServer(options = {}) {
if (!token) return;
const session = await store.sessions.findOne({ tokenHash: hashToken(token), expiresAt: { $gt: nowDate() } });
if (!session) return;
const now = nowDate();
const lastActivityAt = session.lastActivityAt || session.createdAt;
if (!lastActivityAt || now.getTime() - new Date(lastActivityAt).getTime() >= SESSION_SECONDS * 1000) return;
const row = await store.users.findOne({ id: session.userId });
if (!row || row.status === 'suspended') return;
request.user = row;
if (row.status === 'approved' && isRenewableApiRequest(request, allowedOrigins, mode)) {
const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000);
await store.sessions.updateOne({ tokenHash: session.tokenHash, expiresAt: { $gt: now } }, { $set: { expiresAt, lastActivityAt: now } });
reply.setCookie(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure: secureCookie, maxAge: SESSION_SECONDS });
}
});
app.addHook('onClose', async () => { await store.close(); });
@@ -129,8 +144,8 @@ export async function buildServer(options = {}) {
if (row.status === 'suspended') return fail(reply, 403, 'account_suspended', '사용이 중지된 계정입니다');
const token = newSessionToken(); const createdAt = nowDate();
await store.sessions.deleteMany({ expiresAt: { $lte: createdAt } });
await store.sessions.insertOne({ tokenHash: hashToken(token), userId: row.id, expiresAt: new Date(sessionExpiry()), createdAt });
reply.setCookie(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure: secureCookie, maxAge: 7 * 24 * 60 * 60 });
await store.sessions.insertOne({ tokenHash: hashToken(token), userId: row.id, expiresAt: new Date(sessionExpiry(createdAt.getTime())), createdAt, lastActivityAt: createdAt });
reply.setCookie(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure: secureCookie, maxAge: SESSION_SECONDS });
return { user: publicUser(row) };
});
@@ -139,6 +154,8 @@ export async function buildServer(options = {}) {
reply.clearCookie(SESSION_COOKIE, { path: '/' }); return { ok: true };
});
app.post('/api/auth/activity', { preHandler: requireUser, schema: { body: bodySchema({}, []) } }, async () => ({ ok: true }));
app.get('/api/auth/me', { preHandler: requireUser }, async (request) => ({ user: publicUser(request.user) }));
app.get('/api/teams', { preHandler: requireUser }, async (request) => {
+32 -1
View File
@@ -1,7 +1,7 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createInitialPlay } from '../src/domain.js';
import { buildServer } from './app.js';
import { hashPassword } from './security.js';
@@ -56,6 +56,37 @@ describe('Fastify Mongo contract', () => {
const temporaryRoots = [];
afterEach(async () => { await app?.close(); await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); });
it('uses a one hour sliding session and never revives an expired session', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
try {
const store = memoryStore(); await seedUser(store, { id: 'clock', email: 'clock@example.com', displayName: 'Clock' });
app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false });
const start = new Date('2026-01-01T00:00:00.000Z'); vi.setSystemTime(start);
const result = await login(app, 'clock@example.com', 'correct horse battery staple');
expect(result.response.headers['set-cookie']).toContain('Max-Age=3600');
const session = store.sessions.documents[0]; expect(session.expiresAt).toEqual(new Date(start.getTime() + 3_600_000));
vi.setSystemTime(new Date(start.getTime() + 50 * 60_000));
const renewed = await app.inject({ method: 'GET', url: '/api/teams', headers: { cookie: result.cookie } });
expect(renewed.statusCode).toBe(200); expect(session.expiresAt).toEqual(new Date(start.getTime() + 110 * 60_000));
vi.setSystemTime(new Date(start.getTime() + 70 * 60_000)); const activity = await app.inject({ method: 'POST', url: '/api/auth/activity', headers: { cookie: result.cookie }, payload: {} }); expect(activity.statusCode).toBe(200); expect(activity.headers['set-cookie']).toContain('Max-Age=3600');
vi.setSystemTime(new Date(start.getTime() + 130 * 60_000)); const expired = await app.inject({ method: 'POST', url: '/api/auth/activity', headers: { cookie: result.cookie }, payload: {} }); expect(expired.statusCode).toBe(401); expect(session.expiresAt).toEqual(new Date(start.getTime() + 130 * 60_000));
} finally { vi.useRealTimers(); }
});
it('does not renew on health, auth/me, or rejected origins', async () => {
const store = memoryStore(); await seedUser(store, { id: 'origin', email: 'origin@example.com', displayName: 'Origin' }); app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false });
const result = await login(app, 'origin@example.com', 'correct horse battery staple'); const session = store.sessions.documents[0]; const original = session.expiresAt;
expect((await app.inject({ method: 'GET', url: '/api/health', headers: { cookie: result.cookie } })).statusCode).toBe(200); expect(session.expiresAt).toEqual(original);
expect((await app.inject({ method: 'GET', url: '/api/auth/me', headers: { cookie: result.cookie } })).statusCode).toBe(200); expect(session.expiresAt).toEqual(original);
expect((await app.inject({ method: 'POST', url: '/api/auth/activity', headers: { cookie: result.cookie, origin: 'https://evil.example' }, payload: {} })).statusCode).toBe(403); expect(session.expiresAt).toEqual(original);
});
it('rejects a legacy session that has already been idle for an hour', async () => {
const store = memoryStore(); await seedUser(store, { id: 'legacy', email: 'legacy@example.com', displayName: 'Legacy' }); app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false });
const result = await login(app, 'legacy@example.com', 'correct horse battery staple'); const session = store.sessions.documents[0]; delete session.lastActivityAt; session.createdAt = new Date(Date.now() - 3_600_000); session.expiresAt = new Date(Date.now() + 86_400_000);
expect((await app.inject({ method: 'GET', url: '/api/teams', headers: { cookie: result.cookie } })).statusCode).toBe(401);
});
it('rejects unauthenticated and pending access', async () => {
const store = memoryStore(); app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false });
expect((await app.inject({ method: 'GET', url: '/api/teams' })).statusCode).toBe(401);
+2 -2
View File
@@ -7,7 +7,7 @@ const R = 8;
const P = 1;
export const SESSION_COOKIE = 'court_session';
export const SESSION_DAYS = 7;
export const SESSION_SECONDS = 60 * 60;
export function hashToken(token) {
return createHash('sha256').update(token).digest('hex');
@@ -40,7 +40,7 @@ export function newSessionToken() {
}
export function sessionExpiry(now = Date.now()) {
return new Date(now + SESSION_DAYS * 24 * 60 * 60 * 1000).toISOString();
return new Date(now + SESSION_SECONDS * 1000).toISOString();
}
export function validEmail(email) {
+1
View File
@@ -36,6 +36,7 @@ export async function getCurrentUser() { return apiRequest('/api/auth/me'); }
export async function loginAccount(email, password) { return apiRequest('/api/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }); }
export async function registerAccount(email, password, displayName) { return apiRequest('/api/auth/register', { method: 'POST', body: JSON.stringify({ email, password, displayName }) }); }
export async function logoutAccount() { return apiRequest('/api/auth/logout', { method: 'POST', body: '{}' }); }
export async function renewSessionActivity() { return apiRequest('/api/auth/activity', { method: 'POST', body: '{}' }); }
export async function listTeams() { return apiRequest('/api/teams'); }
export async function createTeam(name) { return apiRequest('/api/teams', { method: 'POST', body: JSON.stringify({ name }) }); }
export async function searchTeams(q) { return apiRequest(`/api/teams/search?q=${encodeURIComponent(q)}`); }
+31
View File
@@ -0,0 +1,31 @@
const activityEvents = ['click', 'pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart', 'touchmove'];
export function createSessionActivityTracker({ onActivity, isAuthenticated, throttleMs = 30_000, clock = () => Date.now(), target = globalThis }) {
const timerHost = target.setTimeout ? target : globalThis;
let active = false;
let inFlight = false;
let pending = false;
let timer = null;
let lastSent = -Infinity;
let generation = 0;
const listeners = new Map();
const send = async () => {
if (!active || inFlight || !isAuthenticated()) return;
inFlight = true; pending = false; lastSent = clock();
const sentGeneration = generation;
try { await onActivity(); }
catch (error) { if (sentGeneration === generation && (error?.status === 401 || error?.code === 'unauthenticated')) stop(); }
finally { inFlight = false; if (active && pending) schedule(); }
};
const schedule = () => {
if (!active || inFlight) return;
const wait = Math.max(0, throttleMs - (clock() - lastSent));
if (!wait) { void send(); return; }
if (timer === null) timer = timerHost.setTimeout(() => { timer = null; void send(); }, wait);
};
const activity = () => { if (!active || !isAuthenticated()) return; pending = true; schedule(); };
const start = () => { if (active) return; generation += 1; lastSent = -Infinity; active = true; activityEvents.forEach((event) => { listeners.set(event, activity); target.addEventListener(event, activity, { capture: true, passive: true }); }); };
const stop = () => { generation += 1; active = false; pending = false; if (timer !== null) { timerHost.clearTimeout(timer); timer = null; } listeners.forEach((listener, event) => target.removeEventListener(event, listener, { capture: true })); listeners.clear(); };
return { start, stop, activity, isActive: () => active };
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from 'vitest';
import { createSessionActivityTracker } from './sessionActivity.js';
function harness() {
const target = new EventTarget(); const calls = []; let authenticated = true;
const tracker = createSessionActivityTracker({ target, throttleMs: 1000, isAuthenticated: () => authenticated, onActivity: () => { calls.push('request'); return Promise.resolve(); } });
return { target, calls, tracker, setAuthenticated: (value) => { authenticated = value; } };
}
describe('session activity tracker', () => {
it('sends one immediate request and one trailing request for a burst', async () => {
vi.useFakeTimers(); const { target, calls, tracker } = harness(); tracker.start(); target.dispatchEvent(new Event('pointerdown')); await vi.runOnlyPendingTimersAsync(); expect(calls).toHaveLength(1);
target.dispatchEvent(new Event('pointermove')); target.dispatchEvent(new Event('keydown')); await vi.advanceTimersByTimeAsync(999); expect(calls).toHaveLength(1); await vi.advanceTimersByTimeAsync(1); expect(calls).toHaveLength(2); await vi.advanceTimersByTimeAsync(60_000); expect(calls).toHaveLength(2); tracker.stop(); vi.useRealTimers();
});
it('stays idle, avoids overlap, then stops on the current generation 401', async () => {
vi.useFakeTimers(); const target = new EventTarget(); let resolveFirst; let rejectSecond; let authenticated = true; const calls = []; const onActivity = vi.fn(() => new Promise((resolve, reject) => { calls.push({ resolve, reject }); if (calls.length === 1) resolveFirst = resolve; else rejectSecond = reject; })); const tracker = createSessionActivityTracker({ target, throttleMs: 1000, isAuthenticated: () => authenticated, onActivity });
tracker.start(); await vi.advanceTimersByTimeAsync(3_600_000); expect(onActivity).not.toHaveBeenCalled();
target.dispatchEvent(new Event('click')); target.dispatchEvent(new Event('pointermove')); await vi.runOnlyPendingTimersAsync(); expect(onActivity).toHaveBeenCalledTimes(1); target.dispatchEvent(new Event('keydown')); await vi.advanceTimersByTimeAsync(1000); expect(onActivity).toHaveBeenCalledTimes(1); resolveFirst(); await Promise.resolve(); await vi.runOnlyPendingTimersAsync(); expect(onActivity).toHaveBeenCalledTimes(2); rejectSecond({ status: 401 }); await Promise.resolve(); await vi.runOnlyPendingTimersAsync(); expect(tracker.isActive()).toBe(false); target.dispatchEvent(new Event('click')); await vi.advanceTimersByTimeAsync(10_000); expect(onActivity).toHaveBeenCalledTimes(2); vi.useRealTimers();
});
it('removes listeners and makes no calls while unauthenticated', async () => {
vi.useFakeTimers(); const { target, calls, tracker, setAuthenticated } = harness(); setAuthenticated(false); tracker.start(); target.dispatchEvent(new Event('wheel')); await vi.runOnlyPendingTimersAsync(); expect(calls).toHaveLength(0); tracker.stop(); setAuthenticated(true); target.dispatchEvent(new Event('wheel')); await vi.runOnlyPendingTimersAsync(); expect(calls).toHaveLength(0); vi.useRealTimers();
});
});