From 9b7324e20a3b3d5346b379ef7477c19eb094011a Mon Sep 17 00:00:00 2001 From: ModZero Date: Tue, 8 Oct 2019 23:24:25 +0200 Subject: [PATCH] Session persistence --- package-lock.json | 64 +++++------- package.json | 3 +- .../patches/20190925000000-updated-at/up.sql | 8 +- .../patches/20191008190200-sessions/down.sql | 1 + .../patches/20191008190200-sessions/up.sql | 10 ++ sql/sessions/all.sql | 1 + sql/sessions/clear.sql | 1 + sql/sessions/destroy.sql | 1 + sql/sessions/get.sql | 1 + sql/sessions/length.sql | 1 + sql/sessions/set.sql | 3 + sql/sessions/touch.sql | 1 + src/custom.d.ts | 7 ++ src/db/index.ts | 2 + src/db/models.ts | 5 + src/db/repos/index.ts | 11 ++- src/db/repos/sessions.ts | 70 +++++++++++++ src/db/sql/index.ts | 12 ++- src/index.ts | 7 +- src/sessions.ts | 99 +++++++++++++++++++ 20 files changed, 258 insertions(+), 50 deletions(-) create mode 100644 sql/migrations/patches/20191008190200-sessions/down.sql create mode 100644 sql/migrations/patches/20191008190200-sessions/up.sql create mode 100644 sql/sessions/all.sql create mode 100644 sql/sessions/clear.sql create mode 100644 sql/sessions/destroy.sql create mode 100644 sql/sessions/get.sql create mode 100644 sql/sessions/length.sql create mode 100644 sql/sessions/set.sql create mode 100644 sql/sessions/touch.sql create mode 100644 src/db/repos/sessions.ts create mode 100644 src/sessions.ts diff --git a/package-lock.json b/package-lock.json index 02cf583..bcc7da8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,33 @@ "integrity": "sha512-1dVNHT76Uu5N3eJNTYcvxee+jzX4Z9lfciqRRHCU27ihbUcYi+iSc2iml5Ke1LXe1SyJCLA0+14Jh4tXJgOppA==", "dev": true }, + "@holdyourwaffle/express-session": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@holdyourwaffle/express-session/-/express-session-1.16.2.tgz", + "integrity": "sha512-S4MutTCUvOFawZ2g+PCLkfg7WXK7bR9ZVUCp27X98YBuJLUaRZGO73jVyPKe9S5/rXc2Ppsf36/NwOB9j66n/w==", + "requires": { + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.0", + "uid-safe": "~2.1.5" + }, + "dependencies": { + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, + "safe-buffer": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", + "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==" + } + } + }, "@phc/format": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@phc/format/-/format-0.5.0.tgz", @@ -225,16 +252,6 @@ "@types/range-parser": "*" } }, - "@types/express-session": { - "version": "1.15.14", - "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.15.14.tgz", - "integrity": "sha512-7kVzFTT0Jy0zmUYDt9ik76XbcqyS9NalV4gn4eLwhk1nGQn+lS/HjPODhG3Oi/GBR2w1LQHUdkz/5KICYMACiw==", - "dev": true, - "requires": { - "@types/express": "*", - "@types/node": "*" - } - }, "@types/fs-capacitor": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/fs-capacitor/-/fs-capacitor-2.0.0.tgz", @@ -2653,33 +2670,6 @@ "pino-http": "^4.0.0" } }, - "express-session": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.16.2.tgz", - "integrity": "sha512-oy0sRsdw6n93E9wpCNWKRnSsxYnSDX9Dnr9mhZgqUEEorzcq5nshGYSZ4ZReHFhKQ80WI5iVUUSPW7u3GaKauw==", - "requires": { - "cookie": "0.3.1", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "~2.0.0", - "on-headers": "~1.0.2", - "parseurl": "~1.3.3", - "safe-buffer": "5.1.2", - "uid-safe": "~2.1.5" - }, - "dependencies": { - "cookie": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", - "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=" - }, - "depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" - } - } - }, "extend-shallow": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", diff --git a/package.json b/package.json index e93cb13..5d0ebb6 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "license": "AGPL-3.0-or-later", "private": true, "dependencies": { + "@holdyourwaffle/express-session": "^1.16.2", "@types/pug": "^2.0.4", "apollo-server-express": "^2.9.3", "argon2": "^0.24.1", @@ -24,7 +25,6 @@ "dotenv": "^8.1.0", "express": "^4.17.1", "express-pino-logger": "^4.0.0", - "express-session": "^1.16.2", "graphql": "^14.5.7", "helmet": "^3.21.1", "http-errors": "^1.7.3", @@ -44,7 +44,6 @@ "@types/dotenv": "^6.1.1", "@types/express": "^4.17.1", "@types/express-pino-logger": "^4.0.1", - "@types/express-session": "^1.15.14", "@types/helmet": "0.0.44", "@types/http-errors": "^1.6.2", "@types/luxon": "^1.15.2", diff --git a/sql/migrations/patches/20190925000000-updated-at/up.sql b/sql/migrations/patches/20190925000000-updated-at/up.sql index 9a6fde1..70c07ed 100644 --- a/sql/migrations/patches/20190925000000-updated-at/up.sql +++ b/sql/migrations/patches/20190925000000-updated-at/up.sql @@ -1,11 +1,7 @@ CREATE OR REPLACE FUNCTION set_updated_timestamp() RETURNS TRIGGER AS $$ BEGIN - IF row(NEW.*) IS DISTINCT FROM row(OLD.*) THEN - NEW.updated_at = now(); - RETURN NEW; - ELSE - RETURN OLD; - END IF; + NEW.updated_at = now(); + RETURN NEW; END; $$ language 'plpgsql'; diff --git a/sql/migrations/patches/20191008190200-sessions/down.sql b/sql/migrations/patches/20191008190200-sessions/down.sql new file mode 100644 index 0000000..d49b7ae --- /dev/null +++ b/sql/migrations/patches/20191008190200-sessions/down.sql @@ -0,0 +1 @@ +DROP TABLE "sessions"; \ No newline at end of file diff --git a/sql/migrations/patches/20191008190200-sessions/up.sql b/sql/migrations/patches/20191008190200-sessions/up.sql new file mode 100644 index 0000000..a294b45 --- /dev/null +++ b/sql/migrations/patches/20191008190200-sessions/up.sql @@ -0,0 +1,10 @@ +CREATE TABLE "sessions" ( + "sid" text NOT NULL COLLATE "default", + "session" json NOT NULL, + "expires_at" timestamptz NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE "sessions" ADD CONSTRAINT "session_pkey" PRIMARY KEY ("sid") NOT DEFERRABLE INITIALLY IMMEDIATE; +CREATE TRIGGER "set_sessions_updated" BEFORE UPDATE ON "sessions" FOR EACH ROW EXECUTE PROCEDURE set_updated_timestamp(); diff --git a/sql/sessions/all.sql b/sql/sessions/all.sql new file mode 100644 index 0000000..1a8241b --- /dev/null +++ b/sql/sessions/all.sql @@ -0,0 +1 @@ +SELECT "sid", "session" FROM "sessions"; diff --git a/sql/sessions/clear.sql b/sql/sessions/clear.sql new file mode 100644 index 0000000..e2ddaa4 --- /dev/null +++ b/sql/sessions/clear.sql @@ -0,0 +1 @@ +TRUNCATE "sessions"; \ No newline at end of file diff --git a/sql/sessions/destroy.sql b/sql/sessions/destroy.sql new file mode 100644 index 0000000..8f163e8 --- /dev/null +++ b/sql/sessions/destroy.sql @@ -0,0 +1 @@ +DELETE FROM "sessions" WHERE "sid" = $1; \ No newline at end of file diff --git a/sql/sessions/get.sql b/sql/sessions/get.sql new file mode 100644 index 0000000..9d790a0 --- /dev/null +++ b/sql/sessions/get.sql @@ -0,0 +1 @@ +SELECT "session" FROM "sessions" WHERE "sid"=$1 AND CURRENT_TIMESTAMP < "expires_at"; \ No newline at end of file diff --git a/sql/sessions/length.sql b/sql/sessions/length.sql new file mode 100644 index 0000000..78a579d --- /dev/null +++ b/sql/sessions/length.sql @@ -0,0 +1 @@ +SELECT COUNT(*) as length FROM "sessions"; \ No newline at end of file diff --git a/sql/sessions/set.sql b/sql/sessions/set.sql new file mode 100644 index 0000000..2e43e9f --- /dev/null +++ b/sql/sessions/set.sql @@ -0,0 +1,3 @@ +INSERT INTO "sessions" AS s ("sid", "session", "expires_at") VALUES ($1, $2, $3) + ON CONFLICT ON CONSTRAINT "session_pkey" + DO UPDATE SET "session"=$2, "expires_at"=$3 WHERE "s"."sid"=$1; diff --git a/sql/sessions/touch.sql b/sql/sessions/touch.sql new file mode 100644 index 0000000..788e45d --- /dev/null +++ b/sql/sessions/touch.sql @@ -0,0 +1 @@ +UPDATE "sessions" SET "expires_at"=$2 WHERE "sid"=$1; \ No newline at end of file diff --git a/src/custom.d.ts b/src/custom.d.ts index 5f37445..15c4caa 100644 --- a/src/custom.d.ts +++ b/src/custom.d.ts @@ -14,6 +14,7 @@ // along with this program. If not, see . import { User } from "@kredens/db/models"; +import { Cookie } from "@holdyourwaffle/express-session"; declare global { namespace Express { @@ -21,4 +22,10 @@ declare global { user?: User; } } + + interface SessionData { + cookie: Cookie; + userID?: number; + csrfToken: string; + } } diff --git a/src/db/index.ts b/src/db/index.ts index a444a5d..ad8b032 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -16,6 +16,7 @@ import { Extensions, MigrationRepository, + SessionRepository, TaskRepository, UserRepository } from "@kredens/db/repos"; @@ -34,6 +35,7 @@ const initOptions: IInitOptions = { obj.migrations = new MigrationRepository(obj, pgp); obj.tasks = new TaskRepository(obj, pgp); obj.users = new UserRepository(obj, pgp); + obj.sessions = new SessionRepository(obj, pgp); } }; diff --git a/src/db/models.ts b/src/db/models.ts index 6405062..5022164 100644 --- a/src/db/models.ts +++ b/src/db/models.ts @@ -38,3 +38,8 @@ export interface Task { max_frequency?: number; created_at: DateTime; } + +export interface Session { + sid: string; + session: SessionData; +} diff --git a/src/db/repos/index.ts b/src/db/repos/index.ts index 365a072..d70efb2 100644 --- a/src/db/repos/index.ts +++ b/src/db/repos/index.ts @@ -14,13 +14,20 @@ // along with this program. If not, see . import { MigrationRepository } from "@kredens/db/repos/migrations"; +import { SessionRepository } from "@kredens/db/repos/sessions"; import { TaskRepository } from "@kredens/db/repos/tasks"; import { UserRepository } from "@kredens/db/repos/users"; export interface Extensions { migrations: MigrationRepository; - users: UserRepository; + sessions: SessionRepository; tasks: TaskRepository; + users: UserRepository; } -export { MigrationRepository, UserRepository, TaskRepository }; +export { + MigrationRepository, + UserRepository, + SessionRepository, + TaskRepository +}; diff --git a/src/db/repos/sessions.ts b/src/db/repos/sessions.ts new file mode 100644 index 0000000..51e1872 --- /dev/null +++ b/src/db/repos/sessions.ts @@ -0,0 +1,70 @@ +// Copyright (C) 2019 ModZero +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { Session } from "@kredens/db/models"; +import { sessions as sql } from "@kredens/db/sql"; +import { DateTime } from "luxon"; +import { Maybe } from "monet"; +import { IDatabase, IMain } from "pg-promise"; + +export class SessionRepository { + private db: IDatabase; + + constructor(db: IDatabase, pgp: IMain) { + this.db = db; + } + + public async all(): Promise { + return this.db.map(sql.all, [], row => ({ + session: row.session, + sid: row.sid + })); + } + + public async clear() { + return this.db.none(sql.clear); + } + + public async destroy(sid: string) { + return this.db.none(sql.destroy, [sid]); + } + + public async get(sid: string): Promise> { + return this.db.oneOrNone(sql.get, [sid]).then(row => + row + ? Maybe.Some({ + session: row.session, + sid: row.sid + }) + : Maybe.None() + ); + } + + public async length(): Promise { + return this.db.one(sql.length).then(row => +row.length); + } + + public async set(sid: string, session: SessionData, expiresAt: DateTime) { + return this.db.none(sql.set, [ + sid, + JSON.stringify(session), + expiresAt.toSQL() + ]); + } + + public async touch(sid: string, expiresAt: DateTime) { + return this.db.none(sql.touch, [sid, expiresAt.toSQL()]); + } +} diff --git a/src/db/sql/index.ts b/src/db/sql/index.ts index a740829..e334f74 100644 --- a/src/db/sql/index.ts +++ b/src/db/sql/index.ts @@ -45,7 +45,17 @@ const tasks = { list: sql("tasks/list.sql") }; -export { migrations, users, tasks }; +const sessions = { + all: sql("sessions/all.sql"), + clear: sql("sessions/clear.sql"), + destroy: sql("sessions/destroy.sql"), + get: sql("sessions/get.sql"), + length: sql("sessions/length.sql"), + set: sql("sessions/set.sql"), + touch: sql("sessions/touch.sql") +}; + +export { migrations, users, tasks, sessions }; /** Helper for linking to external query files */ function sql(file: string): QueryFile { diff --git a/src/index.ts b/src/index.ts index 78a97c5..d6e8be9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,19 +13,21 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import session, { SessionOptions } from "@holdyourwaffle/express-session"; import { server as graphqlServer } from "@kredens/api"; import { authMiddleware } from "@kredens/auth"; import { db } from "@kredens/db"; import logger from "@kredens/logger"; import indexRouter from "@kredens/routes/"; import bootstrapRouter from "@kredens/routes/bootstrap"; +import { PgStore } from "@kredens/sessions"; import cookieParser from "cookie-parser"; import csrf from "csurf"; import express from "express"; import pinoExpress from "express-pino-logger"; -import session, { SessionOptions } from "express-session"; import helmet from "helmet"; import createHttpError from "http-errors"; + async function main() { await db.tx(async t => { await t.migrations.create(); @@ -59,7 +61,8 @@ async function main() { const sessionOptions: SessionOptions = { resave: false, saveUninitialized: false, - secret: process.env.SECRET + secret: process.env.SECRET, + store: new PgStore() }; if (app.get("env") === "production") { diff --git a/src/sessions.ts b/src/sessions.ts new file mode 100644 index 0000000..97cfe6e --- /dev/null +++ b/src/sessions.ts @@ -0,0 +1,99 @@ +// Copyright (C) 2019 ModZero +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { Store } from "@holdyourwaffle/express-session"; +import { db } from "@kredens/db"; +import { DateTime, Duration } from "luxon"; + +export class PgStore extends Store { + private ttl: Duration; + constructor(options: { ttl?: Duration } = {}) { + super(); + this.ttl = options.ttl || Duration.fromObject({ days: 1 }); // One day in seconds. + } + + public get( + sid: string, + cb: (err: any, session?: SessionData | null) => void + ) { + db.sessions + .get(sid) + .then(s => cb(null, s.map(ss => ss.session).orNull())) + .catch(r => cb(r, null)); + } + + public set(sid: string, session: SessionData, cb?: (err?: any) => void) { + const expiresAt = this.getExpiresAt(session); + const p = db.sessions.set(sid, session, expiresAt); + + if (cb) { + p.then(s => cb(null)).catch(r => cb(r)); + } + } + + public destroy(sid: string, cb?: (err?: any) => void) { + const p = db.sessions.destroy(sid); + + if (cb) { + p.then(s => cb()).catch(r => cb(r)); + } + } + + public all( + cb: (err: any, obj?: { [sid: string]: SessionData } | null) => void + ) { + db.sessions + .all() + .then(ss => { + const sessions: { [sid: string]: SessionData } = {}; + + for (const s of ss) { + sessions[s.sid] = s.session; + } + + cb(null, sessions); + }) + .catch(r => cb(r, null)); + } + + public length(cb: (err: any, length: number) => void) { + db.sessions + .length() + .then(l => cb(null, l)) + .catch(r => cb(r, 0)); + } + + public clear(cb?: (err?: any) => void) { + const p = db.sessions.clear(); + if (cb) { + p.then(() => cb()).catch(r => cb(r)); + } + } + + public touch(sid: string, session: SessionData, cb?: (err?: any) => void) { + const p = db.sessions.touch(sid, this.getExpiresAt(session)); + if (cb) { + p.then(() => cb()).catch(r => cb(r)); + } + } + + private getExpiresAt(session: SessionData) { + if (session && session.cookie && session.cookie.expires) { + return DateTime.fromJSDate(session.cookie.expires); + } else { + return DateTime.local().plus(this.ttl); + } + } +}