zerobyte/app/server/modules/auth/auth.service.ts
Jakub Trávník a4d0c7b0c9 feat: add two-factor authentication (TOTP) support
- Add 2FA endpoints for setup, enable, disable, and verify
- Encrypt TOTP secrets with AES-256-GCM before storing
- Require password for both enabling and disabling 2FA
- Invalidate all sessions when 2FA status changes
- Add QR code display using qrcode.react (client-side)
- Add 2FA verification step in login flow
- Add CLI recovery command: 2fa disable -u <username>
- Add success variant to Badge component
- Update README with 2FA documentation
2026-01-03 00:19:04 +01:00

452 lines
11 KiB
TypeScript

import { eq, lt } from "drizzle-orm";
import { db } from "../../db/db";
import { sessionsTable, usersTable } from "../../db/schema";
import { cryptoUtils } from "../../utils/crypto";
import { logger } from "../../utils/logger";
import * as OTPAuth from "otpauth";
const SESSION_DURATION = 60 * 60 * 24 * 30 * 1000; // 30 days in milliseconds
const PENDING_2FA_DURATION = 5 * 60 * 1000; // 5 minutes for 2FA verification
// In-memory store for pending 2FA sessions (short-lived, no need for DB persistence)
interface Pending2faSession {
userId: number;
expiresAt: number;
}
const pending2faSessions = new Map<string, Pending2faSession>();
export class AuthService {
/**
* Register a new user with username and password
*/
async register(username: string, password: string) {
const [existingUser] = await db.select().from(usersTable);
if (existingUser) {
throw new Error("Admin user already exists");
}
const passwordHash = await Bun.password.hash(password, {
algorithm: "argon2id",
memoryCost: 19456,
timeCost: 2,
});
const [user] = await db.insert(usersTable).values({ username, passwordHash }).returning();
if (!user) {
throw new Error("User registration failed");
}
logger.info(`User registered: ${username}`);
const sessionId = crypto.randomUUID();
const expiresAt = Date.now() + SESSION_DURATION;
await db.insert(sessionsTable).values({
id: sessionId,
userId: user.id,
expiresAt,
});
return {
user: {
id: user.id,
username: user.username,
createdAt: user.createdAt,
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
},
sessionId,
};
}
/**
* Login user with username and password
* Returns requiresTwoFactor: true if 2FA is enabled, with a pendingSessionId
*/
async login(username: string, password: string) {
const [user] = await db.select().from(usersTable).where(eq(usersTable.username, username));
if (!user) {
throw new Error("Invalid credentials");
}
const isValid = await Bun.password.verify(password, user.passwordHash);
if (!isValid) {
throw new Error("Invalid credentials");
}
// Check if 2FA is enabled
if (user.totpEnabled && user.totpSecret) {
// Create a pending 2FA session in memory
const pendingSessionId = crypto.randomUUID();
const expiresAt = Date.now() + PENDING_2FA_DURATION;
pending2faSessions.set(pendingSessionId, {
userId: user.id,
expiresAt,
});
logger.info(`2FA required for user: ${username}`);
return {
requiresTwoFactor: true as const,
pendingSessionId,
};
}
// No 2FA - create full session
const sessionId = crypto.randomUUID();
const expiresAt = Date.now() + SESSION_DURATION;
await db.insert(sessionsTable).values({
id: sessionId,
userId: user.id,
expiresAt,
});
logger.info(`User logged in: ${username}`);
return {
requiresTwoFactor: false as const,
sessionId,
user: {
id: user.id,
username: user.username,
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
},
expiresAt,
};
}
/**
* Verify 2FA code and complete login
*/
async verifyTwoFactor(pendingSessionId: string, code: string) {
// Find the pending session from memory
const pendingSession = pending2faSessions.get(pendingSessionId);
if (!pendingSession) {
throw new Error("Invalid or expired 2FA session");
}
// Check if session expired
if (pendingSession.expiresAt < Date.now()) {
pending2faSessions.delete(pendingSessionId);
throw new Error("2FA session expired. Please login again.");
}
// Get user from database
const [user] = await db.select().from(usersTable).where(eq(usersTable.id, pendingSession.userId));
if (!user) {
pending2faSessions.delete(pendingSessionId);
throw new Error("User not found");
}
if (!user.totpSecret) {
throw new Error("2FA not configured for this user");
}
// Decrypt the TOTP secret
const decryptedSecret = await cryptoUtils.resolveSecret(user.totpSecret);
// Verify TOTP code
const totp = new OTPAuth.TOTP({
issuer: "Zerobyte",
label: user.username,
algorithm: "SHA1",
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(decryptedSecret),
});
const delta = totp.validate({ token: code, window: 1 });
if (delta === null) {
throw new Error("Invalid 2FA code");
}
// Delete the pending session from memory
pending2faSessions.delete(pendingSessionId);
// Create full session
const sessionId = crypto.randomUUID();
const expiresAt = Date.now() + SESSION_DURATION;
await db.insert(sessionsTable).values({
id: sessionId,
userId: user.id,
expiresAt,
});
logger.info(`User logged in with 2FA: ${user.username}`);
return {
sessionId,
user: {
id: user.id,
username: user.username,
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
},
expiresAt,
};
}
/**
* Clean up expired pending 2FA sessions from memory
*/
cleanupExpiredPending2faSessions() {
const now = Date.now();
let count = 0;
for (const [id, session] of pending2faSessions) {
if (session.expiresAt < now) {
pending2faSessions.delete(id);
count++;
}
}
if (count > 0) {
logger.info(`Cleaned up ${count} expired pending 2FA sessions`);
}
}
/**
* Logout user by deleting their session
*/
async logout(sessionId: string) {
await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId));
logger.info(`User logged out: session ${sessionId}`);
}
/**
* Verify a session and return the associated user
*/
async verifySession(sessionId: string) {
const [session] = await db
.select({
session: sessionsTable,
user: usersTable,
})
.from(sessionsTable)
.innerJoin(usersTable, eq(sessionsTable.userId, usersTable.id))
.where(eq(sessionsTable.id, sessionId));
if (!session) {
return null;
}
if (session.session.expiresAt < Date.now()) {
await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId));
return null;
}
return {
user: {
id: session.user.id,
username: session.user.username,
hasDownloadedResticPassword: session.user.hasDownloadedResticPassword,
},
session: {
id: session.session.id,
expiresAt: session.session.expiresAt,
},
};
}
/**
* Clean up expired sessions
*/
async cleanupExpiredSessions() {
const result = await db.delete(sessionsTable).where(lt(sessionsTable.expiresAt, Date.now())).returning();
if (result.length > 0) {
logger.info(`Cleaned up ${result.length} expired sessions`);
}
}
/**
* Check if any users exist in the system
*/
async hasUsers(): Promise<boolean> {
const [user] = await db.select({ id: usersTable.id }).from(usersTable).limit(1);
return !!user;
}
/**
* Change password for a user
*/
async changePassword(userId: number, currentPassword: string, newPassword: string) {
const [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId));
if (!user) {
throw new Error("User not found");
}
const isValid = await Bun.password.verify(currentPassword, user.passwordHash);
if (!isValid) {
throw new Error("Current password is incorrect");
}
const newPasswordHash = await Bun.password.hash(newPassword, {
algorithm: "argon2id",
memoryCost: 19456,
timeCost: 2,
});
await db.update(usersTable).set({ passwordHash: newPasswordHash }).where(eq(usersTable.id, userId));
logger.info(`Password changed for user: ${user.username}`);
}
/**
* Get 2FA status for a user
*/
async getTwoFactorStatus(userId: number): Promise<boolean> {
const [user] = await db
.select({ totpEnabled: usersTable.totpEnabled })
.from(usersTable)
.where(eq(usersTable.id, userId));
return user?.totpEnabled ?? false;
}
/**
* Generate 2FA setup data (secret and otpauth URI for QR code)
*/
async setupTwoFactor(userId: number): Promise<{ uri: string; secret: string }> {
const [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId));
if (!user) {
throw new Error("User not found");
}
if (user.totpEnabled) {
throw new Error("2FA is already enabled for this account");
}
// Generate a new TOTP secret
const secret = new OTPAuth.Secret({ size: 20 });
const totp = new OTPAuth.TOTP({
issuer: "Zerobyte",
label: user.username,
algorithm: "SHA1",
digits: 6,
period: 30,
secret: secret,
});
return {
uri: totp.toString(),
secret: secret.base32,
};
}
/**
* Enable 2FA after verifying password and code
*/
async enableTwoFactor(userId: number, password: string, secret: string, code: string): Promise<void> {
const [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId));
if (!user) {
throw new Error("User not found");
}
if (user.totpEnabled) {
throw new Error("2FA is already enabled for this account");
}
// Verify password
const isPasswordValid = await Bun.password.verify(password, user.passwordHash);
if (!isPasswordValid) {
throw new Error("Invalid password");
}
// Verify the code with the provided secret
const totp = new OTPAuth.TOTP({
issuer: "Zerobyte",
label: user.username,
algorithm: "SHA1",
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(secret),
});
const delta = totp.validate({ token: code, window: 1 });
if (delta === null) {
throw new Error("Invalid verification code");
}
// Encrypt and save the secret
const encryptedSecret = await cryptoUtils.sealSecret(secret);
await db.transaction(async (tx) => {
await tx
.update(usersTable)
.set({
totpSecret: encryptedSecret,
totpEnabled: true,
updatedAt: Date.now(),
})
.where(eq(usersTable.id, userId));
// Invalidate all sessions except current (user will need to re-login on other devices)
await tx.delete(sessionsTable).where(eq(sessionsTable.userId, userId));
});
logger.info(`2FA enabled for user: ${user.username}`);
}
/**
* Disable 2FA after verifying password and TOTP code
*/
async disableTwoFactor(userId: number, password: string, code: string): Promise<void> {
const [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId));
if (!user) {
throw new Error("User not found");
}
if (!user.totpEnabled || !user.totpSecret) {
throw new Error("2FA is not enabled for this account");
}
// Verify password
const isPasswordValid = await Bun.password.verify(password, user.passwordHash);
if (!isPasswordValid) {
throw new Error("Invalid password");
}
// Decrypt and verify TOTP code
const decryptedSecret = await cryptoUtils.resolveSecret(user.totpSecret);
const totp = new OTPAuth.TOTP({
issuer: "Zerobyte",
label: user.username,
algorithm: "SHA1",
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(decryptedSecret),
});
const delta = totp.validate({ token: code, window: 1 });
if (delta === null) {
throw new Error("Invalid 2FA code");
}
// Disable 2FA
await db.transaction(async (tx) => {
await tx
.update(usersTable)
.set({
totpSecret: null,
totpEnabled: false,
updatedAt: Date.now(),
})
.where(eq(usersTable.id, userId));
// Invalidate all sessions
await tx.delete(sessionsTable).where(eq(sessionsTable.userId, userId));
});
logger.info(`2FA disabled for user: ${user.username}`);
}
}
export const authService = new AuthService();