Add TOTP 2FA and retroactive rule application
- TOTP-based two-factor authentication (RFC 6238, HMAC-SHA1): setup, enable, disable in Settings → Password & Security - Login flow: password verified first, then temp token issued for 5-min TOTP window before full session is granted - QR code generated server-side via `qrcode` package for scanning with any authenticator app - "Run Rules Now" button in TransactionRulesManager applies auto-categorization rules to existing uncategorized expenses Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
5339e9818a
commit
52293d7db3
15 files changed
+877
-90
No files matched your search
@@ -40,6 +40,32 @@ export class AuthService {
|
||||
throw new Error(error.error || 'Login failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.requiresTOTP) {
|
||||
const err: any = new Error('2FA required');
|
||||
err.requiresTOTP = true;
|
||||
err.tempToken = result.tempToken;
|
||||
throw err;
|
||||
}
|
||||
|
||||
this._persist(result.user, result.sessionToken);
|
||||
this.notifyAuthChange(result.user);
|
||||
return result.user;
|
||||
}
|
||||
|
||||
static async signInWithTOTP(tempToken: string, code: string): Promise<User> {
|
||||
const response = await fetch('/api/auth/2fa/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tempToken, code }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || '2FA verification failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
this._persist(result.user, result.sessionToken);
|
||||
this.notifyAuthChange(result.user);
|
||||
|
||||
+103
-3
@@ -2,12 +2,14 @@ import path from 'path';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { encryptData, decryptData } from '@/lib/crypto';
|
||||
import { generateSecret, verifyTOTP } from '@/lib/totp';
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'data');
|
||||
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
||||
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json');
|
||||
const RESET_TOKENS_FILE = path.join(DATA_DIR, 'reset_tokens.json');
|
||||
const SHARES_FILE = path.join(DATA_DIR, 'shares.json');
|
||||
const TEMP_TOKENS_FILE = path.join(DATA_DIR, 'temp_tokens.json');
|
||||
|
||||
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default-key-change-in-production';
|
||||
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
@@ -27,7 +29,7 @@ interface Shares { [shareId: string]: Share }
|
||||
async function initializeDirectories() {
|
||||
try {
|
||||
await fsPromises.mkdir(DATA_DIR, { recursive: true });
|
||||
for (const file of [USERS_FILE, SESSIONS_FILE, RESET_TOKENS_FILE, SHARES_FILE]) {
|
||||
for (const file of [USERS_FILE, SESSIONS_FILE, RESET_TOKENS_FILE, SHARES_FILE, TEMP_TOKENS_FILE]) {
|
||||
try {
|
||||
await fsPromises.access(file);
|
||||
} catch {
|
||||
@@ -45,8 +47,14 @@ interface UserCredentials {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
userId: string;
|
||||
totpSecret?: string;
|
||||
totpEnabled?: boolean;
|
||||
pendingTotpSecret?: string;
|
||||
}
|
||||
|
||||
interface TempToken { userId: string; email: string; expiresAt: string }
|
||||
interface TempTokens { [token: string]: TempToken }
|
||||
|
||||
interface Users {
|
||||
[email: string]: UserCredentials;
|
||||
}
|
||||
@@ -133,20 +141,112 @@ export class FileStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
static async loginUser(email: string, password: string): Promise<{ userId: string; email: string; sessionToken: string }> {
|
||||
static async loginUser(email: string, password: string): Promise<
|
||||
| { userId: string; email: string; sessionToken: string; requiresTOTP?: false }
|
||||
| { requiresTOTP: true; tempToken: string }
|
||||
> {
|
||||
const users = await readJson<Users>(USERS_FILE);
|
||||
const user = users[email];
|
||||
|
||||
// Always run bcrypt compare to prevent timing attacks
|
||||
const hashToCompare = user?.passwordHash ?? '$2a$12$invalidhashpadding000000000000000000000000000000000000000';
|
||||
const valid = await bcrypt.compare(password, hashToCompare);
|
||||
|
||||
if (!user || !valid) throw new Error('Invalid credentials');
|
||||
|
||||
if (user.totpEnabled && user.totpSecret) {
|
||||
const releaseLock = await FileLock.acquire('temp-tokens');
|
||||
try {
|
||||
const temps = await readJson<TempTokens>(TEMP_TOKENS_FILE);
|
||||
const tempToken = crypto.randomUUID();
|
||||
temps[tempToken] = { userId: user.userId, email: user.email, expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString() };
|
||||
await writeJson(TEMP_TOKENS_FILE, temps);
|
||||
return { requiresTOTP: true, tempToken };
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
const sessionToken = await this._createSession(user.userId, email);
|
||||
return { userId: user.userId, email: user.email, sessionToken };
|
||||
}
|
||||
|
||||
static async completeTOTPLogin(tempToken: string, totpCode: string): Promise<{ userId: string; email: string; sessionToken: string }> {
|
||||
const temps = await readJson<TempTokens>(TEMP_TOKENS_FILE);
|
||||
const record = temps[tempToken];
|
||||
if (!record) throw new Error('Invalid or expired token');
|
||||
if (new Date(record.expiresAt) < new Date()) throw new Error('Token expired — please sign in again');
|
||||
|
||||
const users = await readJson<Users>(USERS_FILE);
|
||||
const user = Object.values(users).find(u => u.userId === record.userId);
|
||||
if (!user?.totpSecret) throw new Error('2FA not configured');
|
||||
|
||||
if (!verifyTOTP(user.totpSecret, totpCode)) throw new Error('Invalid 2FA code');
|
||||
|
||||
const releaseLock = await FileLock.acquire('temp-tokens');
|
||||
try {
|
||||
const temps2 = await readJson<TempTokens>(TEMP_TOKENS_FILE);
|
||||
delete temps2[tempToken];
|
||||
await writeJson(TEMP_TOKENS_FILE, temps2);
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
|
||||
const sessionToken = await this._createSession(record.userId, record.email);
|
||||
return { userId: record.userId, email: record.email, sessionToken };
|
||||
}
|
||||
|
||||
static async setup2FA(userId: string): Promise<{ secret: string; otpAuthUrl: string }> {
|
||||
const { getOtpAuthUrl } = await import('@/lib/totp');
|
||||
const users = await readJson<Users>(USERS_FILE);
|
||||
const userEntry = Object.entries(users).find(([, u]) => u.userId === userId);
|
||||
if (!userEntry) throw new Error('User not found');
|
||||
|
||||
const secret = generateSecret();
|
||||
const [email, user] = userEntry;
|
||||
users[email] = { ...user, pendingTotpSecret: secret };
|
||||
await writeJson(USERS_FILE, users);
|
||||
|
||||
return { secret, otpAuthUrl: getOtpAuthUrl(secret, email) };
|
||||
}
|
||||
|
||||
static async enable2FA(userId: string, token: string): Promise<void> {
|
||||
const releaseLock = await FileLock.acquire('users-file');
|
||||
try {
|
||||
const users = await readJson<Users>(USERS_FILE);
|
||||
const entry = Object.entries(users).find(([, u]) => u.userId === userId);
|
||||
if (!entry) throw new Error('User not found');
|
||||
const [email, user] = entry;
|
||||
if (!user.pendingTotpSecret) throw new Error('No pending 2FA setup — start setup first');
|
||||
if (!verifyTOTP(user.pendingTotpSecret, token)) throw new Error('Invalid code — please check your authenticator app');
|
||||
users[email] = { ...user, totpSecret: user.pendingTotpSecret, totpEnabled: true, pendingTotpSecret: undefined };
|
||||
await writeJson(USERS_FILE, users);
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
static async disable2FA(userId: string, token: string): Promise<void> {
|
||||
const releaseLock = await FileLock.acquire('users-file');
|
||||
try {
|
||||
const users = await readJson<Users>(USERS_FILE);
|
||||
const entry = Object.entries(users).find(([, u]) => u.userId === userId);
|
||||
if (!entry) throw new Error('User not found');
|
||||
const [email, user] = entry;
|
||||
if (!user.totpEnabled || !user.totpSecret) throw new Error('2FA is not enabled');
|
||||
if (!verifyTOTP(user.totpSecret, token)) throw new Error('Invalid 2FA code');
|
||||
users[email] = { ...user, totpSecret: undefined, totpEnabled: false, pendingTotpSecret: undefined };
|
||||
await writeJson(USERS_FILE, users);
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
static async get2FAStatus(userId: string): Promise<boolean> {
|
||||
const users = await readJson<Users>(USERS_FILE);
|
||||
const user = Object.values(users).find(u => u.userId === userId);
|
||||
return user?.totpEnabled ?? false;
|
||||
}
|
||||
|
||||
static async validateSession(token: string): Promise<{ userId: string; email: string } | null> {
|
||||
try {
|
||||
const sessions = await readJson<Sessions>(SESSIONS_FILE);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
function base32Encode(buf: Buffer): string {
|
||||
let result = '';
|
||||
let bits = 0;
|
||||
let current = 0;
|
||||
for (const byte of buf) {
|
||||
current = (current << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
result += BASE32_CHARS[(current >> (bits - 5)) & 0x1f];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) result += BASE32_CHARS[(current << (5 - bits)) & 0x1f];
|
||||
return result;
|
||||
}
|
||||
|
||||
function base32Decode(s: string): Buffer {
|
||||
const bytes: number[] = [];
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
for (const ch of s.toUpperCase().replace(/=+$/, '')) {
|
||||
const idx = BASE32_CHARS.indexOf(ch);
|
||||
if (idx < 0) continue;
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bytes.push((value >> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
export function generateSecret(): string {
|
||||
return base32Encode(crypto.randomBytes(20));
|
||||
}
|
||||
|
||||
export function getOtpAuthUrl(secret: string, email: string, issuer = 'Capsule'): string {
|
||||
const label = encodeURIComponent(`${issuer}:${email}`);
|
||||
return `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
|
||||
}
|
||||
|
||||
function computeTOTP(key: Buffer, counter: number): string {
|
||||
const buf = Buffer.alloc(8);
|
||||
buf.writeBigUInt64BE(BigInt(counter));
|
||||
const hmac = crypto.createHmac('sha1', key);
|
||||
hmac.update(buf);
|
||||
const digest = hmac.digest();
|
||||
const offset = digest[digest.length - 1] & 0x0f;
|
||||
const code = (
|
||||
((digest[offset] & 0x7f) << 24) |
|
||||
((digest[offset + 1] & 0xff) << 16) |
|
||||
((digest[offset + 2] & 0xff) << 8) |
|
||||
(digest[offset + 3] & 0xff)
|
||||
) % 1_000_000;
|
||||
return code.toString().padStart(6, '0');
|
||||
}
|
||||
|
||||
export function verifyTOTP(secret: string, token: string): boolean {
|
||||
const key = base32Decode(secret);
|
||||
const step = Math.floor(Date.now() / 1000 / 30);
|
||||
for (const w of [-1, 0, 1]) {
|
||||
if (computeTOTP(key, step + w) === token) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Reference in new issue
Block a user