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
+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) => {