generated from nholtware/service-template-express
feat: passport-local authentication, story ownership
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0ae3f6109b
commit
afbc5e5254
16 changed files with 995 additions and 73 deletions
26
migrations/0003_auth.sql
Normal file
26
migrations/0003_auth.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
-- Up Migration
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX users_username_lower_idx ON users (lower(username));
|
||||
|
||||
-- NULL owner = legacy story from before accounts existed: readable if
|
||||
-- published, but editable by nobody.
|
||||
ALTER TABLE stories ADD COLUMN owner_id UUID REFERENCES users(id) ON DELETE CASCADE;
|
||||
CREATE INDEX stories_owner_idx ON stories (owner_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
sid TEXT PRIMARY KEY,
|
||||
sess JSONB NOT NULL,
|
||||
expire TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX sessions_expire_idx ON sessions (expire);
|
||||
|
||||
-- Down Migration
|
||||
DROP TABLE sessions;
|
||||
DROP INDEX stories_owner_idx;
|
||||
ALTER TABLE stories DROP COLUMN owner_id;
|
||||
DROP TABLE users;
|
||||
194
package-lock.json
generated
194
package-lock.json
generated
|
|
@ -9,8 +9,11 @@
|
|||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"express": "^5.2.0",
|
||||
"express-session": "^1.19.0",
|
||||
"ioredis": "^5.4.0",
|
||||
"node-pg-migrate": "^8.0.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"pg": "^8.13.0",
|
||||
"pino": "^9.5.0",
|
||||
"pino-http": "^10.3.0",
|
||||
|
|
@ -22,7 +25,10 @@
|
|||
"@commitlint/cli": "^19.5.0",
|
||||
"@commitlint/config-conventional": "^19.5.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-session": "^1.19.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"eslint": "^9.13.0",
|
||||
|
|
@ -1583,6 +1589,16 @@
|
|||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express-session": {
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.19.0.tgz",
|
||||
"integrity": "sha512-GbypG0bog68UbOq2tSAp7SclvCUm3ha1uDi58OPRGK1NfRvCIu7Gz0M7fTGtpNG1T9a29GpuurQj9zEcT/lMXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
|
|
@ -1614,6 +1630,39 @@
|
|||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport": {
|
||||
"version": "1.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
|
||||
"integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport-local": {
|
||||
"version": "1.0.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.38.tgz",
|
||||
"integrity": "sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*",
|
||||
"@types/passport": "*",
|
||||
"@types/passport-strategy": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport-strategy": {
|
||||
"version": "0.2.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
|
||||
"integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*",
|
||||
"@types/passport": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
|
||||
|
|
@ -3303,6 +3352,50 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-session": {
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
|
||||
"integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "~0.7.2",
|
||||
"cookie-signature": "~1.0.7",
|
||||
"debug": "~2.6.9",
|
||||
"depd": "~2.0.0",
|
||||
"on-headers": "~1.1.0",
|
||||
"parseurl": "~1.3.3",
|
||||
"safe-buffer": "~5.2.1",
|
||||
"uid-safe": "~2.1.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-session/node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express-session/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express-session/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
|
|
@ -4677,6 +4770,15 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/on-headers": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
|
|
@ -4795,6 +4897,43 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/passport": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
|
||||
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"passport-strategy": "1.x.x",
|
||||
"pause": "0.0.1",
|
||||
"utils-merge": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jaredhanson"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-local": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz",
|
||||
"integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==",
|
||||
"dependencies": {
|
||||
"passport-strategy": "1.x.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-strategy": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||
"integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
|
||||
|
|
@ -4857,6 +4996,11 @@
|
|||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/pause": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.23.1",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
|
||||
|
|
@ -5189,6 +5333,15 @@
|
|||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/random-bytes": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
||||
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
||||
|
|
@ -5347,6 +5500,26 @@
|
|||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
|
|
@ -5955,6 +6128,18 @@
|
|||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uid-safe": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"random-bytes": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
|
|
@ -6015,6 +6200,15 @@
|
|||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
|
|
|
|||
|
|
@ -19,8 +19,11 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"express": "^5.2.0",
|
||||
"express-session": "^1.19.0",
|
||||
"ioredis": "^5.4.0",
|
||||
"node-pg-migrate": "^8.0.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"pg": "^8.13.0",
|
||||
"pino": "^9.5.0",
|
||||
"pino-http": "^10.3.0",
|
||||
|
|
@ -32,7 +35,10 @@
|
|||
"@commitlint/cli": "^19.5.0",
|
||||
"@commitlint/config-conventional": "^19.5.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-session": "^1.19.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"eslint": "^9.13.0",
|
||||
|
|
|
|||
18
src/app.ts
18
src/app.ts
|
|
@ -14,6 +14,13 @@ import { adminRouter } from "./routes/admin.js";
|
|||
import { healthRouter } from "./routes/health.js";
|
||||
import { logDemoRouter } from "./routes/log-demo.js";
|
||||
import { rootRouter } from "./routes/root.js";
|
||||
import { authRouter } from "./routes/auth.js";
|
||||
import { createAuth } from "./lib/auth.js";
|
||||
import {
|
||||
memoryUserStore,
|
||||
pgUserStore,
|
||||
type UserStore,
|
||||
} from "./lib/user-store.js";
|
||||
import { storiesRouter } from "./routes/stories.js";
|
||||
import { memoryStore, pgStore, type StoryStore } from "./lib/story-store.js";
|
||||
|
||||
|
|
@ -25,6 +32,7 @@ export interface Deps {
|
|||
flags?: Flags;
|
||||
runtimeConfig?: RuntimeConfig;
|
||||
store?: StoryStore;
|
||||
users?: UserStore;
|
||||
}
|
||||
|
||||
export function createApp({
|
||||
|
|
@ -35,6 +43,7 @@ export function createApp({
|
|||
flags,
|
||||
runtimeConfig = staticRuntimeConfig(),
|
||||
store = db ? pgStore(db) : memoryStore(),
|
||||
users = db ? pgUserStore(db) : memoryUserStore(),
|
||||
}: Deps) {
|
||||
const app = express();
|
||||
app.disable("x-powered-by");
|
||||
|
|
@ -63,6 +72,15 @@ export function createApp({
|
|||
|
||||
app.use(healthRouter({ db, cache }));
|
||||
app.use(rootRouter({ config, db, cache, flags, runtimeConfig }));
|
||||
const auth = createAuth({ config, users, db });
|
||||
app.use(auth.middleware);
|
||||
app.use(
|
||||
authRouter({
|
||||
auth,
|
||||
users,
|
||||
registrationOpen: () => runtimeConfig.get().registrationOpen,
|
||||
}),
|
||||
);
|
||||
app.use(storiesRouter({ store }));
|
||||
app.use(adminRouter({ runtimeConfig }));
|
||||
app.use(logDemoRouter());
|
||||
|
|
|
|||
113
src/lib/auth.ts
Normal file
113
src/lib/auth.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import type { Request, RequestHandler } from "express";
|
||||
import session from "express-session";
|
||||
import { Passport } from "passport";
|
||||
import { Strategy as LocalStrategy } from "passport-local";
|
||||
import type { Config } from "./config.js";
|
||||
import type { Db } from "./db.js";
|
||||
import { hashPassword, verifyPassword } from "./passwords.js";
|
||||
import { PgSessionStore } from "./session-store.js";
|
||||
import type { User, UserStore } from "./user-store.js";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Express {
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Verified against when the username doesn't exist, so response time doesn't
|
||||
// reveal which usernames are registered.
|
||||
const dummyHash = await hashPassword("not-a-real-password");
|
||||
|
||||
export interface Auth {
|
||||
/** session + passport.initialize + passport.session, in order. */
|
||||
middleware: RequestHandler[];
|
||||
authenticate: RequestHandler;
|
||||
}
|
||||
|
||||
export function createAuth(opts: {
|
||||
config: Config;
|
||||
users: UserStore;
|
||||
db?: Db;
|
||||
}): Auth {
|
||||
const { config, users, db } = opts;
|
||||
const passport = new Passport();
|
||||
|
||||
passport.use(
|
||||
new LocalStrategy(async (username, password, done) => {
|
||||
try {
|
||||
const user = await users.findByUsername(username);
|
||||
const ok = await verifyPassword(
|
||||
password,
|
||||
user?.passwordHash ?? dummyHash,
|
||||
);
|
||||
done(
|
||||
null,
|
||||
ok && user ? { id: user.id, username: user.username } : false,
|
||||
);
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
}),
|
||||
);
|
||||
passport.serializeUser((u, done) => done(null, (u as User).id));
|
||||
passport.deserializeUser(async (id: string, done) => {
|
||||
try {
|
||||
// false (not an error) drops a session whose user no longer exists.
|
||||
done(null, (await users.findById(id)) ?? false);
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
});
|
||||
|
||||
const secret =
|
||||
config.SESSION_SECRET ?? "dev-only-secret-not-for-production-use";
|
||||
return {
|
||||
middleware: [
|
||||
session({
|
||||
name: "adventure.sid",
|
||||
secret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
store: db ? new PgSessionStore(db, SESSION_TTL_MS) : undefined,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: config.COOKIE_SECURE,
|
||||
maxAge: SESSION_TTL_MS,
|
||||
},
|
||||
}),
|
||||
passport.initialize(),
|
||||
passport.session(),
|
||||
],
|
||||
authenticate: (req, res, next) =>
|
||||
passport.authenticate("local", (err: unknown, user?: User | false) => {
|
||||
if (err) return next(err);
|
||||
if (!user) {
|
||||
return void res
|
||||
.status(401)
|
||||
.json({ error: "invalid username or password" });
|
||||
}
|
||||
// req.login regenerates the session id (fixation defence).
|
||||
req.login(user, (e) => {
|
||||
if (e) return next(e);
|
||||
res.json({ user: { id: user.id, username: user.username } });
|
||||
});
|
||||
})(req, res, next),
|
||||
};
|
||||
}
|
||||
|
||||
export const requireAuth: RequestHandler = (req, res, next) => {
|
||||
if (!req.isAuthenticated()) {
|
||||
res.status(401).json({ error: "authentication required" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
export const currentUser = (req: Request): User | undefined => req.user;
|
||||
|
|
@ -10,6 +10,14 @@ const schema = z
|
|||
GIT_SHA: z.string().default("dev"),
|
||||
DATABASE_URL: z.string().url().optional(),
|
||||
REDIS_URL: z.string().url().optional(),
|
||||
// Session cookie signing. Required once there is a database (several
|
||||
// pods must agree). Created per namespace by kubectl, never in git.
|
||||
SESSION_SECRET: z.string().min(32).optional(),
|
||||
// Set true once the site is served over HTTPS.
|
||||
COOKIE_SECURE: z
|
||||
.enum(["true", "false"])
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
// Feature flags (Unleash). Unset UNLEASH_URL = every flag reads false.
|
||||
UNLEASH_URL: z.string().url().optional(),
|
||||
UNLEASH_API_TOKEN: z.string().min(1).optional(),
|
||||
|
|
@ -18,6 +26,11 @@ const schema = z
|
|||
// Unset = schema defaults, no reload.
|
||||
RUNTIME_CONFIG_PATH: z.string().min(1).optional(),
|
||||
})
|
||||
.refine((c) => !c.DATABASE_URL || c.SESSION_SECRET, {
|
||||
message:
|
||||
"SESSION_SECRET (>= 32 chars) is required when DATABASE_URL is set",
|
||||
path: ["SESSION_SECRET"],
|
||||
})
|
||||
.refine((c) => !c.UNLEASH_URL || c.UNLEASH_API_TOKEN, {
|
||||
message: "UNLEASH_API_TOKEN is required when UNLEASH_URL is set",
|
||||
path: ["UNLEASH_API_TOKEN"],
|
||||
|
|
|
|||
29
src/lib/passwords.ts
Normal file
29
src/lib/passwords.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
||||
|
||||
// scrypt via node:crypto: no native dependency. Format: scrypt$<saltHex>$<hashHex>
|
||||
const KEYLEN = 64;
|
||||
|
||||
function derive(password: string, salt: Buffer): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) =>
|
||||
scrypt(password, salt, KEYLEN, (err, key) =>
|
||||
err ? reject(err) : resolve(key),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16);
|
||||
const key = await derive(password, salt);
|
||||
return `scrypt$${salt.toString("hex")}$${key.toString("hex")}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
stored: string,
|
||||
): Promise<boolean> {
|
||||
const [scheme, saltHex, hashHex] = stored.split("$");
|
||||
if (scheme !== "scrypt" || !saltHex || !hashHex) return false;
|
||||
const expected = Buffer.from(hashHex, "hex");
|
||||
const actual = await derive(password, Buffer.from(saltHex, "hex"));
|
||||
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||
}
|
||||
|
|
@ -52,6 +52,7 @@ describe("runtime config", () => {
|
|||
expect(rc.get()).toEqual({
|
||||
greeting: "hello",
|
||||
maxItems: 50,
|
||||
registrationOpen: true,
|
||||
featureHints: [],
|
||||
});
|
||||
expect(rc.snapshot().source).toBe("defaults");
|
||||
|
|
@ -73,6 +74,7 @@ describe("runtime config", () => {
|
|||
expect(rc.get()).toEqual({
|
||||
greeting: "hi",
|
||||
maxItems: 50,
|
||||
registrationOpen: true,
|
||||
featureHints: [],
|
||||
});
|
||||
});
|
||||
|
|
@ -90,6 +92,7 @@ describe("runtime config", () => {
|
|||
expect(rc.get()).toEqual({
|
||||
greeting: "two",
|
||||
maxItems: 5,
|
||||
registrationOpen: true,
|
||||
featureHints: ["a", "b"],
|
||||
});
|
||||
expect(seen.at(-1)?.sort()).toEqual(["featureHints", "greeting"]);
|
||||
|
|
@ -165,7 +168,12 @@ describe("runtime config", () => {
|
|||
expect(admin.status).toBe(200);
|
||||
expect(admin.body).toMatchObject({
|
||||
source: "file",
|
||||
values: { greeting: "howdy", maxItems: 50, featureHints: [] },
|
||||
values: {
|
||||
greeting: "howdy",
|
||||
maxItems: 50,
|
||||
registrationOpen: true,
|
||||
featureHints: [],
|
||||
},
|
||||
});
|
||||
expect(Date.parse(admin.body.loadedAt)).not.toBeNaN();
|
||||
|
||||
|
|
|
|||
70
src/lib/session-store.ts
Normal file
70
src/lib/session-store.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import session from "express-session";
|
||||
import type { Db } from "./db.js";
|
||||
|
||||
/** Postgres-backed sessions so every pod sees the same login. */
|
||||
export class PgSessionStore extends session.Store {
|
||||
private readonly timer: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
private readonly db: Db,
|
||||
private readonly ttlMs: number,
|
||||
) {
|
||||
super();
|
||||
// Expired rows are ignored on read; sweep them occasionally.
|
||||
this.timer = setInterval(() => {
|
||||
void this.db
|
||||
.query("DELETE FROM sessions WHERE expire < now()")
|
||||
.catch(() => {});
|
||||
}, 10 * 60_000);
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
private expiry(sess: session.SessionData): Date {
|
||||
const exp = sess.cookie?.expires;
|
||||
return exp ? new Date(exp) : new Date(Date.now() + this.ttlMs);
|
||||
}
|
||||
|
||||
get(
|
||||
sid: string,
|
||||
cb: (err?: unknown, sess?: session.SessionData | null) => void,
|
||||
) {
|
||||
this.db
|
||||
.query<{ sess: session.SessionData }>(
|
||||
"SELECT sess FROM sessions WHERE sid = $1 AND expire > now()",
|
||||
[sid],
|
||||
)
|
||||
.then((r) => cb(null, r.rows[0]?.sess ?? null), cb);
|
||||
}
|
||||
|
||||
set(sid: string, sess: session.SessionData, cb?: (err?: unknown) => void) {
|
||||
this.db
|
||||
.query(
|
||||
`INSERT INTO sessions (sid, sess, expire) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (sid) DO UPDATE SET sess = EXCLUDED.sess, expire = EXCLUDED.expire`,
|
||||
[sid, JSON.stringify(sess), this.expiry(sess)],
|
||||
)
|
||||
.then(
|
||||
() => cb?.(),
|
||||
(e) => cb?.(e),
|
||||
);
|
||||
}
|
||||
|
||||
touch(sid: string, sess: session.SessionData, cb?: (err?: unknown) => void) {
|
||||
this.db
|
||||
.query("UPDATE sessions SET expire = $2 WHERE sid = $1", [
|
||||
sid,
|
||||
this.expiry(sess),
|
||||
])
|
||||
.then(
|
||||
() => cb?.(),
|
||||
(e) => cb?.(e),
|
||||
);
|
||||
}
|
||||
|
||||
destroy(sid: string, cb?: (err?: unknown) => void) {
|
||||
this.db.query("DELETE FROM sessions WHERE sid = $1", [sid]).then(
|
||||
() => cb?.(),
|
||||
(e) => cb?.(e),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import type { StoryDoc, StoryInput } from "./story.js";
|
|||
|
||||
export interface Story {
|
||||
id: string;
|
||||
ownerId: string | null;
|
||||
title: string;
|
||||
description: string;
|
||||
doc: StoryDoc;
|
||||
|
|
@ -14,9 +15,12 @@ export interface Story {
|
|||
export type StorySummary = Omit<Story, "doc"> & { nodeCount: number };
|
||||
|
||||
export interface StoryStore {
|
||||
list(opts: { publishedOnly: boolean }): Promise<StorySummary[]>;
|
||||
list(opts: {
|
||||
publishedOnly?: boolean;
|
||||
ownerId?: string;
|
||||
}): Promise<StorySummary[]>;
|
||||
get(id: string): Promise<Story | null>;
|
||||
create(input: StoryInput): Promise<Story>;
|
||||
create(ownerId: string, input: StoryInput): Promise<Story>;
|
||||
update(id: string, input: StoryInput): Promise<Story | null>;
|
||||
setPublished(id: string, published: boolean): Promise<Story | null>;
|
||||
remove(id: string): Promise<boolean>;
|
||||
|
|
@ -33,19 +37,21 @@ const summarize = ({ doc, ...rest }: Story): StorySummary => ({
|
|||
export function memoryStore(): StoryStore {
|
||||
const rows = new Map<string, Story>();
|
||||
return {
|
||||
async list({ publishedOnly }) {
|
||||
async list({ publishedOnly, ownerId }) {
|
||||
return [...rows.values()]
|
||||
.filter((s) => !publishedOnly || s.published)
|
||||
.filter((s) => !ownerId || s.ownerId === ownerId)
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
.map(summarize);
|
||||
},
|
||||
async get(id) {
|
||||
return rows.get(id) ?? null;
|
||||
},
|
||||
async create(input) {
|
||||
async create(ownerId, input) {
|
||||
const now = new Date().toISOString();
|
||||
const s: Story = {
|
||||
id: randomUUID(),
|
||||
ownerId,
|
||||
...input,
|
||||
published: false,
|
||||
createdAt: now,
|
||||
|
|
@ -76,6 +82,7 @@ export function memoryStore(): StoryStore {
|
|||
|
||||
interface Row {
|
||||
id: string;
|
||||
owner_id: string | null;
|
||||
title: string;
|
||||
description: string;
|
||||
doc: StoryDoc;
|
||||
|
|
@ -83,9 +90,11 @@ interface Row {
|
|||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
const COLS = "id, title, description, doc, published, created_at, updated_at";
|
||||
const COLS =
|
||||
"id, owner_id, title, description, doc, published, created_at, updated_at";
|
||||
const toStory = (r: Row): Story => ({
|
||||
id: r.id,
|
||||
ownerId: r.owner_id,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
doc: r.doc,
|
||||
|
|
@ -96,9 +105,17 @@ const toStory = (r: Row): Story => ({
|
|||
|
||||
export function pgStore(db: Db): StoryStore {
|
||||
return {
|
||||
async list({ publishedOnly }) {
|
||||
async list({ publishedOnly, ownerId }) {
|
||||
const where: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (publishedOnly) where.push("published");
|
||||
if (ownerId) {
|
||||
params.push(ownerId);
|
||||
where.push(`owner_id = $${params.length}`);
|
||||
}
|
||||
const r = await db.query<Row>(
|
||||
`SELECT ${COLS} FROM stories ${publishedOnly ? "WHERE published" : ""} ORDER BY updated_at DESC LIMIT 500`,
|
||||
`SELECT ${COLS} FROM stories ${where.length ? "WHERE " + where.join(" AND ") : ""} ORDER BY updated_at DESC LIMIT 500`,
|
||||
params,
|
||||
);
|
||||
return r.rows.map((row) => summarize(toStory(row)));
|
||||
},
|
||||
|
|
@ -109,10 +126,10 @@ export function pgStore(db: Db): StoryStore {
|
|||
);
|
||||
return r.rows[0] ? toStory(r.rows[0]) : null;
|
||||
},
|
||||
async create(i) {
|
||||
async create(ownerId, i) {
|
||||
const r = await db.query<Row>(
|
||||
`INSERT INTO stories (title, description, doc) VALUES ($1, $2, $3) RETURNING ${COLS}`,
|
||||
[i.title, i.description, JSON.stringify(i.doc)],
|
||||
`INSERT INTO stories (owner_id, title, description, doc) VALUES ($1, $2, $3, $4) RETURNING ${COLS}`,
|
||||
[ownerId, i.title, i.description, JSON.stringify(i.doc)],
|
||||
);
|
||||
return toStory(r.rows[0]!);
|
||||
},
|
||||
|
|
|
|||
89
src/lib/user-store.ts
Normal file
89
src/lib/user-store.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import type { Db } from "./db.js";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
export interface UserWithHash extends User {
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
export class UsernameTakenError extends Error {}
|
||||
|
||||
export interface UserStore {
|
||||
/** Throws UsernameTakenError (case-insensitive). */
|
||||
create(username: string, passwordHash: string): Promise<User>;
|
||||
findByUsername(username: string): Promise<UserWithHash | null>;
|
||||
findById(id: string): Promise<User | null>;
|
||||
}
|
||||
|
||||
export function memoryUserStore(): UserStore {
|
||||
const rows = new Map<string, UserWithHash>();
|
||||
const byName = (n: string) =>
|
||||
[...rows.values()].find(
|
||||
(u) => u.username.toLowerCase() === n.toLowerCase(),
|
||||
);
|
||||
return {
|
||||
async create(username, passwordHash) {
|
||||
if (byName(username)) throw new UsernameTakenError();
|
||||
const u = { id: randomUUID(), username, passwordHash };
|
||||
rows.set(u.id, u);
|
||||
return { id: u.id, username };
|
||||
},
|
||||
async findByUsername(n) {
|
||||
return byName(n) ?? null;
|
||||
},
|
||||
async findById(id) {
|
||||
const u = rows.get(id);
|
||||
return u ? { id: u.id, username: u.username } : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
}
|
||||
|
||||
export function pgUserStore(db: Db): UserStore {
|
||||
return {
|
||||
async create(username, passwordHash) {
|
||||
try {
|
||||
const r = await db.query<Row>(
|
||||
"INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id, username, password_hash",
|
||||
[username, passwordHash],
|
||||
);
|
||||
return { id: r.rows[0]!.id, username: r.rows[0]!.username };
|
||||
} catch (e) {
|
||||
if ((e as { code?: string }).code === "23505") {
|
||||
throw new UsernameTakenError();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
async findByUsername(username) {
|
||||
const r = await db.query<Row>(
|
||||
"SELECT id, username, password_hash FROM users WHERE lower(username) = lower($1)",
|
||||
[username],
|
||||
);
|
||||
const row = r.rows[0];
|
||||
return row
|
||||
? {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
passwordHash: row.password_hash,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
async findById(id) {
|
||||
const r = await db.query<Row>(
|
||||
"SELECT id, username, password_hash FROM users WHERE id = $1",
|
||||
[id],
|
||||
);
|
||||
const row = r.rows[0];
|
||||
return row ? { id: row.id, username: row.username } : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
150
src/routes/auth.test.ts
Normal file
150
src/routes/auth.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import pino from "pino";
|
||||
import { createApp } from "../app.js";
|
||||
import { parseConfig } from "../lib/config.js";
|
||||
import { hashPassword, verifyPassword } from "../lib/passwords.js";
|
||||
import { staticRuntimeConfig } from "../lib/runtime-config.js";
|
||||
|
||||
const config = parseConfig({ SERVICE_NAME: "t", GIT_SHA: "x" });
|
||||
const mk = (extra = {}) =>
|
||||
createApp({ config, logger: pino({ level: "silent" }), ...extra });
|
||||
const creds = { username: "alice", password: "correct horse" };
|
||||
|
||||
describe("passwords", () => {
|
||||
it("verifies the right password and rejects wrong, malformed and empty hashes", async () => {
|
||||
const h = await hashPassword("hunter2hunter2");
|
||||
expect(h).toMatch(/^scrypt\$[0-9a-f]+\$[0-9a-f]+$/);
|
||||
expect(await verifyPassword("hunter2hunter2", h)).toBe(true);
|
||||
expect(await verifyPassword("wrong", h)).toBe(false);
|
||||
expect(await verifyPassword("x", "garbage")).toBe(false);
|
||||
expect(await hashPassword("same")).not.toBe(await hashPassword("same"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth API", () => {
|
||||
it("registers, logs in the new user, and reports /auth/me", async () => {
|
||||
const agent = request.agent(mk());
|
||||
expect((await agent.get("/auth/me")).body).toEqual({ user: null });
|
||||
const r = await agent.post("/auth/register").send(creds);
|
||||
expect(r.status).toBe(201);
|
||||
expect(r.body.user.username).toBe("alice");
|
||||
expect(JSON.stringify(r.body)).not.toMatch(/hash|password/i);
|
||||
expect((await agent.get("/auth/me")).body.user.username).toBe("alice");
|
||||
const cookie = r.headers["set-cookie"]![0]!;
|
||||
expect(cookie).toMatch(/HttpOnly/i);
|
||||
expect(cookie).toMatch(/SameSite=Lax/i);
|
||||
});
|
||||
|
||||
it("does not create a session for anonymous visitors", async () => {
|
||||
const r = await request(mk()).get("/auth/me");
|
||||
expect(r.headers["set-cookie"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects bad usernames and short passwords", async () => {
|
||||
const app = mk();
|
||||
for (const body of [
|
||||
{ username: "a", password: "correct horse" },
|
||||
{ username: "has space", password: "correct horse" },
|
||||
{ username: "alice", password: "short" },
|
||||
{},
|
||||
]) {
|
||||
expect(
|
||||
(await request(app).post("/auth/register").send(body)).status,
|
||||
).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a duplicate username case-insensitively", async () => {
|
||||
const app = mk();
|
||||
await request(app).post("/auth/register").send(creds);
|
||||
const r = await request(app)
|
||||
.post("/auth/register")
|
||||
.send({ ...creds, username: "ALICE" });
|
||||
expect(r.status).toBe(409);
|
||||
});
|
||||
|
||||
it("logs in with the right password only, with one message for both failures", async () => {
|
||||
const app = mk();
|
||||
await request(app).post("/auth/register").send(creds);
|
||||
const ok = await request(app).post("/auth/login").send(creds);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.headers["set-cookie"]).toBeDefined();
|
||||
const wrongPw = await request(app)
|
||||
.post("/auth/login")
|
||||
.send({ ...creds, password: "nope nope nope" });
|
||||
const noUser = await request(app)
|
||||
.post("/auth/login")
|
||||
.send({ username: "ghost", password: "whatever1" });
|
||||
expect(wrongPw.status).toBe(401);
|
||||
expect(noUser.status).toBe(401);
|
||||
expect(wrongPw.body).toEqual(noUser.body);
|
||||
expect((await request(app).post("/auth/login").send({})).status).toBe(401);
|
||||
});
|
||||
|
||||
it("logout ends the session", async () => {
|
||||
const agent = request.agent(mk());
|
||||
await agent.post("/auth/register").send(creds);
|
||||
expect((await agent.post("/auth/logout")).status).toBe(204);
|
||||
expect((await agent.get("/auth/me")).body).toEqual({ user: null });
|
||||
});
|
||||
|
||||
it("rotates the session id on login", async () => {
|
||||
const app = mk();
|
||||
await request(app).post("/auth/register").send(creds);
|
||||
const sid = (r: request.Response) =>
|
||||
/adventure\.sid=([^;]+)/.exec(r.headers["set-cookie"]![0]!)![1];
|
||||
const a = await request(app).post("/auth/login").send(creds);
|
||||
const b = await request(app).post("/auth/login").send(creds);
|
||||
expect(sid(a)).not.toBe(sid(b));
|
||||
});
|
||||
|
||||
it("locks out after repeated failures for that user", async () => {
|
||||
const app = mk();
|
||||
await request(app).post("/auth/register").send(creds);
|
||||
const bad = { ...creds, password: "wrong wrong" };
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect((await request(app).post("/auth/login").send(bad)).status).toBe(
|
||||
401,
|
||||
);
|
||||
}
|
||||
expect((await request(app).post("/auth/login").send(bad)).status).toBe(429);
|
||||
// even the right password is refused during lockout
|
||||
expect((await request(app).post("/auth/login").send(creds)).status).toBe(
|
||||
429,
|
||||
);
|
||||
// other usernames are unaffected
|
||||
expect(
|
||||
(
|
||||
await request(app)
|
||||
.post("/auth/login")
|
||||
.send({ username: "bob", password: "whatever1" })
|
||||
).status,
|
||||
).toBe(401);
|
||||
});
|
||||
|
||||
it("closes registration via runtime config", async () => {
|
||||
const rc = staticRuntimeConfig();
|
||||
const closed = {
|
||||
...rc,
|
||||
get: () => ({ ...rc.get(), registrationOpen: false }),
|
||||
};
|
||||
const r = await request(mk({ runtimeConfig: closed }))
|
||||
.post("/auth/register")
|
||||
.send(creds);
|
||||
expect(r.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config", () => {
|
||||
it("requires SESSION_SECRET once DATABASE_URL is set", () => {
|
||||
const base = { SERVICE_NAME: "t", DATABASE_URL: "postgres://u:p@h/db" };
|
||||
expect(() => parseConfig(base)).toThrow(/SESSION_SECRET/);
|
||||
expect(() => parseConfig({ ...base, SESSION_SECRET: "short" })).toThrow(
|
||||
/SESSION_SECRET/,
|
||||
);
|
||||
expect(
|
||||
parseConfig({ ...base, SESSION_SECRET: "x".repeat(32) }).COOKIE_SECURE,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
102
src/routes/auth.ts
Normal file
102
src/routes/auth.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import express, { Router, type RequestHandler } from "express";
|
||||
import { z } from "zod";
|
||||
import type { Auth } from "../lib/auth.js";
|
||||
import { hashPassword } from "../lib/passwords.js";
|
||||
import { UsernameTakenError, type UserStore } from "../lib/user-store.js";
|
||||
|
||||
const credentials = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.regex(/^[A-Za-z0-9_-]{3,32}$/, "3-32 characters: letters, digits, _ or -"),
|
||||
password: z.string().min(8, "at least 8 characters").max(128),
|
||||
});
|
||||
|
||||
/** Failed logins per (ip, username): 10 per 15 min, per pod. Cheap brute-force brake. */
|
||||
function loginLimiter(): RequestHandler {
|
||||
const WINDOW = 15 * 60_000;
|
||||
const MAX = 10;
|
||||
const fails = new Map<string, { n: number; reset: number }>();
|
||||
return (req, res, next) => {
|
||||
const key = `${req.ip}|${String(req.body?.username ?? "").toLowerCase()}`;
|
||||
const now = Date.now();
|
||||
const e = fails.get(key);
|
||||
if (e && e.reset > now && e.n >= MAX) {
|
||||
res.status(429).json({ error: "too many attempts; try again later" });
|
||||
return;
|
||||
}
|
||||
res.on("finish", () => {
|
||||
if (res.statusCode === 401) {
|
||||
const cur = fails.get(key);
|
||||
if (!cur || cur.reset <= Date.now()) {
|
||||
fails.set(key, { n: 1, reset: Date.now() + WINDOW });
|
||||
} else cur.n++;
|
||||
} else if (res.statusCode === 200) fails.delete(key);
|
||||
if (fails.size > 10_000) fails.clear();
|
||||
});
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export function authRouter(deps: {
|
||||
auth: Auth;
|
||||
users: UserStore;
|
||||
registrationOpen: () => boolean;
|
||||
}): Router {
|
||||
const router = Router();
|
||||
const json = express.json({ limit: "10kb" });
|
||||
const publicUser = (u: { id: string; username: string }) => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
});
|
||||
|
||||
router.get("/auth/me", (req, res) => {
|
||||
res.json({ user: req.user ? publicUser(req.user) : null });
|
||||
});
|
||||
|
||||
router.post("/auth/register", json, async (req, res, next) => {
|
||||
try {
|
||||
if (!deps.registrationOpen()) {
|
||||
return void res.status(403).json({ error: "registration is closed" });
|
||||
}
|
||||
const parsed = credentials.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return void res.status(400).json({
|
||||
error: parsed.error.issues
|
||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||
.join("; "),
|
||||
});
|
||||
}
|
||||
const user = await deps.users.create(
|
||||
parsed.data.username,
|
||||
await hashPassword(parsed.data.password),
|
||||
);
|
||||
// req.login regenerates the session id (fixation defence).
|
||||
req.login(user, (err) => {
|
||||
if (err) return next(err);
|
||||
res.status(201).json({ user: publicUser(user) });
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof UsernameTakenError) {
|
||||
return void res.status(409).json({ error: "username is taken" });
|
||||
}
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/auth/login", json, loginLimiter(), (req, res, next) => {
|
||||
deps.auth.authenticate(req, res, next);
|
||||
});
|
||||
|
||||
router.post("/auth/logout", (req, res, next) => {
|
||||
req.logout((err) => {
|
||||
if (err) return next(err);
|
||||
req.session.destroy((e) => {
|
||||
if (e) return next(e);
|
||||
res.clearCookie("adventure.sid");
|
||||
res.status(204).end();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
|
@ -10,6 +10,16 @@ const mk = () =>
|
|||
logger: pino({ level: "silent" }),
|
||||
});
|
||||
|
||||
/** A logged-in agent against `app` (a fresh app if none given). */
|
||||
async function login(app = mk(), name = "alice") {
|
||||
const agent = request.agent(app);
|
||||
const r = await agent
|
||||
.post("/auth/register")
|
||||
.send({ username: name, password: "correct horse" });
|
||||
expect(r.status).toBe(201);
|
||||
return agent;
|
||||
}
|
||||
|
||||
const good = {
|
||||
title: "Cave",
|
||||
description: "d",
|
||||
|
|
@ -32,63 +42,61 @@ const good = {
|
|||
|
||||
describe("stories API", () => {
|
||||
it("creates, reads, lists drafts only with mine=1", async () => {
|
||||
const app = mk();
|
||||
const c = await request(app).post("/stories").send(good);
|
||||
const agent = await login();
|
||||
const c = await agent.post("/stories").send(good);
|
||||
expect(c.status).toBe(201);
|
||||
const id = c.body.story.id;
|
||||
expect(c.body.issues).toEqual([]);
|
||||
expect((await request(app).get(`/stories/${id}`)).body.story.title).toBe(
|
||||
"Cave",
|
||||
);
|
||||
expect((await request(app).get("/stories")).body.stories).toHaveLength(0);
|
||||
const mine = await request(app).get("/stories?mine=1");
|
||||
const got = await agent.get(`/stories/${id}`);
|
||||
expect(got.body.story.title).toBe("Cave");
|
||||
expect(got.body.ownedByMe).toBe(true);
|
||||
expect((await agent.get("/stories")).body.stories).toHaveLength(0);
|
||||
const mine = await agent.get("/stories?mine=1");
|
||||
expect(mine.body.stories[0]).toMatchObject({ id, nodeCount: 3 });
|
||||
expect(mine.body.stories[0].doc).toBeUndefined();
|
||||
});
|
||||
|
||||
it("publishes a valid story and lists it publicly", async () => {
|
||||
const app = mk();
|
||||
const id = (await request(app).post("/stories").send(good)).body.story.id;
|
||||
const p = await request(app).post(`/stories/${id}/publish`);
|
||||
const agent = await login();
|
||||
const id = (await agent.post("/stories").send(good)).body.story.id;
|
||||
const p = await agent.post(`/stories/${id}/publish`);
|
||||
expect(p.status).toBe(200);
|
||||
expect(p.body.story.published).toBe(true);
|
||||
expect((await request(app).get("/stories")).body.stories).toHaveLength(1);
|
||||
await request(app).post(`/stories/${id}/unpublish`);
|
||||
expect((await request(app).get("/stories")).body.stories).toHaveLength(0);
|
||||
expect((await agent.get("/stories")).body.stories).toHaveLength(1);
|
||||
await agent.post(`/stories/${id}/unpublish`);
|
||||
expect((await agent.get("/stories")).body.stories).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses to publish a story with errors", async () => {
|
||||
const app = mk();
|
||||
const agent = await login();
|
||||
const bad = structuredClone(good);
|
||||
bad.doc.nodes.a.choices[0]!.to = "nope";
|
||||
const id = (await request(app).post("/stories").send(bad)).body.story.id;
|
||||
const p = await request(app).post(`/stories/${id}/publish`);
|
||||
const id = (await agent.post("/stories").send(bad)).body.story.id;
|
||||
const p = await agent.post(`/stories/${id}/publish`);
|
||||
expect(p.status).toBe(422);
|
||||
expect(p.body.issues[0].message).toMatch(/missing node "nope"/);
|
||||
});
|
||||
|
||||
it("validates input shape", async () => {
|
||||
const app = mk();
|
||||
const r = await request(app)
|
||||
const agent = await login();
|
||||
const r = await agent
|
||||
.post("/stories")
|
||||
.send({ title: "", doc: { start: "A!", nodes: {} } });
|
||||
expect(r.status).toBe(400);
|
||||
});
|
||||
|
||||
it("updates and deletes; 404s for unknown or malformed ids", async () => {
|
||||
const app = mk();
|
||||
const id = (await request(app).post("/stories").send(good)).body.story.id;
|
||||
const u = await request(app)
|
||||
.put(`/stories/${id}`)
|
||||
.send({ ...good, title: "New" });
|
||||
const agent = await login();
|
||||
const id = (await agent.post("/stories").send(good)).body.story.id;
|
||||
const u = await agent.put(`/stories/${id}`).send({ ...good, title: "New" });
|
||||
expect(u.body.story.title).toBe("New");
|
||||
expect((await request(app).delete(`/stories/${id}`)).status).toBe(204);
|
||||
expect((await request(app).get(`/stories/${id}`)).status).toBe(404);
|
||||
expect((await request(app).get("/stories/not-a-uuid")).status).toBe(404);
|
||||
expect((await agent.delete(`/stories/${id}`)).status).toBe(204);
|
||||
expect((await agent.get(`/stories/${id}`)).status).toBe(404);
|
||||
expect((await agent.get("/stories/not-a-uuid")).status).toBe(404);
|
||||
});
|
||||
|
||||
it("reports dead ends, unreachable nodes and missing endings", async () => {
|
||||
const app = mk();
|
||||
const agent = await login();
|
||||
const doc = {
|
||||
title: "t",
|
||||
doc: {
|
||||
|
|
@ -100,10 +108,52 @@ describe("stories API", () => {
|
|||
},
|
||||
},
|
||||
};
|
||||
const r = await request(app).post("/stories").send(doc);
|
||||
const r = await agent.post("/stories").send(doc);
|
||||
const msgs = r.body.issues.map((i: { message: string }) => i.message);
|
||||
expect(msgs).toContain("dead end: no choices and not marked as an ending");
|
||||
expect(msgs).toContain("unreachable from the start node");
|
||||
expect(msgs).toContain("no ending is reachable from the start node");
|
||||
});
|
||||
});
|
||||
|
||||
describe("story authorization", () => {
|
||||
it("requires login to write or to list your own stories", async () => {
|
||||
const app = mk();
|
||||
expect((await request(app).post("/stories").send(good)).status).toBe(401);
|
||||
expect((await request(app).get("/stories?mine=1")).status).toBe(401);
|
||||
const id = (await (await login(app)).post("/stories").send(good)).body.story
|
||||
.id;
|
||||
for (const r of [
|
||||
request(app).put(`/stories/${id}`).send(good),
|
||||
request(app).post(`/stories/${id}/publish`),
|
||||
request(app).post(`/stories/${id}/unpublish`),
|
||||
request(app).delete(`/stories/${id}`),
|
||||
]) {
|
||||
expect((await r).status).toBe(401);
|
||||
}
|
||||
});
|
||||
|
||||
it("hides other users' drafts (404) and forbids editing published ones (403)", async () => {
|
||||
const app = mk();
|
||||
const alice = await login(app, "alice");
|
||||
const bob = await login(app, "bob");
|
||||
const id = (await alice.post("/stories").send(good)).body.story.id;
|
||||
|
||||
// draft: invisible to bob, and to anonymous visitors
|
||||
expect((await bob.get(`/stories/${id}`)).status).toBe(404);
|
||||
expect((await request(app).get(`/stories/${id}`)).status).toBe(404);
|
||||
expect((await bob.put(`/stories/${id}`).send(good)).status).toBe(404);
|
||||
expect((await bob.get("/stories?mine=1")).body.stories).toEqual([]);
|
||||
|
||||
// published: bob can read and play, but not change it
|
||||
await alice.post(`/stories/${id}/publish`);
|
||||
const seen = await bob.get(`/stories/${id}`);
|
||||
expect(seen.status).toBe(200);
|
||||
expect(seen.body.ownedByMe).toBe(false);
|
||||
expect((await request(app).get(`/stories/${id}`)).status).toBe(200);
|
||||
expect((await bob.put(`/stories/${id}`).send(good)).status).toBe(403);
|
||||
expect((await bob.post(`/stories/${id}/unpublish`)).status).toBe(403);
|
||||
expect((await bob.delete(`/stories/${id}`)).status).toBe(403);
|
||||
expect((await alice.get(`/stories/${id}`)).body.story.published).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import express, { Router } from "express";
|
||||
import express, { Router, type Request, type Response } from "express";
|
||||
import { requireAuth } from "../lib/auth.js";
|
||||
import { analyze, hasErrors, storyInputSchema } from "../lib/story.js";
|
||||
import { isUuid, type StoryStore } from "../lib/story-store.js";
|
||||
import { isUuid, type Story, type StoryStore } from "../lib/story-store.js";
|
||||
|
||||
const notFound = (res: Response) =>
|
||||
void res.status(404).json({ error: "story not found" });
|
||||
|
||||
export function storiesRouter({ store }: { store: StoryStore }): Router {
|
||||
const router = Router();
|
||||
|
|
@ -8,20 +12,47 @@ export function storiesRouter({ store }: { store: StoryStore }): Router {
|
|||
|
||||
// Param guard: non-UUID ids are a 404, never a Postgres cast error.
|
||||
router.param("id", (req, res, next, id) => {
|
||||
if (!isUuid(String(id))) {
|
||||
res.status(404).json({ error: "story not found" });
|
||||
return;
|
||||
}
|
||||
if (!isUuid(String(id))) return notFound(res);
|
||||
next();
|
||||
});
|
||||
|
||||
// ?mine=1 is the builder's view: drafts included. No auth (homelab).
|
||||
/**
|
||||
* Load a story the caller may see (published, or their own draft). Returns
|
||||
* null after replying 404. `owner: true` additionally requires ownership:
|
||||
* visible-but-not-yours is 403, hidden drafts stay 404.
|
||||
*/
|
||||
async function load(
|
||||
req: Request,
|
||||
res: Response,
|
||||
owner: boolean,
|
||||
): Promise<Story | null> {
|
||||
const story = await store.get(String(req.params.id));
|
||||
const mine = !!story && !!req.user && story.ownerId === req.user.id;
|
||||
if (!story || (!story.published && !mine)) {
|
||||
notFound(res);
|
||||
return null;
|
||||
}
|
||||
if (owner && !mine) {
|
||||
res.status(403).json({ error: "you do not own this story" });
|
||||
return null;
|
||||
}
|
||||
return story;
|
||||
}
|
||||
|
||||
// ?mine=1 is the builder's view: the caller's own stories, drafts included.
|
||||
router.get("/stories", async (req, res, next) => {
|
||||
try {
|
||||
const stories = await store.list({
|
||||
publishedOnly: req.query.mine !== "1",
|
||||
});
|
||||
res.json({ stories });
|
||||
if (req.query.mine === "1") {
|
||||
if (!req.user) {
|
||||
return void res
|
||||
.status(401)
|
||||
.json({ error: "authentication required" });
|
||||
}
|
||||
return void res.json({
|
||||
stories: await store.list({ ownerId: req.user.id }),
|
||||
});
|
||||
}
|
||||
res.json({ stories: await store.list({ publishedOnly: true }) });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
|
|
@ -29,16 +60,19 @@ export function storiesRouter({ store }: { store: StoryStore }): Router {
|
|||
|
||||
router.get("/stories/:id", async (req, res, next) => {
|
||||
try {
|
||||
const story = await store.get(req.params.id);
|
||||
if (!story)
|
||||
return void res.status(404).json({ error: "story not found" });
|
||||
res.json({ story, issues: analyze(story.doc) });
|
||||
const story = await load(req, res, false);
|
||||
if (!story) return;
|
||||
res.json({
|
||||
story,
|
||||
issues: analyze(story.doc),
|
||||
ownedByMe: !!req.user && story.ownerId === req.user.id,
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/stories", json, async (req, res, next) => {
|
||||
router.post("/stories", requireAuth, json, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = storyInputSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
|
|
@ -46,14 +80,14 @@ export function storiesRouter({ store }: { store: StoryStore }): Router {
|
|||
.status(400)
|
||||
.json({ error: "invalid story", details: parsed.error.issues });
|
||||
}
|
||||
const story = await store.create(parsed.data);
|
||||
const story = await store.create(req.user!.id, parsed.data);
|
||||
res.status(201).json({ story, issues: analyze(story.doc) });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/stories/:id", json, async (req, res, next) => {
|
||||
router.put("/stories/:id", requireAuth, json, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = storyInputSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
|
|
@ -61,20 +95,20 @@ export function storiesRouter({ store }: { store: StoryStore }): Router {
|
|||
.status(400)
|
||||
.json({ error: "invalid story", details: parsed.error.issues });
|
||||
}
|
||||
const story = await store.update(req.params.id, parsed.data);
|
||||
if (!story)
|
||||
return void res.status(404).json({ error: "story not found" });
|
||||
const cur = await load(req, res, true);
|
||||
if (!cur) return;
|
||||
const story = await store.update(cur.id, parsed.data);
|
||||
if (!story) return notFound(res);
|
||||
res.json({ story, issues: analyze(story.doc) });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/stories/:id/publish", async (req, res, next) => {
|
||||
router.post("/stories/:id/publish", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const story = await store.get(req.params.id);
|
||||
if (!story)
|
||||
return void res.status(404).json({ error: "story not found" });
|
||||
const story = await load(req, res, true);
|
||||
if (!story) return;
|
||||
const issues = analyze(story.doc);
|
||||
if (hasErrors(issues)) {
|
||||
return void res.status(422).json({
|
||||
|
|
@ -82,28 +116,29 @@ export function storiesRouter({ store }: { store: StoryStore }): Router {
|
|||
issues,
|
||||
});
|
||||
}
|
||||
const updated = await store.setPublished(story.id, true);
|
||||
res.json({ story: updated, issues });
|
||||
res.json({ story: await store.setPublished(story.id, true), issues });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/stories/:id/unpublish", async (req, res, next) => {
|
||||
router.post("/stories/:id/unpublish", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const story = await store.setPublished(req.params.id, false);
|
||||
if (!story)
|
||||
return void res.status(404).json({ error: "story not found" });
|
||||
const cur = await load(req, res, true);
|
||||
if (!cur) return;
|
||||
const story = await store.setPublished(cur.id, false);
|
||||
if (!story) return notFound(res);
|
||||
res.json({ story, issues: analyze(story.doc) });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/stories/:id", async (req, res, next) => {
|
||||
router.delete("/stories/:id", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const ok = await store.remove(req.params.id);
|
||||
if (!ok) return void res.status(404).json({ error: "story not found" });
|
||||
const cur = await load(req, res, true);
|
||||
if (!cur) return;
|
||||
await store.remove(cur.id);
|
||||
res.status(204).end();
|
||||
} catch (e) {
|
||||
next(e);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import { z } from "zod";
|
|||
export const RuntimeConfigSchema = z.object({
|
||||
greeting: z.string().min(1).default("hello"),
|
||||
maxItems: z.number().int().positive().default(50),
|
||||
// Close signups by committing registrationOpen: false to the ConfigMap.
|
||||
registrationOpen: z.boolean().default(true),
|
||||
featureHints: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue