diff --git a/packages/backend/README.md b/packages/backend/README.md index 1354a5a..faacb53 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -58,6 +58,11 @@ PORT=3000 COOKIE_KEY # Connection string for connecting with the SQL database (MariaDB/MySQL) DB_URL +# How long a new session should be valid for (in seconds). +SESSION_TIMEOUT=604800 +# Interval in seconds at which an active session should be extended to SESSION_TIMEOUT again. +# If you don't want to extend sessions at all, you may set this to -1. +SESSION_REFRESH_INTERVAL=60 ``` ### Eve SSO configuration @@ -90,6 +95,9 @@ CORE_URL CORE_APP_ID # The Secret of the Neucore application (as obtained from Neucore) CORE_APP_TOKEN +# Neucore groups will be cached between requests. This value controls how long the cache +# hould be valid for in seconds. Groups will always be refreshed on mutation requests. +CORE_GROUP_REFRESH_INTERVAL=60 ``` ### User Permission/Role configuration diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 24f11c6..74d8753 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -4,7 +4,7 @@ import bodyParser from 'koa-bodyparser' import { UserRoles } from '@ping-board/common' import { EventsRepository, PingsRepository } from './database' import { getSessionMiddleware, SessionProvider } from './middleware/session' -import { getUserRolesMiddleware } from './middleware/user-roles' +import { getUserRolesMiddleware, NeucoreGroupsProvider } from './middleware/user-roles' import { NeucoreClient } from './neucore' import { getRouter as getApiRouter } from './routes/api' import { getRouter as getAuthRouter } from './routes/auth' @@ -14,8 +14,11 @@ import { SlackClient } from './slack/slack-client' export function getApp(options: { eveSsoClient: EveSSOClient, neucoreClient: NeucoreClient, + neucoreGroupsProvider: NeucoreGroupsProvider, slackClient: SlackClient, sessionProvider: SessionProvider, + sessionTimeout: number, + sessionRefreshInterval: number, cookieSigningKeys?: string[], events: EventsRepository, pings: PingsRepository, @@ -32,6 +35,8 @@ export function getApp(options: { app, sessionCookieName: 'pingboard-session', sessionProvider: options.sessionProvider, + sessionTimeout: options.sessionTimeout, + sessionRefreshInterval: options.sessionRefreshInterval, })) app.use(getUserRolesMiddleware(options)) app.use(bodyParser({ enableTypes: ['json'] })) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index a609444..f8f5482 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -5,6 +5,7 @@ import { EveSSOClient } from './sso/eve-sso-client' import { InMemorySessionProvider } from './util/in-memory-session-provider' import { UserRoles } from '@ping-board/common' import { SlackClient } from './slack/slack-client' +import { NeucoreGroupCache } from './neucore/neucore-group-cache' async function main() { const eveSsoClient = new EveSSOClient({ @@ -19,12 +20,19 @@ async function main() { appId: getFromEnv('CORE_APP_ID'), appToken: getFromEnv('CORE_APP_TOKEN'), }) + const neucoreGroupsProvider = new NeucoreGroupCache({ + neucoreClient, + cacheTTL: getNumberFromEnv('CORE_GROUP_REFRESH_INTERVAL', 60) * 1000, + }) const slackClient = new SlackClient(getFromEnv('SLACK_TOKEN')) + const sessionTimeout = getNumberFromEnv('SESSION_TIMEOUT', 7 * 24 * 60 * 60) * 1000 + const sessionRefreshInterval = getNumberFromEnv('SESSION_REFRESH_INTERVAL', 60) * 1000 const sessionProvider = new InMemorySessionProvider() sessionProvider.startAutoCleanup() + const cookieSigningKeys = process.env.COOKIE_KEY?.split(' ') const knex = await knexInstance() @@ -51,8 +59,11 @@ async function main() { const app = getApp({ eveSsoClient, neucoreClient, + neucoreGroupsProvider, slackClient, sessionProvider, + sessionTimeout, + sessionRefreshInterval, cookieSigningKeys, events, pings, @@ -82,6 +93,21 @@ function getFromEnv(key: string): string { return value } +function getNumberFromEnv(key: string, fallback?: number): number { + const value = process.env[key] + if (typeof value !== 'string') { + if (typeof fallback === 'number') { + return fallback + } + throw new Error(`Missing env variable: ${key}`) + } + const asNumber = Number(value) + if (!Number.isFinite(asNumber)) { + throw new Error(`Env variable is not a number: ${key}`) + } + return asNumber +} + main().catch(error => { console.error(error) process.exit(1) diff --git a/packages/backend/src/middleware/session.ts b/packages/backend/src/middleware/session.ts index bf70323..15bc3a7 100644 --- a/packages/backend/src/middleware/session.ts +++ b/packages/backend/src/middleware/session.ts @@ -1,4 +1,3 @@ -import { NeucoreGroup } from '@ping-board/common' import Koa, { Middleware, Next } from 'koa' export interface SessionProvider { @@ -15,7 +14,6 @@ export interface Session { character?: { id: number name: string - neucoreGroups: NeucoreGroup[] } } @@ -37,13 +35,18 @@ export interface GetSessionMiddlewareOptions { app: Koa sessionCookieName: string sessionProvider: SessionProvider + sessionTimeout: number + sessionRefreshInterval: number } export function getSessionMiddleware({ app, sessionCookieName, sessionProvider, + sessionTimeout, + sessionRefreshInterval, }: GetSessionMiddlewareOptions): Middleware { + const isSigned = Array.isArray(app.keys) && app.keys.length > 0 if (!isSigned) { if (process.env.NODE_ENV !== 'development') { @@ -53,6 +56,14 @@ export function getSessionMiddleware({ } } + const getCookieOptions = () => ({ + httpOnly: true, + signed: isSigned, + overwrite: true, + sameSite: 'lax' as const, + expires: new Date(Date.now() + sessionTimeout), + }) + return async (ctx, next: Next) => { // Alias the context to work around the readonly restrictions of the regular context const sessionCtx = ctx as SessionContext @@ -62,19 +73,12 @@ export function getSessionMiddleware({ if (sessionId) { await sessionProvider.deleteSession(sessionId) } - const sessionTimeout = 1000 * 60 * 60 - const expiresAt = new Date(Date.now() + sessionTimeout) + const cookieOptions = getCookieOptions() const newSession = await sessionProvider.createSession({ - expiresAt, + expiresAt: cookieOptions.expires, ...content, }) - ctx.cookies.set(sessionCookieName, newSession.id, { - httpOnly: true, - signed: isSigned, - overwrite: true, - sameSite: 'lax', - expires: expiresAt, - }) + ctx.cookies.set(sessionCookieName, newSession.id, cookieOptions) sessionCtx.session = newSession return newSession } @@ -88,7 +92,25 @@ export function getSessionMiddleware({ const sessionId = ctx.cookies.get(sessionCookieName, { signed: isSigned }) if (sessionId) { - sessionCtx.session = await sessionProvider.getSession(sessionId) ?? null + const session = await sessionProvider.getSession(sessionId) ?? null + sessionCtx.session = session + if (session) { + const sessionAge = Date.now() + sessionTimeout - session.expiresAt.getTime() + const shouldRefresh = sessionRefreshInterval >= 0 && + sessionAge >= sessionRefreshInterval + + if (shouldRefresh) { + const cookieOptions = getCookieOptions() + await sessionProvider.updateSession({ + ...session, + expiresAt: cookieOptions.expires, + }) + ctx.cookies.set(sessionCookieName, sessionId, cookieOptions) + } + } else { + // The session wasn't found in the database, so there's no point in keeping the cookie + ctx.cookies.set(sessionCookieName, null) + } } return next() diff --git a/packages/backend/src/middleware/user-roles.ts b/packages/backend/src/middleware/user-roles.ts index 161b3dd..d030944 100644 --- a/packages/backend/src/middleware/user-roles.ts +++ b/packages/backend/src/middleware/user-roles.ts @@ -7,32 +7,92 @@ declare module 'koa' { interface BaseContext extends Readonly { } } +export interface NeucoreGroupsProvider { + getGroups(characterId: number, forceRefresh?: boolean): Promise +} + interface SessionContext { - hasRoles(...roles: UserRoles[]): boolean - getRoles(): UserRoles[] + getNeucoreGroups(fresh?: boolean): Promise + hasRoles(...roles: UserRoles[]): Promise + hasFreshRoles(...roles: UserRoles[]): Promise + hasAnyRole(...roles: UserRoles[]): Promise + hasAnyFreshRole(...roles: UserRoles[]): Promise + getRoles(): Promise + getFreshRoles(): Promise } interface GetUserRolesMiddlewareOptions { neucoreToUserRolesMapping: Map + neucoreGroupsProvider: NeucoreGroupsProvider } export function getUserRolesMiddleware({ neucoreToUserRolesMapping, + neucoreGroupsProvider, }: GetUserRolesMiddlewareOptions): Middleware { - return (ctx, next) => { - const neucoreGroups = (ctx.session?.character?.neucoreGroups ?? []).map(g => g.name) - const userRoles = new Set(neucoreGroups.flatMap(g => neucoreToUserRolesMapping.get(g) ?? [])) + function getUserRoles(neucoreGroups: string[]): UserRoles[] { + return neucoreGroups.flatMap(g => neucoreToUserRolesMapping.get(g) ?? []) + } + return (ctx, next) => { const roleCtx = ctx as SessionContext - roleCtx.hasRoles = (...roles: UserRoles[]) => roles.every(r => userRoles.has(r)) - roleCtx.getRoles = () => [...userRoles] + + const getGroups = async (fresh: boolean) => { + if (!ctx.session?.character) { return [] } + return await neucoreGroupsProvider.getGroups(ctx.session.character.id, fresh) + } + roleCtx.getNeucoreGroups = getGroups + + const getRoles = async (fresh: boolean) => { + const groups = await getGroups(fresh) + return getUserRoles(groups) + } + + roleCtx.hasRoles = async (...roles) => { + const userRoles = await getRoles(false) + return roles.every(r => userRoles.includes(r)) + } + roleCtx.hasFreshRoles = async (...roles) => { + const userRoles = await getRoles(true) + return roles.every(r => userRoles.includes(r)) + } + + roleCtx.hasAnyRole = async (...roles) => { + const userRoles = await getRoles(false) + return roles.some(r => userRoles.includes(r)) + } + roleCtx.hasAnyFreshRole = async (...roles) => { + const userRoles = await getRoles(true) + return roles.some(r => userRoles.includes(r)) + } + + roleCtx.getRoles = async () => getRoles(false) + roleCtx.getFreshRoles = async () => getRoles(true) return next() } } export const userRoles = { - requireOneOf: (...roles: UserRoles[]): Middleware => (ctx, next) => { - if (roles.some(r => ctx.hasRoles(r))) { + requireOneOf: (...roles: UserRoles[]): Middleware => async (ctx, next) => { + if (await ctx.hasAnyRole(...roles)) { + return next() + } + if (!ctx.session?.character) { + throw new Unauthorized() + } + throw new Forbidden('insuficcient roles') + }, + requireOneFreshOf: (...roles: UserRoles[]): Middleware => async (ctx, next) => { + if (await ctx.hasAnyFreshRole(...roles)) { + return next() + } + if (!ctx.session?.character) { + throw new Unauthorized() + } + throw new Forbidden('insuficcient roles') + }, + requireAllOf: (...roles: UserRoles[]): Middleware => async (ctx, next) => { + if (await ctx.hasRoles(...roles)) { return next() } if (!ctx.session?.character) { @@ -40,8 +100,8 @@ export const userRoles = { } throw new Forbidden('insuficcient roles') }, - requireAllOf: (...roles: UserRoles[]): Middleware => (ctx, next) => { - if (ctx.hasRoles(...roles)) { + requireAllFreshOf: (...roles: UserRoles[]): Middleware => async (ctx, next) => { + if (await ctx.hasFreshRoles(...roles)) { return next() } if (!ctx.session?.character) { diff --git a/packages/backend/src/neucore/neucore-group-cache.ts b/packages/backend/src/neucore/neucore-group-cache.ts new file mode 100644 index 0000000..c8bbf15 --- /dev/null +++ b/packages/backend/src/neucore/neucore-group-cache.ts @@ -0,0 +1,25 @@ +import { InMemoryTTLCache } from '../util/in-memory-ttl-cache' +import { NeucoreGroupsProvider } from '../middleware/user-roles' +import { NeucoreClient } from './neucore-client' + +export class NeucoreGroupCache implements NeucoreGroupsProvider { + #cache: InMemoryTTLCache + + constructor(options: { + neucoreClient: NeucoreClient + cacheTTL: number + }) { + this.#cache = new InMemoryTTLCache({ + defaultTTL: options.cacheTTL, + get: async characterId => { + console.log('getting neucore groups for', characterId) + const groups = await options.neucoreClient.getCharacterGroups(characterId) + return { value: groups.map(g => g.name) } + }, + }) + } + + async getGroups(characterId: number, forceRefresh = false): Promise { + return await this.#cache.get(characterId, forceRefresh) + } +} diff --git a/packages/backend/src/routes/api.ts b/packages/backend/src/routes/api.ts index df7bd7f..8f09bff 100644 --- a/packages/backend/src/routes/api.ts +++ b/packages/backend/src/routes/api.ts @@ -14,14 +14,14 @@ export function getRouter(options: { }): Router { const router = new Router() - router.get('/me', ctx => { + router.get('/me', async ctx => { const response: ApiMeResponse = ctx.session?.character ? { isLoggedIn: true, character: { id: ctx.session.character.id, name: ctx.session.character.name, - roles: ctx.getRoles(), + roles: await ctx.getRoles(), }, } : { isLoggedIn: false } diff --git a/packages/backend/src/routes/api/events.ts b/packages/backend/src/routes/api/events.ts index ec0b3bb..f1a2320 100644 --- a/packages/backend/src/routes/api/events.ts +++ b/packages/backend/src/routes/api/events.ts @@ -32,7 +32,7 @@ export function getRouter(options: { router.post( '/', - userRoles.requireOneOf(UserRoles.EVENTS_EDIT, UserRoles.EVENTS_ADD), + userRoles.requireOneFreshOf(UserRoles.EVENTS_EDIT, UserRoles.EVENTS_ADD), async ctx => { const event = await validateEventInput(ctx.request.body) const response = await options.events.addEvent(event, ctx.session?.character?.name ?? '') @@ -41,7 +41,7 @@ export function getRouter(options: { } ) - router.put('/:eventId', userRoles.requireOneOf(UserRoles.EVENTS_EDIT), async ctx => { + router.put('/:eventId', userRoles.requireOneFreshOf(UserRoles.EVENTS_EDIT), async ctx => { const eventId = parseInt(ctx.params['eventId']) if (!Number.isFinite(eventId) || eventId < 0) { throw new BadRequest() @@ -55,7 +55,7 @@ export function getRouter(options: { ctx.body = response }) - router.delete('/:eventId', userRoles.requireOneOf(UserRoles.EVENTS_EDIT), async ctx => { + router.delete('/:eventId', userRoles.requireOneFreshOf(UserRoles.EVENTS_EDIT), async ctx => { const eventId = parseInt(ctx.params['eventId']) if (!Number.isFinite(eventId) || eventId < 0) { throw new BadRequest() diff --git a/packages/backend/src/routes/api/pings.ts b/packages/backend/src/routes/api/pings.ts index 48cc2b4..a6a2703 100644 --- a/packages/backend/src/routes/api/pings.ts +++ b/packages/backend/src/routes/api/pings.ts @@ -37,14 +37,14 @@ export function getRouter(options: { const before = typeof beforeParam === 'string' ? new Date(beforeParam) : undefined const pings = await options.pings.getPings({ characterName: ctx.session.character.name, - neucoreGroups: ctx.session.character.neucoreGroups.map(g => g.name), + neucoreGroups: await ctx.getNeucoreGroups(), before, }) const response: ApiPingsResponse = { ...pings } ctx.body = response }) - router.post('/', userRoles.requireOneOf(UserRoles.PING), async ctx => { + router.post('/', userRoles.requireOneFreshOf(UserRoles.PING), async ctx => { if (!ctx.session?.character) { throw new Unauthorized() } @@ -56,10 +56,10 @@ export function getRouter(options: { if (!!ping.scheduledFor !== !!ping.scheduledTitle) { throw new BadRequest('either specify both a calendar time and title or neither of both') } + const groups = await ctx.getNeucoreGroups() if ( template.allowedNeucoreGroups.length > 0 && - !template.allowedNeucoreGroups.some(g => - ctx.session?.character?.neucoreGroups.some(({ name }) => g === name)) + !template.allowedNeucoreGroups.some(g => groups.includes(g)) ) { throw new Forbidden('you are not permitted to ping using this template') } @@ -140,7 +140,7 @@ export function getRouter(options: { const response: ApiScheduledPingsResponse = await options.pings.getScheduledEvents({ characterName: ctx.session.character.name, - neucoreGroups: ctx.session.character.neucoreGroups.map(g => g.name), + neucoreGroups: await ctx.getNeucoreGroups(), before, after, count, @@ -159,7 +159,8 @@ export function getRouter(options: { ctx.body = response }) - router.get('/neucore-groups', + router.get( + '/neucore-groups', userRoles.requireOneOf(UserRoles.PING_TEMPLATES_WRITE), async ctx => { const appInfo = await options.neucoreClient.getAppInfo() @@ -172,12 +173,12 @@ export function getRouter(options: { router.get('/templates', userRoles.requireOneOf(UserRoles.PING), async ctx => { const templates = await options.pings.getPingTemplates() - const canSeeAllTemplates = ctx.hasRoles(UserRoles.PING_TEMPLATES_WRITE) + const canSeeAllTemplates = await ctx.hasRoles(UserRoles.PING_TEMPLATES_WRITE) let response: ApiPingTemplatesResponse if (canSeeAllTemplates) { response = { templates } } else { - const neucoreGroups = ctx.session?.character?.neucoreGroups?.map(g => g.name) ?? [] + const neucoreGroups = await ctx.getNeucoreGroups() response = { templates: templates.filter(t => t.allowedNeucoreGroups.length === 0 || @@ -188,21 +189,26 @@ export function getRouter(options: { ctx.body = response }) - router.post('/templates', userRoles.requireOneOf(UserRoles.PING_TEMPLATES_WRITE), async ctx => { - const template = await validateTemplateInput(ctx.request.body) - const channelName = await options.slackClient.getChannelName(template.slackChannelId) - const createdTemplate = await options.pings.addPingTemplate({ - input: { - ...template, - slackChannelName: channelName, - }, - characterName: ctx.session?.character?.name ?? '', - }) - ctx.body = createdTemplate - }) + router.post( + '/templates', + userRoles.requireOneFreshOf(UserRoles.PING_TEMPLATES_WRITE), + async ctx => { + const template = await validateTemplateInput(ctx.request.body) + const channelName = await options.slackClient.getChannelName(template.slackChannelId) + const createdTemplate = await options.pings.addPingTemplate({ + input: { + ...template, + slackChannelName: channelName, + }, + characterName: ctx.session?.character?.name ?? '', + }) + ctx.body = createdTemplate + } + ) - router.put('/templates/:templateId', - userRoles.requireOneOf(UserRoles.PING_TEMPLATES_WRITE), + router.put( + '/templates/:templateId', + userRoles.requireOneFreshOf(UserRoles.PING_TEMPLATES_WRITE), async ctx => { const templateId = parseInt(ctx.params['templateId'] ?? '', 10) if (!isFinite(templateId)) { @@ -229,8 +235,9 @@ export function getRouter(options: { } ) - router.delete('/templates/:templateId', - userRoles.requireOneOf(UserRoles.PING_TEMPLATES_WRITE), + router.delete( + '/templates/:templateId', + userRoles.requireOneFreshOf(UserRoles.PING_TEMPLATES_WRITE), async ctx => { const templateId = parseInt(ctx.params['templateId'] ?? '', 10) if (!isFinite(templateId)) { @@ -260,8 +267,9 @@ export function getRouter(options: { } ) - router.put('/view-permissions/groups/:group', - userRoles.requireOneOf(UserRoles.PING_TEMPLATES_WRITE), + router.put( + '/view-permissions/groups/:group', + userRoles.requireOneFreshOf(UserRoles.PING_TEMPLATES_WRITE), async ctx => { const neucoreGroup = ctx.params['group'] if (!neucoreGroup) { @@ -288,8 +296,9 @@ export function getRouter(options: { } ) - router.put('/view-permissions/channels/:channelId', - userRoles.requireOneOf(UserRoles.PING_TEMPLATES_WRITE), + router.put( + '/view-permissions/channels/:channelId', + userRoles.requireOneFreshOf(UserRoles.PING_TEMPLATES_WRITE), async ctx => { const channelId = ctx.params['channelId'] if (!channelId) { diff --git a/packages/backend/src/routes/auth.ts b/packages/backend/src/routes/auth.ts index fd34a25..b6ea5b1 100644 --- a/packages/backend/src/routes/auth.ts +++ b/packages/backend/src/routes/auth.ts @@ -32,12 +32,10 @@ export function getRouter(options: { const character = await options.eveSsoClient.handleCallback(session.id, ctx.query, ctx.href) try { - const neucoreGroups = await options.neucoreClient.getCharacterGroups(character.characterId) await ctx.resetSession({ character: { id: character.characterId, name: character.name, - neucoreGroups, }, }) console.log(`Successfully logged in ${character.name}`)