mirror of
https://github.com/Crazyco-xyz/48hr.email.git
synced 2026-01-09 11:19:36 +01:00
[Feat]: Add User Registration
Add User table to sql, add user-repository, add registration and login routes, update config
This commit is contained in:
parent
2a08aa14a8
commit
598cea9b9c
16 changed files with 1432 additions and 135 deletions
19
.env.example
19
.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)
|
||||
|
||||
|
|
|
|||
32
app.js
32
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)
|
||||
// Initialize inbox locking (always available for registered users)
|
||||
const inboxLock = new InboxLock(config.user.lockDbPath)
|
||||
app.set('inboxLock', inboxLock)
|
||||
console.log(`Inbox locking enabled (auto-release after ${config.lock.releaseHours} hours)`)
|
||||
|
||||
debug('Inbox lock service initialized')
|
||||
// Check for inactive locked inboxes
|
||||
setInterval(() => {
|
||||
const inactive = inboxLock.getInactive(config.lock.releaseHours)
|
||||
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)
|
||||
}
|
||||
|
||||
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
246
application/auth-service.js
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
325
domain/user-repository.js
Normal 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
|
||||
122
infrastructure/web/middleware/auth.js
Normal file
122
infrastructure/web/middleware/auth.js
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
259
infrastructure/web/routes/auth.js
Normal file
259
infrastructure/web/routes/auth.js
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
85
infrastructure/web/views/login-auth.twig
Normal file
85
infrastructure/web/views/login-auth.twig
Normal 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 %}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
96
infrastructure/web/views/register.twig
Normal file
96
infrastructure/web/views/register.twig
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
// Catch 404 and forward to error handler
|
||||
app.use((req, res, next) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue