Skip to content

Commit

Permalink
(backend) Implement persistent sessions
Browse files Browse the repository at this point in the history
Fix #63
  • Loading branch information
cmd-johnson committed Feb 22, 2022
1 parent a99f546 commit 4cbad75
Show file tree
Hide file tree
Showing 10 changed files with 213 additions and 60 deletions.
8 changes: 8 additions & 0 deletions packages/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion packages/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand All @@ -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'] }))
Expand Down
26 changes: 26 additions & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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()
Expand All @@ -51,8 +59,11 @@ async function main() {
const app = getApp({
eveSsoClient,
neucoreClient,
neucoreGroupsProvider,
slackClient,
sessionProvider,
sessionTimeout,
sessionRefreshInterval,
cookieSigningKeys,
events,
pings,
Expand Down Expand Up @@ -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)
Expand Down
48 changes: 35 additions & 13 deletions packages/backend/src/middleware/session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { NeucoreGroup } from '@ping-board/common'
import Koa, { Middleware, Next } from 'koa'

export interface SessionProvider {
Expand All @@ -15,7 +14,6 @@ export interface Session {
character?: {
id: number
name: string
neucoreGroups: NeucoreGroup[]
}
}

Expand All @@ -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') {
Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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()
Expand Down
82 changes: 71 additions & 11 deletions packages/backend/src/middleware/user-roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,41 +7,101 @@ declare module 'koa' {
interface BaseContext extends Readonly<SessionContext> { }
}

export interface NeucoreGroupsProvider {
getGroups(characterId: number, forceRefresh?: boolean): Promise<string[]>
}

interface SessionContext {
hasRoles(...roles: UserRoles[]): boolean
getRoles(): UserRoles[]
getNeucoreGroups(fresh?: boolean): Promise<string[]>
hasRoles(...roles: UserRoles[]): Promise<boolean>
hasFreshRoles(...roles: UserRoles[]): Promise<boolean>
hasAnyRole(...roles: UserRoles[]): Promise<boolean>
hasAnyFreshRole(...roles: UserRoles[]): Promise<boolean>
getRoles(): Promise<UserRoles[]>
getFreshRoles(): Promise<UserRoles[]>
}

interface GetUserRolesMiddlewareOptions {
neucoreToUserRolesMapping: Map<string, UserRoles[]>
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) {
throw new Unauthorized()
}
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) {
Expand Down
25 changes: 25 additions & 0 deletions packages/backend/src/neucore/neucore-group-cache.ts
Original file line number Diff line number Diff line change
@@ -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<number, string[]>

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<string[]> {
return await this.#cache.get(characterId, forceRefresh)
}
}
4 changes: 2 additions & 2 deletions packages/backend/src/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
6 changes: 3 additions & 3 deletions packages/backend/src/routes/api/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? '')
Expand All @@ -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()
Expand All @@ -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()
Expand Down
Loading

0 comments on commit 4cbad75

Please sign in to comment.