mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-22 21:50:13 +00:00
Add Bitwarden push relay support
This commit is contained in:
@@ -37,7 +37,7 @@
|
||||
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** |
|
||||
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** |
|
||||
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
|
||||
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
|
||||
| 实时同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
|
||||
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
||||
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
||||
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@
|
||||
| **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** |
|
||||
| **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** |
|
||||
| **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** |
|
||||
| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients |
|
||||
| Real-time sync | ✅ | ✅ | Web, browser extension, desktop, and mobile clients stay in sync in real time |
|
||||
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
|
||||
| Send | ✅ | ✅ | Supports both text and file Sends |
|
||||
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
|
||||
|
||||
@@ -176,6 +176,8 @@ CREATE TABLE IF NOT EXISTS devices (
|
||||
encrypted_user_key TEXT,
|
||||
encrypted_public_key TEXT,
|
||||
encrypted_private_key TEXT,
|
||||
push_uuid TEXT,
|
||||
push_token TEXT,
|
||||
banned INTEGER NOT NULL DEFAULT 0,
|
||||
banned_at TEXT,
|
||||
device_note TEXT,
|
||||
@@ -187,6 +189,7 @@ CREATE TABLE IF NOT EXISTS devices (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DurableObject, waitUntil } from 'cloudflare:workers';
|
||||
import type { Env } from '../types';
|
||||
import { notifyMobilePush } from '../services/push-relay';
|
||||
|
||||
const SIGNALR_RECORD_SEPARATOR = 0x1e;
|
||||
const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]);
|
||||
@@ -767,6 +768,16 @@ async function notifyUserUpdate(
|
||||
},
|
||||
}),
|
||||
});
|
||||
await notifyMobilePush(env, {
|
||||
userId,
|
||||
updateType,
|
||||
revisionDate,
|
||||
contextId,
|
||||
payload: payloadOverride || {
|
||||
UserId: userId,
|
||||
Date: revisionDate,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to broadcast realtime notification:', error);
|
||||
}
|
||||
|
||||
+40
-9
@@ -3,6 +3,7 @@ import { Env } from '../types';
|
||||
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { errorResponse, jsonResponse } from '../utils/response';
|
||||
import { readKnownDeviceProbe } from '../utils/device';
|
||||
@@ -223,6 +224,8 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
|
||||
encryptedUserKey: null,
|
||||
encryptedPublicKey: null,
|
||||
encryptedPrivateKey: null,
|
||||
pushUuid: null,
|
||||
pushToken: null,
|
||||
devicePendingAuthRequest: null,
|
||||
deviceNote: null,
|
||||
lastSeenAt: null,
|
||||
@@ -325,10 +328,12 @@ export async function handleDeleteDevice(
|
||||
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const device = await storage.getDevice(userId, normalized);
|
||||
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
||||
const deleted = await storage.deleteDevice(userId, normalized);
|
||||
if (deleted) {
|
||||
await unregisterMobilePushDevice(env, device?.pushUuid);
|
||||
AuthService.invalidateDeviceCache(userId, normalized);
|
||||
notifyUserLogout(env, userId, normalized);
|
||||
}
|
||||
@@ -537,10 +542,12 @@ export async function handleDeactivateDevice(
|
||||
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const device = await storage.getDevice(userId, normalized);
|
||||
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
||||
const deleted = await storage.deleteDevice(userId, normalized);
|
||||
if (deleted) {
|
||||
await unregisterMobilePushDevice(env, device?.pushUuid);
|
||||
AuthService.invalidateDeviceCache(userId, normalized);
|
||||
notifyUserLogout(env, userId, normalized);
|
||||
}
|
||||
@@ -557,18 +564,36 @@ export async function handleDeactivateDevice(
|
||||
}
|
||||
|
||||
// PUT /api/devices/identifier/{deviceIdentifier}/token
|
||||
// Bitwarden mobile reports push token updates to this endpoint.
|
||||
// NodeWarden does not implement push notifications, so accept and no-op.
|
||||
// Bitwarden mobile reports APNs/FCM push token updates to this endpoint.
|
||||
export async function handleUpdateDeviceToken(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<Response> {
|
||||
void request;
|
||||
void env;
|
||||
void userId;
|
||||
void deviceIdentifier;
|
||||
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
const pushToken = String(body?.pushToken ?? body?.PushToken ?? '').trim();
|
||||
if (!pushToken) return errorResponse('Invalid push token', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const device = await storage.getDevice(userId, normalized);
|
||||
if (!device) return errorResponse('Device not found', 404);
|
||||
|
||||
const pushUuid = device.pushUuid || generateUUID();
|
||||
const updated = await storage.updateDevicePushToken(userId, normalized, pushUuid, pushToken);
|
||||
if (updated) {
|
||||
await registerMobilePushDevice(env, {
|
||||
userId,
|
||||
deviceIdentifier: normalized,
|
||||
type: device.type,
|
||||
pushUuid,
|
||||
pushToken,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
@@ -594,9 +619,15 @@ export async function handleClearDeviceToken(
|
||||
deviceIdentifier: string
|
||||
): Promise<Response> {
|
||||
void request;
|
||||
void env;
|
||||
void userId;
|
||||
void deviceIdentifier;
|
||||
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const cleared = await storage.clearDevicePushToken(userId, normalized);
|
||||
if (cleared?.pushUuid) {
|
||||
await unregisterMobilePushDevice(env, cleared.pushUuid);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { readAuthRequestDeviceInfo } from '../utils/device';
|
||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { issueSendAccessToken } from './sends';
|
||||
import { registerMobilePushDevice } from '../services/push-relay';
|
||||
import {
|
||||
buildAccountKeys,
|
||||
buildUserDecryptionOptions,
|
||||
@@ -50,6 +51,44 @@ async function resolveDeviceSession(
|
||||
return { identifier: deviceInfo.deviceIdentifier, sessionStamp };
|
||||
}
|
||||
|
||||
function readDevicePushToken(body: Record<string, string>): string {
|
||||
return String(readBodyValue(body, ['devicePushToken', 'DevicePushToken', 'device_push_token']) || '').trim();
|
||||
}
|
||||
|
||||
async function persistIdentityDevicePushToken(
|
||||
env: Env,
|
||||
storage: StorageService,
|
||||
userId: string,
|
||||
deviceSession: { identifier: string; sessionStamp: string } | null,
|
||||
deviceType: number,
|
||||
body: Record<string, string>
|
||||
): Promise<void> {
|
||||
if (!deviceSession) return;
|
||||
const pushToken = readDevicePushToken(body);
|
||||
if (!pushToken) return;
|
||||
|
||||
const device = await storage.getDevice(userId, deviceSession.identifier);
|
||||
if (!device) return;
|
||||
|
||||
const pushUuid = device.pushUuid || generateUUID();
|
||||
await storage.updateDevicePushToken(userId, deviceSession.identifier, pushUuid, pushToken);
|
||||
const registered = await registerMobilePushDevice(env, {
|
||||
userId,
|
||||
deviceIdentifier: deviceSession.identifier,
|
||||
type: device.type || deviceType,
|
||||
pushUuid,
|
||||
pushToken,
|
||||
});
|
||||
console.info('Mobile push token updated from identity token request', {
|
||||
userId,
|
||||
deviceIdentifier: deviceSession.identifier,
|
||||
deviceType: device.type || deviceType,
|
||||
pushUuid,
|
||||
pushTokenLength: pushToken.length,
|
||||
relayRegistered: registered,
|
||||
});
|
||||
}
|
||||
|
||||
function shouldUseWebSession(request: Request): boolean {
|
||||
return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1';
|
||||
}
|
||||
@@ -414,6 +453,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
deviceInfo.deviceType,
|
||||
deviceSession.sessionStamp
|
||||
);
|
||||
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
|
||||
}
|
||||
|
||||
// Successful login - clear failed attempts
|
||||
@@ -536,6 +576,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
deviceInfo.deviceType,
|
||||
deviceSession.sessionStamp
|
||||
);
|
||||
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
|
||||
}
|
||||
|
||||
await rateLimit.clearLoginAttempts(loginIdentifier);
|
||||
@@ -664,6 +705,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
deviceInfo.deviceType,
|
||||
deviceSession.sessionStamp
|
||||
);
|
||||
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
|
||||
}
|
||||
|
||||
// Successful login - clear failed attempts
|
||||
|
||||
+19
-15
@@ -20,6 +20,10 @@ import {
|
||||
handleClearDeviceToken,
|
||||
} from './handlers/devices';
|
||||
|
||||
function devicesPath(pattern: string): RegExp {
|
||||
return new RegExp(`^/(?:api/)?devices${pattern}$`, 'i');
|
||||
}
|
||||
|
||||
export async function handleAuthenticatedDeviceRoute(
|
||||
request: Request,
|
||||
env: Env,
|
||||
@@ -27,31 +31,31 @@ export async function handleAuthenticatedDeviceRoute(
|
||||
path: string,
|
||||
method: string
|
||||
): Promise<Response | null> {
|
||||
if (path === '/api/devices') {
|
||||
if (path === '/api/devices' || path === '/devices') {
|
||||
if (method === 'GET') return handleGetDevices(request, env, userId);
|
||||
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path === '/api/devices/authorized') {
|
||||
if (path === '/api/devices/authorized' || path === '/devices/authorized') {
|
||||
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
|
||||
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i);
|
||||
const authorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)'));
|
||||
if (authorizedDeviceMatch && method === 'DELETE') {
|
||||
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
|
||||
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const permanentAuthorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)\/permanent$/i);
|
||||
const permanentAuthorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)/permanent'));
|
||||
if (permanentAuthorizedDeviceMatch && method === 'POST') {
|
||||
const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]);
|
||||
return handleTrustDevicePermanently(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
|
||||
const deleteDeviceMatch = path.match(devicesPath('/([^/]+)'));
|
||||
if (deleteDeviceMatch && method === 'GET') {
|
||||
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
|
||||
return handleGetDevice(request, env, userId, deviceIdentifier);
|
||||
@@ -61,59 +65,59 @@ export async function handleAuthenticatedDeviceRoute(
|
||||
return handleDeleteDevice(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i);
|
||||
const updateDeviceNameMatch = path.match(devicesPath('/([^/]+)/name'));
|
||||
if (updateDeviceNameMatch && method === 'PUT') {
|
||||
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
|
||||
return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
|
||||
const identifierMatch = path.match(devicesPath('/identifier/([^/]+)'));
|
||||
if (identifierMatch && method === 'GET') {
|
||||
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
|
||||
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const deviceKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/keys$/i) || path.match(/^\/api\/devices\/identifier\/([^/]+)\/keys$/i);
|
||||
const deviceKeysMatch = path.match(devicesPath('/([^/]+)/keys')) || path.match(devicesPath('/identifier/([^/]+)/keys'));
|
||||
if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) {
|
||||
const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]);
|
||||
return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const identifierTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
|
||||
const identifierTokenMatch = path.match(devicesPath('/identifier/([^/]+)/token'));
|
||||
if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||
const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]);
|
||||
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const identifierWebPushMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/web-push-auth$/i);
|
||||
const identifierWebPushMatch = path.match(devicesPath('/identifier/([^/]+)/web-push-auth'));
|
||||
if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) {
|
||||
const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]);
|
||||
return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const identifierClearTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i);
|
||||
const identifierClearTokenMatch = path.match(devicesPath('/identifier/([^/]+)/clear-token'));
|
||||
if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||
const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]);
|
||||
return handleClearDeviceToken(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const identifierRetrieveKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/retrieve-keys$/i);
|
||||
const identifierRetrieveKeysMatch = path.match(devicesPath('/([^/]+)/retrieve-keys'));
|
||||
if (identifierRetrieveKeysMatch && method === 'POST') {
|
||||
const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]);
|
||||
return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const identifierDeactivateMatch = path.match(/^\/api\/devices\/([^/]+)\/deactivate$/i);
|
||||
const identifierDeactivateMatch = path.match(devicesPath('/([^/]+)/deactivate'));
|
||||
if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) {
|
||||
const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]);
|
||||
return handleDeactivateDevice(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
if (path === '/api/devices/update-trust' && method === 'POST') {
|
||||
if ((path === '/api/devices/update-trust' || path === '/devices/update-trust') && method === 'POST') {
|
||||
return handleUpdateDeviceTrust(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/devices/untrust' && method === 'POST') {
|
||||
if ((path === '/api/devices/untrust' || path === '/devices/untrust') && method === 'POST') {
|
||||
return handleUntrustDevices(request, env, userId);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import type { Env } from '../types';
|
||||
import {
|
||||
setConfigValue as saveConfigValue,
|
||||
} from './storage-config-repo';
|
||||
|
||||
const PUSH_RELAY_URI = 'https://push.bitwarden.com';
|
||||
const PUSH_IDENTITY_URI = 'https://identity.bitwarden.com';
|
||||
const INSTALLATIONS_URI = 'https://api.bitwarden.com/installations';
|
||||
const PUSH_INSTALLATION_ID_KEY = 'push.installation.id';
|
||||
const PUSH_INSTALLATION_KEY_KEY = 'push.installation.key';
|
||||
const PUSH_REQUEST_TIMEOUT_MS = 5000;
|
||||
|
||||
interface CachedPushAccessToken {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
let cachedPushAccessToken: CachedPushAccessToken | null = null;
|
||||
|
||||
async function fetchPushEndpoint(url: string, init: RequestInit, errorMessage: string): Promise<Response | null> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PUSH_REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} catch (error) {
|
||||
console.error(errorMessage, error);
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function randomInstallationEmail(): string {
|
||||
const bytes = new Uint8Array(10);
|
||||
crypto.getRandomValues(bytes);
|
||||
const localPart = Array.from(bytes, (byte) => (byte % 36).toString(36)).join('');
|
||||
return `${localPart}@nodewarden.app`;
|
||||
}
|
||||
|
||||
async function getConfigKeyPresence(db: D1Database, key: string): Promise<string | null> {
|
||||
const row = await db.prepare('SELECT value FROM config WHERE key = ? LIMIT 1').bind(key).first<{ value: string }>();
|
||||
return typeof row?.value === 'string' ? row.value : null;
|
||||
}
|
||||
|
||||
async function getPushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> {
|
||||
const [id, key] = await Promise.all([
|
||||
getConfigKeyPresence(db, PUSH_INSTALLATION_ID_KEY),
|
||||
getConfigKeyPresence(db, PUSH_INSTALLATION_KEY_KEY),
|
||||
]);
|
||||
const normalizedId = String(id || '').trim();
|
||||
const normalizedKey = String(key || '').trim();
|
||||
return normalizedId && normalizedKey ? { id: normalizedId, key: normalizedKey } : null;
|
||||
}
|
||||
|
||||
export async function ensurePushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> {
|
||||
const existing = await getPushInstallationCredentials(db);
|
||||
if (existing) return existing;
|
||||
|
||||
const response = await fetchPushEndpoint(
|
||||
INSTALLATIONS_URI,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'cache-control': 'no-cache',
|
||||
'content-type': 'application/json',
|
||||
origin: 'https://bitwarden.com',
|
||||
pragma: 'no-cache',
|
||||
priority: 'u=1, i',
|
||||
referer: 'https://bitwarden.com/host/',
|
||||
'sec-ch-ua': '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"Windows"',
|
||||
'sec-fetch-dest': 'empty',
|
||||
'sec-fetch-mode': 'cors',
|
||||
'sec-fetch-site': 'same-site',
|
||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
formName: 'request_host',
|
||||
url: '/host/',
|
||||
locale: 'zh-CN',
|
||||
email: randomInstallationEmail(),
|
||||
region: 'us',
|
||||
}),
|
||||
},
|
||||
'Failed to request Bitwarden push installation:'
|
||||
);
|
||||
if (!response) return null;
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to request Bitwarden push installation:', response.status, await response.text().catch(() => ''));
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => null)) as { id?: string; key?: string; enabled?: boolean } | null;
|
||||
const id = String(body?.id || '').trim();
|
||||
const key = String(body?.key || '').trim();
|
||||
if (!id || !key) {
|
||||
console.error('Bitwarden push installation response did not include id/key');
|
||||
return null;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
saveConfigValue(db, PUSH_INSTALLATION_ID_KEY, id),
|
||||
saveConfigValue(db, PUSH_INSTALLATION_KEY_KEY, key),
|
||||
]);
|
||||
return { id, key };
|
||||
}
|
||||
|
||||
async function getPushAccessToken(env: Env): Promise<string | null> {
|
||||
const credentials = await ensurePushInstallationCredentials(env.DB);
|
||||
if (!credentials) return null;
|
||||
|
||||
const now = Date.now();
|
||||
if (cachedPushAccessToken && cachedPushAccessToken.expiresAt > now + 30_000) {
|
||||
return cachedPushAccessToken.token;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
scope: 'api.push',
|
||||
client_id: `installation.${credentials.id}`,
|
||||
client_secret: credentials.key,
|
||||
});
|
||||
|
||||
const response = await fetchPushEndpoint(
|
||||
`${PUSH_IDENTITY_URI}/connect/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params.toString(),
|
||||
},
|
||||
'Failed to get Bitwarden push relay token:'
|
||||
);
|
||||
if (!response) return null;
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to get Bitwarden push relay token:', response.status, await response.text().catch(() => ''));
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => null)) as { access_token?: string; expires_in?: number } | null;
|
||||
const token = String(body?.access_token || '').trim();
|
||||
if (!token) {
|
||||
console.error('Bitwarden push relay token response did not include an access_token');
|
||||
return null;
|
||||
}
|
||||
|
||||
const expiresInSeconds = Math.max(60, Number(body?.expires_in || 3600));
|
||||
cachedPushAccessToken = {
|
||||
token,
|
||||
expiresAt: now + Math.floor(expiresInSeconds * 500),
|
||||
};
|
||||
return token;
|
||||
}
|
||||
|
||||
async function postToPushRelay(env: Env, path: string, body?: unknown): Promise<boolean> {
|
||||
const token = await getPushAccessToken(env);
|
||||
if (!token) return false;
|
||||
|
||||
const response = await fetchPushEndpoint(
|
||||
`${PUSH_RELAY_URI}${path}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
},
|
||||
`Bitwarden push relay request failed: ${path}`
|
||||
);
|
||||
if (!response) return false;
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Bitwarden push relay request failed:', path, response.status, await response.text().catch(() => ''));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function mobilePayloadFromSignalR(updateType: number, userId: string, revisionDate: string, payload: Record<string, unknown> | null | undefined): Record<string, unknown> {
|
||||
const source = payload || {};
|
||||
const id = source.Id ?? source.id;
|
||||
const organizationId = source.OrganizationId ?? source.organizationId ?? null;
|
||||
const collectionIds = source.CollectionIds ?? source.collectionIds ?? null;
|
||||
|
||||
if (id != null) {
|
||||
return {
|
||||
id,
|
||||
userId: source.UserId ?? source.userId ?? userId,
|
||||
organizationId,
|
||||
collectionIds,
|
||||
revisionDate: source.RevisionDate ?? source.revisionDate ?? revisionDate,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
userId: source.UserId ?? source.userId ?? userId,
|
||||
date: source.Date ?? source.date ?? revisionDate,
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerMobilePushDevice(
|
||||
env: Env,
|
||||
input: {
|
||||
userId: string;
|
||||
deviceIdentifier: string;
|
||||
type: number;
|
||||
pushUuid: string;
|
||||
pushToken: string;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const credentials = await ensurePushInstallationCredentials(env.DB);
|
||||
if (!credentials) return false;
|
||||
|
||||
return postToPushRelay(env, '/push/register', {
|
||||
deviceId: input.pushUuid,
|
||||
pushToken: input.pushToken,
|
||||
userId: input.userId,
|
||||
type: input.type,
|
||||
identifier: input.deviceIdentifier,
|
||||
installationId: credentials.id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function unregisterMobilePushDevice(env: Env, pushUuid: string | null | undefined): Promise<boolean> {
|
||||
const normalized = String(pushUuid || '').trim();
|
||||
if (!normalized) return false;
|
||||
return postToPushRelay(env, `/push/delete/${encodeURIComponent(normalized)}`);
|
||||
}
|
||||
|
||||
export async function notifyMobilePush(
|
||||
env: Env,
|
||||
input: {
|
||||
userId: string;
|
||||
updateType: number;
|
||||
revisionDate: string;
|
||||
contextId: string | null;
|
||||
payload: Record<string, unknown> | null | undefined;
|
||||
}
|
||||
): Promise<void> {
|
||||
const hasPushDevice = await env.DB
|
||||
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1')
|
||||
.bind(input.userId, '')
|
||||
.first<{ '1': number }>();
|
||||
if (!hasPushDevice) return;
|
||||
|
||||
let actingPushUuid: string | null = null;
|
||||
if (input.contextId) {
|
||||
const row = await env.DB
|
||||
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||
.bind(input.userId, input.contextId)
|
||||
.first<{ push_uuid: string | null }>();
|
||||
actingPushUuid = row?.push_uuid ?? null;
|
||||
}
|
||||
|
||||
await postToPushRelay(env, '/push/send', {
|
||||
userId: input.userId,
|
||||
organizationId: null,
|
||||
deviceId: actingPushUuid,
|
||||
identifier: input.contextId,
|
||||
type: input.updateType,
|
||||
payload: mobilePayloadFromSignalR(input.updateType, input.userId, input.revisionDate, input.payload),
|
||||
clientType: null,
|
||||
installationId: null,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Device, TrustedDeviceTokenSummary, User } from '../types';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
|
||||
type GetUserByEmail = (email: string) => Promise<User | null>;
|
||||
type TrustedTokenKeyFn = (token: string) => Promise<string>;
|
||||
@@ -14,6 +15,8 @@ function mapDeviceRow(row: any): Device {
|
||||
encryptedUserKey: row.encrypted_user_key ?? null,
|
||||
encryptedPublicKey: row.encrypted_public_key ?? null,
|
||||
encryptedPrivateKey: row.encrypted_private_key ?? null,
|
||||
pushUuid: row.push_uuid ?? null,
|
||||
pushToken: row.push_token ?? null,
|
||||
lastSeenAt: row.last_seen_at ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
@@ -38,13 +41,15 @@ export async function upsertDevice(
|
||||
const existingDevice = await getDeviceById(userId, deviceIdentifier);
|
||||
const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || '';
|
||||
const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim();
|
||||
const effectivePushUuid = String(existingDevice?.pushUuid || '').trim() || generateUUID();
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
|
||||
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
|
||||
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
|
||||
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
|
||||
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
|
||||
'push_uuid=COALESCE(push_uuid, excluded.push_uuid), ' +
|
||||
'last_seen_at=excluded.last_seen_at, ' +
|
||||
'updated_at=excluded.updated_at'
|
||||
)
|
||||
@@ -57,6 +62,7 @@ export async function upsertDevice(
|
||||
keys?.encryptedUserKey ?? null,
|
||||
keys?.encryptedPublicKey ?? null,
|
||||
keys?.encryptedPrivateKey ?? null,
|
||||
effectivePushUuid,
|
||||
existingDevice?.deviceNote ?? null,
|
||||
now,
|
||||
now,
|
||||
@@ -166,7 +172,7 @@ export async function isKnownDeviceByEmail(
|
||||
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
|
||||
const res = await db
|
||||
.prepare(
|
||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
|
||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, push_token, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
|
||||
'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC'
|
||||
)
|
||||
.bind(userId)
|
||||
@@ -177,7 +183,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis
|
||||
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
|
||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, push_token, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
|
||||
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
|
||||
)
|
||||
.bind(userId, deviceIdentifier)
|
||||
@@ -185,6 +191,63 @@ export async function getDevice(db: D1Database, userId: string, deviceIdentifier
|
||||
return row ? mapDeviceRow(row) : null;
|
||||
}
|
||||
|
||||
export async function updateDevicePushToken(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
pushUuid: string,
|
||||
pushToken: string
|
||||
): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare(
|
||||
'UPDATE devices SET push_uuid = ?, push_token = ?, updated_at = ? ' +
|
||||
'WHERE user_id = ? AND device_identifier = ?'
|
||||
)
|
||||
.bind(pushUuid, pushToken, now, userId, deviceIdentifier)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function clearDevicePushToken(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<{ pushUuid: string | null } | null> {
|
||||
const existing = await db
|
||||
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||
.bind(userId, deviceIdentifier)
|
||||
.first<{ push_uuid: string | null }>();
|
||||
if (!existing) return null;
|
||||
|
||||
await db
|
||||
.prepare('UPDATE devices SET push_token = NULL, updated_at = ? WHERE user_id = ? AND device_identifier = ?')
|
||||
.bind(new Date().toISOString(), userId, deviceIdentifier)
|
||||
.run();
|
||||
|
||||
return { pushUuid: existing.push_uuid ?? null };
|
||||
}
|
||||
|
||||
export async function getDevicePushUuid(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<string | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||
.bind(userId, deviceIdentifier)
|
||||
.first<{ push_uuid: string | null }>();
|
||||
return row?.push_uuid ?? null;
|
||||
}
|
||||
|
||||
export async function userHasPushDevice(db: D1Database, userId: string): Promise<boolean> {
|
||||
const row = await db
|
||||
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1')
|
||||
.bind(userId, '')
|
||||
.first<{ '1': number }>();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||
const result = await db
|
||||
.prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?')
|
||||
|
||||
@@ -94,7 +94,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS devices (' +
|
||||
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
|
||||
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, push_uuid TEXT, push_token TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'PRIMARY KEY (user_id, device_identifier), ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
@@ -103,11 +103,14 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN push_uuid TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN push_token TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN device_note TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
|
||||
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS auth_requests (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' +
|
||||
|
||||
+28
-1
@@ -1,5 +1,6 @@
|
||||
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential, AuthRequestRecord } from '../types';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { ensurePushInstallationCredentials } from './push-relay';
|
||||
import { ensureStorageSchema } from './storage-schema';
|
||||
import {
|
||||
getConfigValue as getStoredConfigValue,
|
||||
@@ -87,10 +88,12 @@ import {
|
||||
import {
|
||||
deleteDevice as deleteStoredDevice,
|
||||
deleteDevicesByUserId as deleteStoredDevicesByUserId,
|
||||
clearDevicePushToken as clearStoredDevicePushToken,
|
||||
clearDeviceKeys as clearStoredDeviceKeys,
|
||||
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
|
||||
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
|
||||
getDevice as findStoredDevice,
|
||||
getDevicePushUuid as findStoredDevicePushUuid,
|
||||
getDevicesByUserId as listStoredDevicesByUserId,
|
||||
getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries,
|
||||
getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId,
|
||||
@@ -101,7 +104,9 @@ import {
|
||||
upsertDevice as saveStoredDevice,
|
||||
updateDeviceName as updateStoredDeviceName,
|
||||
updateDeviceKeys as updateStoredDeviceKeys,
|
||||
updateDevicePushToken as updateStoredDevicePushToken,
|
||||
updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice,
|
||||
userHasPushDevice as getUserHasPushDevice,
|
||||
} from './storage-device-repo';
|
||||
import {
|
||||
createAuthRequest as createStoredAuthRequest,
|
||||
@@ -143,7 +148,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
||||
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
||||
// differs from config.schema.version.
|
||||
const STORAGE_SCHEMA_VERSION = '2026-06-12-auth-requests';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-06-22-push-notifications';
|
||||
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const;
|
||||
|
||||
// D1-backed storage.
|
||||
@@ -235,6 +240,7 @@ export class StorageService {
|
||||
await ensureStorageSchema(this.db);
|
||||
await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION);
|
||||
}
|
||||
await ensurePushInstallationCredentials(this.db);
|
||||
|
||||
StorageService.schemaVerified = true;
|
||||
}
|
||||
@@ -713,6 +719,27 @@ export class StorageService {
|
||||
return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
async updateDevicePushToken(
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
pushUuid: string,
|
||||
pushToken: string
|
||||
): Promise<boolean> {
|
||||
return updateStoredDevicePushToken(this.db, userId, deviceIdentifier, pushUuid, pushToken);
|
||||
}
|
||||
|
||||
async clearDevicePushToken(userId: string, deviceIdentifier: string): Promise<{ pushUuid: string | null } | null> {
|
||||
return clearStoredDevicePushToken(this.db, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
async getDevicePushUuid(userId: string, deviceIdentifier: string): Promise<string | null> {
|
||||
return findStoredDevicePushUuid(this.db, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
async userHasPushDevice(userId: string): Promise<boolean> {
|
||||
return getUserHasPushDevice(this.db, userId);
|
||||
}
|
||||
|
||||
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
|
||||
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
|
||||
}
|
||||
|
||||
@@ -231,6 +231,8 @@ export interface Device {
|
||||
encryptedUserKey: string | null;
|
||||
encryptedPublicKey: string | null;
|
||||
encryptedPrivateKey: string | null;
|
||||
pushUuid: string | null;
|
||||
pushToken: string | null;
|
||||
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
|
||||
lastSeenAt: string | null;
|
||||
createdAt: string;
|
||||
|
||||
Reference in New Issue
Block a user