feedkeeper 0.0.0-stage → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +22 -2
- package/package.json +57 -4
- package/server/dist/api/archive.js +19 -0
- package/server/dist/api/auth.js +167 -0
- package/server/dist/api/feeds.js +207 -0
- package/server/dist/api/filters.js +32 -0
- package/server/dist/api/folders.js +53 -0
- package/server/dist/api/index.js +37 -0
- package/server/dist/api/items.js +129 -0
- package/server/dist/api/oauth.js +14 -0
- package/server/dist/api/onboarding.js +30 -0
- package/server/dist/api/push.js +51 -0
- package/server/dist/api/system.js +42 -0
- package/server/dist/api/tokens.js +28 -0
- package/server/dist/api/v1/index.js +178 -0
- package/server/dist/api/v1/resources.js +653 -0
- package/server/dist/api/v1/serialize.js +48 -0
- package/server/dist/auth/avatar.js +13 -0
- package/server/dist/auth/capabilities.js +53 -0
- package/server/dist/auth/middleware.js +77 -0
- package/server/dist/auth/pairing.js +36 -0
- package/server/dist/auth/password.js +21 -0
- package/server/dist/auth/security.js +126 -0
- package/server/dist/auth/securityRoutes.js +249 -0
- package/server/dist/auth/tokens.js +64 -0
- package/server/dist/auth/users.js +54 -0
- package/server/dist/backup-cli.js +101 -0
- package/server/dist/cli.js +135 -0
- package/server/dist/clientAddress.js +26 -0
- package/server/dist/config.js +51 -0
- package/server/dist/db/index.js +40 -0
- package/server/dist/db/migrations/0001_init.sql +70 -0
- package/server/dist/db/migrations/0002_feed_health.sql +5 -0
- package/server/dist/db/migrations/0003_retention.sql +13 -0
- package/server/dist/db/migrations/0004_bookmarks_and_filters.sql +20 -0
- package/server/dist/db/migrations/0005_folders.sql +15 -0
- package/server/dist/db/migrations/0006_feed_position.sql +5 -0
- package/server/dist/db/migrations/0007_item_image_url.sql +2 -0
- package/server/dist/db/migrations/0008_item_content_html.sql +2 -0
- package/server/dist/db/migrations/0009_item_full_content_html.sql +1 -0
- package/server/dist/db/migrations/0010_token_scope.sql +3 -0
- package/server/dist/db/migrations/0011_user_avatars.sql +8 -0
- package/server/dist/db/migrations/0012_full_text_settings.sql +5 -0
- package/server/dist/db/migrations/0013_feed_icons.sql +4 -0
- package/server/dist/db/migrations/0014_bookmark_archive.sql +16 -0
- package/server/dist/db/migrations/0015_push.sql +13 -0
- package/server/dist/db/migrations/0016_feed_badge.sql +2 -0
- package/server/dist/db/migrations/0017_devices.sql +13 -0
- package/server/dist/db/migrations/0018_change_log.sql +166 -0
- package/server/dist/db/migrations/0019_native_extensions.sql +111 -0
- package/server/dist/db/migrations/0020_fts.sql +32 -0
- package/server/dist/db/migrations/0021_note_revisions.sql +33 -0
- package/server/dist/db/migrations/0022_high_resolution_icons.sql +2 -0
- package/server/dist/db/migrations/0023_server_editions.sql +81 -0
- package/server/dist/db/migrations/0024_inbox.sql +5 -0
- package/server/dist/db/migrations/0025_edition_overview.sql +2 -0
- package/server/dist/db/migrations/0026_article_content.sql +43 -0
- package/server/dist/db/migrations/0027_oauth.sql +38 -0
- package/server/dist/db/migrations/0028_user_intro.sql +2 -0
- package/server/dist/db/migrations/0029_account_security.sql +35 -0
- package/server/dist/db/migrations/0030_feed_poll_jobs.sql +15 -0
- package/server/dist/db/migrations/0031_push_outbox.sql +19 -0
- package/server/dist/db/migrations/0032_normalize_published_at.sql +12 -0
- package/server/dist/feedkeeper.js +38 -0
- package/server/dist/feeds/archive.js +334 -0
- package/server/dist/feeds/articleContent.js +26 -0
- package/server/dist/feeds/cleanup.js +179 -0
- package/server/dist/feeds/dates.js +21 -0
- package/server/dist/feeds/discovery.js +153 -0
- package/server/dist/feeds/extractor.js +208 -0
- package/server/dist/feeds/feedIcon.js +171 -0
- package/server/dist/feeds/fetcher.js +217 -0
- package/server/dist/feeds/fullText.js +91 -0
- package/server/dist/feeds/fullTextQueue.js +159 -0
- package/server/dist/feeds/icon.js +97 -0
- package/server/dist/feeds/imageCache.js +266 -0
- package/server/dist/feeds/imageCacheSettings.js +19 -0
- package/server/dist/feeds/imageTypes.js +16 -0
- package/server/dist/feeds/inbox.js +128 -0
- package/server/dist/feeds/opml.js +190 -0
- package/server/dist/feeds/pollQueue.js +301 -0
- package/server/dist/feeds/pollSettings.js +23 -0
- package/server/dist/feeds/poller.js +223 -0
- package/server/dist/feeds/repository.js +687 -0
- package/server/dist/feeds/requestGate.js +99 -0
- package/server/dist/feeds/service.js +162 -0
- package/server/dist/feeds/ssrfGuard.js +98 -0
- package/server/dist/feeds/text.js +35 -0
- package/server/dist/mcp/digest.js +73 -0
- package/server/dist/mcp/http.js +46 -0
- package/server/dist/mcp/items.js +73 -0
- package/server/dist/mcp/promptsAndResources.js +43 -0
- package/server/dist/mcp/server.js +547 -0
- package/server/dist/native/editionSelection.js +127 -0
- package/server/dist/native/editionText.js +15 -0
- package/server/dist/native/editions.js +218 -0
- package/server/dist/native/preferences.js +66 -0
- package/server/dist/native/requests.js +35 -0
- package/server/dist/oauth/constants.js +6 -0
- package/server/dist/oauth/host.js +8 -0
- package/server/dist/oauth/pages.js +148 -0
- package/server/dist/oauth/routes.js +370 -0
- package/server/dist/oauth/store.js +104 -0
- package/server/dist/push.js +147 -0
- package/server/dist/pushOutbox.js +164 -0
- package/server/dist/security-recover-cli.js +29 -0
- package/server/dist/server.js +108 -0
- package/server/dist/setup-cli.js +97 -0
- package/server/dist/sync/changeLog.js +170 -0
- package/server/dist/sync/mutations.js +144 -0
- package/server/dist/trustProxy.js +14 -0
- package/server/dist/version.js +6 -0
- package/server/package.json +6 -0
- package/web/dist/app-icon-180.png +0 -0
- package/web/dist/app-icon-192.png +0 -0
- package/web/dist/app-icon-512.png +0 -0
- package/web/dist/assets/index-B4e2oqbo.css +2 -0
- package/web/dist/assets/index-xx6TyOwB.js +32 -0
- package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/web/dist/assets/manrope-latin-600-normal-4f0koTD-.woff2 +0 -0
- package/web/dist/assets/manrope-latin-600-normal-BqgrALkZ.woff +0 -0
- package/web/dist/assets/manrope-latin-700-normal-BZp_XxE4.woff2 +0 -0
- package/web/dist/assets/manrope-latin-700-normal-DGRFkw-m.woff +0 -0
- package/web/dist/favicon-32.png +0 -0
- package/web/dist/index.html +40 -0
- package/web/dist/logo.svg +50 -0
- package/web/dist/manifest.webmanifest +64 -0
- package/web/dist/robots.txt +3 -0
- package/web/dist/sw.js +198 -0
- package/web/dist/theme-init.js +10 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { contentMetadata } from "../../feeds/articleContent.js";
|
|
2
|
+
import { db } from "../../db/index.js";
|
|
3
|
+
import { serializeSubscription } from "../../sync/changeLog.js";
|
|
4
|
+
/** A subscription as the list shows it: the synced fields plus counts and polling health. */
|
|
5
|
+
export function serializeSubscriptionDetail(subscription) {
|
|
6
|
+
return {
|
|
7
|
+
...serializeSubscription(subscription),
|
|
8
|
+
folderName: subscription.folder_name,
|
|
9
|
+
unreadCount: subscription.unread_count,
|
|
10
|
+
lastPolledAt: subscription.last_polled_at,
|
|
11
|
+
lastSuccessAt: subscription.last_success_at,
|
|
12
|
+
lastError: subscription.last_error,
|
|
13
|
+
consecutiveErrors: subscription.consecutive_errors,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function readingPositions(userId, itemIds) {
|
|
17
|
+
if (itemIds.length === 0)
|
|
18
|
+
return new Map();
|
|
19
|
+
const rows = db
|
|
20
|
+
.prepare(`SELECT item_id, position FROM item_progress WHERE user_id = ? AND item_id IN (${itemIds.map(() => "?").join(",")})`)
|
|
21
|
+
.all(userId, ...itemIds);
|
|
22
|
+
return new Map(rows.map((row) => [row.item_id, row.position]));
|
|
23
|
+
}
|
|
24
|
+
export function serializeItem(row, progress, withContent, hasNote = false) {
|
|
25
|
+
return {
|
|
26
|
+
id: row.id,
|
|
27
|
+
subscriptionId: row.feed_id,
|
|
28
|
+
subscriptionTitle: row.feed_title,
|
|
29
|
+
title: row.title,
|
|
30
|
+
url: row.link,
|
|
31
|
+
publishedAt: row.published_at,
|
|
32
|
+
addedAt: row.created_at,
|
|
33
|
+
snippet: row.content_snippet,
|
|
34
|
+
imageUrl: row.image_url,
|
|
35
|
+
hasFullText: Boolean(row.full_content_html),
|
|
36
|
+
content: contentMetadata(row),
|
|
37
|
+
...(withContent ? { contentHtml: row.content_html, fullTextHtml: row.full_content_html } : {}),
|
|
38
|
+
state: {
|
|
39
|
+
read: Boolean(row.read),
|
|
40
|
+
saved: Boolean(row.bookmarked),
|
|
41
|
+
savedAt: row.bookmarked_at ?? null,
|
|
42
|
+
archivedAt: row.archived_at ?? null,
|
|
43
|
+
progress: progress.get(row.id) ?? null,
|
|
44
|
+
hasNote,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=serialize.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const AVATAR_MAX_BYTES = 512 * 1024;
|
|
2
|
+
/** Identify an uploaded image by its signature instead of trusting the declared type. */
|
|
3
|
+
export function detectAvatarType(data) {
|
|
4
|
+
if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff)
|
|
5
|
+
return "image/jpeg";
|
|
6
|
+
if (data.length >= 8 && [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((byte, index) => data[index] === byte))
|
|
7
|
+
return "image/png";
|
|
8
|
+
const ascii = (start, end) => String.fromCharCode(...data.subarray(start, end));
|
|
9
|
+
if (data.length >= 12 && ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP")
|
|
10
|
+
return "image/webp";
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=avatar.js.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
class DefaultCapabilitiesProvider {
|
|
2
|
+
getCapabilitiesForUser(_userId) {
|
|
3
|
+
return {
|
|
4
|
+
type: "selfhosted",
|
|
5
|
+
features: {
|
|
6
|
+
mcp: true,
|
|
7
|
+
sync: true,
|
|
8
|
+
notes: true,
|
|
9
|
+
editions: true,
|
|
10
|
+
fulltext: true,
|
|
11
|
+
"search.fts": true,
|
|
12
|
+
inbox: true,
|
|
13
|
+
},
|
|
14
|
+
manageUrl: null,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
let activeCapabilitiesProvider = new DefaultCapabilitiesProvider();
|
|
19
|
+
export function setCapabilitiesProvider(provider) {
|
|
20
|
+
activeCapabilitiesProvider = provider;
|
|
21
|
+
}
|
|
22
|
+
export function getCapabilitiesProvider() {
|
|
23
|
+
return activeCapabilitiesProvider;
|
|
24
|
+
}
|
|
25
|
+
export function getUserCapabilities(userId) {
|
|
26
|
+
return activeCapabilitiesProvider.getCapabilitiesForUser(userId);
|
|
27
|
+
}
|
|
28
|
+
/** The advertised feature map is also the authority for enforcement. Missing keys deny access. */
|
|
29
|
+
export function hasUserCapability(userId, capability) {
|
|
30
|
+
return getUserCapabilities(userId).features[capability] === true;
|
|
31
|
+
}
|
|
32
|
+
export function capabilityDenial(userId, capability) {
|
|
33
|
+
return {
|
|
34
|
+
error: "capability_not_available",
|
|
35
|
+
capability,
|
|
36
|
+
manageUrl: getUserCapabilities(userId).manageUrl ?? null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function requireCapability(capability) {
|
|
40
|
+
return (req, res, next) => {
|
|
41
|
+
const userId = req.user?.id;
|
|
42
|
+
if (!userId) {
|
|
43
|
+
res.status(401).json({ error: "not_authenticated" });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!hasUserCapability(userId, capability)) {
|
|
47
|
+
res.status(403).json(capabilityDenial(userId, capability));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
next();
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=capabilities.js.map
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { resolveToken } from "./tokens.js";
|
|
2
|
+
import { findUserById, toPublicUser } from "./users.js";
|
|
3
|
+
import { sessionIsSecure } from "./security.js";
|
|
4
|
+
import { RESOURCE_METADATA_URL } from "../oauth/constants.js";
|
|
5
|
+
// Web UI auth: cookie session set at login.
|
|
6
|
+
export function requireSession(req, res, next) {
|
|
7
|
+
const userId = req.session?.userId;
|
|
8
|
+
if (!userId || !sessionIsSecure(req, userId)) {
|
|
9
|
+
res.status(401).json({ error: "not_authenticated" });
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const user = findUserById(userId);
|
|
13
|
+
if (!user) {
|
|
14
|
+
res.status(401).json({ error: "not_authenticated" });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
req.user = toPublicUser(user);
|
|
18
|
+
next();
|
|
19
|
+
}
|
|
20
|
+
function bearerToken(req) {
|
|
21
|
+
const header = req.headers.authorization;
|
|
22
|
+
return header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : undefined;
|
|
23
|
+
}
|
|
24
|
+
// MCP auth: Bearer personal access token or OAuth access token (not a paired device), scopes the request to its owner.
|
|
25
|
+
export function requireBearerToken(req, res, next) {
|
|
26
|
+
authenticateBearer(["api", "oauth"], RESOURCE_METADATA_URL)(req, res, next);
|
|
27
|
+
}
|
|
28
|
+
// Native app API: a paired device token or a hand-made token; the web UI may call it with its session.
|
|
29
|
+
export function requireSessionOrDevice(req, res, next) {
|
|
30
|
+
if (bearerToken(req))
|
|
31
|
+
authenticateBearer(["api", "device"])(req, res, next);
|
|
32
|
+
else
|
|
33
|
+
requireSession(req, res, next);
|
|
34
|
+
}
|
|
35
|
+
/** `resourceMetadata` adds the RFC 9728 challenge that tells OAuth clients where to sign in. */
|
|
36
|
+
export function authenticateBearer(kinds, resourceMetadata) {
|
|
37
|
+
return (req, res, next) => {
|
|
38
|
+
bearerAuth(req, res, next, kinds, resourceMetadata);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function bearerAuth(req, res, next, kinds, resourceMetadata) {
|
|
42
|
+
const reject = (error) => {
|
|
43
|
+
if (resourceMetadata) {
|
|
44
|
+
const detail = error === "invalid_token" ? `error="invalid_token", ` : "";
|
|
45
|
+
res.setHeader("WWW-Authenticate", `Bearer ${detail}resource_metadata="${resourceMetadata}"`);
|
|
46
|
+
}
|
|
47
|
+
res.status(401).json({ error });
|
|
48
|
+
};
|
|
49
|
+
const token = bearerToken(req);
|
|
50
|
+
if (!token) {
|
|
51
|
+
reject("missing_token");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const resolved = resolveToken(token);
|
|
55
|
+
if (!resolved || !kinds.includes(resolved.kind)) {
|
|
56
|
+
reject("invalid_token");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const user = findUserById(resolved.userId);
|
|
60
|
+
if (!user) {
|
|
61
|
+
reject("invalid_token");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
req.user = toPublicUser(user);
|
|
65
|
+
req.tokenScope = resolved.scope;
|
|
66
|
+
req.tokenId = resolved.tokenId;
|
|
67
|
+
req.tokenKind = resolved.kind;
|
|
68
|
+
next();
|
|
69
|
+
}
|
|
70
|
+
export function requireAdmin(req, res, next) {
|
|
71
|
+
if (req.user?.role !== "admin") {
|
|
72
|
+
res.status(403).json({ error: "forbidden" });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
next();
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=middleware.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { db } from "../db/index.js";
|
|
3
|
+
import { createPersonalAccessToken } from "./tokens.js";
|
|
4
|
+
const CODE_TTL_MS = 10 * 60 * 1000;
|
|
5
|
+
// Without 0, O, 1, I and L, so a code can be read out or typed by hand.
|
|
6
|
+
const ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
|
7
|
+
const CODE_LENGTH = 12;
|
|
8
|
+
function normalize(code) {
|
|
9
|
+
return code.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
10
|
+
}
|
|
11
|
+
function hashCode(code) {
|
|
12
|
+
return createHash("sha256").update(normalize(code)).digest("hex");
|
|
13
|
+
}
|
|
14
|
+
/** A fresh single-use code for the user (about 60 bits); it replaces any code they still had. */
|
|
15
|
+
export function createPairingCode(userId) {
|
|
16
|
+
const bytes = randomBytes(CODE_LENGTH);
|
|
17
|
+
const raw = Array.from(bytes, (byte) => ALPHABET[byte % ALPHABET.length]).join("");
|
|
18
|
+
const code = raw.match(/.{4}/g).join("-");
|
|
19
|
+
const expiresAt = new Date(Date.now() + CODE_TTL_MS).toISOString();
|
|
20
|
+
db.prepare(`INSERT INTO pairing_codes (user_id, code_hash, expires_at) VALUES (?, ?, ?)
|
|
21
|
+
ON CONFLICT(user_id) DO UPDATE SET code_hash = excluded.code_hash, expires_at = excluded.expires_at, created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`).run(userId, hashCode(code), expiresAt);
|
|
22
|
+
return { code, expiresAt };
|
|
23
|
+
}
|
|
24
|
+
/** Exchange a valid code for a device token. A code works once; expired and unknown codes give null. */
|
|
25
|
+
export function redeemPairingCode(code, device) {
|
|
26
|
+
return db.transaction(() => {
|
|
27
|
+
db.prepare("DELETE FROM pairing_codes WHERE expires_at <= ?").run(new Date().toISOString());
|
|
28
|
+
const row = db.prepare("SELECT user_id FROM pairing_codes WHERE code_hash = ?").get(hashCode(code));
|
|
29
|
+
if (!row)
|
|
30
|
+
return null;
|
|
31
|
+
db.prepare("DELETE FROM pairing_codes WHERE user_id = ?").run(row.user_id);
|
|
32
|
+
const { id, token } = createPersonalAccessToken(row.user_id, device.name, "write", { platform: device.platform, appVersion: device.appVersion });
|
|
33
|
+
return { userId: row.user_id, deviceId: id, token };
|
|
34
|
+
})();
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=pairing.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import bcrypt from "bcryptjs";
|
|
3
|
+
const SALT_ROUNDS = process.env.NODE_ENV === "test" ? 4 : 12;
|
|
4
|
+
export function hashPassword(plain) {
|
|
5
|
+
return bcrypt.hashSync(plain, SALT_ROUNDS);
|
|
6
|
+
}
|
|
7
|
+
export function verifyPassword(plain, hash) {
|
|
8
|
+
return bcrypt.compareSync(plain, hash);
|
|
9
|
+
}
|
|
10
|
+
// A hash nobody knows the password for. Checking it costs what checking a real one costs.
|
|
11
|
+
let unknownAccountHash;
|
|
12
|
+
/**
|
|
13
|
+
* Like verifyPassword for a sign-in, where the account may not exist: an unknown address takes as long as a known
|
|
14
|
+
* one, so the response time does not tell whether an account is registered.
|
|
15
|
+
*/
|
|
16
|
+
export function verifyPasswordOrDummy(plain, hash) {
|
|
17
|
+
unknownAccountHash ??= bcrypt.hashSync(randomBytes(16).toString("hex"), SALT_ROUNDS);
|
|
18
|
+
const matches = bcrypt.compareSync(plain, hash ?? unknownAccountHash);
|
|
19
|
+
return hash !== undefined && matches;
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=password.js.map
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { TOTP, Secret } from "otpauth";
|
|
3
|
+
import { db } from "../db/index.js";
|
|
4
|
+
import { config } from "../config.js";
|
|
5
|
+
export const opaque = () => randomBytes(32).toString("base64url");
|
|
6
|
+
const hash = (text) => createHash("sha256").update(text).digest("hex");
|
|
7
|
+
const encryptionKey = () => createHash("sha256").update(`feedkeeper-totp:${config.sessionSecret}`).digest();
|
|
8
|
+
export function seal(text) {
|
|
9
|
+
const iv = randomBytes(12);
|
|
10
|
+
const cipher = createCipheriv("aes-256-gcm", encryptionKey(), iv);
|
|
11
|
+
const ciphertext = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
|
|
12
|
+
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString("base64url");
|
|
13
|
+
}
|
|
14
|
+
function unseal(text) {
|
|
15
|
+
const data = Buffer.from(text, "base64url");
|
|
16
|
+
const decipher = createDecipheriv("aes-256-gcm", encryptionKey(), data.subarray(0, 12));
|
|
17
|
+
decipher.setAuthTag(data.subarray(12, 28));
|
|
18
|
+
return Buffer.concat([decipher.update(data.subarray(28)), decipher.final()]).toString("utf8");
|
|
19
|
+
}
|
|
20
|
+
export function securityAccount(userId) {
|
|
21
|
+
db.prepare("INSERT OR IGNORE INTO account_security (user_id, user_handle) VALUES (?, ?)").run(userId, opaque());
|
|
22
|
+
return db.prepare("SELECT * FROM account_security WHERE user_id = ?").get(userId);
|
|
23
|
+
}
|
|
24
|
+
export function securityVersion(userId) {
|
|
25
|
+
return db.prepare("SELECT version FROM account_security WHERE user_id = ?").get(userId)?.version ?? 0;
|
|
26
|
+
}
|
|
27
|
+
export function sessionIsSecure(req, userId) {
|
|
28
|
+
return (req.session?.securityVersion ?? 0) === securityVersion(userId);
|
|
29
|
+
}
|
|
30
|
+
export function hasTotp(userId) { return Boolean(securityAccount(userId).totp_secret); }
|
|
31
|
+
export function securityBinding(req, channel) {
|
|
32
|
+
if (channel === "native-login")
|
|
33
|
+
return channel;
|
|
34
|
+
if (req.headers.authorization)
|
|
35
|
+
return hash(`${channel}:${req.headers.authorization}`);
|
|
36
|
+
req.session ??= {};
|
|
37
|
+
req.session.securityBinding ??= opaque();
|
|
38
|
+
return hash(`${channel}:${req.session.securityBinding}`);
|
|
39
|
+
}
|
|
40
|
+
export function issueSecurityChallenge(purpose, userId, binding, payload, ttl = 5 * 60_000) {
|
|
41
|
+
db.prepare("DELETE FROM security_challenges WHERE expires_at <= ?").run(Date.now());
|
|
42
|
+
const id = opaque();
|
|
43
|
+
db.prepare("INSERT INTO security_challenges (id_hash, purpose, user_id, binding, version, payload, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
|
44
|
+
.run(hash(id), purpose, userId, binding, userId ? securityVersion(userId) : 0, JSON.stringify(payload), Date.now() + ttl);
|
|
45
|
+
return id;
|
|
46
|
+
}
|
|
47
|
+
/** Count attempts before checking the factor, including requests that end in an error. */
|
|
48
|
+
export function readSecurityChallenge(id, purpose, binding, consume = false) {
|
|
49
|
+
if (typeof id !== "string" || !/^[\w-]{43}$/.test(id))
|
|
50
|
+
return null;
|
|
51
|
+
return db.transaction(() => {
|
|
52
|
+
const row = db.prepare("SELECT * FROM security_challenges WHERE id_hash = ?").get(hash(id));
|
|
53
|
+
if (!row || row.purpose !== purpose || row.binding !== binding || row.expires_at <= Date.now() || row.attempts >= 15 || (row.user_id && row.version !== securityVersion(row.user_id)))
|
|
54
|
+
return null;
|
|
55
|
+
if (consume)
|
|
56
|
+
db.prepare("DELETE FROM security_challenges WHERE id_hash = ?").run(hash(id));
|
|
57
|
+
else
|
|
58
|
+
db.prepare("UPDATE security_challenges SET attempts = attempts + 1 WHERE id_hash = ?").run(hash(id));
|
|
59
|
+
return row;
|
|
60
|
+
})();
|
|
61
|
+
}
|
|
62
|
+
export function discardSecurityChallenge(id) { db.prepare("DELETE FROM security_challenges WHERE id_hash = ?").run(hash(id)); }
|
|
63
|
+
export function totpFor(secret, label = "FeedKeeper") {
|
|
64
|
+
return new TOTP({ issuer: "FeedKeeper", label, algorithm: "SHA1", digits: 6, period: 30, secret: secret ? Secret.fromBase32(secret) : new Secret({ size: 20 }) });
|
|
65
|
+
}
|
|
66
|
+
export function verifySecondFactor(userId, code) {
|
|
67
|
+
if (typeof code !== "string" || code.length > 80)
|
|
68
|
+
return false;
|
|
69
|
+
return db.transaction(() => {
|
|
70
|
+
const account = securityAccount(userId);
|
|
71
|
+
if (!account.totp_secret)
|
|
72
|
+
return false;
|
|
73
|
+
if (/^\d{6}$/.test(code)) {
|
|
74
|
+
let delta;
|
|
75
|
+
try {
|
|
76
|
+
delta = totpFor(unseal(account.totp_secret)).validate({ token: code, window: 1 });
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
if (delta === null)
|
|
82
|
+
return false;
|
|
83
|
+
const step = Math.floor(Date.now() / 30_000) + delta;
|
|
84
|
+
return db.prepare("UPDATE account_security SET totp_last_step = ? WHERE user_id = ? AND totp_last_step < ?").run(step, userId, step).changes === 1;
|
|
85
|
+
}
|
|
86
|
+
const normalized = code.toUpperCase().replace(/[ -]/g, "");
|
|
87
|
+
if (!/^[A-F0-9]{24}$/.test(normalized))
|
|
88
|
+
return false;
|
|
89
|
+
return db.prepare("DELETE FROM recovery_codes WHERE user_id = ? AND code_hash = ?").run(userId, hash(normalized)).changes === 1;
|
|
90
|
+
})();
|
|
91
|
+
}
|
|
92
|
+
export function newRecoveryCodes(userId) {
|
|
93
|
+
db.prepare("DELETE FROM recovery_codes WHERE user_id = ?").run(userId);
|
|
94
|
+
const codes = Array.from({ length: 10 }, () => randomBytes(12).toString("hex").toUpperCase());
|
|
95
|
+
for (const code of codes)
|
|
96
|
+
db.prepare("INSERT INTO recovery_codes (user_id, code_hash) VALUES (?, ?)").run(userId, hash(code));
|
|
97
|
+
return codes.map(code => code.match(/.{6}/g).join("-"));
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* A new password ends every browser session and pending sign-in that knew the old one. Access tokens, paired
|
|
101
|
+
* devices and connected apps are the person's own and keep working; they can be removed one by one.
|
|
102
|
+
*/
|
|
103
|
+
export function revokeBrowserSessions(userId) {
|
|
104
|
+
securityAccount(userId);
|
|
105
|
+
db.prepare("UPDATE account_security SET version = version + 1 WHERE user_id = ?").run(userId);
|
|
106
|
+
db.prepare("DELETE FROM security_challenges WHERE user_id = ?").run(userId);
|
|
107
|
+
}
|
|
108
|
+
/** Enabling MFA invalidates older password-only sessions, tokens, pairing codes and pending logins. */
|
|
109
|
+
export function revokeSecuritySessions(userId) {
|
|
110
|
+
db.prepare("UPDATE account_security SET version = version + 1 WHERE user_id = ?").run(userId);
|
|
111
|
+
db.prepare("DELETE FROM personal_access_tokens WHERE user_id = ?").run(userId);
|
|
112
|
+
db.prepare("DELETE FROM pairing_codes WHERE user_id = ?").run(userId);
|
|
113
|
+
db.prepare("DELETE FROM security_challenges WHERE user_id = ?").run(userId);
|
|
114
|
+
db.prepare("DELETE FROM oauth_grants WHERE user_id = ?").run(userId);
|
|
115
|
+
db.prepare("DELETE FROM oauth_codes WHERE user_id = ?").run(userId);
|
|
116
|
+
}
|
|
117
|
+
export function beginMfa(req, userId, channel, context) {
|
|
118
|
+
if (!hasTotp(userId))
|
|
119
|
+
return null;
|
|
120
|
+
const challengeId = issueSecurityChallenge("mfa", userId, securityBinding(req, `${channel}-login`), context);
|
|
121
|
+
if (channel === "web") {
|
|
122
|
+
req.session.pendingMfa = challengeId;
|
|
123
|
+
}
|
|
124
|
+
return { error: "mfa_required", challengeId, methods: ["totp", "recovery"], expiresAt: new Date(Date.now() + 5 * 60_000).toISOString() };
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=security.js.map
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import rateLimit from "express-rate-limit";
|
|
3
|
+
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server";
|
|
4
|
+
import { db } from "../db/index.js";
|
|
5
|
+
import { config } from "../config.js";
|
|
6
|
+
import { findUserById } from "./users.js";
|
|
7
|
+
import { verifyPassword } from "./password.js";
|
|
8
|
+
import { requireSessionOrDevice } from "./middleware.js";
|
|
9
|
+
import { discardSecurityChallenge, hasTotp, issueSecurityChallenge, newRecoveryCodes, readSecurityChallenge, revokeSecuritySessions, seal, securityAccount, securityBinding, securityVersion, totpFor, verifySecondFactor } from "./security.js";
|
|
10
|
+
export function createSecurityRouter(options) {
|
|
11
|
+
const router = Router();
|
|
12
|
+
const origin = new URL(options.publicUrl ?? config.publicUrl).origin;
|
|
13
|
+
const rpID = new URL(origin).hostname;
|
|
14
|
+
const passkeysEnabled = origin.startsWith("https:") || rpID === "localhost" || rpID === "127.0.0.1";
|
|
15
|
+
const channel = options.channel ?? "web";
|
|
16
|
+
const binding = (req) => securityBinding(req, `${channel}-login`);
|
|
17
|
+
const managementBinding = (req) => securityBinding(req, `${channel}-management`);
|
|
18
|
+
const invalid = (res) => { res.status(400).json({ error: "invalid_or_expired_code" }); };
|
|
19
|
+
const limiter = rateLimit({ windowMs: 5 * 60_000, limit: 120, standardHeaders: true, legacyHeaders: false, message: { error: "too_many_requests" } });
|
|
20
|
+
router.use((_req, res, next) => { res.setHeader("Cache-Control", "private, no-store"); next(); });
|
|
21
|
+
router.use((req, res, next) => {
|
|
22
|
+
if (req.method !== "GET" && (req.headers["sec-fetch-site"] === "cross-site" || (req.headers.origin && req.headers.origin !== origin))) {
|
|
23
|
+
res.status(403).json({ error: "invalid_origin" });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
next();
|
|
27
|
+
});
|
|
28
|
+
router.use(limiter);
|
|
29
|
+
const management = [options.authenticate ?? requireSessionOrDevice, (req, res, next) => {
|
|
30
|
+
if (req.tokenScope === "read" || (req.tokenKind && req.tokenKind !== "device")) {
|
|
31
|
+
res.status(403).json({ error: "forbidden" });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
next();
|
|
35
|
+
}];
|
|
36
|
+
const proof = (req) => {
|
|
37
|
+
const row = readSecurityChallenge(req.body?.reauthToken, "reauth", managementBinding(req), true);
|
|
38
|
+
return row?.user_id === req.user.id;
|
|
39
|
+
};
|
|
40
|
+
const issueProof = (req, res) => res.json({ reauthToken: issueSecurityChallenge("reauth", req.user.id, managementBinding(req), {}) });
|
|
41
|
+
const list = (userId) => db.prepare("SELECT id, name, created_at, last_used_at FROM passkeys WHERE user_id = ? ORDER BY created_at").all(userId);
|
|
42
|
+
router.get("/config", (_req, res) => res.json({ passkeys: passkeysEnabled, totp: true }));
|
|
43
|
+
router.get("/pending", (req, res) => res.json({ challengeId: req.session?.pendingMfa ?? null }));
|
|
44
|
+
router.post("/mfa", (req, res) => {
|
|
45
|
+
const row = readSecurityChallenge(req.body?.challengeId, "mfa", binding(req));
|
|
46
|
+
if (!row?.user_id || !verifySecondFactor(row.user_id, req.body?.code)) {
|
|
47
|
+
invalid(res);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
discardSecurityChallenge(req.body.challengeId);
|
|
51
|
+
options.completeLogin(req, res, row.user_id, JSON.parse(row.payload));
|
|
52
|
+
});
|
|
53
|
+
router.post("/passkeys/options", async (req, res) => {
|
|
54
|
+
if (!passkeysEnabled) {
|
|
55
|
+
res.status(503).json({ error: "https_required" });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const context = options.loginContext?.(req) ?? (channel === "web" ? { remember: req.body?.remember === true } : null);
|
|
59
|
+
if (context === null) {
|
|
60
|
+
res.status(400).json({ error: "invalid_input" });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const publicKey = await generateAuthenticationOptions({ rpID, userVerification: "required" });
|
|
64
|
+
const challengeId = issueSecurityChallenge("passkey-login", null, binding(req), { challenge: publicKey.challenge, context });
|
|
65
|
+
res.json({ challengeId, publicKey });
|
|
66
|
+
});
|
|
67
|
+
async function assertion(req, purpose, keyBinding) {
|
|
68
|
+
const row = readSecurityChallenge(req.body?.challengeId, purpose, keyBinding, true);
|
|
69
|
+
if (!row)
|
|
70
|
+
return null;
|
|
71
|
+
const credential = req.body?.credential;
|
|
72
|
+
if (typeof credential?.id !== "string")
|
|
73
|
+
return null;
|
|
74
|
+
const saved = db.prepare("SELECT * FROM passkeys WHERE id = ?").get(credential.id);
|
|
75
|
+
if (!saved || (row.user_id && saved.user_id !== row.user_id))
|
|
76
|
+
return null;
|
|
77
|
+
const handle = credential.response?.userHandle;
|
|
78
|
+
// Discoverable login must identify the account. A scoped reauthentication may omit the handle.
|
|
79
|
+
if ((!row.user_id && !handle) || (handle != null && handle !== securityAccount(saved.user_id).user_handle))
|
|
80
|
+
return null;
|
|
81
|
+
const version = securityVersion(saved.user_id);
|
|
82
|
+
try {
|
|
83
|
+
const result = await verifyAuthenticationResponse({ response: credential, expectedChallenge: JSON.parse(row.payload).challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true,
|
|
84
|
+
credential: { id: saved.id, publicKey: new Uint8Array(saved.public_key), counter: saved.counter, transports: JSON.parse(saved.transports) } });
|
|
85
|
+
if (!result.verified || version !== securityVersion(saved.user_id))
|
|
86
|
+
return null;
|
|
87
|
+
// Avoid accepting two assertions against the same older counter after asynchronous verification.
|
|
88
|
+
const updated = db.prepare("UPDATE passkeys SET counter = ?, last_used_at = ? WHERE id = ? AND counter = ?").run(result.authenticationInfo.newCounter, new Date().toISOString(), saved.id, saved.counter);
|
|
89
|
+
if (!updated.changes)
|
|
90
|
+
return null;
|
|
91
|
+
return { userId: saved.user_id, payload: JSON.parse(row.payload) };
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
router.post("/passkeys/verify", async (req, res) => {
|
|
98
|
+
const result = await assertion(req, "passkey-login", binding(req));
|
|
99
|
+
if (!result) {
|
|
100
|
+
invalid(res);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
options.completeLogin(req, res, result.userId, result.payload.context);
|
|
104
|
+
});
|
|
105
|
+
router.get("/", ...management, (req, res) => res.json({ totpEnabled: hasTotp(req.user.id), passkeysEnabled, emailReauthEnabled: options.emailReauthEnabled?.(req.user.id) === true, passkeys: list(req.user.id), recoveryCodesRemaining: db.prepare("SELECT COUNT(*) AS n FROM recovery_codes WHERE user_id = ?").get(req.user.id).n }));
|
|
106
|
+
router.post("/reauth", ...management, (req, res) => {
|
|
107
|
+
const user = findUserById(req.user.id);
|
|
108
|
+
if (typeof req.body?.password !== "string" || req.body.password.length > 1024 || !verifyPassword(req.body.password, user.password_hash) || (hasTotp(user.id) && !verifySecondFactor(user.id, req.body.code))) {
|
|
109
|
+
res.status(401).json({ error: "invalid_credentials" });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
issueProof(req, res);
|
|
113
|
+
});
|
|
114
|
+
router.post("/reauth/passkey/options", ...management, async (req, res) => {
|
|
115
|
+
if (!passkeysEnabled) {
|
|
116
|
+
res.status(503).json({ error: "https_required" });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const publicKey = await generateAuthenticationOptions({ rpID, userVerification: "required", allowCredentials: list(req.user.id).map(key => ({ id: key.id })) });
|
|
120
|
+
res.json({ challengeId: issueSecurityChallenge("passkey-reauth", req.user.id, managementBinding(req), { challenge: publicKey.challenge }), publicKey });
|
|
121
|
+
});
|
|
122
|
+
router.post("/reauth/passkey/verify", ...management, async (req, res) => {
|
|
123
|
+
const result = await assertion(req, "passkey-reauth", managementBinding(req));
|
|
124
|
+
if (!result || result.userId !== req.user.id) {
|
|
125
|
+
invalid(res);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
issueProof(req, res);
|
|
129
|
+
});
|
|
130
|
+
router.post("/passkeys/register/options", ...management, async (req, res) => {
|
|
131
|
+
if (!passkeysEnabled) {
|
|
132
|
+
res.status(503).json({ error: "https_required" });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!proof(req)) {
|
|
136
|
+
res.status(401).json({ error: "reauth_required" });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const user = findUserById(req.user.id);
|
|
140
|
+
if (list(user.id).length >= 20) {
|
|
141
|
+
res.status(400).json({ error: "passkey_limit" });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const publicKey = await generateRegistrationOptions({ rpID, rpName: "FeedKeeper", userName: user.email, userDisplayName: user.display_name, userID: Buffer.from(securityAccount(user.id).user_handle, "base64url"), attestationType: "none", excludeCredentials: list(user.id).map(key => ({ id: key.id })), authenticatorSelection: { residentKey: "required", userVerification: "required" } });
|
|
145
|
+
res.json({ challengeId: issueSecurityChallenge("passkey-register", user.id, managementBinding(req), { challenge: publicKey.challenge }), publicKey });
|
|
146
|
+
});
|
|
147
|
+
router.post("/passkeys/register/verify", ...management, async (req, res) => {
|
|
148
|
+
const row = readSecurityChallenge(req.body?.challengeId, "passkey-register", managementBinding(req), true);
|
|
149
|
+
if (row?.user_id !== req.user.id || typeof req.body?.name !== "string" || !req.body.name.trim() || req.body.name.length > 100) {
|
|
150
|
+
invalid(res);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const result = await verifyRegistrationResponse({ response: req.body.credential, expectedChallenge: JSON.parse(row.payload).challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true });
|
|
155
|
+
if (!result.verified || !result.registrationInfo || row.version !== securityVersion(req.user.id)) {
|
|
156
|
+
invalid(res);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (list(req.user.id).length >= 20) {
|
|
160
|
+
res.status(400).json({ error: "passkey_limit" });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const key = result.registrationInfo.credential;
|
|
164
|
+
const transports = key.transports ?? [];
|
|
165
|
+
db.prepare("INSERT INTO passkeys (id, user_id, public_key, counter, transports, name, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)").run(key.id, req.user.id, Buffer.from(key.publicKey), key.counter, JSON.stringify(transports), req.body.name.trim(), new Date().toISOString());
|
|
166
|
+
res.status(201).json({ passkeys: list(req.user.id) });
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
invalid(res);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
router.delete("/passkeys/:id", ...management, (req, res) => {
|
|
173
|
+
if (!proof(req)) {
|
|
174
|
+
res.status(401).json({ error: "reauth_required" });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// A host may have passwordless accounts: never remove the final usable passkey through this endpoint.
|
|
178
|
+
if (list(req.user.id).length <= 1 && !(options.canRemoveLastPasskey?.(req.user.id) ?? true)) {
|
|
179
|
+
res.status(409).json({ error: "last_passkey" });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
db.prepare("DELETE FROM passkeys WHERE id = ? AND user_id = ?").run(String(req.params.id), req.user.id);
|
|
183
|
+
res.status(204).end();
|
|
184
|
+
});
|
|
185
|
+
router.post("/totp/setup", ...management, (req, res) => {
|
|
186
|
+
if (!proof(req)) {
|
|
187
|
+
res.status(401).json({ error: "reauth_required" });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (hasTotp(req.user.id)) {
|
|
191
|
+
res.status(409).json({ error: "totp_already_enabled" });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const totp = totpFor(undefined, req.user.email);
|
|
195
|
+
res.json({ challengeId: issueSecurityChallenge("totp-setup", req.user.id, managementBinding(req), { secret: seal(totp.secret.base32) }), secret: totp.secret.base32, uri: totp.toString() });
|
|
196
|
+
});
|
|
197
|
+
router.post("/totp/confirm", ...management, (req, res) => {
|
|
198
|
+
const row = readSecurityChallenge(req.body?.challengeId, "totp-setup", managementBinding(req));
|
|
199
|
+
if (row?.user_id !== req.user.id || typeof req.body?.code !== "string" || !/^\d{6}$/.test(req.body.code) || hasTotp(req.user.id)) {
|
|
200
|
+
invalid(res);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const result = db.transaction(() => {
|
|
204
|
+
db.prepare("UPDATE account_security SET totp_secret = ?, totp_last_step = -1 WHERE user_id = ?").run(JSON.parse(row.payload).secret, row.user_id);
|
|
205
|
+
if (!verifySecondFactor(row.user_id, req.body.code)) {
|
|
206
|
+
db.prepare("UPDATE account_security SET totp_secret = NULL WHERE user_id = ?").run(row.user_id);
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
const recoveryCodes = newRecoveryCodes(row.user_id);
|
|
210
|
+
revokeSecuritySessions(row.user_id);
|
|
211
|
+
// Keep this authenticated browser open so the recovery codes can be saved; other sessions are revoked.
|
|
212
|
+
if (!req.headers.authorization)
|
|
213
|
+
req.session.securityVersion = securityVersion(row.user_id);
|
|
214
|
+
return recoveryCodes;
|
|
215
|
+
})();
|
|
216
|
+
if (!result) {
|
|
217
|
+
invalid(res);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
res.json({ recoveryCodes: result });
|
|
221
|
+
});
|
|
222
|
+
router.post("/totp/disable", ...management, (req, res) => {
|
|
223
|
+
if (!proof(req)) {
|
|
224
|
+
res.status(401).json({ error: "reauth_required" });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
db.transaction(() => {
|
|
228
|
+
db.prepare("UPDATE account_security SET totp_secret = NULL, totp_last_step = -1 WHERE user_id = ?").run(req.user.id);
|
|
229
|
+
db.prepare("DELETE FROM recovery_codes WHERE user_id = ?").run(req.user.id);
|
|
230
|
+
revokeSecuritySessions(req.user.id);
|
|
231
|
+
if (!req.headers.authorization)
|
|
232
|
+
req.session.securityVersion = securityVersion(req.user.id);
|
|
233
|
+
})();
|
|
234
|
+
res.status(204).end();
|
|
235
|
+
});
|
|
236
|
+
router.post("/recovery-codes", ...management, (req, res) => {
|
|
237
|
+
if (!proof(req)) {
|
|
238
|
+
res.status(401).json({ error: "reauth_required" });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (!hasTotp(req.user.id)) {
|
|
242
|
+
res.status(400).json({ error: "totp_required" });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
res.json({ recoveryCodes: db.transaction(() => newRecoveryCodes(req.user.id))() });
|
|
246
|
+
});
|
|
247
|
+
return router;
|
|
248
|
+
}
|
|
249
|
+
//# sourceMappingURL=securityRoutes.js.map
|