feat: 添加密码管理功能,包括 API、数据库支持和前端界面
This commit is contained in:
17
apps/server/migrations/0004_credentials.sql
Normal file
17
apps/server/migrations/0004_credentials.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
create table if not exists credentials (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
site_origin text not null,
|
||||
username text not null,
|
||||
password_enc text not null,
|
||||
password_iv text not null,
|
||||
password_tag text not null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create unique index if not exists idx_credentials_user_origin_username
|
||||
on credentials (user_id, site_origin, username);
|
||||
|
||||
create index if not exists idx_credentials_user_origin
|
||||
on credentials (user_id, site_origin);
|
||||
@@ -2,11 +2,11 @@
|
||||
"name": "@browser-bookmark/server",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.4",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"dev": "node --watch src/index.js",
|
||||
"build": "node -c src/index.js && node -c src/routes/auth.routes.js && node -c src/routes/bookmarks.routes.js && node -c src/routes/folders.routes.js && node -c src/routes/importExport.routes.js && node -c src/routes/sync.routes.js",
|
||||
"build": "node -c src/index.js && node -c src/routes/auth.routes.js && node -c src/routes/bookmarks.routes.js && node -c src/routes/folders.routes.js && node -c src/routes/importExport.routes.js && node -c src/routes/sync.routes.js && node -c src/routes/credentials.routes.js",
|
||||
"test": "node --test",
|
||||
"lint": "eslint .",
|
||||
"db:migrate": "node src/migrate.js",
|
||||
|
||||
115
apps/server/src/__tests__/credentials.api.test.js
Normal file
115
apps/server/src/__tests__/credentials.api.test.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildApp } from "../app.js";
|
||||
|
||||
const key = process.env.CREDENTIAL_MASTER_KEY;
|
||||
const jwtSecret = process.env.AUTH_JWT_SECRET;
|
||||
const keyValid = (() => {
|
||||
if (!key) return false;
|
||||
try {
|
||||
return Buffer.from(key, "base64").length === 32;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!keyValid || !jwtSecret) {
|
||||
test("credentials api: skipped (missing/invalid env)", () => {
|
||||
assert.ok(true);
|
||||
});
|
||||
} else {
|
||||
test("credentials api: create and list with plaintext", async () => {
|
||||
const app = await buildApp();
|
||||
|
||||
const email = `api-${Date.now()}@example.com`;
|
||||
const password = "password123";
|
||||
|
||||
const registerRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: { email, password }
|
||||
});
|
||||
assert.equal(registerRes.statusCode, 200);
|
||||
const registerBody = registerRes.json();
|
||||
const token = registerBody.token;
|
||||
|
||||
const createRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/credentials",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
siteOrigin: "https://example.com",
|
||||
username: "user1",
|
||||
password: "secret"
|
||||
}
|
||||
});
|
||||
assert.equal(createRes.statusCode, 200);
|
||||
|
||||
const listRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/credentials?siteOrigin=https://example.com",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(listRes.statusCode, 200);
|
||||
const listBody = listRes.json();
|
||||
assert.equal(listBody.length, 1);
|
||||
assert.equal(listBody[0].password, null);
|
||||
|
||||
const listPwdRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/credentials?siteOrigin=https://example.com&includePassword=true",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(listPwdRes.statusCode, 200);
|
||||
const listPwdBody = listPwdRes.json();
|
||||
assert.equal(listPwdBody[0].password, "secret");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test("credentials api: admin access", async () => {
|
||||
const adminEmail = `admin-${Date.now()}@example.com`;
|
||||
const userEmail = `user-${Date.now()}@example.com`;
|
||||
process.env.ADMIN_EMAIL = adminEmail;
|
||||
|
||||
const app = await buildApp();
|
||||
|
||||
const adminRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: { email: adminEmail, password: "password123" }
|
||||
});
|
||||
const adminToken = adminRes.json().token;
|
||||
|
||||
const userRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: { email: userEmail, password: "password123" }
|
||||
});
|
||||
const userId = userRes.json().user.id;
|
||||
const userToken = userRes.json().token;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/credentials",
|
||||
headers: { Authorization: `Bearer ${userToken}` },
|
||||
payload: {
|
||||
siteOrigin: "https://example.com",
|
||||
username: "user2",
|
||||
password: "secret2"
|
||||
}
|
||||
});
|
||||
|
||||
const adminList = await app.inject({
|
||||
method: "GET",
|
||||
url: `/admin/users/${userId}/credentials?includePassword=true`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` }
|
||||
});
|
||||
|
||||
assert.equal(adminList.statusCode, 200);
|
||||
const adminBody = adminList.json();
|
||||
assert.equal(adminBody[0].password, "secret2");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
}
|
||||
66
apps/server/src/__tests__/credentials.test.js
Normal file
66
apps/server/src/__tests__/credentials.test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createPool } from "../db.js";
|
||||
import { encryptPassword, decryptPassword } from "../lib/crypto.js";
|
||||
|
||||
const key = process.env.CREDENTIAL_MASTER_KEY;
|
||||
const keyValid = (() => {
|
||||
if (!key) return false;
|
||||
try {
|
||||
return Buffer.from(key, "base64").length === 32;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!keyValid) {
|
||||
test("credentials: skipped (missing CREDENTIAL_MASTER_KEY)", () => {
|
||||
assert.ok(true);
|
||||
});
|
||||
} else {
|
||||
test("credentials: encrypt/decrypt", () => {
|
||||
const enc = encryptPassword("secret", key);
|
||||
const dec = decryptPassword({ cipherText: enc.cipherText, iv: enc.iv, tag: enc.tag }, key);
|
||||
assert.equal(dec, "secret");
|
||||
});
|
||||
|
||||
test("credentials: insert/select", async (t) => {
|
||||
const pool = createPool();
|
||||
const email = `test-${Date.now()}@example.com`;
|
||||
let userId = "";
|
||||
|
||||
t.after(async () => {
|
||||
if (userId) {
|
||||
await pool.query("delete from users where id=$1", [userId]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
const userRes = await pool.query(
|
||||
"insert into users (email, password_hash) values ($1, $2) returning id",
|
||||
[email, "hash"]
|
||||
);
|
||||
userId = userRes.rows[0].id;
|
||||
|
||||
const enc = encryptPassword("pass123", key);
|
||||
const ins = await pool.query(
|
||||
"insert into credentials (user_id, site_origin, username, password_enc, password_iv, password_tag) values ($1,$2,$3,$4,$5,$6) returning *",
|
||||
[userId, "https://example.com", "user1", enc.cipherText, enc.iv, enc.tag]
|
||||
);
|
||||
|
||||
assert.equal(ins.rows[0].site_origin, "https://example.com");
|
||||
|
||||
const res = await pool.query(
|
||||
"select * from credentials where user_id=$1 and site_origin=$2",
|
||||
[userId, "https://example.com"]
|
||||
);
|
||||
|
||||
assert.equal(res.rowCount, 1);
|
||||
const dec = decryptPassword({
|
||||
cipherText: res.rows[0].password_enc,
|
||||
iv: res.rows[0].password_iv,
|
||||
tag: res.rows[0].password_tag
|
||||
}, key);
|
||||
assert.equal(dec, "pass123");
|
||||
});
|
||||
}
|
||||
90
apps/server/src/app.js
Normal file
90
apps/server/src/app.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import multipart from "@fastify/multipart";
|
||||
import jwt from "@fastify/jwt";
|
||||
import { getConfig } from "./config.js";
|
||||
import { createPool } from "./db.js";
|
||||
import { authRoutes } from "./routes/auth.routes.js";
|
||||
import { adminRoutes } from "./routes/admin.routes.js";
|
||||
import { foldersRoutes } from "./routes/folders.routes.js";
|
||||
import { bookmarksRoutes } from "./routes/bookmarks.routes.js";
|
||||
import { importExportRoutes } from "./routes/importExport.routes.js";
|
||||
import { syncRoutes } from "./routes/sync.routes.js";
|
||||
import { credentialsRoutes } from "./routes/credentials.routes.js";
|
||||
|
||||
export async function buildApp() {
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
// Plugins
|
||||
const config = getConfig();
|
||||
await app.register(cors, {
|
||||
origin: config.corsOrigins,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "Accept"]
|
||||
});
|
||||
await app.register(multipart);
|
||||
|
||||
const jwtSecret = process.env.AUTH_JWT_SECRET;
|
||||
if (!jwtSecret) {
|
||||
throw new Error("AUTH_JWT_SECRET is required");
|
||||
}
|
||||
await app.register(jwt, { secret: jwtSecret });
|
||||
|
||||
const pool = createPool();
|
||||
app.decorate("pg", pool);
|
||||
|
||||
// Detect optional DB features (for backwards compatibility when migrations haven't run yet).
|
||||
async function hasColumn(tableName, columnName) {
|
||||
try {
|
||||
const r = await app.pg.query(
|
||||
"select 1 from information_schema.columns where table_schema=current_schema() and table_name=$1 and column_name=$2 limit 1",
|
||||
[tableName, columnName]
|
||||
);
|
||||
return r.rowCount > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const folderSortOrderSupported = await hasColumn("bookmark_folders", "sort_order");
|
||||
const bookmarkSortOrderSupported = await hasColumn("bookmarks", "sort_order");
|
||||
|
||||
app.decorate("features", {
|
||||
folderSortOrder: folderSortOrderSupported,
|
||||
bookmarkSortOrder: bookmarkSortOrderSupported
|
||||
});
|
||||
|
||||
app.decorate("authenticate", async (req, reply) => {
|
||||
try {
|
||||
await req.jwtVerify();
|
||||
} catch (err) {
|
||||
reply.code(401);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
app.setErrorHandler((err, _req, reply) => {
|
||||
const statusCode = err.statusCode || 500;
|
||||
reply.code(statusCode).send({ message: err.message || "server error" });
|
||||
});
|
||||
|
||||
app.get("/health", async () => ({ ok: true }));
|
||||
|
||||
// Routes
|
||||
app.decorate("config", config);
|
||||
|
||||
await authRoutes(app);
|
||||
await adminRoutes(app);
|
||||
await foldersRoutes(app);
|
||||
await bookmarksRoutes(app);
|
||||
await importExportRoutes(app);
|
||||
await syncRoutes(app);
|
||||
await credentialsRoutes(app);
|
||||
|
||||
app.addHook("onClose", async (instance) => {
|
||||
await instance.pg.end();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -25,7 +25,9 @@ export function getConfig() {
|
||||
const adminEmail = String(process.env.ADMIN_EMAIL || "").trim().toLowerCase();
|
||||
const corsOriginsRaw = String(process.env.CORS_ORIGINS || "").trim();
|
||||
const corsOrigins = corsOriginsRaw
|
||||
? corsOriginsRaw.split(",").map((item) => item.trim()).filter(Boolean)
|
||||
? (corsOriginsRaw === "*"
|
||||
? true
|
||||
: corsOriginsRaw.split(",").map((item) => item.trim()).filter(Boolean))
|
||||
: true;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,87 +1,5 @@
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import multipart from "@fastify/multipart";
|
||||
import jwt from "@fastify/jwt";
|
||||
import { getConfig } from "./config.js";
|
||||
import { createPool } from "./db.js";
|
||||
import { authRoutes } from "./routes/auth.routes.js";
|
||||
import { adminRoutes } from "./routes/admin.routes.js";
|
||||
import { foldersRoutes } from "./routes/folders.routes.js";
|
||||
import { bookmarksRoutes } from "./routes/bookmarks.routes.js";
|
||||
import { importExportRoutes } from "./routes/importExport.routes.js";
|
||||
import { syncRoutes } from "./routes/sync.routes.js";
|
||||
import { buildApp } from "./app.js";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
// Plugins
|
||||
const config = getConfig();
|
||||
await app.register(cors, {
|
||||
origin: config.corsOrigins,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "Accept"]
|
||||
});
|
||||
await app.register(multipart);
|
||||
|
||||
const jwtSecret = process.env.AUTH_JWT_SECRET;
|
||||
if (!jwtSecret) {
|
||||
throw new Error("AUTH_JWT_SECRET is required");
|
||||
}
|
||||
await app.register(jwt, { secret: jwtSecret });
|
||||
|
||||
const pool = createPool();
|
||||
app.decorate("pg", pool);
|
||||
|
||||
// Detect optional DB features (for backwards compatibility when migrations haven't run yet).
|
||||
async function hasColumn(tableName, columnName) {
|
||||
try {
|
||||
const r = await app.pg.query(
|
||||
"select 1 from information_schema.columns where table_schema=current_schema() and table_name=$1 and column_name=$2 limit 1",
|
||||
[tableName, columnName]
|
||||
);
|
||||
return r.rowCount > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const folderSortOrderSupported = await hasColumn("bookmark_folders", "sort_order");
|
||||
const bookmarkSortOrderSupported = await hasColumn("bookmarks", "sort_order");
|
||||
|
||||
app.decorate("features", {
|
||||
folderSortOrder: folderSortOrderSupported,
|
||||
bookmarkSortOrder: bookmarkSortOrderSupported
|
||||
});
|
||||
|
||||
app.decorate("authenticate", async (req, reply) => {
|
||||
try {
|
||||
await req.jwtVerify();
|
||||
} catch (err) {
|
||||
reply.code(401);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
app.setErrorHandler((err, _req, reply) => {
|
||||
const statusCode = err.statusCode || 500;
|
||||
reply.code(statusCode).send({ message: err.message || "server error" });
|
||||
});
|
||||
|
||||
app.get("/health", async () => ({ ok: true }));
|
||||
|
||||
// Routes
|
||||
app.decorate("config", config);
|
||||
|
||||
await authRoutes(app);
|
||||
await adminRoutes(app);
|
||||
await foldersRoutes(app);
|
||||
await bookmarksRoutes(app);
|
||||
await importExportRoutes(app);
|
||||
await syncRoutes(app);
|
||||
|
||||
app.addHook("onClose", async (instance) => {
|
||||
await instance.pg.end();
|
||||
});
|
||||
|
||||
const { serverPort } = config;
|
||||
const app = await buildApp();
|
||||
const { serverPort } = app.config;
|
||||
await app.listen({ port: serverPort, host: "0.0.0.0" });
|
||||
|
||||
41
apps/server/src/lib/crypto.js
Normal file
41
apps/server/src/lib/crypto.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import crypto from "node:crypto";
|
||||
import { httpError } from "./httpErrors.js";
|
||||
|
||||
const ALGO = "aes-256-gcm";
|
||||
|
||||
function loadKey(keyBase64) {
|
||||
const raw = String(keyBase64 || "").trim();
|
||||
if (!raw) throw httpError(500, "CREDENTIAL_MASTER_KEY is required");
|
||||
const key = Buffer.from(raw, "base64");
|
||||
if (key.length !== 32) throw httpError(500, "CREDENTIAL_MASTER_KEY must be 32 bytes base64");
|
||||
return key;
|
||||
}
|
||||
|
||||
export function encryptPassword(plaintext, keyBase64) {
|
||||
const key = loadKey(keyBase64);
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
||||
const enc = Buffer.concat([cipher.update(String(plaintext || ""), "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return {
|
||||
cipherText: enc.toString("base64"),
|
||||
iv: iv.toString("base64"),
|
||||
tag: tag.toString("base64")
|
||||
};
|
||||
}
|
||||
|
||||
export function decryptPassword({ cipherText, iv, tag }, keyBase64) {
|
||||
const key = loadKey(keyBase64);
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(iv, "base64"));
|
||||
decipher.setAuthTag(Buffer.from(tag, "base64"));
|
||||
const out = Buffer.concat([
|
||||
decipher.update(Buffer.from(cipherText, "base64")),
|
||||
decipher.final()
|
||||
]);
|
||||
return out.toString("utf8");
|
||||
} catch {
|
||||
throw httpError(500, "failed to decrypt credential");
|
||||
}
|
||||
}
|
||||
13
apps/server/src/lib/reauth.js
Normal file
13
apps/server/src/lib/reauth.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { httpError } from "./httpErrors.js";
|
||||
|
||||
export async function verifyReauthToken(app, userId, token) {
|
||||
if (!token) throw httpError(403, "reauth required");
|
||||
try {
|
||||
const payload = await app.jwt.verify(token);
|
||||
if (!payload?.reauth) throw httpError(403, "reauth invalid");
|
||||
if (payload.sub !== userId) throw httpError(403, "reauth invalid");
|
||||
return payload;
|
||||
} catch {
|
||||
throw httpError(403, "reauth invalid");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,16 @@ import { computeUrlHash, normalizeUrl } from "@browser-bookmark/shared";
|
||||
import { httpError } from "../lib/httpErrors.js";
|
||||
import { requireAdmin, isAdminEmail } from "../lib/admin.js";
|
||||
import { bookmarkRowToDto, folderRowToDto, userRowToDto } from "../lib/rows.js";
|
||||
import { encryptPassword, decryptPassword } from "../lib/crypto.js";
|
||||
|
||||
function normalizeOrigin(input) {
|
||||
try {
|
||||
const u = new URL(String(input || "").trim());
|
||||
return u.origin;
|
||||
} catch {
|
||||
throw httpError(400, "siteOrigin invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function toUserDtoWithAdminOverride(app, row) {
|
||||
const dto = userRowToDto(row);
|
||||
@@ -65,6 +75,125 @@ export async function adminRoutes(app) {
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/admin/users/:id/credentials",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async (req) => {
|
||||
const userId = req.params?.id;
|
||||
if (!userId) throw httpError(400, "user id required");
|
||||
|
||||
const includePassword = String(req.query?.includePassword || "").toLowerCase() === "true";
|
||||
const res = await app.pg.query(
|
||||
"select * from credentials where user_id=$1 order by updated_at desc",
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (!includePassword) {
|
||||
return res.rows.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
siteOrigin: r.site_origin,
|
||||
username: r.username,
|
||||
password: null,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at
|
||||
}));
|
||||
}
|
||||
|
||||
const key = process.env.CREDENTIAL_MASTER_KEY;
|
||||
return res.rows.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
siteOrigin: r.site_origin,
|
||||
username: r.username,
|
||||
password: decryptPassword({
|
||||
cipherText: r.password_enc,
|
||||
iv: r.password_iv,
|
||||
tag: r.password_tag
|
||||
}, key),
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/admin/users/:userId/credentials/:credentialId",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async (req) => {
|
||||
const userId = req.params?.userId;
|
||||
const credentialId = req.params?.credentialId;
|
||||
if (!userId || !credentialId) throw httpError(400, "userId and credentialId required");
|
||||
|
||||
const body = req.body || {};
|
||||
const sets = [];
|
||||
const params = [];
|
||||
let i = 1;
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "siteOrigin")) {
|
||||
const origin = normalizeOrigin(body.siteOrigin);
|
||||
sets.push(`site_origin=$${i++}`);
|
||||
params.push(origin);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "username")) {
|
||||
const name = String(body.username || "").trim();
|
||||
if (!name) throw httpError(400, "username required");
|
||||
sets.push(`username=$${i++}`);
|
||||
params.push(name);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "password")) {
|
||||
const pwd = String(body.password || "");
|
||||
if (!pwd) throw httpError(400, "password required");
|
||||
const key = process.env.CREDENTIAL_MASTER_KEY;
|
||||
const enc = encryptPassword(pwd, key);
|
||||
sets.push(`password_enc=$${i++}`);
|
||||
params.push(enc.cipherText);
|
||||
sets.push(`password_iv=$${i++}`);
|
||||
params.push(enc.iv);
|
||||
sets.push(`password_tag=$${i++}`);
|
||||
params.push(enc.tag);
|
||||
}
|
||||
|
||||
if (sets.length === 0) throw httpError(400, "no fields to update");
|
||||
|
||||
params.push(credentialId, userId);
|
||||
const res = await app.pg.query(
|
||||
`update credentials set ${sets.join(", ")}, updated_at=now() where id=$${i++} and user_id=$${i} returning *`,
|
||||
params
|
||||
);
|
||||
if (!res.rows[0]) throw httpError(404, "credential not found");
|
||||
|
||||
return {
|
||||
id: res.rows[0].id,
|
||||
userId: res.rows[0].user_id,
|
||||
siteOrigin: res.rows[0].site_origin,
|
||||
username: res.rows[0].username,
|
||||
password: null,
|
||||
createdAt: res.rows[0].created_at,
|
||||
updatedAt: res.rows[0].updated_at
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/admin/users/:userId/credentials/:credentialId",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async (req) => {
|
||||
const userId = req.params?.userId;
|
||||
const credentialId = req.params?.credentialId;
|
||||
if (!userId || !credentialId) throw httpError(400, "userId and credentialId required");
|
||||
|
||||
const res = await app.pg.query(
|
||||
"delete from credentials where id=$1 and user_id=$2 returning id",
|
||||
[credentialId, userId]
|
||||
);
|
||||
if (!res.rows[0]) throw httpError(404, "credential not found");
|
||||
return { ok: true };
|
||||
}
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/admin/users/:userId/bookmarks/:bookmarkId",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
|
||||
@@ -71,4 +71,5 @@ export async function authRoutes(app) {
|
||||
return toUserDtoWithAdminOverride(app, row);
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
151
apps/server/src/routes/credentials.routes.js
Normal file
151
apps/server/src/routes/credentials.routes.js
Normal file
@@ -0,0 +1,151 @@
|
||||
import { httpError } from "../lib/httpErrors.js";
|
||||
import { encryptPassword, decryptPassword } from "../lib/crypto.js";
|
||||
|
||||
function normalizeOrigin(input) {
|
||||
try {
|
||||
const u = new URL(String(input || "").trim());
|
||||
return u.origin;
|
||||
} catch {
|
||||
throw httpError(400, "siteOrigin invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function credentialRowToDto(row, { includePassword = false, passwordPlain = null } = {}) {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
siteOrigin: row.site_origin,
|
||||
username: row.username,
|
||||
password: includePassword ? passwordPlain : null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export async function credentialsRoutes(app) {
|
||||
app.get(
|
||||
"/credentials",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const siteOrigin = req.query?.siteOrigin ? normalizeOrigin(req.query.siteOrigin) : null;
|
||||
const includePassword = String(req.query?.includePassword || "").toLowerCase() === "true";
|
||||
|
||||
const params = [userId];
|
||||
let where = "where user_id=$1";
|
||||
if (siteOrigin) {
|
||||
params.push(siteOrigin);
|
||||
where += ` and site_origin=$${params.length}`;
|
||||
}
|
||||
|
||||
const res = await app.pg.query(
|
||||
`select * from credentials ${where} order by updated_at desc`,
|
||||
params
|
||||
);
|
||||
|
||||
if (!includePassword) return res.rows.map((r) => credentialRowToDto(r));
|
||||
|
||||
const key = process.env.CREDENTIAL_MASTER_KEY;
|
||||
return res.rows.map((r) => {
|
||||
const passwordPlain = decryptPassword({
|
||||
cipherText: r.password_enc,
|
||||
iv: r.password_iv,
|
||||
tag: r.password_tag
|
||||
}, key);
|
||||
return credentialRowToDto(r, { includePassword: true, passwordPlain });
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/credentials",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const { siteOrigin, username, password } = req.body || {};
|
||||
if (!siteOrigin) throw httpError(400, "siteOrigin required");
|
||||
if (!username) throw httpError(400, "username required");
|
||||
if (!password) throw httpError(400, "password required");
|
||||
|
||||
const origin = normalizeOrigin(siteOrigin);
|
||||
const key = process.env.CREDENTIAL_MASTER_KEY;
|
||||
const enc = encryptPassword(password, key);
|
||||
|
||||
const res = await app.pg.query(
|
||||
`insert into credentials (user_id, site_origin, username, password_enc, password_iv, password_tag)
|
||||
values ($1,$2,$3,$4,$5,$6)
|
||||
on conflict (user_id, site_origin, username)
|
||||
do update set password_enc=excluded.password_enc, password_iv=excluded.password_iv, password_tag=excluded.password_tag, updated_at=now()
|
||||
returning *`,
|
||||
[userId, origin, username, enc.cipherText, enc.iv, enc.tag]
|
||||
);
|
||||
|
||||
return credentialRowToDto(res.rows[0]);
|
||||
}
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/credentials/:id",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const id = req.params?.id;
|
||||
const body = req.body || {};
|
||||
|
||||
const sets = [];
|
||||
const params = [];
|
||||
let i = 1;
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "siteOrigin")) {
|
||||
const origin = normalizeOrigin(body.siteOrigin);
|
||||
sets.push(`site_origin=$${i++}`);
|
||||
params.push(origin);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "username")) {
|
||||
const name = String(body.username || "").trim();
|
||||
if (!name) throw httpError(400, "username required");
|
||||
sets.push(`username=$${i++}`);
|
||||
params.push(name);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "password")) {
|
||||
const pwd = String(body.password || "");
|
||||
if (!pwd) throw httpError(400, "password required");
|
||||
const key = process.env.CREDENTIAL_MASTER_KEY;
|
||||
const enc = encryptPassword(pwd, key);
|
||||
sets.push(`password_enc=$${i++}`);
|
||||
params.push(enc.cipherText);
|
||||
sets.push(`password_iv=$${i++}`);
|
||||
params.push(enc.iv);
|
||||
sets.push(`password_tag=$${i++}`);
|
||||
params.push(enc.tag);
|
||||
}
|
||||
|
||||
if (sets.length === 0) throw httpError(400, "no fields to update");
|
||||
|
||||
params.push(id, userId);
|
||||
const res = await app.pg.query(
|
||||
`update credentials set ${sets.join(", ")}, updated_at=now() where id=$${i++} and user_id=$${i} returning *`,
|
||||
params
|
||||
);
|
||||
if (!res.rows[0]) throw httpError(404, "credential not found");
|
||||
return credentialRowToDto(res.rows[0]);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/credentials/:id",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const id = req.params?.id;
|
||||
const res = await app.pg.query(
|
||||
"delete from credentials where id=$1 and user_id=$2 returning id",
|
||||
[id, userId]
|
||||
);
|
||||
if (!res.rows[0]) throw httpError(404, "credential not found");
|
||||
return { ok: true };
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user