mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add token revocation endpoint and enhance ciphers import request structure
This commit is contained in:
@@ -18,9 +18,7 @@ function twoFactorRequiredResponse(message: string = 'Two factor required.'): Re
|
|||||||
error_description: message,
|
error_description: message,
|
||||||
TwoFactorProviders: [0],
|
TwoFactorProviders: [0],
|
||||||
TwoFactorProviders2: {
|
TwoFactorProviders2: {
|
||||||
'0': {
|
'0': null,
|
||||||
Priority: 1,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
ErrorModel: {
|
ErrorModel: {
|
||||||
Message: message,
|
Message: message,
|
||||||
@@ -115,6 +113,10 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
// Optional 2FA: enabled only when TOTP_SECRET is configured in Workers env.
|
// Optional 2FA: enabled only when TOTP_SECRET is configured in Workers env.
|
||||||
let trustedTwoFactorTokenToReturn: string | undefined;
|
let trustedTwoFactorTokenToReturn: string | undefined;
|
||||||
if (isTotpEnabled(env.TOTP_SECRET)) {
|
if (isTotpEnabled(env.TOTP_SECRET)) {
|
||||||
|
if (twoFactorProvider !== undefined && String(twoFactorProvider) !== '0') {
|
||||||
|
return identityErrorResponse('Unsupported two-factor provider', 'invalid_grant', 400);
|
||||||
|
}
|
||||||
|
|
||||||
const rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
|
const rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
|
||||||
|
|
||||||
// Bitwarden may reuse twoFactorToken as a remembered-device token on subsequent logins.
|
// Bitwarden may reuse twoFactorToken as a remembered-device token on subsequent logins.
|
||||||
@@ -142,7 +144,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
429
|
429
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return identityErrorResponse('Invalid two-factor token', 'invalid_grant', 400);
|
return twoFactorRequiredResponse();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,3 +289,30 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
|
|||||||
kdfParallelism: kdfParallelism,
|
kdfParallelism: kdfParallelism,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /identity/connect/revocation
|
||||||
|
// Best-effort OAuth token revocation endpoint.
|
||||||
|
// RFC 7009 allows returning 200 even if token is unknown.
|
||||||
|
export async function handleRevocation(request: Request, env: Env): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
let body: Record<string, string>;
|
||||||
|
const contentType = request.headers.get('content-type') || '';
|
||||||
|
try {
|
||||||
|
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
body = Object.fromEntries(formData.entries()) as Record<string, string>;
|
||||||
|
} else {
|
||||||
|
body = await request.json();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = String(body.token || '').trim();
|
||||||
|
if (token) {
|
||||||
|
await storage.deleteRefreshToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|||||||
+44
-37
@@ -8,10 +8,12 @@ import { LIMITS } from '../config/limits';
|
|||||||
interface CiphersImportRequest {
|
interface CiphersImportRequest {
|
||||||
ciphers: Array<{
|
ciphers: Array<{
|
||||||
type: number;
|
type: number;
|
||||||
name: string;
|
name?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
favorite?: boolean;
|
favorite?: boolean;
|
||||||
reprompt?: number;
|
reprompt?: number;
|
||||||
|
sshKey?: any | null;
|
||||||
|
key?: string | null;
|
||||||
login?: {
|
login?: {
|
||||||
uris?: Array<{ uri: string | null; match?: number | null }> | null;
|
uris?: Array<{ uri: string | null; match?: number | null }> | null;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
@@ -62,6 +64,7 @@ interface CiphersImportRequest {
|
|||||||
password: string;
|
password: string;
|
||||||
lastUsedDate: string;
|
lastUsedDate: string;
|
||||||
}> | null;
|
}> | null;
|
||||||
|
[key: string]: any;
|
||||||
}>;
|
}>;
|
||||||
folders: Array<{
|
folders: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
@@ -153,61 +156,65 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
userId: userId,
|
userId: userId,
|
||||||
type: c.type as CipherType,
|
type: c.type as CipherType,
|
||||||
folderId: folderId,
|
folderId: folderId,
|
||||||
name: c.name || 'Untitled',
|
name: c.name ?? 'Untitled',
|
||||||
notes: c.notes || null,
|
notes: c.notes ?? null,
|
||||||
favorite: c.favorite || false,
|
favorite: c.favorite ?? false,
|
||||||
login: c.login ? {
|
login: c.login ? {
|
||||||
...c.login,
|
...c.login,
|
||||||
username: c.login.username || null,
|
username: c.login.username ?? null,
|
||||||
password: c.login.password || null,
|
password: c.login.password ?? null,
|
||||||
uris: c.login.uris?.map(u => ({
|
uris: c.login.uris?.map(u => ({
|
||||||
uri: u.uri || null,
|
...u,
|
||||||
|
uri: u.uri ?? null,
|
||||||
uriChecksum: null,
|
uriChecksum: null,
|
||||||
match: u.match ?? null,
|
match: u.match ?? null,
|
||||||
})) || null,
|
})) || null,
|
||||||
totp: c.login.totp || null,
|
totp: c.login.totp ?? null,
|
||||||
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
|
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
|
||||||
fido2Credentials: c.login.fido2Credentials ?? null,
|
fido2Credentials: c.login.fido2Credentials ?? null,
|
||||||
uri: c.login.uri ?? null,
|
uri: c.login.uri ?? null,
|
||||||
passwordRevisionDate: c.login.passwordRevisionDate ?? null,
|
passwordRevisionDate: c.login.passwordRevisionDate ?? null,
|
||||||
} : null,
|
} : null,
|
||||||
card: c.card ? {
|
card: c.card ? {
|
||||||
cardholderName: c.card.cardholderName || null,
|
...c.card,
|
||||||
brand: c.card.brand || null,
|
cardholderName: c.card.cardholderName ?? null,
|
||||||
number: c.card.number || null,
|
brand: c.card.brand ?? null,
|
||||||
expMonth: c.card.expMonth || null,
|
number: c.card.number ?? null,
|
||||||
expYear: c.card.expYear || null,
|
expMonth: c.card.expMonth ?? null,
|
||||||
code: c.card.code || null,
|
expYear: c.card.expYear ?? null,
|
||||||
|
code: c.card.code ?? null,
|
||||||
} : null,
|
} : null,
|
||||||
identity: c.identity ? {
|
identity: c.identity ? {
|
||||||
title: c.identity.title || null,
|
...c.identity,
|
||||||
firstName: c.identity.firstName || null,
|
title: c.identity.title ?? null,
|
||||||
middleName: c.identity.middleName || null,
|
firstName: c.identity.firstName ?? null,
|
||||||
lastName: c.identity.lastName || null,
|
middleName: c.identity.middleName ?? null,
|
||||||
address1: c.identity.address1 || null,
|
lastName: c.identity.lastName ?? null,
|
||||||
address2: c.identity.address2 || null,
|
address1: c.identity.address1 ?? null,
|
||||||
address3: c.identity.address3 || null,
|
address2: c.identity.address2 ?? null,
|
||||||
city: c.identity.city || null,
|
address3: c.identity.address3 ?? null,
|
||||||
state: c.identity.state || null,
|
city: c.identity.city ?? null,
|
||||||
postalCode: c.identity.postalCode || null,
|
state: c.identity.state ?? null,
|
||||||
country: c.identity.country || null,
|
postalCode: c.identity.postalCode ?? null,
|
||||||
company: c.identity.company || null,
|
country: c.identity.country ?? null,
|
||||||
email: c.identity.email || null,
|
company: c.identity.company ?? null,
|
||||||
phone: c.identity.phone || null,
|
email: c.identity.email ?? null,
|
||||||
ssn: c.identity.ssn || null,
|
phone: c.identity.phone ?? null,
|
||||||
username: c.identity.username || null,
|
ssn: c.identity.ssn ?? null,
|
||||||
passportNumber: c.identity.passportNumber || null,
|
username: c.identity.username ?? null,
|
||||||
licenseNumber: c.identity.licenseNumber || null,
|
passportNumber: c.identity.passportNumber ?? null,
|
||||||
|
licenseNumber: c.identity.licenseNumber ?? null,
|
||||||
} : null,
|
} : null,
|
||||||
secureNote: c.secureNote || null,
|
secureNote: c.secureNote ?? null,
|
||||||
fields: c.fields?.map(f => ({
|
fields: c.fields?.map(f => ({
|
||||||
name: f.name || null,
|
...f,
|
||||||
value: f.value || null,
|
name: f.name ?? null,
|
||||||
|
value: f.value ?? null,
|
||||||
type: f.type,
|
type: f.type,
|
||||||
linkedId: f.linkedId ?? null,
|
linkedId: f.linkedId ?? null,
|
||||||
})) || null,
|
})) || null,
|
||||||
passwordHistory: c.passwordHistory || null,
|
passwordHistory: c.passwordHistory ?? null,
|
||||||
reprompt: c.reprompt || 0,
|
reprompt: c.reprompt ?? 0,
|
||||||
sshKey: (c as any).sshKey ?? null,
|
sshKey: (c as any).sshKey ?? null,
|
||||||
key: (c as any).key ?? null,
|
key: (c as any).key ?? null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
|||||||
+13
-32
@@ -7,21 +7,6 @@ let dbInitialized = false;
|
|||||||
let dbInitError: string | null = null;
|
let dbInitError: string | null = null;
|
||||||
let dbInitPromise: Promise<void> | null = null;
|
let dbInitPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
function shouldSkipDatabaseInit(request: Request): boolean {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const path = url.pathname;
|
|
||||||
const method = request.method;
|
|
||||||
|
|
||||||
if (method === 'OPTIONS') return true;
|
|
||||||
if (method === 'GET' && (path === '/favicon.ico' || path === '/favicon.svg')) return true;
|
|
||||||
if (method === 'GET' && path === '/.well-known/appspecific/com.chrome.devtools.json') return true;
|
|
||||||
if (method === 'GET' && path.startsWith('/icons/')) return true;
|
|
||||||
if (path.startsWith('/notifications/')) return true;
|
|
||||||
if (method === 'GET' && (path === '/config' || path === '/api/config' || path === '/api/version')) return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
||||||
if (dbInitialized) return;
|
if (dbInitialized) return;
|
||||||
|
|
||||||
@@ -47,24 +32,20 @@ async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
|||||||
export default {
|
export default {
|
||||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||||
void ctx;
|
void ctx;
|
||||||
const requiresDatabase = !shouldSkipDatabaseInit(request);
|
await ensureDatabaseInitialized(env);
|
||||||
|
if (dbInitError) {
|
||||||
if (requiresDatabase) {
|
const resp = jsonResponse(
|
||||||
await ensureDatabaseInitialized(env);
|
{
|
||||||
if (dbInitError) {
|
error: 'Database not initialized',
|
||||||
const resp = jsonResponse(
|
error_description: dbInitError,
|
||||||
{
|
ErrorModel: {
|
||||||
error: 'Database not initialized',
|
Message: dbInitError,
|
||||||
error_description: dbInitError,
|
Object: 'error',
|
||||||
ErrorModel: {
|
|
||||||
Message: dbInitError,
|
|
||||||
Object: 'error',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
500
|
},
|
||||||
);
|
500
|
||||||
return applyCors(request, resp);
|
);
|
||||||
}
|
return applyCors(request, resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await handleRequest(request, env);
|
const resp = await handleRequest(request, env);
|
||||||
|
|||||||
+5
-1
@@ -5,7 +5,7 @@ import { handleCors, errorResponse, jsonResponse } from './utils/response';
|
|||||||
import { LIMITS } from './config/limits';
|
import { LIMITS } from './config/limits';
|
||||||
|
|
||||||
// Identity handlers
|
// Identity handlers
|
||||||
import { handleToken, handlePrelogin } from './handlers/identity';
|
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
||||||
|
|
||||||
// Account handlers
|
// Account handlers
|
||||||
import { handleRegister, handleGetProfile, handleUpdateProfile, handleSetKeys, handleGetRevisionDate, handleVerifyPassword } from './handlers/accounts';
|
import { handleRegister, handleGetProfile, handleUpdateProfile, handleSetKeys, handleGetRevisionDate, handleVerifyPassword } from './handlers/accounts';
|
||||||
@@ -229,6 +229,10 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
return handleToken(request, env);
|
return handleToken(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') {
|
||||||
|
return handleRevocation(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/identity/accounts/prelogin' && method === 'POST') {
|
if (path === '/identity/accounts/prelogin' && method === 'POST') {
|
||||||
return handlePrelogin(request, env);
|
return handlePrelogin(request, env);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-18
@@ -2,7 +2,6 @@ import { User, Cipher, Folder, Attachment, Device } from '../types';
|
|||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const SCHEMA_HASH_CONFIG_KEY = 'schema_hash';
|
|
||||||
|
|
||||||
// IMPORTANT:
|
// IMPORTANT:
|
||||||
// Keep this schema list in sync with migrations/0001_init.sql.
|
// Keep this schema list in sync with migrations/0001_init.sql.
|
||||||
@@ -123,29 +122,15 @@ export class StorageService {
|
|||||||
// --- Database initialization ---
|
// --- Database initialization ---
|
||||||
// Strategy:
|
// Strategy:
|
||||||
// - Run only once per isolate.
|
// - Run only once per isolate.
|
||||||
// - Persist schema hash in DB config; if unchanged, skip all schema SQL.
|
// - Execute idempotent schema SQL on first DB use in each isolate.
|
||||||
// - Keep statements idempotent so updates are safe.
|
// - Keep statements idempotent so updates are safe.
|
||||||
async initializeDatabase(): Promise<void> {
|
async initializeDatabase(): Promise<void> {
|
||||||
if (StorageService.schemaVerified) return;
|
if (StorageService.schemaVerified) return;
|
||||||
|
|
||||||
await this.db.prepare('PRAGMA foreign_keys = ON').run();
|
await this.db.prepare('PRAGMA foreign_keys = ON').run();
|
||||||
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
|
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
|
||||||
|
for (const stmt of SCHEMA_STATEMENTS) {
|
||||||
const schemaHash = await this.sha256Hex(SCHEMA_STATEMENTS.join('\n'));
|
await this.executeSchemaStatement(stmt);
|
||||||
const current = await this.db.prepare('SELECT value FROM config WHERE key = ?')
|
|
||||||
.bind(SCHEMA_HASH_CONFIG_KEY)
|
|
||||||
.first<{ value: string }>();
|
|
||||||
|
|
||||||
if (current?.value !== schemaHash) {
|
|
||||||
for (const stmt of SCHEMA_STATEMENTS) {
|
|
||||||
await this.executeSchemaStatement(stmt);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.db.prepare(
|
|
||||||
'INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
|
||||||
)
|
|
||||||
.bind(SCHEMA_HASH_CONFIG_KEY, schemaHash)
|
|
||||||
.run();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
StorageService.schemaVerified = true;
|
StorageService.schemaVerified = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user