mirror of
https://github.com/Crazyco-xyz/48hr.email.git
synced 2026-01-10 03:29: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
|
# 3 = shuffle all
|
||||||
HTTP_HIDE_OTHER=false # true = only show first domain, false = show all
|
HTTP_HIDE_OTHER=false # true = only show first domain, false = show all
|
||||||
|
|
||||||
# --- INBOX LOCKING (optional) ---
|
# --- USER AUTHENTICATION & INBOX LOCKING ---
|
||||||
LOCK_ENABLED=false # Enable inbox locking with passwords
|
# Authentication System
|
||||||
LOCK_SESSION_SECRET="change-this-secret-in-production" # Secret for session encryption
|
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_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 ImapService = require('./application/imap-service')
|
||||||
const MailProcessingService = require('./application/mail-processing-service')
|
const MailProcessingService = require('./application/mail-processing-service')
|
||||||
const SmtpService = require('./application/smtp-service')
|
const SmtpService = require('./application/smtp-service')
|
||||||
|
const AuthService = require('./application/auth-service')
|
||||||
const MailRepository = require('./domain/mail-repository')
|
const MailRepository = require('./domain/mail-repository')
|
||||||
const InboxLock = require('./domain/inbox-lock')
|
const InboxLock = require('./domain/inbox-lock')
|
||||||
const VerificationStore = require('./domain/verification-store')
|
const VerificationStore = require('./domain/verification-store')
|
||||||
|
const UserRepository = require('./domain/user-repository')
|
||||||
|
|
||||||
const clientNotification = new ClientNotification()
|
const clientNotification = new ClientNotification()
|
||||||
debug('Client notification service initialized')
|
debug('Client notification service initialized')
|
||||||
clientNotification.use(io)
|
clientNotification.use(io)
|
||||||
|
|
||||||
let inboxLock = null
|
// Initialize inbox locking (always available for registered users)
|
||||||
// Initialize inbox locking if enabled
|
const inboxLock = new InboxLock(config.user.lockDbPath)
|
||||||
if (config.lock.enabled) {
|
|
||||||
inboxLock = new InboxLock(config.lock.dbPath)
|
|
||||||
app.set('inboxLock', inboxLock)
|
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
|
// Check for inactive locked inboxes
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const inactive = inboxLock.getInactive(config.lock.releaseHours)
|
const inactive = inboxLock.getInactive(config.user.lockReleaseHours)
|
||||||
if (inactive.length > 0) {
|
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))
|
inactive.forEach(address => inboxLock.release(address))
|
||||||
}
|
}
|
||||||
}, config.imap.refreshIntervalSeconds * 1000)
|
}, config.imap.refreshIntervalSeconds * 1000)
|
||||||
}
|
|
||||||
|
|
||||||
const imapService = new ImapService(config, inboxLock)
|
const imapService = new ImapService(config, inboxLock)
|
||||||
debug('IMAP service initialized')
|
debug('IMAP service initialized')
|
||||||
|
|
@ -45,6 +43,22 @@ const verificationStore = new VerificationStore()
|
||||||
debug('Verification store initialized')
|
debug('Verification store initialized')
|
||||||
app.set('verificationStore', verificationStore)
|
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(
|
const mailProcessingService = new MailProcessingService(
|
||||||
new MailRepository(),
|
new MailRepository(),
|
||||||
imapService,
|
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)
|
hideOther: parseBool(process.env.HTTP_HIDE_OTHER)
|
||||||
},
|
},
|
||||||
|
|
||||||
lock: {
|
user: {
|
||||||
enabled: parseBool(process.env.LOCK_ENABLED) || false,
|
// Authentication System
|
||||||
sessionSecret: parseValue(process.env.LOCK_SESSION_SECRET) || 'change-me-in-production',
|
authEnabled: parseBool(process.env.USER_AUTH_ENABLED) || false,
|
||||||
dbPath: parseValue(process.env.LOCK_DATABASE_PATH) || './db/locked-inboxes.db',
|
|
||||||
releaseHours: Number(process.env.LOCK_RELEASE_HOURS) || 720 // 30 days default
|
// 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)
|
* @returns {string} - HMAC signature (hex)
|
||||||
*/
|
*/
|
||||||
signCookie(email) {
|
signCookie(email) {
|
||||||
const secret = config.lock.sessionSecret
|
const secret = config.user.sessionSecret
|
||||||
const hmac = crypto.createHmac('sha256', secret)
|
const hmac = crypto.createHmac('sha256', secret)
|
||||||
hmac.update(email.toLowerCase())
|
hmac.update(email.toLowerCase())
|
||||||
const signature = hmac.digest('hex')
|
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 */
|
/* Reset apple form styles */
|
||||||
|
|
||||||
input,
|
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,
|
totalcount: totalcount,
|
||||||
mailSummaries: mailProcessingService.getMailSummaries(req.params.address),
|
mailSummaries: mailProcessingService.getMailSummaries(req.params.address),
|
||||||
branding: config.http.branding,
|
branding: config.http.branding,
|
||||||
lockEnabled: config.lock.enabled,
|
authEnabled: config.user.authEnabled,
|
||||||
isLocked: isLocked,
|
isLocked: isLocked,
|
||||||
hasAccess: hasAccess,
|
hasAccess: hasAccess,
|
||||||
unlockError: unlockErrorSession,
|
unlockError: unlockErrorSession,
|
||||||
locktimer: config.lock.releaseHours,
|
locktimer: config.user.lockReleaseHours,
|
||||||
error: lockError,
|
error: lockError,
|
||||||
redirectTo: req.originalUrl,
|
redirectTo: req.originalUrl,
|
||||||
expiryTime: config.email.purgeTime.time,
|
expiryTime: config.email.purgeTime.time,
|
||||||
|
|
@ -216,7 +216,7 @@ router.get(
|
||||||
cryptoAttachments: cryptoAttachments,
|
cryptoAttachments: cryptoAttachments,
|
||||||
uid: req.params.uid,
|
uid: req.params.uid,
|
||||||
branding: config.http.branding,
|
branding: config.http.branding,
|
||||||
lockEnabled: config.lock.enabled,
|
authEnabled: config.user.authEnabled,
|
||||||
isLocked: isLocked,
|
isLocked: isLocked,
|
||||||
hasAccess: hasAccess,
|
hasAccess: hasAccess,
|
||||||
errorMessage: errorMessage,
|
errorMessage: errorMessage,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
<div class="action-links">
|
<div class="action-links">
|
||||||
{% if lockEnabled %}
|
{% if authEnabled %}
|
||||||
{% if isLocked and hasAccess %}
|
{% if isLocked and hasAccess %}
|
||||||
<a href="#" id="removeLockBtn" aria-label="Remove password lock">Remove Lock</a>
|
<a href="#" id="removeLockBtn" aria-label="Remove password lock">Remove Lock</a>
|
||||||
{% elseif isLocked %}
|
{% elseif isLocked %}
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="#" id="forwardAllBtn" aria-label="Forward all emails">Forward All</a>
|
<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>
|
<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>
|
<a href="/lock/logout" aria-label="Logout">Logout</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/logout" aria-label="Logout">Logout</a>
|
<a href="/logout" aria-label="Logout">Logout</a>
|
||||||
|
|
@ -88,7 +88,7 @@
|
||||||
</blockquote>
|
</blockquote>
|
||||||
{% endif %}
|
{% 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>
|
<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 -->
|
<!-- Lock Modal -->
|
||||||
<div id="lockModal" class="modal" style="display: none;" data-lock-error="{{ error|default('') }}">
|
<div id="lockModal" class="modal" style="display: none;" data-lock-error="{{ error|default('') }}">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
|
|
@ -116,7 +116,7 @@
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if lockEnabled and isLocked and not hasAccess %}
|
{% if authEnabled and isLocked and not hasAccess %}
|
||||||
<!-- Unlock Modal -->
|
<!-- Unlock Modal -->
|
||||||
<div id="unlockModal" class="modal" style="display: none;" data-unlock-error="{{ unlockError|default('') }}">
|
<div id="unlockModal" class="modal" style="display: none;" data-unlock-error="{{ unlockError|default('') }}">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
|
|
@ -148,7 +148,7 @@
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if lockEnabled and isLocked and hasAccess %}
|
{% if authEnabled and isLocked and hasAccess %}
|
||||||
<!-- Remove Lock Modal -->
|
<!-- Remove Lock Modal -->
|
||||||
<div id="removeLockModal" class="modal" style="display: none;">
|
<div id="removeLockModal" class="modal" style="display: none;">
|
||||||
<div class="modal-content">
|
<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="#" 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 }}/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>
|
<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>
|
<a href="/lock/logout" aria-label="Logout">Logout</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/logout" aria-label="Logout">Logout</a>
|
<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>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const inboxRouter = require('./routes/inbox')
|
||||||
const loginRouter = require('./routes/login')
|
const loginRouter = require('./routes/login')
|
||||||
const errorRouter = require('./routes/error')
|
const errorRouter = require('./routes/error')
|
||||||
const lockRouter = require('./routes/lock')
|
const lockRouter = require('./routes/lock')
|
||||||
|
const authRouter = require('./routes/auth')
|
||||||
const { sanitizeHtmlTwigFilter } = require('./views/twig-filters')
|
const { sanitizeHtmlTwigFilter } = require('./views/twig-filters')
|
||||||
|
|
||||||
const Helper = require('../../application/helper')
|
const Helper = require('../../application/helper')
|
||||||
|
|
@ -96,11 +97,12 @@ app.use((req, res, next) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
app.use('/', loginRouter)
|
app.use('/', loginRouter)
|
||||||
|
if (config.user.authEnabled) {
|
||||||
|
app.use('/', authRouter)
|
||||||
|
}
|
||||||
app.use('/inbox', inboxRouter)
|
app.use('/inbox', inboxRouter)
|
||||||
app.use('/error', errorRouter)
|
app.use('/error', errorRouter)
|
||||||
if (config.lock.enabled) {
|
|
||||||
app.use('/lock', lockRouter)
|
app.use('/lock', lockRouter)
|
||||||
}
|
|
||||||
|
|
||||||
// Catch 404 and forward to error handler
|
// Catch 404 and forward to error handler
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue