[Feat]: Add User Registration

Add User table to sql, add user-repository, add registration and login routes, update config
This commit is contained in:
ClaraCrazy 2026-01-02 16:27:43 +01:00
parent 2a08aa14a8
commit 598cea9b9c
No known key found for this signature in database
GPG key ID: EBBC896ACB497011
16 changed files with 1432 additions and 135 deletions

View file

@ -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)

38
app.js
View file

@ -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)
setInterval(() => {
const inactive = inboxLock.getInactive(config.user.lockReleaseHours)
if (inactive.length > 0) {
console.log(`Releasing ${inactive.length} inactive locked inbox(es)`)
debug(`Releasing ${inactive.length} inactive locked inbox(es)`)
inactive.forEach(address => inboxLock.release(address))
}
}, config.imap.refreshIntervalSeconds * 1000)
}
}, 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,

246
application/auth-service.js Normal file
View file

@ -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<string>} - 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<boolean>}
*/
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

View file

@ -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
}
};

View file

@ -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')

325
domain/user-repository.js Normal file
View file

@ -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

View file

@ -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
}

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -2,7 +2,7 @@
{% block header %}
<div class="action-links">
{% if lockEnabled %}
{% if authEnabled %}
{% if isLocked and hasAccess %}
<a href="#" id="removeLockBtn" aria-label="Remove password lock">Remove Lock</a>
{% elseif isLocked %}
@ -13,7 +13,7 @@
{% endif %}
<a href="#" id="forwardAllBtn" aria-label="Forward all emails">Forward All</a>
<a href="/inbox/{{ address }}/delete-all" aria-label="Delete all emails">Wipe Inbox</a>
{% if lockEnabled and hasAccess %}
{% if authEnabled and hasAccess %}
<a href="/lock/logout" aria-label="Logout">Logout</a>
{% else %}
<a href="/logout" aria-label="Logout">Logout</a>
@ -88,7 +88,7 @@
</blockquote>
{% endif %}
<div class="refresh-countdown" id="refreshCountdown" title="New emails are fetched from the server only when the timer hits zero. Reloading this page has no effect on fetching.">Fetching new mails in <span id="refreshTimer">--</span>s</div>
{% if lockEnabled and not isLocked %}
{% if authEnabled and not isLocked %}
<!-- Lock Modal -->
<div id="lockModal" class="modal" style="display: none;" data-lock-error="{{ error|default('') }}">
<div class="modal-content">
@ -116,7 +116,7 @@
{% endif %}
{% if lockEnabled and isLocked and not hasAccess %}
{% if authEnabled and isLocked and not hasAccess %}
<!-- Unlock Modal -->
<div id="unlockModal" class="modal" style="display: none;" data-unlock-error="{{ unlockError|default('') }}">
<div class="modal-content">
@ -148,7 +148,7 @@
{% endif %}
{% if lockEnabled and isLocked and hasAccess %}
{% if authEnabled and isLocked and hasAccess %}
<!-- Remove Lock Modal -->
<div id="removeLockModal" class="modal" style="display: none;">
<div class="modal-content">

View file

@ -0,0 +1,85 @@
{% extends 'layout.twig' %}
{% block header %}
<div class="action-links">
<a href="/" aria-label="Return to home">← Home</a>
<a href="/register" aria-label="Register">Register</a>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode">
<svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
<svg class="theme-icon theme-icon-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</button>
</div>
{% endblock %}
{% block body %}
<div id="login-auth" class="auth-container">
<div class="auth-card">
<h1>Welcome Back</h1>
<p class="auth-subtitle">Login to access your account</p>
{% if errorMessage %}
<div class="unlock-error">
{{ errorMessage }}
</div>
{% endif %}
{% if successMessage %}
<div class="success-message">
{{ successMessage }}
</div>
{% endif %}
<form method="POST" action="/login">
<fieldset>
<label for="username">Username</label>
<input
type="text"
id="username"
name="username"
placeholder="Enter your username"
required
autocomplete="username"
>
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
placeholder="Enter your password"
required
autocomplete="current-password"
>
<div class="auth-actions">
<button class="button button-primary" type="submit">Login</button>
</div>
</fieldset>
</form>
<div class="auth-footer">
<p>Don't have an account? <a href="/register">Register here</a></p>
</div>
</div>
<div class="auth-features">
<h3>Account Features</h3>
<ul>
<li>✓ Forward emails to verified addresses</li>
<li>✓ Lock and protect up to 5 inboxes</li>
<li>✓ Manage forwarding destinations</li>
<li>✓ Access from any device</li>
</ul>
<div class="auth-guest-section">
<h4>Guest Access</h4>
<p>You can still use temporary inboxes without an account, but forwarding and locking require registration.</p>
<a href="/" class="button">Browse as Guest</a>
</div>
</div>
</div>
{% endblock %}

View file

@ -6,7 +6,7 @@
<a href="#" id="forwardBtn" aria-label="Forward this email">Forward Email</a>
<a href="/inbox/{{ address }}/{{ uid }}/delete" aria-label="Delete this email">Delete Email</a>
<a href="/inbox/{{ address }}/{{ uid }}/raw" target="_blank" aria-label="View raw email">View Raw</a>
{% if lockEnabled and isLocked and hasAccess %}
{% if authEnabled and isLocked and hasAccess %}
<a href="/lock/logout" aria-label="Logout">Logout</a>
{% else %}
<a href="/logout" aria-label="Logout">Logout</a>

View file

@ -0,0 +1,96 @@
{% extends 'layout.twig' %}
{% block header %}
<div class="action-links">
<a href="/" aria-label="Return to home">← Home</a>
<a href="/login" aria-label="Login">Login</a>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode">
<svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
<svg class="theme-icon theme-icon-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</button>
</div>
{% endblock %}
{% block body %}
<div id="register" class="auth-container">
<div class="auth-card">
<h1>Create Account</h1>
<p class="auth-subtitle">Sign up to unlock email forwarding and inbox locking</p>
{% if errorMessage %}
<div class="unlock-error">
{{ errorMessage }}
</div>
{% endif %}
{% if successMessage %}
<div class="success-message">
{{ successMessage }}
</div>
{% endif %}
<form method="POST" action="/register">
<fieldset>
<label for="username">Username</label>
<input
type="text"
id="username"
name="username"
placeholder="3-20 characters, alphanumeric and underscore"
required
minlength="3"
maxlength="20"
pattern="[a-zA-Z0-9_]+"
autocomplete="username"
>
<small>Only letters, numbers, and underscores allowed</small>
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
placeholder="Min 8 characters"
required
minlength="8"
autocomplete="new-password"
>
<small>Must contain uppercase, lowercase, and number</small>
<label for="confirmPassword">Confirm Password</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
placeholder="Re-enter your password"
required
minlength="8"
autocomplete="new-password"
>
<div class="auth-actions">
<button class="button button-primary" type="submit">Create Account</button>
</div>
</fieldset>
</form>
<div class="auth-footer">
<p>Already have an account? <a href="/login">Login here</a></p>
</div>
</div>
<div class="auth-features">
<h3>Why Register?</h3>
<ul>
<li>✓ Forward emails to your real address</li>
<li>✓ Lock up to 5 inboxes with passwords</li>
<li>✓ Manage multiple verified forwarding emails</li>
<li>✓ Access your locked inboxes from anywhere</li>
</ul>
</div>
</div>
{% endblock %}

View file

@ -42,102 +42,4 @@
</div>
</div>
</div>
<style>
.verification-success-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
padding: 2rem;
}
.verification-success-card {
background: var(--background-color);
border: 2px solid var(--success-color, #28a745);
border-radius: 8px;
padding: 3rem 2rem;
max-width: 500px;
width: 100%;
text-align: center;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.verification-success-icon {
width: 80px;
height: 80px;
margin: 0 auto 1.5rem;
color: var(--success-color, #28a745);
}
.verification-success-icon svg {
width: 100%;
height: 100%;
}
.verification-success-card h1 {
color: var(--success-color, #28a745);
margin-bottom: 1.5rem;
font-size: 1.8rem;
}
.verification-details {
margin: 2rem 0;
padding: 1rem;
background: var(--code-background, #f5f5f5);
border-radius: 5px;
}
.verification-details p {
margin: 0.5rem 0;
}
.verified-email {
font-family: monospace;
font-size: 1.1rem;
font-weight: bold;
color: var(--primary-color);
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;
}
.button-primary {
display: inline-block;
background: var(--primary-color);
color: white;
padding: 0.75rem 2rem;
border-radius: 5px;
text-decoration: none;
font-weight: 600;
transition: background-color 0.2s;
}
.button-primary:hover {
background: var(--primary-hover-color, #0056b3);
}
@media (max-width: 600px) {
.verification-success-card {
padding: 2rem 1rem;
}
.verification-success-card h1 {
font-size: 1.5rem;
}
}
</style>
{% endblock %}

View file

@ -15,6 +15,7 @@ const inboxRouter = require('./routes/inbox')
const loginRouter = require('./routes/login')
const errorRouter = require('./routes/error')
const lockRouter = require('./routes/lock')
const authRouter = require('./routes/auth')
const { sanitizeHtmlTwigFilter } = require('./views/twig-filters')
const Helper = require('../../application/helper')
@ -96,11 +97,12 @@ app.use((req, res, next) => {
})
app.use('/', loginRouter)
if (config.user.authEnabled) {
app.use('/', authRouter)
}
app.use('/inbox', inboxRouter)
app.use('/error', errorRouter)
if (config.lock.enabled) {
app.use('/lock', lockRouter)
}
app.use('/lock', lockRouter)
// Catch 404 and forward to error handler
app.use((req, res, next) => {