mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat(devices): add functionality to delete all authorized devices
This commit is contained in:
@@ -2,6 +2,7 @@ import { Env } from '../types';
|
|||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { errorResponse, jsonResponse } from '../utils/response';
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
import { readKnownDeviceProbe } from '../utils/device';
|
import { readKnownDeviceProbe } from '../utils/device';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
|
||||||
// GET /api/devices/knowndevice
|
// GET /api/devices/knowndevice
|
||||||
// Compatible with Bitwarden/Vaultwarden behavior:
|
// Compatible with Bitwarden/Vaultwarden behavior:
|
||||||
@@ -133,10 +134,29 @@ export async function handleDeleteDevice(
|
|||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||||
|
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
||||||
const deleted = await storage.deleteDevice(userId, normalized);
|
const deleted = await storage.deleteDevice(userId, normalized);
|
||||||
return jsonResponse({ success: deleted });
|
return jsonResponse({ success: deleted });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DELETE /api/devices
|
||||||
|
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
const [removedTrusted, removedSessions, removedDevices] = await Promise.all([
|
||||||
|
storage.deleteTrustedTwoFactorTokensByUserId(userId),
|
||||||
|
storage.deleteRefreshTokensByUserId(userId),
|
||||||
|
storage.deleteDevicesByUserId(userId),
|
||||||
|
]);
|
||||||
|
user.securityStamp = generateUUID();
|
||||||
|
user.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveUser(user);
|
||||||
|
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
|
||||||
|
}
|
||||||
|
|
||||||
// PUT /api/devices/identifier/{deviceIdentifier}/token
|
// PUT /api/devices/identifier/{deviceIdentifier}/token
|
||||||
// Bitwarden mobile reports push token updates to this endpoint.
|
// Bitwarden mobile reports push token updates to this endpoint.
|
||||||
// NodeWarden does not implement push notifications, so accept and no-op.
|
// NodeWarden does not implement push notifications, so accept and no-op.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
|||||||
import { createRefreshToken } from '../utils/jwt';
|
import { createRefreshToken } from '../utils/jwt';
|
||||||
import { readAuthRequestDeviceInfo } from '../utils/device';
|
import { readAuthRequestDeviceInfo } from '../utils/device';
|
||||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { issueSendAccessToken } from './sends';
|
import { issueSendAccessToken } from './sends';
|
||||||
import {
|
import {
|
||||||
buildAccountKeys,
|
buildAccountKeys,
|
||||||
@@ -227,15 +228,25 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist device only after successful password + (optional) 2FA verification.
|
// Persist device only after successful password + (optional) 2FA verification.
|
||||||
if (deviceInfo.deviceIdentifier) {
|
const deviceSession =
|
||||||
await storage.upsertDevice(user.id, deviceInfo.deviceIdentifier, deviceInfo.deviceName, deviceInfo.deviceType);
|
deviceInfo.deviceIdentifier
|
||||||
|
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
|
||||||
|
: null;
|
||||||
|
if (deviceSession) {
|
||||||
|
await storage.upsertDevice(
|
||||||
|
user.id,
|
||||||
|
deviceSession.identifier,
|
||||||
|
deviceInfo.deviceName,
|
||||||
|
deviceInfo.deviceType,
|
||||||
|
deviceSession.sessionStamp
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful login - clear failed attempts
|
// Successful login - clear failed attempts
|
||||||
await rateLimit.clearLoginAttempts(loginIdentifier);
|
await rateLimit.clearLoginAttempts(loginIdentifier);
|
||||||
|
|
||||||
const accessToken = await auth.generateAccessToken(user);
|
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||||
const refreshToken = await auth.generateRefreshToken(user.id);
|
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||||
|
|
||||||
const response: TokenResponse = {
|
const response: TokenResponse = {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
@@ -346,8 +357,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
Date.now() + LIMITS.auth.refreshTokenOverlapGraceMs
|
Date.now() + LIMITS.auth.refreshTokenOverlapGraceMs
|
||||||
);
|
);
|
||||||
|
|
||||||
const { accessToken, user } = result;
|
const { accessToken, user, device } = result;
|
||||||
const newRefreshToken = await auth.generateRefreshToken(user.id);
|
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
|
||||||
|
|
||||||
const response: TokenResponse = {
|
const response: TokenResponse = {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
|
|||||||
+4
-2
@@ -75,6 +75,7 @@ import {
|
|||||||
handleGetDevices,
|
handleGetDevices,
|
||||||
handleRevokeAllTrustedDevices,
|
handleRevokeAllTrustedDevices,
|
||||||
handleRevokeTrustedDevice,
|
handleRevokeTrustedDevice,
|
||||||
|
handleDeleteAllDevices,
|
||||||
handleDeleteDevice,
|
handleDeleteDevice,
|
||||||
handleUpdateDeviceToken
|
handleUpdateDeviceToken
|
||||||
} from './handlers/devices';
|
} from './handlers/devices';
|
||||||
@@ -750,8 +751,9 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Devices endpoint
|
// Devices endpoint
|
||||||
if (path === '/api/devices' && method === 'GET') {
|
if (path === '/api/devices') {
|
||||||
return handleGetDevices(request, env, userId);
|
if (method === 'GET') return handleGetDevices(request, env, userId);
|
||||||
|
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/devices/authorized') {
|
if (path === '/api/devices/authorized') {
|
||||||
|
|||||||
+32
-9
@@ -61,22 +61,23 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate access token
|
// Generate access token
|
||||||
async generateAccessToken(user: User): Promise<string> {
|
async generateAccessToken(user: User, device?: { identifier: string; sessionStamp: string } | null): Promise<string> {
|
||||||
return createJWT(
|
return createJWT(
|
||||||
{
|
{
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
sstamp: user.securityStamp,
|
sstamp: user.securityStamp,
|
||||||
|
...(device?.identifier ? { did: device.identifier, dstamp: device.sessionStamp } : {}),
|
||||||
},
|
},
|
||||||
this.env.JWT_SECRET
|
this.env.JWT_SECRET
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate refresh token
|
// Generate refresh token
|
||||||
async generateRefreshToken(userId: string): Promise<string> {
|
async generateRefreshToken(userId: string, device?: { identifier: string; sessionStamp: string } | null): Promise<string> {
|
||||||
const token = createRefreshToken();
|
const token = createRefreshToken();
|
||||||
await this.storage.saveRefreshToken(token, userId);
|
await this.storage.saveRefreshToken(token, userId, undefined, device?.identifier ?? null, device?.sessionStamp ?? null);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,22 +101,44 @@ export class AuthService {
|
|||||||
return null; // Token was issued before password change
|
return null; // Token was issued before password change
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.did) {
|
||||||
|
const device = await this.storage.getDevice(user.id, payload.did);
|
||||||
|
if (!device) return null;
|
||||||
|
if (!payload.dstamp || payload.dstamp !== device.sessionStamp) return null;
|
||||||
|
}
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh access token
|
// Refresh access token
|
||||||
async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; user: User } | null> {
|
async refreshAccessToken(
|
||||||
const userId = await this.storage.getRefreshTokenUserId(refreshToken);
|
refreshToken: string
|
||||||
if (!userId) return null;
|
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
|
||||||
|
const record = await this.storage.getRefreshTokenRecord(refreshToken);
|
||||||
|
if (!record?.userId) return null;
|
||||||
|
|
||||||
const user = await this.storage.getUserById(userId);
|
const user = await this.storage.getUserById(record.userId);
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
if (user.status !== 'active') {
|
if (user.status !== 'active') {
|
||||||
await this.storage.deleteRefreshToken(refreshToken);
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = await this.generateAccessToken(user);
|
let device: { identifier: string; sessionStamp: string } | null = null;
|
||||||
return { accessToken, user };
|
if (record.deviceIdentifier) {
|
||||||
|
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
|
||||||
|
if (!boundDevice) {
|
||||||
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!record.deviceSessionStamp || boundDevice.sessionStamp !== record.deviceSessionStamp) {
|
||||||
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = await this.generateAccessToken(user, device);
|
||||||
|
return { accessToken, user, device };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+93
-22
@@ -1,4 +1,4 @@
|
|||||||
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, SendAuthType, TrustedDeviceTokenSummary } from '../types';
|
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, SendAuthType, TrustedDeviceTokenSummary, RefreshTokenRecord } 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;
|
||||||
@@ -52,9 +52,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'ALTER TABLE sends ADD COLUMN emails TEXT',
|
'ALTER TABLE sends ADD COLUMN emails TEXT',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
|
'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
|
||||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, ' +
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'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 INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
|
||||||
|
'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT',
|
||||||
|
'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS invites (' +
|
'CREATE TABLE IF NOT EXISTS invites (' +
|
||||||
'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
@@ -70,11 +72,14 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
|
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS devices (' +
|
'CREATE TABLE IF NOT EXISTS devices (' +
|
||||||
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, ' +
|
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' +
|
||||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
'PRIMARY KEY (user_id, device_identifier), ' +
|
'PRIMARY KEY (user_id, device_identifier), ' +
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'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 INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at)',
|
||||||
|
'ALTER TABLE devices ADD COLUMN session_stamp TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
|
||||||
|
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
|
'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, ' +
|
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||||
@@ -749,40 +754,57 @@ export class StorageService {
|
|||||||
|
|
||||||
// --- Refresh tokens ---
|
// --- Refresh tokens ---
|
||||||
|
|
||||||
async saveRefreshToken(token: string, userId: string, expiresAtMs?: number): Promise<void> {
|
async saveRefreshToken(
|
||||||
|
token: string,
|
||||||
|
userId: string,
|
||||||
|
expiresAtMs?: number,
|
||||||
|
deviceIdentifier?: string | null,
|
||||||
|
deviceSessionStamp?: string | null
|
||||||
|
): Promise<void> {
|
||||||
const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs);
|
const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs);
|
||||||
await this.maybeCleanupExpiredRefreshTokens(Date.now());
|
await this.maybeCleanupExpiredRefreshTokens(Date.now());
|
||||||
const tokenKey = await this.refreshTokenKey(token);
|
const tokenKey = await this.refreshTokenKey(token);
|
||||||
await this.db.prepare(
|
await this.db.prepare(
|
||||||
'INSERT INTO refresh_tokens(token, user_id, expires_at) VALUES(?, ?, ?) ' +
|
'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp) VALUES(?, ?, ?, ?, ?) ' +
|
||||||
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at'
|
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, device_session_stamp=excluded.device_session_stamp'
|
||||||
)
|
)
|
||||||
.bind(tokenKey, userId, expiresAt)
|
.bind(tokenKey, userId, expiresAt, deviceIdentifier ?? null, deviceSessionStamp ?? null)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRefreshTokenUserId(token: string): Promise<string | null> {
|
async getRefreshTokenRecord(token: string): Promise<RefreshTokenRecord | null> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await this.maybeCleanupExpiredRefreshTokens(now);
|
await this.maybeCleanupExpiredRefreshTokens(now);
|
||||||
const tokenKey = await this.refreshTokenKey(token);
|
const tokenKey = await this.refreshTokenKey(token);
|
||||||
|
|
||||||
let row = await this.db.prepare('SELECT user_id, expires_at FROM refresh_tokens WHERE token = ?')
|
let row = await this.db.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
|
||||||
.bind(tokenKey)
|
.bind(tokenKey)
|
||||||
.first<{ user_id: string; expires_at: number }>();
|
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
const legacyRow = await this.db.prepare('SELECT user_id, expires_at FROM refresh_tokens WHERE token = ?')
|
const legacyRow = await this.db.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
|
||||||
.bind(token)
|
.bind(token)
|
||||||
.first<{ user_id: string; expires_at: number }>();
|
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
|
||||||
|
|
||||||
if (legacyRow) {
|
if (legacyRow) {
|
||||||
if (legacyRow.expires_at && legacyRow.expires_at < now) {
|
if (legacyRow.expires_at && legacyRow.expires_at < now) {
|
||||||
await this.deleteRefreshToken(token);
|
await this.deleteRefreshToken(token);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
await this.saveRefreshToken(token, legacyRow.user_id, legacyRow.expires_at);
|
await this.saveRefreshToken(
|
||||||
|
token,
|
||||||
|
legacyRow.user_id,
|
||||||
|
legacyRow.expires_at,
|
||||||
|
legacyRow.device_identifier ?? null,
|
||||||
|
legacyRow.device_session_stamp ?? null
|
||||||
|
);
|
||||||
await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
|
await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
|
||||||
return legacyRow.user_id;
|
return {
|
||||||
|
userId: legacyRow.user_id,
|
||||||
|
expiresAt: legacyRow.expires_at,
|
||||||
|
deviceIdentifier: legacyRow.device_identifier ?? null,
|
||||||
|
deviceSessionStamp: legacyRow.device_session_stamp ?? null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -791,7 +813,17 @@ export class StorageService {
|
|||||||
await this.deleteRefreshToken(token);
|
await this.deleteRefreshToken(token);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return row.user_id;
|
return {
|
||||||
|
userId: row.user_id,
|
||||||
|
expiresAt: row.expires_at,
|
||||||
|
deviceIdentifier: row.device_identifier ?? null,
|
||||||
|
deviceSessionStamp: row.device_session_stamp ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRefreshTokenUserId(token: string): Promise<string | null> {
|
||||||
|
const record = await this.getRefreshTokenRecord(token);
|
||||||
|
return record?.userId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteRefreshToken(token: string): Promise<void> {
|
async deleteRefreshToken(token: string): Promise<void> {
|
||||||
@@ -915,8 +947,17 @@ export class StorageService {
|
|||||||
return (res.results || []).map(row => this.mapSendRow(row));
|
return (res.results || []).map(row => this.mapSendRow(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteRefreshTokensByUserId(userId: string): Promise<void> {
|
async deleteRefreshTokensByUserId(userId: string): Promise<number> {
|
||||||
await this.db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(userId).run();
|
const result = await this.db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(userId).run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRefreshTokensByDevice(userId: string, deviceIdentifier: string): Promise<number> {
|
||||||
|
const result = await this.db
|
||||||
|
.prepare('DELETE FROM refresh_tokens WHERE user_id = ? AND device_identifier = ?')
|
||||||
|
.bind(userId, deviceIdentifier)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep a short overlap window for rotated refresh token to reduce
|
// Keep a short overlap window for rotated refresh token to reduce
|
||||||
@@ -946,13 +987,14 @@ export class StorageService {
|
|||||||
|
|
||||||
// --- Devices ---
|
// --- Devices ---
|
||||||
|
|
||||||
async upsertDevice(userId: string, deviceIdentifier: string, name: string, type: number): Promise<void> {
|
async upsertDevice(userId: string, deviceIdentifier: string, name: string, type: number, sessionStamp?: string): Promise<void> {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
const effectiveSessionStamp = String(sessionStamp || '').trim() || (await this.getDevice(userId, deviceIdentifier))?.sessionStamp || '';
|
||||||
await this.db.prepare(
|
await this.db.prepare(
|
||||||
'INSERT INTO devices(user_id, device_identifier, name, type, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?) ' +
|
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, 0, NULL, ?, ?) ' +
|
||||||
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, updated_at=excluded.updated_at'
|
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, updated_at=excluded.updated_at'
|
||||||
)
|
)
|
||||||
.bind(userId, deviceIdentifier, name, type, now, now)
|
.bind(userId, deviceIdentifier, name, type, effectiveSessionStamp, now, now)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -973,7 +1015,7 @@ export class StorageService {
|
|||||||
async getDevicesByUserId(userId: string): Promise<Device[]> {
|
async getDevicesByUserId(userId: string): Promise<Device[]> {
|
||||||
const res = await this.db
|
const res = await this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT user_id, device_identifier, name, type, created_at, updated_at ' +
|
'SELECT user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at ' +
|
||||||
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
|
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
|
||||||
)
|
)
|
||||||
.bind(userId)
|
.bind(userId)
|
||||||
@@ -983,11 +1025,32 @@ export class StorageService {
|
|||||||
deviceIdentifier: row.device_identifier,
|
deviceIdentifier: row.device_identifier,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
|
sessionStamp: row.session_stamp || '',
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDevice(userId: string, deviceIdentifier: string): Promise<Device | null> {
|
||||||
|
const row = await this.db
|
||||||
|
.prepare(
|
||||||
|
'SELECT user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at ' +
|
||||||
|
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
|
||||||
|
)
|
||||||
|
.bind(userId, deviceIdentifier)
|
||||||
|
.first<any>();
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
userId: row.user_id,
|
||||||
|
deviceIdentifier: row.device_identifier,
|
||||||
|
name: row.name,
|
||||||
|
type: row.type,
|
||||||
|
sessionStamp: row.session_stamp || '',
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async deleteDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
|
async deleteDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||||
const result = await this.db
|
const result = await this.db
|
||||||
.prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?')
|
.prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?')
|
||||||
@@ -996,6 +1059,14 @@ export class StorageService {
|
|||||||
return Number(result.meta.changes ?? 0) > 0;
|
return Number(result.meta.changes ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteDevicesByUserId(userId: string): Promise<number> {
|
||||||
|
const result = await this.db
|
||||||
|
.prepare('DELETE FROM devices WHERE user_id = ?')
|
||||||
|
.bind(userId)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise<TrustedDeviceTokenSummary[]> {
|
async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise<TrustedDeviceTokenSummary[]> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await this.db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run();
|
await this.db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run();
|
||||||
|
|||||||
@@ -183,10 +183,18 @@ export interface Device {
|
|||||||
deviceIdentifier: string;
|
deviceIdentifier: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: number;
|
type: number;
|
||||||
|
sessionStamp: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RefreshTokenRecord {
|
||||||
|
userId: string;
|
||||||
|
expiresAt: number;
|
||||||
|
deviceIdentifier: string | null;
|
||||||
|
deviceSessionStamp: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrustedDeviceTokenSummary {
|
export interface TrustedDeviceTokenSummary {
|
||||||
deviceIdentifier: string;
|
deviceIdentifier: string;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
@@ -257,6 +265,8 @@ export interface JWTPayload {
|
|||||||
email_verified: boolean; // required by mobile client
|
email_verified: boolean; // required by mobile client
|
||||||
amr: string[]; // authentication methods reference - required by mobile client
|
amr: string[]; // authentication methods reference - required by mobile client
|
||||||
sstamp: string; // security stamp - invalidates token when user changes password
|
sstamp: string; // security stamp - invalidates token when user changes password
|
||||||
|
did?: string; // device identifier - invalidates per-device sessions
|
||||||
|
dstamp?: string; // device session stamp
|
||||||
iat: number;
|
iat: number;
|
||||||
exp: number;
|
exp: number;
|
||||||
iss: string;
|
iss: string;
|
||||||
|
|||||||
+25
-1
@@ -43,6 +43,7 @@ import {
|
|||||||
getPreloginKdfConfig,
|
getPreloginKdfConfig,
|
||||||
getProfile,
|
getProfile,
|
||||||
getAuthorizedDevices,
|
getAuthorizedDevices,
|
||||||
|
getCurrentDeviceIdentifier,
|
||||||
getSetupStatus,
|
getSetupStatus,
|
||||||
getSends,
|
getSends,
|
||||||
getTotpStatus,
|
getTotpStatus,
|
||||||
@@ -60,6 +61,7 @@ import {
|
|||||||
saveSession,
|
saveSession,
|
||||||
setTotp,
|
setTotp,
|
||||||
setUserStatus,
|
setUserStatus,
|
||||||
|
deleteAllAuthorizedDevices,
|
||||||
deleteAuthorizedDevice,
|
deleteAuthorizedDevice,
|
||||||
uploadCipherAttachment,
|
uploadCipherAttachment,
|
||||||
updateCipher,
|
updateCipher,
|
||||||
@@ -969,10 +971,21 @@ export default function App() {
|
|||||||
|
|
||||||
async function removeDeviceAction(device: AuthorizedDevice) {
|
async function removeDeviceAction(device: AuthorizedDevice) {
|
||||||
await deleteAuthorizedDevice(authedFetch, device.identifier);
|
await deleteAuthorizedDevice(authedFetch, device.identifier);
|
||||||
|
if (device.identifier === getCurrentDeviceIdentifier()) {
|
||||||
|
pushToast('success', t('txt_device_removed'));
|
||||||
|
logoutNow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
await authorizedDevicesQuery.refetch();
|
await authorizedDevicesQuery.refetch();
|
||||||
pushToast('success', t('txt_device_removed'));
|
pushToast('success', t('txt_device_removed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeAllDevicesAction() {
|
||||||
|
await deleteAllAuthorizedDevices(authedFetch);
|
||||||
|
pushToast('success', t('txt_all_devices_removed'));
|
||||||
|
logoutNow();
|
||||||
|
}
|
||||||
|
|
||||||
async function createVaultItem(draft: VaultDraft, attachments: File[] = []) {
|
async function createVaultItem(draft: VaultDraft, attachments: File[] = []) {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
try {
|
try {
|
||||||
@@ -2004,7 +2017,7 @@ export default function App() {
|
|||||||
onRemoveDevice={(device) => {
|
onRemoveDevice={(device) => {
|
||||||
setConfirm({
|
setConfirm({
|
||||||
title: t('txt_remove_device'),
|
title: t('txt_remove_device'),
|
||||||
message: t('txt_remove_device_name_and_clear_its_2fa_trust', { name: device.name }),
|
message: t('txt_remove_device_and_sign_out_name', { name: device.name }),
|
||||||
danger: true,
|
danger: true,
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
setConfirm(null);
|
setConfirm(null);
|
||||||
@@ -2023,6 +2036,17 @@ export default function App() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onRemoveAll={() => {
|
||||||
|
setConfirm({
|
||||||
|
title: t('txt_remove_all_devices'),
|
||||||
|
message: t('txt_remove_all_devices_and_sign_out_all_sessions'),
|
||||||
|
danger: true,
|
||||||
|
onConfirm: () => {
|
||||||
|
setConfirm(null);
|
||||||
|
void removeAllDevicesAction();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface SecurityDevicesPageProps {
|
|||||||
onRevokeTrust: (device: AuthorizedDevice) => void;
|
onRevokeTrust: (device: AuthorizedDevice) => void;
|
||||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||||
onRevokeAll: () => void;
|
onRevokeAll: () => void;
|
||||||
|
onRemoveAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(value: string | null | undefined): string {
|
function formatDateTime(value: string | null | undefined): string {
|
||||||
@@ -47,7 +48,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
<div>
|
<div>
|
||||||
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
|
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
|
||||||
<div className="muted-inline" style={{ marginTop: 4 }}>
|
<div className="muted-inline" style={{ marginTop: 4 }}>
|
||||||
{t('txt_manage_authorized_devices_and_30_day_totp_trusted_sessions')}
|
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
@@ -59,6 +60,10 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
<ShieldOff size={14} className="btn-icon" />
|
<ShieldOff size={14} className="btn-icon" />
|
||||||
{t('txt_revoke_all_trusted')}
|
{t('txt_revoke_all_trusted')}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger small" onClick={props.onRemoveAll}>
|
||||||
|
<Trash2 size={14} className="btn-icon" />
|
||||||
|
{t('txt_remove_all_devices')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -119,6 +119,10 @@ function getOrCreateDeviceIdentifier(): string {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCurrentDeviceIdentifier(): string {
|
||||||
|
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
function guessDeviceName(): string {
|
function guessDeviceName(): string {
|
||||||
const ua = (typeof navigator !== 'undefined' ? navigator.userAgent : '').toLowerCase();
|
const ua = (typeof navigator !== 'undefined' ? navigator.userAgent : '').toLowerCase();
|
||||||
const platform = (typeof navigator !== 'undefined' ? navigator.platform : '').trim();
|
const platform = (typeof navigator !== 'undefined' ? navigator.platform : '').trim();
|
||||||
@@ -772,6 +776,13 @@ export async function deleteAuthorizedDevice(
|
|||||||
if (!resp.ok) throw new Error('Failed to remove device');
|
if (!resp.ok) throw new Error('Failed to remove device');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteAllAuthorizedDevices(
|
||||||
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>
|
||||||
|
): Promise<void> {
|
||||||
|
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
|
||||||
|
if (!resp.ok) throw new Error('Failed to remove all devices');
|
||||||
|
}
|
||||||
|
|
||||||
export async function listAdminUsers(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<AdminUser[]> {
|
export async function listAdminUsers(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<AdminUser[]> {
|
||||||
const resp = await authedFetch('/api/admin/users');
|
const resp = await authedFetch('/api/admin/users');
|
||||||
if (!resp.ok) throw new Error('Failed to load users');
|
if (!resp.ok) throw new Error('Failed to load users');
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_login_success: "Login success",
|
txt_login_success: "Login success",
|
||||||
txt_macos_desktop: "macOS Desktop",
|
txt_macos_desktop: "macOS Desktop",
|
||||||
txt_manage_authorized_devices_and_30_day_totp_trusted_sessions: "Manage authorized devices and 30-day TOTP trusted sessions.",
|
txt_manage_authorized_devices_and_30_day_totp_trusted_sessions: "Manage authorized devices and 30-day TOTP trusted sessions.",
|
||||||
|
txt_manage_device_sessions_and_30_day_totp_trusted_sessions: "Manage device sessions and 30-day TOTP trusted sessions.",
|
||||||
txt_master_password: "Master Password",
|
txt_master_password: "Master Password",
|
||||||
txt_master_password_changed_please_login_again: "Master password changed. Please login again.",
|
txt_master_password_changed_please_login_again: "Master password changed. Please login again.",
|
||||||
txt_master_password_is_required: "Master password is required",
|
txt_master_password_is_required: "Master password is required",
|
||||||
@@ -301,7 +302,11 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_ignore: "Ignore",
|
txt_ignore: "Ignore",
|
||||||
txt_remove_device: "Remove device",
|
txt_remove_device: "Remove device",
|
||||||
txt_remove_device_2: "Remove Device",
|
txt_remove_device_2: "Remove Device",
|
||||||
|
txt_remove_all_devices: "Remove all devices",
|
||||||
|
txt_remove_all_devices_and_clear_all_2fa_trust: "Remove all devices and clear all 2FA trust?",
|
||||||
|
txt_remove_all_devices_and_sign_out_all_sessions: "Remove all devices, clear all trust, and sign out every device?",
|
||||||
txt_remove_device_name_and_clear_its_2fa_trust: "Remove device \"{name}\" and clear its 2FA trust?",
|
txt_remove_device_name_and_clear_its_2fa_trust: "Remove device \"{name}\" and clear its 2FA trust?",
|
||||||
|
txt_remove_device_and_sign_out_name: "Remove device \"{name}\", clear its trust, and sign it out?",
|
||||||
txt_reveal: "Reveal",
|
txt_reveal: "Reveal",
|
||||||
txt_revoke: "Revoke",
|
txt_revoke: "Revoke",
|
||||||
txt_revoke_30_day_totp_trust_for_name: "Revoke 30-day TOTP trust for \"{name}\"?",
|
txt_revoke_30_day_totp_trust_for_name: "Revoke 30-day TOTP trust for \"{name}\"?",
|
||||||
@@ -384,6 +389,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_unlock_vault: "Unlock Vault",
|
txt_unlock_vault: "Unlock Vault",
|
||||||
txt_unignore: "Unignore",
|
txt_unignore: "Unignore",
|
||||||
txt_unlocked: "Unlocked",
|
txt_unlocked: "Unlocked",
|
||||||
|
txt_all_devices_removed: "All devices removed",
|
||||||
txt_update_item_failed: "Update item failed",
|
txt_update_item_failed: "Update item failed",
|
||||||
txt_update_send_failed: "Update send failed",
|
txt_update_send_failed: "Update send failed",
|
||||||
txt_use_recovery_code: "Use Recovery Code",
|
txt_use_recovery_code: "Use Recovery Code",
|
||||||
@@ -610,6 +616,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_copy_secret: '复制密钥',
|
txt_copy_secret: '复制密钥',
|
||||||
txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically: '这是一次性恢复代码,使用后将自动生成新的恢复代码。',
|
txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically: '这是一次性恢复代码,使用后将自动生成新的恢复代码。',
|
||||||
txt_manage_authorized_devices_and_30_day_totp_trusted_sessions: '管理已授权设备和 30 天 TOTP 受信会话。',
|
txt_manage_authorized_devices_and_30_day_totp_trusted_sessions: '管理已授权设备和 30 天 TOTP 受信会话。',
|
||||||
|
txt_manage_device_sessions_and_30_day_totp_trusted_sessions: '管理设备会话和 30 天 TOTP 受信状态。',
|
||||||
txt_role: '角色',
|
txt_role: '角色',
|
||||||
txt_status: '状态',
|
txt_status: '状态',
|
||||||
txt_actions: '操作',
|
txt_actions: '操作',
|
||||||
@@ -619,6 +626,10 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_revoke_30_day_totp_trust_from_all_devices: '确认撤销所有设备的 30 天 TOTP 信任吗?',
|
txt_revoke_30_day_totp_trust_from_all_devices: '确认撤销所有设备的 30 天 TOTP 信任吗?',
|
||||||
txt_revoke_30_day_totp_trust_for_name: '确认撤销“{name}”的 30 天 TOTP 信任吗?',
|
txt_revoke_30_day_totp_trust_for_name: '确认撤销“{name}”的 30 天 TOTP 信任吗?',
|
||||||
txt_remove_device_name_and_clear_its_2fa_trust: '确认移除设备“{name}”并清除其 2FA 信任吗?',
|
txt_remove_device_name_and_clear_its_2fa_trust: '确认移除设备“{name}”并清除其 2FA 信任吗?',
|
||||||
|
txt_remove_all_devices: '移除所有设备',
|
||||||
|
txt_remove_all_devices_and_clear_all_2fa_trust: '确认移除所有设备并清除全部 2FA 信任吗?',
|
||||||
|
txt_remove_all_devices_and_sign_out_all_sessions: '确认移除所有设备、清除全部信任,并让所有设备重新登录吗?',
|
||||||
|
txt_remove_device_and_sign_out_name: '确认移除设备“{name}”、清除其信任,并让它重新登录吗?',
|
||||||
txt_role_admin: '管理员',
|
txt_role_admin: '管理员',
|
||||||
txt_role_user: '用户',
|
txt_role_user: '用户',
|
||||||
txt_status_active: '正常',
|
txt_status_active: '正常',
|
||||||
@@ -766,6 +777,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_unlock_failed: '解锁失败',
|
txt_unlock_failed: '解锁失败',
|
||||||
txt_unlock_failed_master_password_is_incorrect: '解锁失败,主密码不正确。',
|
txt_unlock_failed_master_password_is_incorrect: '解锁失败,主密码不正确。',
|
||||||
txt_unlocked: '已解锁',
|
txt_unlocked: '已解锁',
|
||||||
|
txt_all_devices_removed: '已移除所有设备',
|
||||||
txt_update_item_failed: '更新项目失败',
|
txt_update_item_failed: '更新项目失败',
|
||||||
txt_update_send_failed: '更新发送失败',
|
txt_update_send_failed: '更新发送失败',
|
||||||
txt_use_your_one_time_recovery_code_to_disable_two_step_verification: '使用一次性恢复代码禁用两步验证。',
|
txt_use_your_one_time_recovery_code_to_disable_two_step_verification: '使用一次性恢复代码禁用两步验证。',
|
||||||
|
|||||||
Reference in New Issue
Block a user