feat(auth): password change (self + admin reset) with rate limiting; dedupe watchlist to user menu
Build & Push Docker Image / build (push) Successful in 2m19s
Build & Push Docker Image / build (push) Successful in 2m19s
This commit is contained in:
@@ -13,16 +13,17 @@
|
||||
"@workspace/api-zod": "workspace:*",
|
||||
"@workspace/db": "workspace:*",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"zod": "catalog:",
|
||||
"connect-pg-simple": "^10.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.6",
|
||||
"drizzle-orm": "catalog:",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.6.1",
|
||||
"express-session": "^1.19.0",
|
||||
"openid-client": "^5.7.1",
|
||||
"pino": "^9.14.0",
|
||||
"pino-http": "^10.5.0"
|
||||
"pino-http": "^10.5.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import rateLimit from "express-rate-limit";
|
||||
import type { Request } from "express";
|
||||
|
||||
export const loginRateLimit = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
limit: 10,
|
||||
standardHeaders: "draft-7",
|
||||
legacyHeaders: false,
|
||||
message: { error: "Too many login attempts, please try again later." },
|
||||
});
|
||||
|
||||
export const passwordRateLimit = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
limit: 5,
|
||||
standardHeaders: "draft-7",
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req: Request): string => String(req.session.user?.sub ?? req.ip ?? "unknown"),
|
||||
message: { error: "Too many password attempts, please try again later." },
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import { eq, and, inArray, isNull } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db, usersTable, toolsTable, ratingsTable } from "@workspace/db";
|
||||
import { logger } from "../lib/logger";
|
||||
import { writeAuditLog } from "../lib/audit";
|
||||
import { loginRateLimit, passwordRateLimit } from "../lib/rate-limit";
|
||||
import { getEntitlements, requireFeature } from "../middleware/feature";
|
||||
|
||||
const router: IRouter = Router();
|
||||
@@ -107,7 +109,7 @@ router.get("/auth/mode", (_req, res): void => {
|
||||
res.json({ mode: isOidcConfigured() ? "oidc" : "local" });
|
||||
});
|
||||
|
||||
router.post("/auth/login", async (req, res): Promise<void> => {
|
||||
router.post("/auth/login", loginRateLimit, async (req, res): Promise<void> => {
|
||||
if (isOidcConfigured()) {
|
||||
res.status(400).json({ error: "Use OIDC login when Keycloak is configured." });
|
||||
return;
|
||||
@@ -279,6 +281,58 @@ router.get("/auth/me", async (req, res): Promise<void> => {
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/auth/password-redirect", async (req, res): Promise<void> => {
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
res.json({ url: null });
|
||||
return;
|
||||
}
|
||||
const realm = client.issuer.metadata.issuer ?? "";
|
||||
res.json({ url: `${realm}/account/password` });
|
||||
});
|
||||
|
||||
const ChangePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(8),
|
||||
});
|
||||
|
||||
router.post("/auth/me/password", passwordRateLimit, async (req, res): Promise<void> => {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ error: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
const dbUser = await resolveDbUser(req.session.user);
|
||||
if (!dbUser) {
|
||||
res.status(401).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
if (dbUser.authProvider !== "local") {
|
||||
res.status(422).json({ error: "oidc" });
|
||||
return;
|
||||
}
|
||||
const parsed = ChangePasswordSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
const { currentPassword, newPassword } = parsed.data;
|
||||
if (currentPassword === newPassword) {
|
||||
res.status(400).json({ error: "New password must differ from current password" });
|
||||
return;
|
||||
}
|
||||
if (!dbUser.passwordHash || !(await bcrypt.compare(currentPassword, dbUser.passwordHash))) {
|
||||
res.status(400).json({ error: "Current password is incorrect" });
|
||||
return;
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||
await db.update(usersTable).set({ passwordHash }).where(eq(usersTable.id, dbUser.id));
|
||||
await writeAuditLog(req, "user", dbUser.id, "change_password", {});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
req.session.regenerate((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
type SessionUser = NonNullable<import("express-session").SessionData["user"]>;
|
||||
|
||||
async function resolveDbUser(u: SessionUser) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import bcrypt from "bcryptjs";
|
||||
import { db, usersTable } from "@workspace/db";
|
||||
import { requireAdmin } from "../middleware/auth";
|
||||
import { writeAuditLog } from "../lib/audit";
|
||||
import { passwordRateLimit } from "../lib/rate-limit";
|
||||
import { z } from "zod";
|
||||
|
||||
const router: IRouter = Router();
|
||||
@@ -23,6 +24,46 @@ const UserUpdateSchema = z.object({
|
||||
tier: Tier.optional(),
|
||||
});
|
||||
|
||||
const SetPasswordSchema = z.object({
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
router.patch("/users/:id/password", requireAdmin, passwordRateLimit, async (req, res): Promise<void> => {
|
||||
const id = parseInt(String(req.params.id), 10);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({ error: "Invalid user id" });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = SetPasswordSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const [target] = await db
|
||||
.select({ id: usersTable.id, authProvider: usersTable.authProvider, username: usersTable.username })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!target) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.authProvider !== "local") {
|
||||
res.status(422).json({ error: "oidc" });
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
||||
await db.update(usersTable).set({ passwordHash }).where(eq(usersTable.id, id));
|
||||
|
||||
await writeAuditLog(req, "user", id, "set_password", { username: target.username });
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
const users = await db
|
||||
.select({
|
||||
@@ -31,6 +72,7 @@ router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
email: usersTable.email,
|
||||
role: usersTable.role,
|
||||
tier: usersTable.tier,
|
||||
authProvider: usersTable.authProvider,
|
||||
createdAt: usersTable.createdAt,
|
||||
})
|
||||
.from(usersTable)
|
||||
@@ -56,7 +98,7 @@ router.post("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(parsed.data.password, 10);
|
||||
const passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
||||
const [user] = await db
|
||||
.insert(usersTable)
|
||||
.values({
|
||||
|
||||
Reference in New Issue
Block a user