# Initialize monorepo structure
mkdir -p server/src/{routes,utils}
mkdir -p client/src/{components,hooks,lib,styles}
mkdir -p data
# Initialize package.json files
npm init -y (root)
cd server && npm init -y
cd ../client && npm create vite@latest . -- --template react{
"dependencies": {
"express": "^4.18.2",
"better-sqlite3": "^9.2.2",
"@journeyapps/sqlcipher": "^5.3.1",
"express-session": "^1.17.3",
"express-rate-limit": "^7.1.5",
"bcrypt": "^5.1.1",
"otplib": "^12.0.1",
"qrcode-reader": "^1.0.4",
"jimp": "^0.22.10",
"dotenv": "^16.3.1",
"helmet": "^7.1.0",
"cors": "^2.8.5"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}{
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1",
"axios": "^1.6.5",
"zustand": "^4.4.7",
"qr-scanner": "^1.4.2",
"@radix-ui/react-*": "shadcn/ui dependencies",
"tailwindcss": "^3.4.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0",
"lucide-react": "^0.303.0"
}
}-- server/src/migrations/001_initial.sql
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
issuer TEXT,
secret TEXT NOT NULL,
algorithm TEXT DEFAULT 'SHA1',
digits INTEGER DEFAULT 6,
period INTEGER DEFAULT 30,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_accounts_label ON accounts(label);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);# .env.example
NODE_ENV=development
PORT=3000
DB_PATH=./data/app.db
DB_ENCRYPTION_KEY=change-this-to-32-char-secret
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH=$2b$10$HASH_HERE
VIEWER_USERNAME=viewer
VIEWER_PASSWORD_HASH=$2b$10$HASH_HERE
SESSION_SECRET=change-this-session-secret
SESSION_TIMEOUT=86400000server/src/config.js
// Load and validate environment variables
// Export configuration objectserver/src/db.js
// SQLite connection with SQLCipher
// Run migrations
// Export database instanceserver/src/utils/encryption.js
// encryptSecret(plaintext) -> ciphertext
// decryptSecret(ciphertext) -> plaintext
// Using crypto module with AES-256-GCMserver/src/totp.js
// generateTOTP(secret, algorithm, digits, period)
// parseOtpAuthUrl(url) -> { secret, label, issuer, ... }
// getCurrentCode(account) -> { code, timeRemaining }server/src/utils/qrcode.js
// parseQRCodeImage(buffer) -> otpauth URL
// Using qrcode-reader + jimpserver/src/utils/session.js
// createSession(username)
// validateSession(sessionId)
// destroySession(sessionId)
// cleanExpiredSessions()server/src/auth.js
// Middleware: requireAuth
// Middleware: requireAdmin
// hashPassword(password)
// comparePassword(password, hash)server/src/routes/auth.js
POST /api/auth/login
- Validate credentials against hardcoded users
- Create session
- Set httpOnly cookie
- Return user info
POST /api/auth/logout
- Destroy session
- Clear cookie
GET /api/auth/me
- Return current user from sessionserver/src/routes/accounts.js
GET /api/accounts
- Fetch all accounts
- Generate current TOTP codes
- Return with time_remaining
POST /api/accounts
- [requireAdmin]
- Accept: { label, secret } OR { qrCodeImage }
- Parse QR if provided
- Encrypt secret
- Store in database
DELETE /api/accounts/:id
- [requireAdmin]
- Delete from database
GET /api/accounts/:id/otp
- Generate fresh TOTP code
- Return { code, timeRemaining }server/src/index.js
const express = require('express');
const session = require('express-session');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
// Initialize database
require('./db');
// Middleware
app.use(helmet());
app.use(cors({ credentials: true }));
app.use(express.json({ limit: '10mb' })); // For QR images
app.use(session({ ... }));
// Rate limiting on login
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5
});
app.use('/api/auth/login', loginLimiter);
// Routes
app.use('/api/auth', require('./routes/auth'));
app.use('/api/accounts', require('./routes/accounts'));
// Serve frontend in production
if (process.env.NODE_ENV === 'production') {
app.use(express.static('../client/dist'));
}
app.listen(PORT);cd client
npx shadcn-ui@latest init
npx shadcn-ui@latest add button input card dialog progress toast
npx shadcn-ui@latest add tabs label alert dropdown-menuclient/src/lib/api.js
// axios instance with credentials
// API methods:
// - login(username, password)
// - logout()
// - getMe()
// - getAccounts()
// - addAccount(data)
// - deleteAccount(id)
// - getOTP(id)client/src/hooks/useAuth.js
// Zustand store for authentication
// - user: { username, role }
// - login(username, password)
// - logout()
// - checkAuth() - on mountclient/src/hooks/useAccounts.js
// Zustand store for accounts
// - accounts: []
// - fetchAccounts()
// - addAccount(data)
// - deleteAccount(id)
// - updateCode(accountId, code, timeRemaining)client/src/components/LoginForm.jsx
// Form with username + password inputs
// Submit button with loading state
// Error message display
// Redirect to dashboard on successclient/src/components/Dashboard.jsx
// Header: username, logout button
// Add Account button (if admin)
// Grid of AccountCard components
// Empty state if no accounts
// Auto-refresh every second to update codesclient/src/components/AccountCard.jsx
// Display:
// - Label + Issuer
// - Large TOTP code (123 456 format)
// - Progress bar (time remaining)
// - Copy button -> clipboard
// - Delete button (admin only)
//
// Auto-update when time_remaining changesclient/src/components/AddAccountDialog.jsx
// Dialog with tabs:
// 1. QR Code Upload
// - Drag-drop zone
// - File input (accept: image/*)
// - Preview image
//
// 2. Manual Entry
// - Label input
// - Secret input (base32)
// - Issuer input (optional)
//
// Submit button
// Error handlingclient/src/App.jsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
function PrivateRoute({ children }) {
const { user } = useAuth();
return user ? children : <Navigate to="/login" />;
}
<Routes>
<Route path="/login" element={<LoginForm />} />
<Route path="/" element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
} />
</Routes>// In Dashboard component:
useEffect(() => {
const interval = setInterval(() => {
// For each account:
// 1. Calculate time remaining in current 30s window
// 2. If < 1 second, fetch new code from API
// 3. Update local state with new code + timeRemaining
}, 1000);
return () => clearInterval(interval);
}, [accounts]);Dockerfile
FROM node:20-alpine
WORKDIR /app
# Copy server
COPY server/package*.json ./server/
RUN cd server && npm ci --production
# Copy client build
COPY client/dist ./client/dist
# Copy server source
COPY server/src ./server/src
WORKDIR /app/server
EXPOSE 3000
CMD ["node", "src/index.js"]docker-compose.yml
version: '3.8'
services:
authenticator:
build: .
ports:
- "3000:3000"
volumes:
- ./data:/app/data
env_file:
- .env
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/auth/me"]
interval: 30s
timeout: 10s
retries: 3package.json (root)
{
"scripts": {
"dev:server": "cd server && npm run dev",
"dev:client": "cd client && npm run dev",
"build": "npm run build:client && npm run build:server",
"build:client": "cd client && npm run build",
"build:server": "cd server && npm ci --production",
"hash-password": "node scripts/hash-password.js",
"docker:build": "docker-compose build",
"docker:up": "docker-compose up -d",
"docker:down": "docker-compose down"
}
}scripts/hash-password.js
const bcrypt = require('bcrypt');
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question('Enter password to hash: ', async (password) => {
const hash = await bcrypt.hash(password, 10);
console.log('\nBcrypt hash:');
console.log(hash);
rl.close();
});Manual Testing:
- Login as admin
- Login as viewer
- Invalid credentials show error
- Add account via QR code upload
- Add account via manual secret
- TOTP codes display correctly
- Codes refresh every 30 seconds
- Copy to clipboard works
- Admin can delete accounts
- Viewer cannot delete accounts
- Viewer cannot add accounts
- Logout works
- Session persists on refresh
- Responsive design on mobile
- Multiple tabs sync correctly
Security Testing:
- Sessions expire after timeout
- httpOnly cookies prevent XSS
- Rate limiting blocks brute force
- Encrypted secrets in database
- Viewer cannot access admin endpoints
README.md
# TOTP Authenticator
## Quick Start
1. Copy `.env.example` to `.env`
2. Generate password hashes: `npm run hash-password`
3. Update `.env` with hashes
4. Run: `docker-compose up -d`
5. Access: http://localhost:3000
## Default Credentials
- Admin: admin / (set in .env)
- Viewer: viewer / (set in .env)
## Adding Accounts
1. Login as admin
2. Click "Add Account"
3. Upload QR code OR paste secret key
4. Codes auto-refresh every 30 seconds- β Project structure
- β Database setup + migrations
- β Authentication system
- β TOTP generation logic
- β API routes (auth + accounts)
- β React setup with Tailwind + shadcn/ui
- β Login page
- β Dashboard with account cards
- β Add account dialog (QR + manual)
- β Auto-refresh logic
- β Docker configuration
- β Testing + deployment
# Development
npm run dev:server # Backend on :3000
npm run dev:client # Frontend on :5173
# Production Build
npm run build # Build both client + server
npm run hash-password # Generate bcrypt hashes
# Docker Deployment
npm run docker:build # Build image
npm run docker:up # Start container
npm run docker:down # Stop container
# Access
http://localhost:3000 # Production (Docker)
http://localhost:5173 # Development (Vite)- Environment variables configured
- Password hashes generated
- Database encryption key set (32 chars)
- Session secret set (random string)
- Docker container builds successfully
- Volume mount for persistent data
- HTTPS reverse proxy configured (optional)
- Firewall rules applied (optional)
- Backup strategy defined
- Admin credentials shared securely
Issue: "Database is encrypted"
- Solution: Ensure
DB_ENCRYPTION_KEYmatches between runs
Issue: "Session not persisting"
- Solution: Check
SESSION_SECRETis set, cookies enabled
Issue: "QR code not parsing"
- Solution: Ensure QR contains
otpauth://totp/...URL
Issue: "Codes don't match Google Authenticator"
- Solution: Check system time is synced (NTP)
- Export/Import - Backup and restore accounts
- Audit Logging - Track code access history
- Search/Filter - Find accounts quickly
- Categories/Tags - Organize many accounts
- Browser Extension - Quick access from toolbar
- Mobile App - Native iOS/Android