mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: Implement TOTP-based two-factor authentication
- Added TOTP support for two-factor authentication in user profiles and login flows. - Introduced device management endpoints to handle known devices and their registration. - Enhanced database schema to include devices and trusted two-factor tokens. - Updated response handling to include two-factor token in successful login responses. - Modified registration and login pages to guide users through enabling TOTP. - Improved device identification and management utilities for better user experience.
This commit is contained in:
@@ -18,10 +18,10 @@ English:[`README_EN.md`](./README_EN.md)
|
|||||||
| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 |
|
| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 |
|
||||||
| 导入功能 | ✅ | ✅ | 覆盖常见导入路径 |
|
| 导入功能 | ✅ | ✅ | 覆盖常见导入路径 |
|
||||||
| 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` |
|
| 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` |
|
||||||
| 密码条目 TOTP 字段 | ❌ | ✅ |官方需要会员,我们的不需要 |
|
| passkey、TOTP | ❌ | ✅ |官方需要会员,我们的不需要 |
|
||||||
| 多用户 | ✅ | ❌ | NodeWarden 定位单用户 |
|
| 多用户 | ✅ | ❌ | NodeWarden 定位单用户 |
|
||||||
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 |
|
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 |
|
||||||
| 登录 2FA(TOTP/WebAuthn/Duo/Email) | ✅ | ❌ | 暂未实现 |
|
| 登录 2FA(TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ 部分支持 | 仅支持 TOTP(通过 `TOTP_SECRET`) |
|
||||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
|
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
|
||||||
| Send | ✅ | ❌ | 基本没人用 |
|
| Send | ✅ | ❌ | 基本没人用 |
|
||||||
| 紧急访问 | ✅ | ❌ | 没必要实现 |
|
| 紧急访问 | ✅ | ❌ | 没必要实现 |
|
||||||
@@ -58,6 +58,13 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 可选:登录 TOTP(2FA)
|
||||||
|
|
||||||
|
- 在 Workers 的 Variables and Secrets 里新增 Secret:`TOTP_SECRET`(Base32)。
|
||||||
|
- 配置了 `TOTP_SECRET` 就启用登录 TOTP;删除该变量即关闭。
|
||||||
|
- 客户端流程:密码 -> TOTP 验证码。
|
||||||
|
- 支持“记住此设备”30 天。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|||||||
+9
-2
@@ -20,10 +20,10 @@ A **Bitwarden-compatible** server that runs on **Cloudflare Workers**.
|
|||||||
| Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 |
|
| Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 |
|
||||||
| Import flow (common clients) | ✅ | ✅ | Common import paths covered |
|
| Import flow (common clients) | ✅ | ✅ | Common import paths covered |
|
||||||
| Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` |
|
| Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` |
|
||||||
| Vault item TOTP field | ❌ | ✅ | Official service requires premium; NodeWarden does not |
|
| passkey、TOTP | ❌ | ✅ | Official service requires premium; NodeWarden does not |
|
||||||
| Multi-user | ✅ | ❌ | NodeWarden is single-user by design |
|
| Multi-user | ✅ | ❌ | NodeWarden is single-user by design |
|
||||||
| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement |
|
| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement |
|
||||||
| Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ❌ | Not implemented yet |
|
| Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ Partial | TOTP-only via `TOTP_SECRET` |
|
||||||
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement |
|
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement |
|
||||||
| Send | ✅ | ❌ | Not necessary to implement |
|
| Send | ✅ | ❌ | Not necessary to implement |
|
||||||
| Emergency access | ✅ | ❌ | Not necessary to implement |
|
| Emergency access | ✅ | ❌ | Not necessary to implement |
|
||||||
@@ -61,6 +61,13 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Optional Login TOTP (2FA)
|
||||||
|
|
||||||
|
- Add Workers Secret `TOTP_SECRET` (Base32) to enable login TOTP.
|
||||||
|
- Remove `TOTP_SECRET` to disable login TOTP.
|
||||||
|
- Client flow: password -> TOTP code.
|
||||||
|
- "Remember this device" is supported for 30 days.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
-- IMPORTANT:
|
||||||
|
-- Keep this file in sync with src/services/storage.ts (SCHEMA_STATEMENTS).
|
||||||
|
-- Any new table/column/index must be added to both places together.
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS config (
|
CREATE TABLE IF NOT EXISTS config (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
@@ -77,6 +81,28 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
device_identifier TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type INTEGER NOT NULL,
|
||||||
|
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
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
device_identifier TEXT NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
|
||||||
|
ON trusted_two_factor_device_tokens(user_id, device_identifier);
|
||||||
|
|
||||||
-- Rate limiting
|
-- Rate limiting
|
||||||
CREATE TABLE IF NOT EXISTS login_attempts_ip (
|
CREATE TABLE IF NOT EXISTS login_attempts_ip (
|
||||||
ip TEXT PRIMARY KEY,
|
ip TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AuthService } from '../services/auth';
|
|||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
|
import { isTotpEnabled } from '../utils/totp';
|
||||||
|
|
||||||
function looksLikeEncString(value: string): boolean {
|
function looksLikeEncString(value: string): boolean {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
@@ -128,7 +129,7 @@ export async function handleGetProfile(request: Request, env: Env, userId: strin
|
|||||||
usesKeyConnector: false,
|
usesKeyConnector: false,
|
||||||
masterPasswordHint: null,
|
masterPasswordHint: null,
|
||||||
culture: 'en-US',
|
culture: 'en-US',
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: isTotpEnabled(env.TOTP_SECRET),
|
||||||
key: user.key,
|
key: user.key,
|
||||||
privateKey: user.privateKey,
|
privateKey: user.privateKey,
|
||||||
accountKeys: null,
|
accountKeys: null,
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Env } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { jsonResponse } from '../utils/response';
|
||||||
|
import { readKnownDeviceProbe } from '../utils/device';
|
||||||
|
|
||||||
|
// GET /api/devices/knowndevice
|
||||||
|
// Compatible with Bitwarden/Vaultwarden behavior:
|
||||||
|
// - X-Request-Email: base64url(email) without padding
|
||||||
|
// - X-Device-Identifier: client device identifier
|
||||||
|
export async function handleKnownDevice(request: Request, env: Env): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const { email, deviceIdentifier } = readKnownDeviceProbe(request);
|
||||||
|
|
||||||
|
if (!email || !deviceIdentifier) {
|
||||||
|
return jsonResponse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const known = await storage.isKnownDeviceByEmail(email, deviceIdentifier);
|
||||||
|
return jsonResponse(known);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/devices
|
||||||
|
export async function handleGetDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const devices = await storage.getDevicesByUserId(userId);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
data: devices.map(device => ({
|
||||||
|
id: device.deviceIdentifier,
|
||||||
|
name: device.name,
|
||||||
|
identifier: device.deviceIdentifier,
|
||||||
|
type: device.type,
|
||||||
|
creationDate: device.createdAt,
|
||||||
|
revisionDate: device.updatedAt,
|
||||||
|
object: 'device',
|
||||||
|
})),
|
||||||
|
object: 'list',
|
||||||
|
continuationToken: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,6 +4,48 @@ import { AuthService } from '../services/auth';
|
|||||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||||
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
|
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||||
|
import { createRefreshToken } from '../utils/jwt';
|
||||||
|
import { readAuthRequestDeviceInfo } from '../utils/device';
|
||||||
|
|
||||||
|
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
|
||||||
|
// Bitwarden clients rely on these fields to trigger the 2FA UI flow.
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
error: 'invalid_grant',
|
||||||
|
error_description: message,
|
||||||
|
TwoFactorProviders: [0],
|
||||||
|
TwoFactorProviders2: {
|
||||||
|
'0': {
|
||||||
|
Priority: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ErrorModel: {
|
||||||
|
Message: message,
|
||||||
|
Object: 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordFailedLoginAndBuildResponse(
|
||||||
|
rateLimit: RateLimitService,
|
||||||
|
loginIdentifier: string,
|
||||||
|
message: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const result = await rateLimit.recordFailedLogin(loginIdentifier);
|
||||||
|
if (result.locked) {
|
||||||
|
return identityErrorResponse(
|
||||||
|
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
|
||||||
|
'TooManyRequests',
|
||||||
|
429
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return identityErrorResponse(message, 'invalid_grant', 400);
|
||||||
|
}
|
||||||
|
|
||||||
// POST /identity/connect/token
|
// POST /identity/connect/token
|
||||||
export async function handleToken(request: Request, env: Env): Promise<Response> {
|
export async function handleToken(request: Request, env: Env): Promise<Response> {
|
||||||
@@ -30,7 +72,11 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
// Login with password
|
// Login with password
|
||||||
const email = body.username?.toLowerCase();
|
const email = body.username?.toLowerCase();
|
||||||
const passwordHash = body.password;
|
const passwordHash = body.password;
|
||||||
|
const twoFactorToken = body.twoFactorToken;
|
||||||
|
const twoFactorProvider = body.twoFactorProvider;
|
||||||
|
const twoFactorRemember = body.twoFactorRemember;
|
||||||
const loginIdentifier = getClientIdentifier(request);
|
const loginIdentifier = getClientIdentifier(request);
|
||||||
|
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||||
|
|
||||||
if (!email || !passwordHash) {
|
if (!email || !passwordHash) {
|
||||||
// Bitwarden clients expect OAuth-style error fields.
|
// Bitwarden clients expect OAuth-style error fields.
|
||||||
@@ -55,16 +101,60 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
|
|
||||||
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash);
|
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
// Record failed login attempt
|
return recordFailedLoginAndBuildResponse(
|
||||||
const result = await rateLimit.recordFailedLogin(loginIdentifier);
|
rateLimit,
|
||||||
if (result.locked) {
|
loginIdentifier,
|
||||||
return identityErrorResponse(
|
'Username or password is incorrect. Try again'
|
||||||
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
|
);
|
||||||
'TooManyRequests',
|
}
|
||||||
429
|
|
||||||
|
if (deviceInfo.deviceIdentifier) {
|
||||||
|
await storage.upsertDevice(user.id, deviceInfo.deviceIdentifier, deviceInfo.deviceName, deviceInfo.deviceType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional 2FA: enabled only when TOTP_SECRET is configured in Workers env.
|
||||||
|
let trustedTwoFactorTokenToReturn: string | undefined;
|
||||||
|
if (isTotpEnabled(env.TOTP_SECRET)) {
|
||||||
|
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.
|
||||||
|
let passedByRememberToken = false;
|
||||||
|
if (twoFactorToken && !/^\d{6}$/.test(twoFactorToken) && deviceInfo.deviceIdentifier) {
|
||||||
|
const trustedUserId = await storage.getTrustedTwoFactorDeviceTokenUserId(
|
||||||
|
twoFactorToken,
|
||||||
|
deviceInfo.deviceIdentifier
|
||||||
|
);
|
||||||
|
passedByRememberToken = trustedUserId === user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passedByRememberToken && !twoFactorToken) {
|
||||||
|
return twoFactorRequiredResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passedByRememberToken) {
|
||||||
|
const totpOk = await verifyTotpToken(env.TOTP_SECRET!, twoFactorToken);
|
||||||
|
if (!totpOk) {
|
||||||
|
const failed = await rateLimit.recordFailedLogin(loginIdentifier);
|
||||||
|
if (failed.locked) {
|
||||||
|
return identityErrorResponse(
|
||||||
|
`Too many failed login attempts. Account locked for ${Math.ceil(failed.retryAfterSeconds! / 60)} minutes.`,
|
||||||
|
'TooManyRequests',
|
||||||
|
429
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return identityErrorResponse('Invalid two-factor token', 'invalid_grant', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rememberRequested && deviceInfo.deviceIdentifier) {
|
||||||
|
trustedTwoFactorTokenToReturn = createRefreshToken();
|
||||||
|
await storage.saveTrustedTwoFactorDeviceToken(
|
||||||
|
trustedTwoFactorTokenToReturn,
|
||||||
|
user.id,
|
||||||
|
deviceInfo.deviceIdentifier,
|
||||||
|
Date.now() + TWO_FACTOR_REMEMBER_TTL_MS
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful login - clear failed attempts
|
// Successful login - clear failed attempts
|
||||||
@@ -78,6 +168,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
|
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||||
Key: user.key,
|
Key: user.key,
|
||||||
PrivateKey: user.privateKey,
|
PrivateKey: user.privateKey,
|
||||||
Kdf: user.kdfType,
|
Kdf: user.kdfType,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { StorageService } from '../services/storage';
|
|||||||
import { errorResponse } from '../utils/response';
|
import { errorResponse } from '../utils/response';
|
||||||
import { cipherToResponse } from './ciphers';
|
import { cipherToResponse } from './ciphers';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
|
import { isTotpEnabled } from '../utils/totp';
|
||||||
|
|
||||||
interface SyncCacheEntry {
|
interface SyncCacheEntry {
|
||||||
body: string;
|
body: string;
|
||||||
@@ -73,7 +74,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
usesKeyConnector: false,
|
usesKeyConnector: false,
|
||||||
masterPasswordHint: null,
|
masterPasswordHint: null,
|
||||||
culture: 'en-US',
|
culture: 'en-US',
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: isTotpEnabled(env.TOTP_SECRET),
|
||||||
key: user.key,
|
key: user.key,
|
||||||
privateKey: user.privateKey,
|
privateKey: user.privateKey,
|
||||||
accountKeys: null,
|
accountKeys: null,
|
||||||
|
|||||||
+55
-26
@@ -3,39 +3,68 @@ import { handleRequest } from './router';
|
|||||||
import { StorageService } from './services/storage';
|
import { StorageService } from './services/storage';
|
||||||
import { applyCors, jsonResponse } from './utils/response';
|
import { applyCors, jsonResponse } from './utils/response';
|
||||||
|
|
||||||
// Per-isolate flags. Each Worker isolate may have its own copy of these flags.
|
|
||||||
// initializeDatabase() only validates schema presence, so retries are cheap.
|
|
||||||
let dbInitialized = false;
|
let dbInitialized = false;
|
||||||
let dbInitError: string | null = null;
|
let dbInitError: string | 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> {
|
||||||
|
if (dbInitialized) return;
|
||||||
|
|
||||||
|
if (!dbInitPromise) {
|
||||||
|
dbInitPromise = (async () => {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
await storage.initializeDatabase();
|
||||||
|
dbInitialized = true;
|
||||||
|
dbInitError = null;
|
||||||
|
})()
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error('Failed to initialize database:', error);
|
||||||
|
dbInitError = error instanceof Error ? error.message : 'Unknown database initialization error';
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
dbInitPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbInitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||||
// Auto-initialize database on first request
|
void ctx;
|
||||||
if (!dbInitialized) {
|
const requiresDatabase = !shouldSkipDatabaseInit(request);
|
||||||
try {
|
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
await storage.initializeDatabase();
|
|
||||||
dbInitialized = true;
|
|
||||||
dbInitError = null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to initialize database:', error);
|
|
||||||
dbInitError = error instanceof Error ? error.message : 'Unknown database initialization error';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
+6
-9
@@ -37,6 +37,7 @@ import { handleSync } from './handlers/sync';
|
|||||||
|
|
||||||
// Setup handlers
|
// Setup handlers
|
||||||
import { handleSetupPage, handleSetupStatus, handleDisableSetup } from './handlers/setup';
|
import { handleSetupPage, handleSetupStatus, handleDisableSetup } from './handlers/setup';
|
||||||
|
import { handleKnownDevice, handleGetDevices } from './handlers/devices';
|
||||||
|
|
||||||
// Import handler
|
// Import handler
|
||||||
import { handleCiphersImport } from './handlers/import';
|
import { handleCiphersImport } from './handlers/import';
|
||||||
@@ -218,13 +219,9 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Known device check (no auth required) - returns plain string "true" or "false"
|
// Known device check (no auth required)
|
||||||
if (path.startsWith('/api/devices/knowndevice')) {
|
if (path === '/api/devices/knowndevice' && method === 'GET') {
|
||||||
return new Response('true', {
|
return handleKnownDevice(request, env);
|
||||||
headers: {
|
|
||||||
'Content-Type': 'text/plain',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identity endpoints (no auth required)
|
// Identity endpoints (no auth required)
|
||||||
@@ -540,9 +537,9 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Devices endpoint (stub) - for authenticated requests
|
// Devices endpoint
|
||||||
if (path === '/api/devices' && method === 'GET') {
|
if (path === '/api/devices' && method === 'GET') {
|
||||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
return handleGetDevices(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not found
|
// Not found
|
||||||
|
|||||||
+185
-53
@@ -1,6 +1,71 @@
|
|||||||
import { User, Cipher, Folder, Attachment } from '../types';
|
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 SCHEMA_HASH_CONFIG_KEY = 'schema_hash';
|
||||||
|
|
||||||
|
// IMPORTANT:
|
||||||
|
// Keep this schema list in sync with migrations/0001_init.sql.
|
||||||
|
// Any new table/column/index must be added to both places together.
|
||||||
|
const SCHEMA_STATEMENTS: readonly string[] = [
|
||||||
|
'CREATE TABLE IF NOT EXISTS users (' +
|
||||||
|
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||||
|
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||||
|
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||||
|
'security_stamp TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
||||||
|
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS ciphers (' +
|
||||||
|
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' +
|
||||||
|
'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' +
|
||||||
|
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS folders (' +
|
||||||
|
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS attachments (' +
|
||||||
|
'id TEXT PRIMARY KEY, cipher_id TEXT NOT NULL, file_name TEXT NOT NULL, size INTEGER NOT NULL, ' +
|
||||||
|
'size_name TEXT NOT NULL, key TEXT, ' +
|
||||||
|
'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
|
||||||
|
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS devices (' +
|
||||||
|
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, ' +
|
||||||
|
'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)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
|
||||||
|
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS api_rate_limits (' +
|
||||||
|
'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' +
|
||||||
|
'PRIMARY KEY (identifier, window_start))',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
|
||||||
|
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
|
||||||
|
'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)',
|
||||||
|
];
|
||||||
|
|
||||||
// D1-backed storage.
|
// D1-backed storage.
|
||||||
// Contract:
|
// Contract:
|
||||||
// - All methods are scoped by userId where applicable.
|
// - All methods are scoped by userId where applicable.
|
||||||
@@ -56,69 +121,49 @@ export class StorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Database initialization ---
|
// --- Database initialization ---
|
||||||
// One-click deploy requires zero manual migration steps.
|
// Strategy:
|
||||||
// This method idempotently creates required schema objects on first request.
|
// - Run only once per isolate.
|
||||||
|
// - Persist schema hash in DB config; if unchanged, skip all schema SQL.
|
||||||
|
// - Keep statements idempotent so updates are safe.
|
||||||
async initializeDatabase(): Promise<void> {
|
async initializeDatabase(): Promise<void> {
|
||||||
if (StorageService.schemaVerified) return;
|
if (StorageService.schemaVerified) return;
|
||||||
|
|
||||||
const schemaStatements = [
|
await this.db.prepare('PRAGMA foreign_keys = ON').run();
|
||||||
'PRAGMA foreign_keys = ON',
|
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)',
|
const schemaHash = await this.sha256Hex(SCHEMA_STATEMENTS.join('\n'));
|
||||||
|
const current = await this.db.prepare('SELECT value FROM config WHERE key = ?')
|
||||||
|
.bind(SCHEMA_HASH_CONFIG_KEY)
|
||||||
|
.first<{ value: string }>();
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS users (' +
|
if (current?.value !== schemaHash) {
|
||||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' +
|
for (const stmt of SCHEMA_STATEMENTS) {
|
||||||
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
await this.executeSchemaStatement(stmt);
|
||||||
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
}
|
||||||
'security_stamp TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
await this.db.prepare(
|
||||||
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
'INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
)
|
||||||
|
.bind(SCHEMA_HASH_CONFIG_KEY, schemaHash)
|
||||||
'CREATE TABLE IF NOT EXISTS ciphers (' +
|
.run();
|
||||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' +
|
|
||||||
'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' +
|
|
||||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, ' +
|
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS folders (' +
|
|
||||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at)',
|
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS attachments (' +
|
|
||||||
'id TEXT PRIMARY KEY, cipher_id TEXT NOT NULL, file_name TEXT NOT NULL, size INTEGER NOT NULL, ' +
|
|
||||||
'size_name TEXT NOT NULL, key TEXT, ' +
|
|
||||||
'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)',
|
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
|
|
||||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
|
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS api_rate_limits (' +
|
|
||||||
'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' +
|
|
||||||
'PRIMARY KEY (identifier, window_start))',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)',
|
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
|
|
||||||
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
|
|
||||||
'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const stmt of schemaStatements) {
|
|
||||||
await this.db.prepare(stmt).run();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
StorageService.schemaVerified = true;
|
StorageService.schemaVerified = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async executeSchemaStatement(statement: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.db.prepare(statement).run();
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
||||||
|
// Keep migration resilient if a future non-idempotent DDL is retried.
|
||||||
|
if (msg.includes('already exists') || msg.includes('duplicate column name')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Config / setup ---
|
// --- Config / setup ---
|
||||||
|
|
||||||
async isRegistered(): Promise<boolean> {
|
async isRegistered(): Promise<boolean> {
|
||||||
@@ -613,6 +658,93 @@ export class StorageService {
|
|||||||
await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run();
|
await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async trustedTwoFactorTokenKey(token: string): Promise<string> {
|
||||||
|
const digest = await this.sha256Hex(token);
|
||||||
|
return `sha256:${digest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Devices ---
|
||||||
|
|
||||||
|
async upsertDevice(userId: string, deviceIdentifier: string, name: string, type: number): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await this.db.prepare(
|
||||||
|
'INSERT INTO devices(user_id, device_identifier, name, type, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, updated_at=excluded.updated_at'
|
||||||
|
)
|
||||||
|
.bind(userId, deviceIdentifier, name, type, now, now)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async isKnownDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||||
|
const row = await this.db
|
||||||
|
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||||
|
.bind(userId, deviceIdentifier)
|
||||||
|
.first<{ '1': number }>();
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async isKnownDeviceByEmail(email: string, deviceIdentifier: string): Promise<boolean> {
|
||||||
|
const user = await this.getUser(email);
|
||||||
|
if (!user) return false;
|
||||||
|
return this.isKnownDevice(user.id, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDevicesByUserId(userId: string): Promise<Device[]> {
|
||||||
|
const res = await this.db
|
||||||
|
.prepare(
|
||||||
|
'SELECT user_id, device_identifier, name, type, created_at, updated_at ' +
|
||||||
|
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
|
||||||
|
)
|
||||||
|
.bind(userId)
|
||||||
|
.all<any>();
|
||||||
|
return (res.results || []).map(row => ({
|
||||||
|
userId: row.user_id,
|
||||||
|
deviceIdentifier: row.device_identifier,
|
||||||
|
name: row.name,
|
||||||
|
type: row.type,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Trusted 2FA remember tokens (device-bound) ---
|
||||||
|
|
||||||
|
async saveTrustedTwoFactorDeviceToken(
|
||||||
|
token: string,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
expiresAtMs?: number
|
||||||
|
): Promise<void> {
|
||||||
|
const expiresAt = expiresAtMs ?? (Date.now() + TWO_FACTOR_REMEMBER_TTL_MS);
|
||||||
|
const tokenKey = await this.trustedTwoFactorTokenKey(token);
|
||||||
|
|
||||||
|
await this.db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(Date.now()).run();
|
||||||
|
await this.db.prepare(
|
||||||
|
'INSERT INTO trusted_two_factor_device_tokens(token, user_id, device_identifier, expires_at) VALUES(?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, device_identifier=excluded.device_identifier, expires_at=excluded.expires_at'
|
||||||
|
)
|
||||||
|
.bind(tokenKey, userId, deviceIdentifier, expiresAt)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTrustedTwoFactorDeviceTokenUserId(token: string, deviceIdentifier: string): Promise<string | null> {
|
||||||
|
const now = Date.now();
|
||||||
|
const tokenKey = await this.trustedTwoFactorTokenKey(token);
|
||||||
|
const row = await this.db
|
||||||
|
.prepare(
|
||||||
|
'SELECT user_id, expires_at FROM trusted_two_factor_device_tokens WHERE token = ? AND device_identifier = ?'
|
||||||
|
)
|
||||||
|
.bind(tokenKey, deviceIdentifier)
|
||||||
|
.first<{ user_id: string; expires_at: number }>();
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
if (row.expires_at && row.expires_at < now) {
|
||||||
|
await this.db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE token = ?').bind(tokenKey).run();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return row.user_id;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Revision dates ---
|
// --- Revision dates ---
|
||||||
|
|
||||||
async getRevisionDate(userId: string): Promise<string> {
|
async getRevisionDate(userId: string): Promise<string> {
|
||||||
|
|||||||
+93
-16
@@ -528,7 +528,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="step4" class="step">
|
<section id="step4" class="step">
|
||||||
<h2 id="t_s4_title">Final step</h2>
|
<h2 id="t_s4_title">Create account</h2>
|
||||||
<p class="lead" id="t_s4_desc"></p>
|
<p class="lead" id="t_s4_desc"></p>
|
||||||
|
|
||||||
<div id="setup-form">
|
<div id="setup-form">
|
||||||
@@ -561,6 +561,35 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
<button type="submit" id="submitBtn" class="btn primary" style="width:100%;height:52px;">Create account</button>
|
<button type="submit" id="submitBtn" class="btn primary" style="width:100%;height:52px;">Create account</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="step5" class="step">
|
||||||
|
<h2 id="t_s5_title">Optional: login TOTP (2FA)</h2>
|
||||||
|
<p class="lead" id="t_s5_desc"></p>
|
||||||
|
|
||||||
|
<div class="kv" style="margin-top:14px;">
|
||||||
|
<h3 id="t_s5_enable_title">Enable on server (Cloudflare Workers)</h3>
|
||||||
|
<ol>
|
||||||
|
<li id="t_s5_enable_1"></li>
|
||||||
|
<li id="t_s5_enable_2"></li>
|
||||||
|
<li id="t_s5_enable_3"></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<h3 id="t_s5_client_title">Use in Bitwarden client</h3>
|
||||||
|
<ol>
|
||||||
|
<li id="t_s5_client_1"></li>
|
||||||
|
<li id="t_s5_client_2"></li>
|
||||||
|
<li id="t_s5_client_3"></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="step6" class="step">
|
||||||
|
<h2 id="t_s6_title">Final step</h2>
|
||||||
|
<p class="lead" id="t_s6_desc"></p>
|
||||||
|
|
||||||
<div id="registered-view" style="display:none;">
|
<div id="registered-view" style="display:none;">
|
||||||
<div class="kv">
|
<div class="kv">
|
||||||
@@ -594,6 +623,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
<span class="dot" data-step="2"></span>
|
<span class="dot" data-step="2"></span>
|
||||||
<span class="dot" data-step="3"></span>
|
<span class="dot" data-step="3"></span>
|
||||||
<span class="dot" data-step="4"></span>
|
<span class="dot" data-step="4"></span>
|
||||||
|
<span class="dot" data-step="5"></span>
|
||||||
|
<span class="dot" data-step="6"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flow-actions" style="justify-content:flex-end;">
|
<div class="flow-actions" style="justify-content:flex-end;">
|
||||||
<button id="nextBtn" class="btn primary" type="button">Next</button>
|
<button id="nextBtn" class="btn primary" type="button">Next</button>
|
||||||
@@ -667,7 +698,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
copy: '复制',
|
copy: '复制',
|
||||||
copied: '已复制',
|
copied: '已复制',
|
||||||
|
|
||||||
s3Title: '同步策略(可跳过)',
|
s3Title: '更新策略(可跳过)',
|
||||||
s3CommonTitle: '共同前置步骤',
|
s3CommonTitle: '共同前置步骤',
|
||||||
s3Common1: '如果还没 fork,请先 fork 本项目到你自己的 GitHub。',
|
s3Common1: '如果还没 fork,请先 fork 本项目到你自己的 GitHub。',
|
||||||
s3Common2: 'Cloudflare 控制台 → Workers 和 Pages → NodeWarden → 设置 → 构建 → Git 存储库 → 断开联机。',
|
s3Common2: 'Cloudflare 控制台 → Workers 和 Pages → NodeWarden → 设置 → 构建 → Git 存储库 → 断开联机。',
|
||||||
@@ -682,8 +713,20 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
s3AutoStep2: '点击 “I understand my workflows, go ahead and enable them”。',
|
s3AutoStep2: '点击 “I understand my workflows, go ahead and enable them”。',
|
||||||
s3AutoStep3: '默认每天凌晨 3 点自动同步;需要时可手动点 “Run workflow”。',
|
s3AutoStep3: '默认每天凌晨 3 点自动同步;需要时可手动点 “Run workflow”。',
|
||||||
|
|
||||||
s4Title: '最终:创建账号',
|
s4Title: '创建账号',
|
||||||
s4Desc: '填写信息并创建你的唯一账号,完成初始化。',
|
s4Desc: '填写信息并创建你的唯一账号。创建成功后会进入登录 TOTP 教程。',
|
||||||
|
s5Title: '开启登录 TOTP(2FA,可跳过)',
|
||||||
|
s5Desc: '这一页可跳过。如果你想开启登录二次验证,按下面步骤设置。',
|
||||||
|
s5EnableTitle: '服务端开启(Cloudflare Workers)',
|
||||||
|
s5Enable1: '打开 Cloudflare 控制台 -> Workers 和 Pages -> NodeWarden -> 设置 -> 变量和机密。',
|
||||||
|
s5Enable2: '新增 Secret:TOTP_SECRET,值填写 Base32 密钥 。',
|
||||||
|
s5Enable3: '保存并等待重新部署。删除 TOTP_SECRET 即关闭登录 2FA。',
|
||||||
|
s5ClientTitle: '客户端使用',
|
||||||
|
s5Client1: '下次登录时先输入主密码。',
|
||||||
|
s5Client2: '客户端会弹出 2FA 验证码输入框,输入验证器里的 6 位码。',
|
||||||
|
s5Client3: '如需可勾选记住此设备(30 天)。',
|
||||||
|
s6Title: '最终页面',
|
||||||
|
s6Desc: '最后一步:查看客户端使用地址,并可选择隐藏初始化页面。',
|
||||||
nameLabel: '昵称',
|
nameLabel: '昵称',
|
||||||
emailLabel: '邮箱',
|
emailLabel: '邮箱',
|
||||||
pwLabel: '主密码',
|
pwLabel: '主密码',
|
||||||
@@ -769,8 +812,20 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
s3AutoStep2: 'Click “I understand my workflows, go ahead and enable them”.',
|
s3AutoStep2: 'Click “I understand my workflows, go ahead and enable them”.',
|
||||||
s3AutoStep3: 'It runs daily at 03:00 by default; you can also click “Run workflow”.',
|
s3AutoStep3: 'It runs daily at 03:00 by default; you can also click “Run workflow”.',
|
||||||
|
|
||||||
s4Title: 'Final: create account',
|
s4Title: 'Create account',
|
||||||
s4Desc: 'Create your single user account to finish setup.',
|
s4Desc: 'Create your single user account. After success, you will see the optional login TOTP guide.',
|
||||||
|
s5Title: 'Optional: login TOTP (2FA)',
|
||||||
|
s5Desc: 'You can skip this step. If you want login 2FA, follow the steps below.',
|
||||||
|
s5EnableTitle: 'Enable on server (Cloudflare Workers)',
|
||||||
|
s5Enable1: 'Open Cloudflare Dashboard -> Workers & Pages -> your service -> Settings -> Variables and Secrets.',
|
||||||
|
s5Enable2: 'Add Secret: TOTP_SECRET, value is a Base32 secret (example: JBSWY3DPEHPK3PXP).',
|
||||||
|
s5Enable3: 'Save and wait for redeploy. Remove TOTP_SECRET to disable login 2FA.',
|
||||||
|
s5ClientTitle: 'Use in Bitwarden client',
|
||||||
|
s5Client1: 'On next login, enter your master password first.',
|
||||||
|
s5Client2: 'Client will prompt 2FA code. Enter the 6-digit code from your authenticator app.',
|
||||||
|
s5Client3: 'Optionally choose remember this device (30 days).',
|
||||||
|
s6Title: 'Final step',
|
||||||
|
s6Desc: 'Last step: check your server URL, then optionally hide this setup page.',
|
||||||
nameLabel: 'Name',
|
nameLabel: 'Name',
|
||||||
emailLabel: 'Email',
|
emailLabel: 'Email',
|
||||||
pwLabel: 'Master password',
|
pwLabel: 'Master password',
|
||||||
@@ -886,6 +941,18 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
setText('t_hide_title', t('hideTitle'));
|
setText('t_hide_title', t('hideTitle'));
|
||||||
setText('t_hide_desc', t('hideDesc'));
|
setText('t_hide_desc', t('hideDesc'));
|
||||||
setText('hideBtn', t('hideBtn'));
|
setText('hideBtn', t('hideBtn'));
|
||||||
|
setText('t_s5_title', t('s5Title'));
|
||||||
|
setText('t_s5_desc', t('s5Desc'));
|
||||||
|
setText('t_s5_enable_title', t('s5EnableTitle'));
|
||||||
|
setText('t_s5_enable_1', t('s5Enable1'));
|
||||||
|
setText('t_s5_enable_2', t('s5Enable2'));
|
||||||
|
setText('t_s5_enable_3', t('s5Enable3'));
|
||||||
|
setText('t_s5_client_title', t('s5ClientTitle'));
|
||||||
|
setText('t_s5_client_1', t('s5Client1'));
|
||||||
|
setText('t_s5_client_2', t('s5Client2'));
|
||||||
|
setText('t_s5_client_3', t('s5Client3'));
|
||||||
|
setText('t_s6_title', t('s6Title'));
|
||||||
|
setText('t_s6_desc', t('s6Desc'));
|
||||||
setText('hideModalTitle', t('hideModalTitle'));
|
setText('hideModalTitle', t('hideModalTitle'));
|
||||||
setText('hideModalDesc', t('hideModalDesc'));
|
setText('hideModalDesc', t('hideModalDesc'));
|
||||||
setText('hideModalWarn', t('hideModalWarn'));
|
setText('hideModalWarn', t('hideModalWarn'));
|
||||||
@@ -952,10 +1019,11 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
|
|
||||||
function goToStep(targetStep) {
|
function goToStep(targetStep) {
|
||||||
// 安全限制:JWT_SECRET 不合规时,只允许访问第 1/2 步。
|
// 安全限制:JWT_SECRET 不合规时,只允许访问第 1/2 步。
|
||||||
const maxStep = JWT_STATE ? 2 : 4;
|
const maxStep = JWT_STATE ? 2 : (isRegistered ? 6 : 4);
|
||||||
currentStep = Math.max(1, Math.min(maxStep, targetStep));
|
currentStep = Math.max(1, Math.min(maxStep, targetStep));
|
||||||
|
if (isRegistered && currentStep === 4) currentStep = 5;
|
||||||
|
|
||||||
for (let i = 1; i <= 4; i++) {
|
for (let i = 1; i <= 6; i++) {
|
||||||
const el = document.getElementById('step' + i);
|
const el = document.getElementById('step' + i);
|
||||||
if (el) el.classList.toggle('active', i === currentStep);
|
if (el) el.classList.toggle('active', i === currentStep);
|
||||||
}
|
}
|
||||||
@@ -971,7 +1039,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
|
|
||||||
const nextBtn = document.getElementById('nextBtn');
|
const nextBtn = document.getElementById('nextBtn');
|
||||||
if (nextBtn) {
|
if (nextBtn) {
|
||||||
nextBtn.style.display = currentStep >= 4 ? 'none' : 'inline-flex';
|
const reachedEnd = isRegistered ? (currentStep >= 6) : (currentStep >= 4);
|
||||||
|
nextBtn.style.display = reachedEnd ? 'none' : 'inline-flex';
|
||||||
if (currentStep === 2 && !!JWT_STATE) {
|
if (currentStep === 2 && !!JWT_STATE) {
|
||||||
nextBtn.disabled = true;
|
nextBtn.disabled = true;
|
||||||
nextBtn.textContent = t('keyWaitRefresh');
|
nextBtn.textContent = t('keyWaitRefresh');
|
||||||
@@ -999,8 +1068,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
// 已注册但密钥不安全:只能停留在首页/密钥页,不能直接进入后续页面。
|
// 已注册但密钥不安全:只能停留在首页/密钥页,不能直接进入后续页面。
|
||||||
goToStep(2);
|
goToStep(2);
|
||||||
} else {
|
} else {
|
||||||
goToStep(4);
|
goToStep(6);
|
||||||
showRegisteredView();
|
showFinalView();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1008,8 +1077,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showRegisteredView() {
|
function showFinalView() {
|
||||||
isRegistered = true;
|
|
||||||
const setupForm = document.getElementById('setup-form');
|
const setupForm = document.getElementById('setup-form');
|
||||||
const registeredView = document.getElementById('registered-view');
|
const registeredView = document.getElementById('registered-view');
|
||||||
const serverUrl = document.getElementById('serverUrl');
|
const serverUrl = document.getElementById('serverUrl');
|
||||||
@@ -1132,7 +1200,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
async function handleSubmit(event) {
|
async function handleSubmit(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (isRegistered) {
|
if (isRegistered) {
|
||||||
showRegisteredView();
|
goToStep(6);
|
||||||
|
showFinalView();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1197,7 +1266,9 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (response.ok && result.success) {
|
if (response.ok && result.success) {
|
||||||
showRegisteredView();
|
isRegistered = true;
|
||||||
|
goToStep(5);
|
||||||
|
showFinalView();
|
||||||
} else {
|
} else {
|
||||||
showMessage(result.error || (result.ErrorModel && result.ErrorModel.Message) || t('errRegisterFailed'), 'error');
|
showMessage(result.error || (result.ErrorModel && result.ErrorModel.Message) || t('errRegisterFailed'), 'error');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
@@ -1226,6 +1297,10 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
if (prevBtn) {
|
if (prevBtn) {
|
||||||
prevBtn.addEventListener('click', () => {
|
prevBtn.addEventListener('click', () => {
|
||||||
if (currentStep <= 1) return;
|
if (currentStep <= 1) return;
|
||||||
|
if (isRegistered && currentStep === 5) {
|
||||||
|
goToStep(3);
|
||||||
|
return;
|
||||||
|
}
|
||||||
goToStep(currentStep - 1);
|
goToStep(currentStep - 1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1235,7 +1310,9 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
nextBtn.addEventListener('click', () => {
|
nextBtn.addEventListener('click', () => {
|
||||||
if (currentStep === 1) goToStep(2);
|
if (currentStep === 1) goToStep(2);
|
||||||
else if (currentStep === 2) goToStep(3);
|
else if (currentStep === 2) goToStep(3);
|
||||||
else if (currentStep === 3) goToStep(4);
|
else if (currentStep === 3) goToStep(isRegistered ? 5 : 4);
|
||||||
|
else if (currentStep === 4) goToStep(5);
|
||||||
|
else if (currentStep === 5) goToStep(6);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export interface Env {
|
|||||||
DB: D1Database;
|
DB: D1Database;
|
||||||
ATTACHMENTS: R2Bucket;
|
ATTACHMENTS: R2Bucket;
|
||||||
JWT_SECRET: string;
|
JWT_SECRET: string;
|
||||||
|
TOTP_SECRET?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sample JWT secret used by `.dev.vars.example`.
|
// Sample JWT secret used by `.dev.vars.example`.
|
||||||
@@ -147,6 +148,15 @@ export interface Folder {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Device {
|
||||||
|
userId: string;
|
||||||
|
deviceIdentifier: string;
|
||||||
|
name: string;
|
||||||
|
type: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
// JWT Payload
|
// JWT Payload
|
||||||
export interface JWTPayload {
|
export interface JWTPayload {
|
||||||
sub: string; // user id
|
sub: string; // user id
|
||||||
@@ -190,6 +200,7 @@ export interface TokenResponse {
|
|||||||
expires_in: number;
|
expires_in: number;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
|
TwoFactorToken?: string;
|
||||||
Key: string;
|
Key: string;
|
||||||
PrivateKey: string | null;
|
PrivateKey: string | null;
|
||||||
Kdf: number;
|
Kdf: number;
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
const DEFAULT_DEVICE_NAME = 'Unknown device';
|
||||||
|
const DEFAULT_DEVICE_TYPE = 14;
|
||||||
|
|
||||||
|
function decodeBase64UrlUtf8(value: string): string | null {
|
||||||
|
try {
|
||||||
|
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padding = normalized.length % 4;
|
||||||
|
const padded = padding === 0 ? normalized : normalized + '='.repeat(4 - padding);
|
||||||
|
const binary = atob(padded);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDeviceIdentifier(value: string | undefined | null): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
if (!normalized) return null;
|
||||||
|
return normalized.slice(0, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDeviceName(value: string | undefined | null): string {
|
||||||
|
const normalized = String(value || '').trim();
|
||||||
|
if (!normalized) return DEFAULT_DEVICE_NAME;
|
||||||
|
return normalized.slice(0, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDeviceType(value: string | number | undefined | null): number {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return Math.max(0, Math.floor(value));
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(String(value || ''), 10);
|
||||||
|
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
||||||
|
return DEFAULT_DEVICE_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthRequestDeviceInfo {
|
||||||
|
deviceIdentifier: string | null;
|
||||||
|
deviceName: string;
|
||||||
|
deviceType: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAuthRequestDeviceInfo(
|
||||||
|
body: Record<string, string | undefined>,
|
||||||
|
request: Request
|
||||||
|
): AuthRequestDeviceInfo {
|
||||||
|
const bodyIdentifier = body.deviceIdentifier || body.device_identifier;
|
||||||
|
const headerIdentifier = request.headers.get('X-Device-Identifier') || undefined;
|
||||||
|
const bodyName = body.deviceName || body.device_name;
|
||||||
|
const headerName = request.headers.get('X-Device-Name') || undefined;
|
||||||
|
const bodyType = body.deviceType || body.device_type;
|
||||||
|
const headerType = request.headers.get('Device-Type') || undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceIdentifier: normalizeDeviceIdentifier(bodyIdentifier || headerIdentifier),
|
||||||
|
deviceName: normalizeDeviceName(bodyName || headerName),
|
||||||
|
deviceType: parseDeviceType(bodyType || headerType),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readKnownDeviceProbe(request: Request): { email: string | null; deviceIdentifier: string | null } {
|
||||||
|
const encodedEmail = request.headers.get('X-Request-Email') || '';
|
||||||
|
const decodedEmail = decodeBase64UrlUtf8(encodedEmail);
|
||||||
|
const fallbackRawEmail = request.headers.get('X-Request-Email');
|
||||||
|
const email = (decodedEmail || fallbackRawEmail || '').trim().toLowerCase() || null;
|
||||||
|
const deviceIdentifier = normalizeDeviceIdentifier(request.headers.get('X-Device-Identifier'));
|
||||||
|
return { email, deviceIdentifier };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
|
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
|
||||||
const CORS_HEADERS = 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version';
|
const CORS_HEADERS = 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version, X-Request-Email, X-Device-Identifier, X-Device-Name';
|
||||||
|
|
||||||
function isTrustedClientOrigin(origin: string): boolean {
|
function isTrustedClientOrigin(origin: string): boolean {
|
||||||
// Official browser extension / desktop-webview common origins.
|
// Official browser extension / desktop-webview common origins.
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
const TOTP_STEP_SECONDS = 30;
|
||||||
|
const TOTP_DIGITS = 6;
|
||||||
|
const TOTP_WINDOW = 1; // allow previous/current/next step for small clock drift
|
||||||
|
|
||||||
|
function normalizeBase32(input: string): string {
|
||||||
|
return input.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function base32Decode(input: string): Uint8Array | null {
|
||||||
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
const normalized = normalizeBase32(input);
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
let bits = 0;
|
||||||
|
let value = 0;
|
||||||
|
const output: number[] = [];
|
||||||
|
|
||||||
|
for (const char of normalized) {
|
||||||
|
const idx = alphabet.indexOf(char);
|
||||||
|
if (idx === -1) return null;
|
||||||
|
value = (value << 5) | idx;
|
||||||
|
bits += 5;
|
||||||
|
if (bits >= 8) {
|
||||||
|
bits -= 8;
|
||||||
|
output.push((value >> bits) & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.length > 0 ? new Uint8Array(output) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hotp(secret: Uint8Array, counter: number): Promise<string> {
|
||||||
|
const counterBytes = new Uint8Array(8);
|
||||||
|
let c = counter;
|
||||||
|
for (let i = 7; i >= 0; i--) {
|
||||||
|
counterBytes[i] = c & 0xff;
|
||||||
|
c = Math.floor(c / 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
secret,
|
||||||
|
{ name: 'HMAC', hash: 'SHA-1' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
const signature = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBytes));
|
||||||
|
const offset = signature[signature.length - 1] & 0x0f;
|
||||||
|
const binary =
|
||||||
|
((signature[offset] & 0x7f) << 24) |
|
||||||
|
((signature[offset + 1] & 0xff) << 16) |
|
||||||
|
((signature[offset + 2] & 0xff) << 8) |
|
||||||
|
(signature[offset + 3] & 0xff);
|
||||||
|
|
||||||
|
const otp = binary % (10 ** TOTP_DIGITS);
|
||||||
|
return otp.toString().padStart(TOTP_DIGITS, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToken(token: string): string {
|
||||||
|
return token.replace(/\s+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> {
|
||||||
|
const token = normalizeToken(tokenRaw);
|
||||||
|
if (!/^\d{6}$/.test(token)) return false;
|
||||||
|
|
||||||
|
const secret = base32Decode(secretRaw);
|
||||||
|
if (!secret) return false;
|
||||||
|
|
||||||
|
const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS);
|
||||||
|
for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) {
|
||||||
|
const expected = await hotp(secret, currentCounter + delta);
|
||||||
|
if (expected === token) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTotpEnabled(secretRaw: string | undefined | null): boolean {
|
||||||
|
return Boolean(secretRaw && normalizeBase32(secretRaw).length > 0);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user