diff --git a/package-lock.json b/package-lock.json index 269d7a2..25683dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "cookie-parser": "^1.4.6", "copyfiles": "^2.4.1", "cors": "^2.8.5", + "csrf-csrf": "^2.3.0", "dotenv": "^16.0.3", "ejs": "^3.1.9", "express": "^4.18.2", @@ -1676,6 +1677,14 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "node_modules/csrf-csrf": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/csrf-csrf/-/csrf-csrf-2.3.0.tgz", + "integrity": "sha512-bUVpFobukoKdE2h0VNTgRmPelVnsGcnVavUOCYLFBnl6ss98bW7hPFWsQyuHMVdYK2NGRlQvthUEb4iX5nUb1w==", + "dependencies": { + "http-errors": "^2.0.0" + } + }, "node_modules/date-fns": { "version": "2.29.3", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", diff --git a/package.json b/package.json index 5b5136b..f11d8f7 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "cookie-parser": "^1.4.6", "copyfiles": "^2.4.1", "cors": "^2.8.5", + "csrf-csrf": "^2.3.0", "dotenv": "^16.0.3", "ejs": "^3.1.9", "express": "^4.18.2", diff --git a/src/admin/api/users.ts b/src/admin/api/users.ts new file mode 100644 index 0000000..105dcc6 --- /dev/null +++ b/src/admin/api/users.ts @@ -0,0 +1,95 @@ +import { Router } from "express"; +import { z } from "zod"; +import * as userStore from "../../proxy/auth/user-store"; +import { UserSchema, UserSchemaWithToken, parseSort, sortBy } from "../common"; + +const router = Router(); + +/** + * Returns a list of all users, sorted by prompt count and then last used time. + * GET /admin/users + */ +router.get("/", (req, res) => { + const sort = parseSort(req.query.sort) || ["promptCount", "lastUsedAt"]; + const users = userStore.getUsers().sort(sortBy(sort, false)); + res.json({ users, count: users.length }); +}); + +/** + * Returns the user with the given token. + * GET /admin/users/:token + */ +router.get("/:token", (req, res) => { + const user = userStore.getUser(req.params.token); + if (!user) { + return res.status(404).json({ error: "Not found" }); + } + res.json(user); +}); + +/** + * Creates a new user. + * Returns the created user's token. + * POST /admin/users + */ +router.post("/", (req, res) => { + const token = userStore.createUser(); + res.json({ token }); +}); + +/** + * Updates the user with the given token, creating them if they don't exist. + * Accepts a JSON body containing at least one field on the User type. + * Returns the upserted user. + * PUT /admin/users/:token + */ +router.put("/:token", (req, res) => { + const result = UserSchema.safeParse(req.body); + if (!result.success) { + return res.status(400).json({ error: result.error }); + } + userStore.upsertUser({ ...result.data, token: req.params.token }); + res.json(userStore.getUser(req.params.token)); +}); + +/** + * Bulk-upserts users given a list of User updates. + * Accepts a JSON body with the field `users` containing an array of updates. + * Returns an object containing the upserted users and the number of upserts. + * PUT /admin/users + */ +router.put("/", (req, res) => { + const result = z.array(UserSchemaWithToken).safeParse(req.body.users); + if (!result.success) { + return res.status(400).json({ error: result.error }); + } + const upserts = result.data.map((user) => userStore.upsertUser(user)); + res.json({ + upserted_users: upserts, + count: upserts.length, + }); +}); + +/** + * Disables the user with the given token. Optionally accepts a `disabledReason` + * query parameter. + * Returns the disabled user. + * DELETE /admin/users/:token + */ +router.delete("/:token", (req, res) => { + const user = userStore.getUser(req.params.token); + const disabledReason = z + .string() + .optional() + .safeParse(req.query.disabledReason); + if (!disabledReason.success) { + return res.status(400).json({ error: disabledReason.error }); + } + if (!user) { + return res.status(404).json({ error: "Not found" }); + } + userStore.disableUser(req.params.token, disabledReason.data); + res.json(userStore.getUser(req.params.token)); +}); + +export { router as usersApiRouter }; diff --git a/src/admin/auth.ts b/src/admin/auth.ts index ce3e514..115fd77 100644 --- a/src/admin/auth.ts +++ b/src/admin/auth.ts @@ -4,43 +4,47 @@ import { config } from "../config"; const ADMIN_KEY = config.adminKey; const failedAttempts = new Map(); -export const auth: RequestHandler = (req, res, next) => { - const bearerToken = req.headers.authorization?.slice("Bearer ".length); - const cookieToken = req.cookies["admin-token"]; - const token = bearerToken ?? cookieToken; - const attempts = failedAttempts.get(req.ip) ?? 0; +type AuthorizeParams = { via: "cookie" | "header" }; - if (!token) { - return res.redirect("/admin/login"); - } +export const authorize: ({ via }: AuthorizeParams) => RequestHandler = + ({ via }) => + (req, res, next) => { + const bearerToken = req.headers.authorization?.slice("Bearer ".length); + const cookieToken = req.cookies["admin-token"]; + const token = via === "cookie" ? cookieToken : bearerToken; + const attempts = failedAttempts.get(req.ip) ?? 0; - if (!ADMIN_KEY) { - req.log.warn( - { ip: req.ip }, - `Blocked admin request because no admin key is configured` - ); - return res.status(401).json({ error: "Unauthorized" }); - } + if (!token) { + return res.status(401).json({ error: "Unauthorized" }); + } - if (attempts > 5) { - req.log.warn( - { ip: req.ip, token: bearerToken }, - `Blocked admin request due to too many failed attempts` - ); - return res.status(401).json({ error: "Too many attempts" }); - } + if (!ADMIN_KEY) { + req.log.warn( + { ip: req.ip }, + `Blocked admin request because no admin key is configured` + ); + return res.status(401).json({ error: "Unauthorized" }); + } - if (token !== ADMIN_KEY) { - req.log.warn( - { ip: req.ip, attempts, token }, - `Attempted admin request with invalid token` - ); - return handleFailedLogin(req, res); - } + if (attempts > 5) { + req.log.warn( + { ip: req.ip, token: bearerToken }, + `Blocked admin request due to too many failed attempts` + ); + return res.status(401).json({ error: "Too many attempts" }); + } - req.log.info({ ip: req.ip }, `Admin request authorized`); - next(); -}; + if (token !== ADMIN_KEY) { + req.log.warn( + { ip: req.ip, attempts, token }, + `Attempted admin request with invalid token` + ); + return handleFailedLogin(req, res); + } + + req.log.info({ ip: req.ip }, `Admin request authorized`); + next(); + }; function handleFailedLogin(req: Request, res: Response) { const attempts = failedAttempts.get(req.ip) ?? 0; @@ -49,5 +53,6 @@ function handleFailedLogin(req: Request, res: Response) { if (req.accepts("json", "html") === "json") { return res.status(401).json({ error: "Unauthorized" }); } + res.clearCookie("admin-token"); return res.redirect("/admin/login?failed=true"); } diff --git a/src/admin/common.ts b/src/admin/common.ts new file mode 100644 index 0000000..36a273f --- /dev/null +++ b/src/admin/common.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; +import { Query } from "express-serve-static-core"; + +export function parseSort(sort: Query["sort"]) { + if (!sort) return null; + if (typeof sort === "string") return sort.split(","); + if (Array.isArray(sort)) return sort.splice(3) as string[]; + return null; +} + +export function sortBy(fields: string[], asc = true) { + return (a: any, b: any) => { + for (const field of fields) { + if (a[field] !== b[field]) { + // always sort nulls to the end + if (a[field] == null) return 1; + if (b[field] == null) return -1; + + const valA = Array.isArray(a[field]) ? a[field].length : a[field]; + const valB = Array.isArray(b[field]) ? b[field].length : b[field]; + + const result = valA < valB ? -1 : 1; + return asc ? result : -result; + } + } + return 0; + }; +} + +export function paginate(set: unknown[], page: number, pageSize: number = 20) { + const p = Math.max(1, Math.min(page, Math.ceil(set.length / pageSize))); + return { + page: p, + items: set.slice((p - 1) * pageSize, p * pageSize), + pageSize, + pageCount: Math.ceil(set.length / pageSize), + totalCount: set.length, + nextPage: p * pageSize < set.length ? p + 1 : null, + prevPage: p > 1 ? p - 1 : null, + }; +} + +export const UserSchema = z + .object({ + ip: z.array(z.string()).optional(), + type: z.enum(["normal", "special"]).optional(), + promptCount: z.number().optional(), + tokenCount: z.number().optional(), + createdAt: z.number().optional(), + lastUsedAt: z.number().optional(), + disabledAt: z.number().optional(), + disabledReason: z.string().optional(), + }) + .strict(); + +export const UserSchemaWithToken = UserSchema.extend({ + token: z.string(), +}).strict(); diff --git a/src/admin/controllers/users.ts b/src/admin/controllers/users.ts deleted file mode 100644 index 4fcb1a9..0000000 --- a/src/admin/controllers/users.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { Router } from "express"; -import { Query } from "express-serve-static-core"; -import multer from "multer"; -import { z } from "zod"; -import * as userStore from "../../proxy/auth/user-store"; - -const upload = multer({ - storage: multer.memoryStorage(), - fileFilter: (_req, file, cb) => { - if (file.mimetype !== "application/json") { - cb(new Error("Invalid file type")); - } else { - cb(null, true); - } - }, -}); - -const usersRouter = Router(); - -export const UserSchema = z - .object({ - ip: z.array(z.string()).optional(), - type: z.enum(["normal", "special"]).optional(), - promptCount: z.number().optional(), - tokenCount: z.number().optional(), - createdAt: z.number().optional(), - lastUsedAt: z.number().optional(), - disabledAt: z.number().optional(), - disabledReason: z.string().optional(), - }) - .strict(); - -const UserSchemaWithToken = UserSchema.extend({ - token: z.string(), -}).strict(); - -function paginate(set: unknown[], page: number, pageSize: number = 20) { - return { - page, - items: set.slice((page - 1) * pageSize, page * pageSize), - pageSize, - pageCount: Math.ceil(set.length / pageSize), - totalCount: set.length, - nextPage: page * pageSize < set.length ? page + 1 : null, - prevPage: page > 1 ? page - 1 : null, - }; -} - -function parseSort(sort: Query["sort"]) { - if (!sort) return null; - if (typeof sort === "string") return sort.split(","); - if (Array.isArray(sort)) return sort.splice(3) as string[]; - return null; -} - -function sortBy(fields: string[], asc = true) { - return (a: any, b: any) => { - for (const field of fields) { - if (a[field] !== b[field]) { - // always sort nulls to the end - if (a[field] == null) return 1; - if (b[field] == null) return -1; - - const valA = Array.isArray(a[field]) ? a[field].length : a[field]; - const valB = Array.isArray(b[field]) ? b[field].length : b[field]; - - const result = valA < valB ? -1 : 1; - return asc ? result : -result; - } - } - return 0; - }; -} - -function isFromUi(req: any) { - return req.accepts("json", "html") === "html"; -} - -// UI-specific routes -usersRouter.get("/create-user", (req, res) => { - const recentUsers = userStore - .getUsers() - .sort(sortBy(["createdAt"], false)) - .slice(0, 5); - res.render("admin/create-user", { - recentUsers, - newToken: !!req.query.created, - }); -}); - -usersRouter.get("/import-users", (req, res) => { - const imported = Number(req.query.imported) || 0; - res.render("admin/import-users", { imported }); -}); - -usersRouter.post("/import-users", upload.single("users"), (req, res) => { - if (!req.file) { - return res.status(400).json({ error: "No file uploaded" }); - } - const data = JSON.parse(req.file.buffer.toString()); - const result = z.array(UserSchemaWithToken).safeParse(data.users); - if (!result.success) { - return res.status(400).json({ error: result.error }); - } - const upserts = result.data.map((user) => userStore.upsertUser(user)); - res.redirect(`/admin/users/import-users?imported=${upserts.length}`); -}); - -usersRouter.get("/export-users", (req, res) => { - const users = userStore.getUsers(); - res.render("admin/export-users", { users }); -}); - -// API routes -/** - * Returns a list of all users, sorted by prompt count and then last used time. - * GET /admin/users - */ -usersRouter.get("/", (req, res) => { - const sort = parseSort(req.query.sort) || ["promptCount", "lastUsedAt"]; - const users = userStore.getUsers().sort(sortBy(sort, false)); - - if (isFromUi(req)) { - const page = Number(req.query.page) || 1; - const { items, ...pagination } = paginate(users, page); - - return res.render("admin/list-users", { - sort: sort.join(","), - users: items, - ...pagination, - }); - } - - res.json({ users, count: users.length }); -}); - -/** - * Returns the user with the given token. - * GET /admin/users/:token - */ -usersRouter.get("/:token", (req, res) => { - const user = userStore.getUser(req.params.token); - if (!user) { - return res.status(404).json({ error: "Not found" }); - } - res.json(user); -}); - -/** - * Creates a new user. - * Returns the created user's token. - * POST /admin/users - */ -usersRouter.post("/", (req, res) => { - const token = userStore.createUser(); - if (isFromUi(req)) { - return res.redirect(`/admin/users/create-user?created=true`); - } - res.json({ token }); -}); - -/** - * Updates the user with the given token, creating them if they don't exist. - * Accepts a JSON body containing at least one field on the User type. - * Returns the upserted user. - * PUT /admin/users/:token - */ -usersRouter.put("/:token", (req, res) => { - const result = UserSchema.safeParse(req.body); - if (!result.success) { - return res.status(400).json({ error: result.error }); - } - userStore.upsertUser({ ...result.data, token: req.params.token }); - res.json(userStore.getUser(req.params.token)); -}); - -/** - * Bulk-upserts users given a list of User updates. - * Accepts a JSON body with the field `users` containing an array of updates. - * Returns an object containing the upserted users and the number of upserts. - * PUT /admin/users - */ -usersRouter.put("/", (req, res) => { - const result = z.array(UserSchemaWithToken).safeParse(req.body.users); - if (!result.success) { - return res.status(400).json({ error: result.error }); - } - const upserts = result.data.map((user) => userStore.upsertUser(user)); - res.json({ - upserted_users: upserts, - count: upserts.length, - }); -}); - -/** - * Disables the user with the given token. Optionally accepts a `disabledReason` - * query parameter. - * Returns the disabled user. - * DELETE /admin/users/:token - */ -usersRouter.delete("/:token", (req, res) => { - const user = userStore.getUser(req.params.token); - const disabledReason = z - .string() - .optional() - .safeParse(req.query.disabledReason); - if (!disabledReason.success) { - return res.status(400).json({ error: disabledReason.error }); - } - if (!user) { - return res.status(404).json({ error: "Not found" }); - } - userStore.disableUser(req.params.token, disabledReason.data); - res.json(userStore.getUser(req.params.token)); -}); - -export { usersRouter }; diff --git a/src/admin/csrf.ts b/src/admin/csrf.ts new file mode 100644 index 0000000..6f57f58 --- /dev/null +++ b/src/admin/csrf.ts @@ -0,0 +1,24 @@ +import { doubleCsrf } from "csrf-csrf"; +import { v4 as uuid } from "uuid"; +import express from "express"; + +const CSRF_SECRET = uuid(); + +const { generateToken, doubleCsrfProtection } = doubleCsrf({ + getSecret: () => CSRF_SECRET, + cookieName: "csrf", + cookieOptions: { sameSite: "strict", path: "/" }, + getTokenFromRequest: (req) => req.body["_csrf"] || req.query["_csrf"], +}); + +const injectCsrfToken: express.RequestHandler = (req, res, next) => { + res.locals.csrfToken = generateToken(res, req); + // force generation of new token on back button + // TODO: implement session-based CSRF tokens + res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); + next(); +}; + +export { injectCsrfToken, doubleCsrfProtection as checkCsrfToken }; diff --git a/src/admin/controllers/login.ts b/src/admin/login.ts similarity index 76% rename from src/admin/controllers/login.ts rename to src/admin/login.ts index f1564cc..1a38a20 100644 --- a/src/admin/controllers/login.ts +++ b/src/admin/login.ts @@ -19,4 +19,11 @@ loginRouter.get("/logout", (req, res) => { res.redirect("/admin/login"); }); +loginRouter.get("/", (req, res) => { + if (req.cookies["admin-token"]) { + return res.redirect("/admin/manage"); + } + res.redirect("/admin/login"); +}); + export { loginRouter }; diff --git a/src/admin/routes.ts b/src/admin/routes.ts index bda7f03..27aadfd 100644 --- a/src/admin/routes.ts +++ b/src/admin/routes.ts @@ -1,9 +1,10 @@ import express, { Router } from "express"; import cookieParser from "cookie-parser"; -import { config } from "../config"; -import { auth } from "./auth"; -import { loginRouter } from "./controllers/login"; -import { usersRouter } from "./controllers/users"; +import { authorize } from "./auth"; +import { injectCsrfToken, checkCsrfToken } from "./csrf"; +import { usersApiRouter as apiRouter } from "./api/users"; +import { usersUiRouter as uiRouter } from "./ui/users"; +import { loginRouter } from "./login"; const adminRouter = Router(); @@ -12,14 +13,15 @@ adminRouter.use( express.urlencoded({ extended: true, limit: "20mb" }) ); adminRouter.use(cookieParser()); +adminRouter.use(injectCsrfToken); -adminRouter.use("/", loginRouter); -adminRouter.use(auth); -adminRouter.use("/users", usersRouter); -adminRouter.get("/", (_req, res) => { - res.render("admin/index", { - isPersistenceEnabled: config.gatekeeperStore !== "memory", - }); -}); +adminRouter.use("/", checkCsrfToken, loginRouter); +adminRouter.use("/users", authorize({ via: "header" }), apiRouter); +adminRouter.use( + "/manage", + authorize({ via: "cookie" }), + checkCsrfToken, + uiRouter +); export { adminRouter }; diff --git a/src/admin/ui/users.ts b/src/admin/ui/users.ts new file mode 100644 index 0000000..53529a0 --- /dev/null +++ b/src/admin/ui/users.ts @@ -0,0 +1,135 @@ +import { Router } from "express"; +import multer from "multer"; +import { z } from "zod"; +import { config } from "../../config"; +import * as userStore from "../../proxy/auth/user-store"; +import { + UserSchemaWithToken, + parseSort, + sortBy, + paginate, + UserSchema, +} from "../common"; + +const router = Router(); + +const upload = multer({ + storage: multer.memoryStorage(), + fileFilter: (_req, file, cb) => { + if (file.mimetype !== "application/json") { + cb(new Error("Invalid file type")); + } else { + cb(null, true); + } + }, +}); + +router.get("/create-user", (req, res) => { + const recentUsers = userStore + .getUsers() + .sort(sortBy(["createdAt"], false)) + .slice(0, 5); + res.render("admin/create-user", { + recentUsers, + newToken: !!req.query.created, + }); +}); + +router.post("/create-user", (_req, res) => { + userStore.createUser(); + return res.redirect(`/admin/manage/create-user?created=true`); +}); + +router.get("/view-user/:token", (req, res) => { + const user = userStore.getUser(req.params.token); + if (!user) { + return res.status(404).send("User not found"); + } + res.render("admin/view-user", { user }); +}); + +router.get("/list-users", (req, res) => { + const sort = parseSort(req.query.sort) || ["promptCount", "lastUsedAt"]; + const requestedPageSize = + Number(req.query.perPage) || Number(req.cookies.perPage) || 20; + const perPage = Math.max(1, Math.min(1000, requestedPageSize)); + const users = userStore.getUsers().sort(sortBy(sort, false)); + + const page = Number(req.query.page) || 1; + const { items, ...pagination } = paginate(users, page, perPage); + + return res.render("admin/list-users", { + sort: sort.join(","), + users: items, + ...pagination, + }); +}); + +router.get("/import-users", (req, res) => { + const imported = Number(req.query.imported) || 0; + res.render("admin/import-users", { imported }); +}); + +router.post("/import-users", upload.single("users"), (req, res) => { + if (!req.file) { + return res.status(400).json({ error: "No file uploaded" }); + } + const data = JSON.parse(req.file.buffer.toString()); + const result = z.array(UserSchemaWithToken).safeParse(data.users); + if (!result.success) { + return res.status(400).json({ error: result.error }); + } + const upserts = result.data.map((user) => userStore.upsertUser(user)); + res.redirect(`/admin/manage/import-users?imported=${upserts.length}`); +}); + +router.get("/export-users", (_req, res) => { + res.render("admin/export-users"); +}); + +router.get("/export-users.json", (_req, res) => { + const users = userStore.getUsers(); + res.setHeader("Content-Disposition", "attachment; filename=users.json"); + res.setHeader("Content-Type", "application/json"); + res.send(JSON.stringify({ users }, null, 2)); +}); + +router.get("/", (_req, res) => { + res.render("admin/index", { + isPersistenceEnabled: config.gatekeeperStore !== "memory", + }); +}); + +router.post("/edit-user/:token", (req, res) => { + const result = UserSchema.safeParse(req.body); + if (!result.success) { + return res.status(400).send(result.error); + } + userStore.upsertUser({ ...result.data, token: req.params.token }); + return res.sendStatus(204); +}); + +router.post("/reactivate-user/:token", (req, res) => { + const user = userStore.getUser(req.params.token); + if (!user) { + return res.status(404).send("User not found"); + } + userStore.upsertUser({ + token: user.token, + disabledAt: 0, + disabledReason: "", + }); + return res.sendStatus(204); +}); + +router.post("/disable-user/:token", (req, res) => { + const user = userStore.getUser(req.params.token); + if (!user) { + return res.status(404).send("User not found"); + } + userStore.disableUser(req.params.token, req.body.reason); + return res.sendStatus(204); +}); + + +export { router as usersUiRouter }; diff --git a/src/views/_partials/admin-header.ejs b/src/views/_partials/admin-header.ejs index 72a1e25..f4d4ca5 100644 --- a/src/views/_partials/admin-header.ejs +++ b/src/views/_partials/admin-header.ejs @@ -2,6 +2,7 @@ + <%= title %>