Extend authenticated session activity
This commit is contained in:
+20
-3
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user