セキュリティ重要アプリケーションでの堅牢な状態管理の構築
はじめに
スマートロック管理のようなセキュリティ重要システムでは、ブラウザセッション、ネットワーク中断、ユーザーインタラクション全体にわたって一貫したアプリケーション状態を維持することが最重要です。単一の状態不整合がアクセス制御の失敗やセキュリティ脆弱性を引き起こす可能性があります。この記事では、不利な条件下でもセキュリティ保証を維持する堅牢な状態管理システムを構築するためのパターンを探ります。
状態の永続化と復旧パターン
ブラウザセッション間同期
Webベースのセキュリティシステムの最も困難な側面の一つは、複数のブラウザインスタンス間で状態の一貫性を維持することです。ユーザーはチェックインプロセス中に複数のタブを開いたり、デバイスを切り替えたりすることがよくあります。
interface CheckinState {
sessionId: string;
userId: string;
facilityId: string;
entryKeyExpiresAt: Date;
authenticationComplete: boolean;
}
class SecureStateManager {
private readonly STORAGE_KEY = 'secure_checkin_state';
async persistState(state: CheckinState): Promise<void> {
// Encrypt sensitive state before storage
const encryptedState = await this.encrypt(state);
localStorage.setItem(this.STORAGE_KEY, encryptedState);
// Broadcast state change to other tabs
this.broadcastStateChange(state.sessionId);
}
async recoverState(): Promise<CheckinState | null> {
const stored = localStorage.getItem(this.STORAGE_KEY);
if (!stored) return null;
try {
const decrypted = await this.decrypt(stored);
return this.validateStateIntegrity(decrypted);
} catch (error) {
// Clear corrupted state to prevent security issues
this.clearState();
throw new StateCorruptionError('Failed to recover state');
}
}
private validateStateIntegrity(state: any): CheckinState {
if (!state.sessionId || !state.facilityId) {
throw new StateValidationError('Invalid state structure');
}
// Verify expiration times haven't been tampered with
const expiresAt = new Date(state.entryKeyExpiresAt);
if (expiresAt < new Date()) {
throw new StateExpirationError('State has expired');
}
return state as CheckinState;
}
}復旧リンクの実装
復旧メカニズムでの無限ループは、サービス拒否状態を引き起こす可能性があります。このような問題を防ぐパターンは以下の通りです:
class RecoveryLinkManager {
private attempts = new Map<string, number>();
private readonly MAX_ATTEMPTS = 3;
private readonly COOLDOWN_MS = 300000; // 5 minutes
async handleRecoveryAttempt(token: string, email: string): Promise<boolean> {
const key = `${email}:${token}`;
const attemptCount = this.attempts.get(key) || 0;
if (attemptCount >= this.MAX_ATTEMPTS) {
await this.logSecurityEvent('recovery_rate_limit', { email, token });
throw new RateLimitError('Too many recovery attempts');
}
try {
const isValid = await this.validateRecoveryToken(token, email);
if (isValid) {
this.attempts.delete(key);
return true;
} else {
this.attempts.set(key, attemptCount + 1);
this.scheduleCleanup(key);
return false;
}
} catch (error) {
await this.logSecurityEvent('recovery_validation_error', {
email,
error: error.message
});
throw error;
}
}
private scheduleCleanup(key: string): void {
setTimeout(() => {
this.attempts.delete(key);
}, this.COOLDOWN_MS);
}
}時間ベースのセキュリティ制御
動的キー有効性管理
アクセス制御システムでのセキュリティキーは、正確に制御された有効期間を持つ必要があります。異なるシナリオには異なる有効期限戦略が必要です:
interface KeyValidityConfig {
planType: 'one-time' | 'hourly' | 'daily';
timezone: string;
baseValidityMinutes: number;
}
class KeyValidityManager {
calculateKeyExpiration(config: KeyValidityConfig): Date {
const now = new Date();
const userTimezone = new Intl.DateTimeFormat('en', {
timeZone: config.timezone
}).resolvedOptions().timeZone;
switch (config.planType) {
case 'one-time':
// Fixed 3-minute window for immediate use
return new Date(now.getTime() + 3 * 60 * 1000);
case 'hourly':
// Valid until end of current hour in user's timezone
return this.getEndOfHour(now, userTimezone);
case 'daily':
// Valid until end of day in user's timezone
return this.getEndOfDay(now, userTimezone);
default:
throw new InvalidPlanTypeError(`Unknown plan type: ${config.planType}`);
}
}
isKeyValid(expiresAt: Date): boolean {
return new Date() < expiresAt;
}
async refreshKeyIfNeeded(sessionId: string): Promise<EntryKey | null> {
const currentKey = await this.getCurrentKey(sessionId);
if (!currentKey || !this.isKeyValid(currentKey.expiresAt)) {
// Log key refresh for audit trail
await this.auditLog('key_refresh', {
sessionId,
reason: currentKey ? 'expired' : 'missing',
previousExpiry: currentKey?.expiresAt
});
return this.generateNewKey(sessionId);
}
return currentKey;
}
}セキュリティ制約付きUI状態同期
ユーザーインターフェースは、システムのセキュリティ状態を正確に反映する必要があります。信頼性の高いUI更新を実装する方法は以下の通りです:
interface SecurityUIState {
keyStatus: 'valid' | 'expired' | 'refreshing' | 'error';
expiresAt: Date | null;
canRefresh: boolean;
lastRefresh: Date | null;
}
class SecurityUIController {
private state: SecurityUIState = {
keyStatus: 'expired',
expiresAt: null,
canRefresh: true,
lastRefresh: null
};
async updateKeyStatus(): Promise<void> {
try {
this.setState({ keyStatus: 'refreshing' });
const keyData = await this.keyManager.refreshKeyIfNeeded(this.sessionId);
if (keyData) {
this.setState({
keyStatus: 'valid',
expiresAt: keyData.expiresAt,
lastRefresh: new Date()
});
// Schedule automatic refresh before expiration
this.scheduleRefresh(keyData.expiresAt);
} else {
this.setState({ keyStatus: 'error' });
}
} catch (error) {
await this.handleSecurityError(error);
this.setState({ keyStatus: 'error' });
}
}
private scheduleRefresh(expiresAt: Date): void {
const refreshTime = expiresAt.getTime() - Date.now() - 30000; // 30s buffer
if (refreshTime > 0) {
setTimeout(() => {
this.updateKeyStatus();
}, refreshTime);
}
}
}包括的なEnd-to-Endテスト
セキュリティ重要システムでは、状態遷移とエッジケースの徹底的なテストが必要です:
// キー有効性シナリオのテスト構造例
describe('Key Validity State Machine', () => {
const testCases = [
{
name: 'one-time plan key expires after 3 minutes',
planType: 'one-time',
expectedDurationMs: 3 * 60 * 1000,
allowRefresh: false
},
{
name: 'hourly plan key valid until end of hour',
planType: 'hourly',
timezone: 'Asia/Tokyo',
allowRefresh: true
},
{
name: 'expired key shows grayscale overlay',
planType: 'one-time',
testExpiredUI: true
}
];
testCases.forEach(testCase => {
test(testCase.name, async ({ page }) => {
await page.goto('/checkin?plan=' + testCase.planType);
// Verify initial state
const keyElement = page.locator('[data-testid="qr-code"]');
await expect(keyElement).toBeVisible();
if (testCase.expectedDurationMs) {
// Fast-forward time to test expiration
await page.evaluate((ms) => {
Date.now = () => Date.now() + ms + 1000;
}, testCase.expectedDurationMs);
await page.reload();
if (testCase.testExpiredUI) {
await expect(keyElement).toHaveClass(/expired/);
}
}
if (testCase.allowRefresh) {
const refreshBtn = page.locator('[data-testid="refresh-key"]');
await expect(refreshBtn).toBeVisible();
}
});
});
});エラーハンドリングと復旧
グレースフル劣化パターン
セキュリティシステムがエラーに遭遇した場合、明確な復旧パスを提供しながら安全に失敗する必要があります:
class SecureErrorHandler {
async handleAuthenticationError(error: AuthError, context: AuthContext): Promise<RecoveryAction> {
// Log security-relevant errors
await this.securityLogger.log('auth_error', {
errorType: error.constructor.name,
userId: context.userId,
timestamp: new Date().toISOString(),
userAgent: context.userAgent,
ipAddress: this.hashIP(context.ipAddress)
});
switch (error.type) {
case 'TOKEN_EXPIRED':
return {
action: 'redirect_to_login',
message: 'Your session has expired. Please log in again.',
retryable: true
};
case 'INVALID_CREDENTIALS':
return {
action: 'show_error',
message: 'Invalid credentials. Please check and try again.',
retryable: true,
rateLimitKey: context.userId
};
case 'ACCOUNT_LOCKED':
return {
action: 'show_recovery_options',
message: 'Account temporarily locked. Use recovery email to regain access.',
retryable: false
};
default:
return {
action: 'show_generic_error',
message: 'Authentication failed. Please try again later.',
retryable: false
};
}
}
}まとめ
セキュリティ重要アプリケーションの堅牢な状態管理を構築するには、以下への細心の注意が必要です:
- 状態の永続化: 整合性検証を伴う暗号化ストレージの実装
- セッション間同期: 複数のブラウザインスタンス間での状態調整
- 時間ベース制御: タイムゾーン対応の計算によるキー有効期限管理
- 復旧メカニズム: セキュリティを維持しながら無限ループを防止
- 包括的テスト: 全ての状態遷移とエッジケースのカバー
- エラーハンドリング: 明確な復旧パスを提供しながら安全に失敗
これらのパターンにより、不利な条件下でも、システムが信頼性の高いユーザー体験を提供しながらセキュリティ保証を維持することが保証されます。重要なのは、状態管理を単なる技術的な懸念としてではなく、認証や認可メカニズムと同じ厳密さを要求する重要なセキュリティ制御として扱うことです。