Session persistence

This commit is contained in:
Gender Shrapnel 2019-10-08 23:24:25 +02:00
parent c996881547
commit 9b7324e20a
20 changed files with 258 additions and 50 deletions

64
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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';

View File

@ -0,0 +1 @@
DROP TABLE "sessions";

View File

@ -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();

1
sql/sessions/all.sql Normal file
View File

@ -0,0 +1 @@
SELECT "sid", "session" FROM "sessions";

1
sql/sessions/clear.sql Normal file
View File

@ -0,0 +1 @@
TRUNCATE "sessions";

1
sql/sessions/destroy.sql Normal file
View File

@ -0,0 +1 @@
DELETE FROM "sessions" WHERE "sid" = $1;

1
sql/sessions/get.sql Normal file
View File

@ -0,0 +1 @@
SELECT "session" FROM "sessions" WHERE "sid"=$1 AND CURRENT_TIMESTAMP < "expires_at";

1
sql/sessions/length.sql Normal file
View File

@ -0,0 +1 @@
SELECT COUNT(*) as length FROM "sessions";

3
sql/sessions/set.sql Normal file
View File

@ -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;

1
sql/sessions/touch.sql Normal file
View File

@ -0,0 +1 @@
UPDATE "sessions" SET "expires_at"=$2 WHERE "sid"=$1;

7
src/custom.d.ts vendored
View File

@ -14,6 +14,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
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;
}
}

View File

@ -16,6 +16,7 @@
import {
Extensions,
MigrationRepository,
SessionRepository,
TaskRepository,
UserRepository
} from "@kredens/db/repos";
@ -34,6 +35,7 @@ const initOptions: IInitOptions<Extensions> = {
obj.migrations = new MigrationRepository(obj, pgp);
obj.tasks = new TaskRepository(obj, pgp);
obj.users = new UserRepository(obj, pgp);
obj.sessions = new SessionRepository(obj, pgp);
}
};

View File

@ -38,3 +38,8 @@ export interface Task {
max_frequency?: number;
created_at: DateTime;
}
export interface Session {
sid: string;
session: SessionData;
}

View File

@ -14,13 +14,20 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
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
};

70
src/db/repos/sessions.ts Normal file
View File

@ -0,0 +1,70 @@
// Copyright (C) 2019 ModZero <modzero@modzero.xyz>
//
// 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 <http://www.gnu.org/licenses/>.
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<any>;
constructor(db: IDatabase<any>, pgp: IMain) {
this.db = db;
}
public async all(): Promise<Session[]> {
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<Maybe<Session>> {
return this.db.oneOrNone(sql.get, [sid]).then(row =>
row
? Maybe.Some({
session: row.session,
sid: row.sid
})
: Maybe.None()
);
}
public async length(): Promise<number> {
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()]);
}
}

View File

@ -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 {

View File

@ -13,19 +13,21 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
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") {

99
src/sessions.ts Normal file
View File

@ -0,0 +1,99 @@
// Copyright (C) 2019 ModZero <modzero@modzero.xyz>
//
// 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 <http://www.gnu.org/licenses/>.
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);
}
}
}