diff --git a/.env.example b/.env.example index 67a40ea..e929505 100644 --- a/.env.example +++ b/.env.example @@ -43,8 +43,19 @@ HTTP_DISPLAY_SORT=2 # Domain display # 3 = shuffle all HTTP_HIDE_OTHER=false # true = only show first domain, false = show all -# --- INBOX LOCKING (optional) --- -LOCK_ENABLED=false # Enable inbox locking with passwords -LOCK_SESSION_SECRET="change-this-secret-in-production" # Secret for session encryption +# --- USER AUTHENTICATION & INBOX LOCKING --- +# Authentication System +USER_AUTH_ENABLED=false # Enable user registration/login system (default: false) + +# Session Secret (shared for both locking and user sessions) +USER_SESSION_SECRET="change-this-secret-in-production" # Secret for session encryption (used for auth & locking) + +# Database Paths +USER_DATABASE_PATH="./db/users.db" # Path to user database LOCK_DATABASE_PATH="./db/locked-inboxes.db" # Path to lock database -LOCK_RELEASE_HOURS=720 # Auto-release locked inboxes after X hours of inactivity (default 30 days) + +# Feature Limits +USER_MAX_FORWARD_EMAILS=5 # Maximum verified forwarding emails per user +USER_MAX_LOCKED_INBOXES=5 # Maximum locked inboxes per user +LOCK_RELEASE_HOURS=720 # Auto-release locked inboxes after X hours of inactivity (default: 720 = 30 days) + diff --git a/app.js b/app.js index 2b2c54e..0946e4b 100644 --- a/app.js +++ b/app.js @@ -10,30 +10,28 @@ const ClientNotification = require('./infrastructure/web/client-notification') const ImapService = require('./application/imap-service') const MailProcessingService = require('./application/mail-processing-service') const SmtpService = require('./application/smtp-service') +const AuthService = require('./application/auth-service') const MailRepository = require('./domain/mail-repository') const InboxLock = require('./domain/inbox-lock') const VerificationStore = require('./domain/verification-store') +const UserRepository = require('./domain/user-repository') const clientNotification = new ClientNotification() debug('Client notification service initialized') clientNotification.use(io) -let inboxLock = null - // Initialize inbox locking if enabled -if (config.lock.enabled) { - inboxLock = new InboxLock(config.lock.dbPath) - app.set('inboxLock', inboxLock) - console.log(`Inbox locking enabled (auto-release after ${config.lock.releaseHours} hours)`) - +// Initialize inbox locking (always available for registered users) +const inboxLock = new InboxLock(config.user.lockDbPath) +app.set('inboxLock', inboxLock) +debug('Inbox lock service initialized') // Check for inactive locked inboxes - setInterval(() => { - const inactive = inboxLock.getInactive(config.lock.releaseHours) - if (inactive.length > 0) { - console.log(`Releasing ${inactive.length} inactive locked inbox(es)`) - inactive.forEach(address => inboxLock.release(address)) - } - }, config.imap.refreshIntervalSeconds * 1000) -} +setInterval(() => { + const inactive = inboxLock.getInactive(config.user.lockReleaseHours) + if (inactive.length > 0) { + debug(`Releasing ${inactive.length} inactive locked inbox(es)`) + inactive.forEach(address => inboxLock.release(address)) + } +}, config.imap.refreshIntervalSeconds * 1000) const imapService = new ImapService(config, inboxLock) debug('IMAP service initialized') @@ -45,6 +43,22 @@ const verificationStore = new VerificationStore() debug('Verification store initialized') app.set('verificationStore', verificationStore) +// Initialize user repository and auth service (if enabled) +if (config.user.authEnabled) { + const userRepository = new UserRepository(config.user.databasePath) + debug('User repository initialized') + app.set('userRepository', userRepository) + + const authService = new AuthService(userRepository, config) + debug('Auth service initialized') + app.set('authService', authService) + console.log('User authentication system enabled') +} else { + app.set('userRepository', null) + app.set('authService', null) + debug('User authentication system disabled') +} + const mailProcessingService = new MailProcessingService( new MailRepository(), imapService, diff --git a/application/auth-service.js b/application/auth-service.js new file mode 100644 index 0000000..ffab747 --- /dev/null +++ b/application/auth-service.js @@ -0,0 +1,246 @@ +const bcrypt = require('bcrypt') +const debug = require('debug')('48hr-email:auth-service') + +/** + * Authentication Service - Business logic for user authentication + * Handles registration, login, validation, and password management + */ +class AuthService { + constructor(userRepository, config) { + this.userRepository = userRepository + this.config = config + this.BCRYPT_ROUNDS = 12 + } + + /** + * Register a new user + * @param {string} username - Username (3-20 alphanumeric + underscore) + * @param {string} password - Password (min 8 chars, complexity requirements) + * @returns {Promise<{success: boolean, user?: Object, error?: string}>} + */ + async register(username, password) { + // Validate username + const usernameValidation = this.validateUsername(username) + if (!usernameValidation.valid) { + debug(`Registration failed: ${usernameValidation.error}`) + return { success: false, error: usernameValidation.error } + } + + // Validate password + const passwordValidation = this.validatePassword(password) + if (!passwordValidation.valid) { + debug(`Registration failed: ${passwordValidation.error}`) + return { success: false, error: passwordValidation.error } + } + + try { + // Hash password + const passwordHash = await this.hashPassword(password) + + // Create user + const user = this.userRepository.createUser(username, passwordHash) + + debug(`User registered successfully: ${username} (ID: ${user.id})`) + + return { + success: true, + user: { + id: user.id, + username: user.username, + created_at: user.created_at + } + } + } catch (error) { + if (error.message === 'Username already exists') { + debug(`Registration failed: Username already exists: ${username}`) + return { success: false, error: 'Username already exists' } + } + + debug(`Registration error: ${error.message}`) + return { success: false, error: 'Registration failed. Please try again.' } + } + } + + /** + * Login user with username and password + * @param {string} username + * @param {string} password + * @returns {Promise<{success: boolean, user?: Object, error?: string}>} + */ + async login(username, password) { + if (!username || !password) { + debug('Login failed: Missing username or password') + return { success: false, error: 'Username and password are required' } + } + + try { + // Get user from database + const user = this.userRepository.getUserByUsername(username) + + if (!user) { + debug(`Login failed: User not found: ${username}`) + // Use generic error to prevent username enumeration + return { success: false, error: 'Invalid username or password' } + } + + // Verify password + const isValid = await this.verifyPassword(password, user.password_hash) + + if (!isValid) { + debug(`Login failed: Invalid password for user: ${username}`) + return { success: false, error: 'Invalid username or password' } + } + + // Update last login + this.userRepository.updateLastLogin(user.id) + + debug(`User logged in successfully: ${username} (ID: ${user.id})`) + + return { + success: true, + user: { + id: user.id, + username: user.username, + created_at: user.created_at, + last_login: Date.now() + } + } + } catch (error) { + debug(`Login error: ${error.message}`) + return { success: false, error: 'Login failed. Please try again.' } + } + } + + /** + * Validate username format + * @param {string} username + * @returns {{valid: boolean, error?: string}} + */ + validateUsername(username) { + if (!username) { + return { valid: false, error: 'Username is required' } + } + + if (typeof username !== 'string') { + return { valid: false, error: 'Username must be a string' } + } + + const trimmed = username.trim() + + if (trimmed.length < 3) { + return { valid: false, error: 'Username must be at least 3 characters' } + } + + if (trimmed.length > 20) { + return { valid: false, error: 'Username must be at most 20 characters' } + } + + // Only allow alphanumeric and underscore + const usernameRegex = /^[a-zA-Z0-9_]+$/ + if (!usernameRegex.test(trimmed)) { + return { valid: false, error: 'Username can only contain letters, numbers, and underscores' } + } + + return { valid: true } + } + + /** + * Validate password strength + * @param {string} password + * @returns {{valid: boolean, error?: string}} + */ + validatePassword(password) { + if (!password) { + return { valid: false, error: 'Password is required' } + } + + if (typeof password !== 'string') { + return { valid: false, error: 'Password must be a string' } + } + + if (password.length < 8) { + return { valid: false, error: 'Password must be at least 8 characters' } + } + + if (password.length > 72) { + // Bcrypt max length + return { valid: false, error: 'Password must be at most 72 characters' } + } + + // Check for at least one uppercase letter + if (!/[A-Z]/.test(password)) { + return { valid: false, error: 'Password must contain at least one uppercase letter' } + } + + // Check for at least one lowercase letter + if (!/[a-z]/.test(password)) { + return { valid: false, error: 'Password must contain at least one lowercase letter' } + } + + // Check for at least one number + if (!/[0-9]/.test(password)) { + return { valid: false, error: 'Password must contain at least one number' } + } + + return { valid: true } + } + + /** + * Hash password using bcrypt + * @param {string} password + * @returns {Promise} - Password hash + */ + async hashPassword(password) { + try { + const hash = await bcrypt.hash(password, this.BCRYPT_ROUNDS) + debug('Password hashed successfully') + return hash + } catch (error) { + debug(`Error hashing password: ${error.message}`) + throw new Error('Failed to hash password') + } + } + + /** + * Verify password against hash + * @param {string} password + * @param {string} hash + * @returns {Promise} + */ + async verifyPassword(password, hash) { + try { + const isValid = await bcrypt.compare(password, hash) + debug(`Password verification: ${isValid ? 'success' : 'failed'}`) + return isValid + } catch (error) { + debug(`Error verifying password: ${error.message}`) + return false + } + } + + /** + * Get user for session (without sensitive data) + * @param {number} userId + * @returns {Object|null} - User session data + */ + getUserForSession(userId) { + try { + const user = this.userRepository.getUserById(userId) + if (!user) { + return null + } + + return { + id: user.id, + username: user.username, + created_at: user.created_at, + last_login: user.last_login + } + } catch (error) { + debug(`Error getting user for session: ${error.message}`) + return null + } + } +} + +module.exports = AuthService diff --git a/application/config.js b/application/config.js index 4471aff..a451eb4 100644 --- a/application/config.js +++ b/application/config.js @@ -74,11 +74,21 @@ const config = { hideOther: parseBool(process.env.HTTP_HIDE_OTHER) }, - lock: { - enabled: parseBool(process.env.LOCK_ENABLED) || false, - sessionSecret: parseValue(process.env.LOCK_SESSION_SECRET) || 'change-me-in-production', - dbPath: parseValue(process.env.LOCK_DATABASE_PATH) || './db/locked-inboxes.db', - releaseHours: Number(process.env.LOCK_RELEASE_HOURS) || 720 // 30 days default + user: { + // Authentication System + authEnabled: parseBool(process.env.USER_AUTH_ENABLED) || false, + + // Database + databasePath: parseValue(process.env.USER_DATABASE_PATH) || './db/users.db', + lockDbPath: parseValue(process.env.LOCK_DATABASE_PATH) || './db/locked-inboxes.db', + + // Session & Auth + sessionSecret: parseValue(process.env.USER_SESSION_SECRET) || 'change-me-in-production', + + // Feature Limits + maxForwardEmails: Number(process.env.USER_MAX_FORWARD_EMAILS) || 5, + maxLockedInboxes: Number(process.env.USER_MAX_LOCKED_INBOXES) || 5, + lockReleaseHours: Number(process.env.LOCK_RELEASE_HOURS) || 720 // 30 days default } }; diff --git a/application/helper.js b/application/helper.js index 81d97d3..f1c5992 100644 --- a/application/helper.js +++ b/application/helper.js @@ -199,7 +199,7 @@ class Helper { * @returns {string} - HMAC signature (hex) */ signCookie(email) { - const secret = config.lock.sessionSecret + const secret = config.user.sessionSecret const hmac = crypto.createHmac('sha256', secret) hmac.update(email.toLowerCase()) const signature = hmac.digest('hex') diff --git a/domain/user-repository.js b/domain/user-repository.js new file mode 100644 index 0000000..525dde2 --- /dev/null +++ b/domain/user-repository.js @@ -0,0 +1,325 @@ +const Database = require('better-sqlite3') +const debug = require('debug')('48hr-email:user-repository') +const fs = require('fs') +const path = require('path') + +/** + * User Repository - Data access layer for user accounts + * Manages users, their verified forwarding emails, and locked inboxes + */ +class UserRepository { + constructor(dbPath) { + this.dbPath = dbPath + this.db = null + this._initialize() + } + + /** + * Initialize database connection and create schema + * @private + */ + _initialize() { + try { + // Ensure directory exists + const dbDir = path.dirname(this.dbPath) + if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }) + debug(`Created database directory: ${dbDir}`) + } + + // Open database connection + this.db = new Database(this.dbPath) + this.db.pragma('journal_mode = WAL') + debug(`Connected to user database: ${this.dbPath}`) + + // Load and execute schema + const schemaPath = path.join(__dirname, '../db/schema.sql') + const schema = fs.readFileSync(schemaPath, 'utf8') + this.db.exec(schema) + debug('Database schema initialized') + } catch (error) { + console.error('Failed to initialize user database:', error) + throw error + } + } + + /** + * Create a new user + * @param {string} username - Unique username (3-20 chars) + * @param {string} passwordHash - Bcrypt password hash + * @returns {Object} - Created user object {id, username, created_at} + */ + createUser(username, passwordHash) { + try { + const now = Date.now() + const stmt = this.db.prepare(` + INSERT INTO users (username, password_hash, created_at) + VALUES (?, ?, ?) + `) + const result = stmt.run(username.toLowerCase(), passwordHash, now) + + debug(`User created: ${username} (ID: ${result.lastInsertRowid})`) + + return { + id: result.lastInsertRowid, + username: username.toLowerCase(), + created_at: now + } + } catch (error) { + if (error.message.includes('UNIQUE constraint failed')) { + debug(`Username already exists: ${username}`) + throw new Error('Username already exists') + } + debug(`Error creating user: ${error.message}`) + throw error + } + } + + /** + * Get user by username + * @param {string} username + * @returns {Object|null} - User object or null if not found + */ + getUserByUsername(username) { + try { + const stmt = this.db.prepare(` + SELECT id, username, password_hash, created_at, last_login + FROM users + WHERE username = ? + `) + const user = stmt.get(username.toLowerCase()) + + if (user) { + debug(`User found: ${username} (ID: ${user.id})`) + } else { + debug(`User not found: ${username}`) + } + + return user || null + } catch (error) { + debug(`Error getting user by username: ${error.message}`) + throw error + } + } + + /** + * Get user by ID + * @param {number} userId + * @returns {Object|null} - User object or null if not found + */ + getUserById(userId) { + try { + const stmt = this.db.prepare(` + SELECT id, username, password_hash, created_at, last_login + FROM users + WHERE id = ? + `) + const user = stmt.get(userId) + + if (user) { + debug(`User found by ID: ${userId}`) + } else { + debug(`User not found by ID: ${userId}`) + } + + return user || null + } catch (error) { + debug(`Error getting user by ID: ${error.message}`) + throw error + } + } + + /** + * Update user's last login timestamp + * @param {number} userId + */ + updateLastLogin(userId) { + try { + const now = Date.now() + const stmt = this.db.prepare(` + UPDATE users + SET last_login = ? + WHERE id = ? + `) + stmt.run(now, userId) + debug(`Updated last login for user ID: ${userId}`) + } catch (error) { + debug(`Error updating last login: ${error.message}`) + throw error + } + } + + /** + * Add a verified forwarding email for a user + * @param {number} userId + * @param {string} email - Verified email address + * @returns {Object} - Created forward email entry + */ + addForwardEmail(userId, email) { + try { + const now = Date.now() + const stmt = this.db.prepare(` + INSERT INTO user_forward_emails (user_id, email, verified_at, created_at) + VALUES (?, ?, ?, ?) + `) + const result = stmt.run(userId, email.toLowerCase(), now, now) + + debug(`Forward email added for user ${userId}: ${email}`) + + return { + id: result.lastInsertRowid, + user_id: userId, + email: email.toLowerCase(), + verified_at: now, + created_at: now + } + } catch (error) { + if (error.message.includes('UNIQUE constraint failed')) { + debug(`Forward email already exists for user ${userId}: ${email}`) + throw new Error('Email already added to your account') + } + debug(`Error adding forward email: ${error.message}`) + throw error + } + } + + /** + * Get all verified forwarding emails for a user + * @param {number} userId + * @returns {Array} - Array of email objects + */ + getForwardEmails(userId) { + try { + const stmt = this.db.prepare(` + SELECT id, email, verified_at, created_at + FROM user_forward_emails + WHERE user_id = ? + ORDER BY created_at DESC + `) + const emails = stmt.all(userId) + debug(`Found ${emails.length} forward emails for user ${userId}`) + return emails + } catch (error) { + debug(`Error getting forward emails: ${error.message}`) + throw error + } + } + + /** + * Check if user has a specific forwarding email + * @param {number} userId + * @param {string} email + * @returns {boolean} + */ + hasForwardEmail(userId, email) { + try { + const stmt = this.db.prepare(` + SELECT COUNT(*) as count + FROM user_forward_emails + WHERE user_id = ? AND email = ? + `) + const result = stmt.get(userId, email.toLowerCase()) + return result.count > 0 + } catch (error) { + debug(`Error checking forward email: ${error.message}`) + throw error + } + } + + /** + * Remove a forwarding email from user's account + * @param {number} userId + * @param {string} email + * @returns {boolean} - True if deleted, false if not found + */ + removeForwardEmail(userId, email) { + try { + const stmt = this.db.prepare(` + DELETE FROM user_forward_emails + WHERE user_id = ? AND email = ? + `) + const result = stmt.run(userId, email.toLowerCase()) + + if (result.changes > 0) { + debug(`Forward email removed for user ${userId}: ${email}`) + return true + } else { + debug(`Forward email not found for user ${userId}: ${email}`) + return false + } + } catch (error) { + debug(`Error removing forward email: ${error.message}`) + throw error + } + } + + /** + * Get count of user's forwarding emails + * @param {number} userId + * @returns {number} + */ + getForwardEmailCount(userId) { + try { + const stmt = this.db.prepare(` + SELECT COUNT(*) as count + FROM user_forward_emails + WHERE user_id = ? + `) + const result = stmt.get(userId) + return result.count + } catch (error) { + debug(`Error getting forward email count: ${error.message}`) + throw error + } + } + + /** + * Get user statistics + * @param {number} userId + * @returns {Object} - {lockedInboxesCount, forwardEmailsCount, accountAge} + */ + getUserStats(userId) { + try { + const user = this.getUserById(userId) + if (!user) { + return null + } + + const lockedInboxesStmt = this.db.prepare(` + SELECT COUNT(*) as count FROM user_locked_inboxes WHERE user_id = ? + `) + const forwardEmailsStmt = this.db.prepare(` + SELECT COUNT(*) as count FROM user_forward_emails WHERE user_id = ? + `) + + const lockedInboxesCount = lockedInboxesStmt.get(userId).count + const forwardEmailsCount = forwardEmailsStmt.get(userId).count + const accountAge = Date.now() - user.created_at + + debug(`Stats for user ${userId}: ${lockedInboxesCount} locked inboxes, ${forwardEmailsCount} forward emails`) + + return { + lockedInboxesCount, + forwardEmailsCount, + accountAge, + createdAt: user.created_at, + lastLogin: user.last_login + } + } catch (error) { + debug(`Error getting user stats: ${error.message}`) + throw error + } + } + + /** + * Close database connection + */ + close() { + if (this.db) { + this.db.close() + debug('Database connection closed') + } + } +} + +module.exports = UserRepository diff --git a/infrastructure/web/middleware/auth.js b/infrastructure/web/middleware/auth.js new file mode 100644 index 0000000..deae5c6 --- /dev/null +++ b/infrastructure/web/middleware/auth.js @@ -0,0 +1,122 @@ +const debug = require('debug')('48hr-email:auth-middleware') + +/** + * Authentication middleware functions + * Handle session-based authentication and authorization + */ + +/** + * Require authenticated user - redirect to login if not authenticated + * @param {Object} req - Express request + * @param {Object} res - Express response + * @param {Function} next - Express next function + */ +function requireAuth(req, res, next) { + if (req.session && req.session.userId && req.session.isAuthenticated) { + // User is authenticated + debug(`Authenticated request from user ${req.session.username} (ID: ${req.session.userId})`) + + // Populate req.user for convenience + req.user = { + id: req.session.userId, + username: req.session.username, + created_at: req.session.createdAt + } + + return next() + } + + // User is not authenticated + debug('Unauthenticated request, redirecting to login') + + // Store the original URL to redirect back after login + req.session.redirectAfterLogin = req.originalUrl + + // Redirect to login + return res.redirect('/login') +} + +/** + * Optional authentication - populate req.user if authenticated, but don't redirect + * @param {Object} req - Express request + * @param {Object} res - Express response + * @param {Function} next - Express next function + */ +function optionalAuth(req, res, next) { + if (req.session && req.session.userId && req.session.isAuthenticated) { + // User is authenticated, populate req.user + debug(`Optional auth: User ${req.session.username} (ID: ${req.session.userId}) is authenticated`) + + req.user = { + id: req.session.userId, + username: req.session.username, + created_at: req.session.createdAt + } + } else { + // User is not authenticated, set req.user to null + req.user = null + debug('Optional auth: User not authenticated') + } + + next() +} + +/** + * Check if user owns a specific locked inbox + * Used to verify user can access/modify a locked inbox + * @param {Object} req - Express request + * @param {Object} res - Express response + * @param {Function} next - Express next function + */ +function checkUserOwnsInbox(req, res, next) { + if (!req.user) { + debug('Check inbox ownership: User not authenticated') + return res.status(401).json({ error: 'Authentication required' }) + } + + const inboxAddress = req.params.address + if (!inboxAddress) { + debug('Check inbox ownership: No inbox address provided') + return res.status(400).json({ error: 'Inbox address required' }) + } + + // Get user repository from app + const userRepository = req.app.get('userRepository') + if (!userRepository) { + debug('Check inbox ownership: User repository not available') + return res.status(500).json({ error: 'Service not available' }) + } + + try { + // Check if inbox is in user's locked inboxes + // This will be implemented when we migrate inbox-lock.js + // For now, we'll trust the session + debug(`Check inbox ownership: User ${req.user.username} accessing inbox ${inboxAddress}`) + next() + } catch (error) { + debug(`Check inbox ownership error: ${error.message}`) + return res.status(500).json({ error: 'Failed to verify inbox ownership' }) + } +} + +/** + * Middleware to prevent access for authenticated users (e.g., login/register pages) + * @param {Object} req - Express request + * @param {Object} res - Express response + * @param {Function} next - Express next function + */ +function redirectIfAuthenticated(req, res, next) { + if (req.session && req.session.userId && req.session.isAuthenticated) { + debug(`User ${req.session.username} already authenticated, redirecting to home`) + return res.redirect('/') + } + + next() +} + +module.exports = { + requireAuth, + optionalAuth, + checkUserOwnsInbox, + redirectIfAuthenticated +} diff --git a/infrastructure/web/public/stylesheets/custom.css b/infrastructure/web/public/stylesheets/custom.css index f2895e5..6af2e43 100644 --- a/infrastructure/web/public/stylesheets/custom.css +++ b/infrastructure/web/public/stylesheets/custom.css @@ -297,6 +297,231 @@ text-muted { } +/* Auth pages */ + +.auth-container { + max-width: 900px; + margin: 2rem auto; + padding: 0 1rem; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3rem; + align-items: start; +} + +.auth-card { + background: var(--color-bg-dark); + border: 1px solid var(--color-border-dark); + border-radius: 8px; + padding: 2rem; +} + +.auth-card h1 { + margin-bottom: 0.5rem; + color: var(--color-accent-purple); +} + +.auth-subtitle { + color: var(--color-text-gray); + margin-bottom: 2rem; +} + +.auth-card fieldset { + border: none; + padding: 0; + margin: 0; +} + +.auth-card label { + display: block; + margin-top: 1rem; + margin-bottom: 0.5rem; + font-weight: 600; +} + +.auth-card small { + display: block; + color: var(--color-text-gray); + font-size: 0.85rem; + margin-top: 0.25rem; + margin-bottom: 0.5rem; +} + +.auth-card input[type="text"], +.auth-card input[type="password"] { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--color-border-dark); + border-radius: 4px; + background: var(--color-bg-medium); + color: var(--color-text-light); +} + +.auth-actions { + margin-top: 2rem; +} + +.button-primary { + width: 100%; + background: var(--color-accent-purple); + color: white; + border: none; + padding: 0.75rem; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.button-primary:hover { + background: var(--color-accent-purple-light); +} + +.auth-footer { + text-align: center; + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--color-border-dark); +} + +.auth-footer a { + color: var(--color-accent-purple); + text-decoration: none; + font-weight: 600; +} + +.auth-footer a:hover { + text-decoration: underline; +} + +.auth-features { + padding: 2rem; +} + +.auth-features h3, +.auth-features h4 { + color: var(--color-accent-purple); + margin-bottom: 1rem; +} + +.auth-features ul { + list-style: none; + padding: 0; +} + +.auth-features li { + padding: 0.75rem 0; + font-size: 1.05rem; +} + +.auth-guest-section { + margin-top: 2rem; + padding-top: 2rem; + border-top: 1px solid var(--color-border-dark); +} + +@media (max-width: 768px) { + .auth-container { + grid-template-columns: 1fr; + gap: 2rem; + } + .auth-features { + padding: 1rem; + } +} + + +/* Verification success page */ + +.verification-success-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 60vh; + padding: 2rem; +} + +.verification-success-card { + background: var(--color-bg-dark); + border: 2px solid #28a745; + border-radius: 8px; + padding: 3rem 2rem; + max-width: 500px; + width: 100%; + text-align: center; + box-shadow: 0 4px 6px var(--overlay-black-40); +} + +.verification-success-icon { + width: 80px; + height: 80px; + margin: 0 auto 1.5rem; + color: #28a745; +} + +.verification-success-icon svg { + width: 100%; + height: 100%; +} + +.verification-success-card h1 { + color: #28a745; + margin-bottom: 1.5rem; + font-size: 1.8rem; +} + +.verification-details { + margin: 2rem 0; + padding: 1rem; + background: var(--overlay-white-05); + border-radius: 5px; +} + +.verification-details p { + margin: 0.5rem 0; +} + +.verified-email { + font-family: monospace; + font-size: 1.1rem; + font-weight: bold; + color: var(--color-accent-purple); + word-break: break-all; +} + +.verification-info { + margin: 2rem 0; + text-align: left; + padding: 0 1rem; +} + +.verification-info p { + margin: 0.75rem 0; + line-height: 1.6; +} + +.verification-actions { + margin-top: 2rem; +} + +.verification-actions .button-primary { + display: inline-block; + width: auto; + padding: 0.75rem 2rem; + border-radius: 5px; + text-decoration: none; +} + +@media (max-width: 600px) { + .verification-success-card { + padding: 2rem 1rem; + } + .verification-success-card h1 { + font-size: 1.5rem; + } +} + + /* Reset apple form styles */ input, diff --git a/infrastructure/web/routes/auth.js b/infrastructure/web/routes/auth.js new file mode 100644 index 0000000..83baf3c --- /dev/null +++ b/infrastructure/web/routes/auth.js @@ -0,0 +1,259 @@ +const express = require('express') +const router = new express.Router() +const { body, validationResult } = require('express-validator') +const debug = require('debug')('48hr-email:auth-routes') +const { redirectIfAuthenticated } = require('../middleware/auth') + +// Simple in-memory rate limiters for registration and login +const registrationRateLimitStore = new Map() +const loginRateLimitStore = new Map() + +// Registration rate limiter: 5 attempts per IP per hour +const registrationRateLimiter = (req, res, next) => { + const ip = req.ip || req.connection.remoteAddress + const now = Date.now() + const windowMs = 60 * 60 * 1000 // 1 hour + const maxRequests = 5 + + // Clean up old entries + for (const [key, data] of registrationRateLimitStore.entries()) { + if (now - data.resetTime > windowMs) { + registrationRateLimitStore.delete(key) + } + } + + // Get or create entry for this IP + let ipData = registrationRateLimitStore.get(ip) + if (!ipData || now - ipData.resetTime > windowMs) { + ipData = { count: 0, resetTime: now } + registrationRateLimitStore.set(ip, ipData) + } + + // Check if limit exceeded + if (ipData.count >= maxRequests) { + debug(`Registration rate limit exceeded for IP ${ip}`) + req.session.errorMessage = 'Too many registration attempts. Please try again after 1 hour.' + return res.redirect('/register') + } + + // Increment counter + ipData.count++ + next() +} + +// Login rate limiter: 10 attempts per IP per 15 minutes +const loginRateLimiter = (req, res, next) => { + const ip = req.ip || req.connection.remoteAddress + const now = Date.now() + const windowMs = 15 * 60 * 1000 // 15 minutes + const maxRequests = 10 + + // Clean up old entries + for (const [key, data] of loginRateLimitStore.entries()) { + if (now - data.resetTime > windowMs) { + loginRateLimitStore.delete(key) + } + } + + // Get or create entry for this IP + let ipData = loginRateLimitStore.get(ip) + if (!ipData || now - ipData.resetTime > windowMs) { + ipData = { count: 0, resetTime: now } + loginRateLimitStore.set(ip, ipData) + } + + // Check if limit exceeded + if (ipData.count >= maxRequests) { + debug(`Login rate limit exceeded for IP ${ip}`) + req.session.errorMessage = 'Too many login attempts. Please try again after 15 minutes.' + return res.redirect('/login') + } + + // Increment counter + ipData.count++ + next() +} + +// GET /register - Show registration form +router.get('/register', redirectIfAuthenticated, (req, res) => { + const config = req.app.get('config') + const errorMessage = req.session.errorMessage + const successMessage = req.session.successMessage + + // Clear messages after reading + delete req.session.errorMessage + delete req.session.successMessage + + res.render('register', { + title: `Register | ${config.http.branding[0]}`, + branding: config.http.branding, + errorMessage, + successMessage + }) +}) + +// POST /register - Process registration +router.post('/register', + redirectIfAuthenticated, + registrationRateLimiter, + body('username').trim().notEmpty().withMessage('Username is required'), + body('password').notEmpty().withMessage('Password is required'), + body('confirmPassword').notEmpty().withMessage('Password confirmation is required'), + async(req, res) => { + try { + const errors = validationResult(req) + if (!errors.isEmpty()) { + const firstError = errors.array()[0].msg + debug(`Registration validation failed: ${firstError}`) + req.session.errorMessage = firstError + return res.redirect('/register') + } + + const { username, password, confirmPassword } = req.body + + // Check if passwords match + if (password !== confirmPassword) { + debug('Registration failed: Passwords do not match') + req.session.errorMessage = 'Passwords do not match' + return res.redirect('/register') + } + + const authService = req.app.get('authService') + const result = await authService.register(username, password) + + if (result.success) { + debug(`User registered successfully: ${username}`) + req.session.successMessage = 'Registration successful! Please log in.' + return res.redirect('/login') + } else { + debug(`Registration failed: ${result.error}`) + req.session.errorMessage = result.error + return res.redirect('/register') + } + } catch (error) { + debug(`Registration error: ${error.message}`) + console.error('Error during registration', error) + req.session.errorMessage = 'An unexpected error occurred. Please try again.' + res.redirect('/register') + } + } +) + +// GET /login - Show login form +router.get('/login', redirectIfAuthenticated, (req, res) => { + const config = req.app.get('config') + const errorMessage = req.session.errorMessage + const successMessage = req.session.successMessage + + // Clear messages after reading + delete req.session.errorMessage + delete req.session.successMessage + + res.render('login-auth', { + title: `Login | ${config.http.branding[0]}`, + branding: config.http.branding, + errorMessage, + successMessage + }) +}) + +// POST /login - Process login +router.post('/login', + redirectIfAuthenticated, + loginRateLimiter, + body('username').trim().notEmpty().withMessage('Username is required'), + body('password').notEmpty().withMessage('Password is required'), + async(req, res) => { + try { + const errors = validationResult(req) + if (!errors.isEmpty()) { + const firstError = errors.array()[0].msg + debug(`Login validation failed: ${firstError}`) + req.session.errorMessage = firstError + return res.redirect('/login') + } + + const { username, password } = req.body + const authService = req.app.get('authService') + const result = await authService.login(username, password) + + if (result.success) { + debug(`User logged in successfully: ${username}`) + + // Regenerate session to prevent fixation attacks + const redirectUrl = req.session.redirectAfterLogin || '/' + + req.session.regenerate((err) => { + if (err) { + debug(`Session regeneration error: ${err.message}`) + req.session.errorMessage = 'Login failed. Please try again.' + return res.redirect('/login') + } + + // Set session data + req.session.userId = result.user.id + req.session.username = result.user.username + req.session.isAuthenticated = true + req.session.createdAt = result.user.created_at + + req.session.save((err) => { + if (err) { + debug(`Session save error: ${err.message}`) + req.session.errorMessage = 'Login failed. Please try again.' + return res.redirect('/login') + } + + debug(`Session created for user: ${username}`) + res.redirect(redirectUrl) + }) + }) + } else { + debug(`Login failed: ${result.error}`) + req.session.errorMessage = result.error + return res.redirect('/login') + } + } catch (error) { + debug(`Login error: ${error.message}`) + console.error('Error during login', error) + req.session.errorMessage = 'An unexpected error occurred. Please try again.' + res.redirect('/login') + } + } +) + +// GET /logout - Logout user +router.get('/logout', (req, res) => { + if (req.session) { + const username = req.session.username + req.session.destroy((err) => { + if (err) { + debug(`Logout error: ${err.message}`) + console.error('Error during logout', err) + } else { + debug(`User logged out: ${username}`) + } + res.redirect('/') + }) + } else { + res.redirect('/') + } +}) + +// GET /auth/check - JSON endpoint for checking auth status (AJAX) +router.get('/auth/check', (req, res) => { + if (req.session && req.session.userId && req.session.isAuthenticated) { + res.json({ + authenticated: true, + user: { + id: req.session.userId, + username: req.session.username + } + }) + } else { + res.json({ + authenticated: false + }) + } +}) + +module.exports = router diff --git a/infrastructure/web/routes/inbox.js b/infrastructure/web/routes/inbox.js index 3ec9fd7..14bea1d 100644 --- a/infrastructure/web/routes/inbox.js +++ b/infrastructure/web/routes/inbox.js @@ -137,11 +137,11 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, checkLo totalcount: totalcount, mailSummaries: mailProcessingService.getMailSummaries(req.params.address), branding: config.http.branding, - lockEnabled: config.lock.enabled, + authEnabled: config.user.authEnabled, isLocked: isLocked, hasAccess: hasAccess, unlockError: unlockErrorSession, - locktimer: config.lock.releaseHours, + locktimer: config.user.lockReleaseHours, error: lockError, redirectTo: req.originalUrl, expiryTime: config.email.purgeTime.time, @@ -216,7 +216,7 @@ router.get( cryptoAttachments: cryptoAttachments, uid: req.params.uid, branding: config.http.branding, - lockEnabled: config.lock.enabled, + authEnabled: config.user.authEnabled, isLocked: isLocked, hasAccess: hasAccess, errorMessage: errorMessage, diff --git a/infrastructure/web/views/inbox.twig b/infrastructure/web/views/inbox.twig index 6bf478a..4139d93 100644 --- a/infrastructure/web/views/inbox.twig +++ b/infrastructure/web/views/inbox.twig @@ -2,7 +2,7 @@ {% block header %}