diff --git a/backend/.env.example b/backend/.env.example index 58b7c46..b448618 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,160 +1,25 @@ -# ============================================================================== -# Tududi Environment Configuration -# ============================================================================== -# Copy this file to .env and update the values for your environment -# ============================================================================== - -# ============================================================================== -# Application Configuration -# ============================================================================== - -# Environment: production, development, or test NODE_ENV=development -# Server host and port HOST=0.0.0.0 PORT=3002 -# Frontend URL (for redirects and CORS) FRONTEND_URL=http://localhost:8080 +BACKEND_URL=http://localhost:3002 -# ============================================================================== -# User Configuration -# ============================================================================== - -# Default user credentials (used during initial setup) TUDUDI_USER_EMAIL=admin@example.com TUDUDI_USER_PASSWORD=change-me-to-secure-password - -# Session secret (generate with: openssl rand -hex 64) TUDUDI_SESSION_SECRET=your-random-64-character-hex-string-here -# ============================================================================== -# Database Configuration -# ============================================================================== - -# Custom database file location (optional) -# If not set, defaults to backend/db/{environment}.sqlite3 -# DB_FILE=/path/to/custom/database.sqlite3 - -# ============================================================================== -# CORS Configuration -# ============================================================================== - -# Comma-separated list of allowed origins for CORS -# If not set, defaults to localhost development URLs -# TUDUDI_ALLOWED_ORIGINS=https://yourdomain.com,http://localhost:8080,http://localhost:9292 - -# ============================================================================== -# File Upload Configuration -# ============================================================================== - -# Custom upload directory path (optional) -# If not set, defaults to backend/uploads -# TUDUDI_UPLOAD_PATH=/path/to/custom/uploads - -# ============================================================================== -# Email/SMTP Configuration -# ============================================================================== - -# Enable/disable email functionality ENABLE_EMAIL=false - -# SMTP server configuration EMAIL_SMTP_HOST=smtp.gmail.com EMAIL_SMTP_PORT=587 EMAIL_SMTP_SECURE=false - -# SMTP authentication EMAIL_SMTP_USERNAME=your-email@example.com EMAIL_SMTP_PASSWORD=your-app-password - -# Email sender information EMAIL_FROM_ADDRESS=noreply@example.com EMAIL_FROM_NAME=Tududi -# ============================================================================== -# Task Scheduler Configuration -# ============================================================================== +REGISTRATION_TOKEN_EXPIRY_HOURS=24 -# Disable the task scheduler (useful for development/testing) -# Set to 'true' to disable recurring task processing DISABLE_SCHEDULER=false - -# ============================================================================== -# Telegram Bot Configuration -# ============================================================================== - -# Disable Telegram integration (useful for development/testing) -# Set to 'true' to disable Telegram bot functionality DISABLE_TELEGRAM=false - -# Telegram bot token (get from @BotFather on Telegram) -# TELEGRAM_BOT_TOKEN=your-telegram-bot-token - -# ============================================================================== -# API Documentation (Swagger) -# ============================================================================== - -# Enable/disable Swagger API documentation -# Default: enabled in all environments, protected by user authentication -# Set to 'false' to disable API documentation -# SWAGGER_ENABLED=true - -# ============================================================================== -# API Versioning -# ============================================================================== - -# API version (e.g., v1, v2) -# If not set, defaults to 'v1' -# API_VERSION=v1 - -# ============================================================================== -# Rate Limiting Configuration -# ============================================================================== -# Rate limiting helps prevent abuse and brute force attacks -# All time windows are in milliseconds -# Set RATE_LIMITING_ENABLED=false to completely disable rate limiting - -# Enable/disable rate limiting globally -# Automatically disabled in test environment -# RATE_LIMITING_ENABLED=true - -# Authentication endpoints (login, register) -# Default: 5 requests per 15 minutes -# RATE_LIMIT_AUTH_WINDOW_MS=900000 -# RATE_LIMIT_AUTH_MAX=5 - -# General API for unauthenticated requests -# Default: 100 requests per 15 minutes -# RATE_LIMIT_API_WINDOW_MS=900000 -# RATE_LIMIT_API_MAX=100 - -# Authenticated API requests -# Default: 1000 requests per 15 minutes -# RATE_LIMIT_AUTH_API_WINDOW_MS=900000 -# RATE_LIMIT_AUTH_API_MAX=1000 - -# Resource creation endpoints (POST requests) -# Default: 50 requests per 15 minutes -# RATE_LIMIT_CREATE_WINDOW_MS=900000 -# RATE_LIMIT_CREATE_MAX=50 - -# API key management endpoints -# Default: 10 requests per hour -# RATE_LIMIT_API_KEY_WINDOW_MS=3600000 -# RATE_LIMIT_API_KEY_MAX=10 - -# ============================================================================== -# Production Security Notes -# ============================================================================== -# When deploying to production, make sure to: -# 1. Change NODE_ENV to 'production' -# 2. Use strong, randomly generated TUDUDI_SESSION_SECRET -# 3. Use strong TUDUDI_USER_PASSWORD -# 4. Set proper TUDUDI_ALLOWED_ORIGINS for your domain -# 5. Enable HTTPS and set EMAIL_SMTP_SECURE=true if using TLS -# 6. Keep email passwords and API tokens secure -# 7. Consider adjusting rate limits based on your traffic patterns -# 8. Regularly update dependencies and review security advisories -# ============================================================================== diff --git a/backend/config/config.js b/backend/config/config.js index 5e42c15..aa563d0 100644 --- a/backend/config/config.js +++ b/backend/config/config.js @@ -27,6 +27,31 @@ const credentials = { const defaultHost = environment === 'test' ? '127.0.0.1' : '0.0.0.0'; +const emailConfig = { + enabled: process.env.ENABLE_EMAIL === 'true', + smtp: { + host: process.env.EMAIL_SMTP_HOST, + port: process.env.EMAIL_SMTP_PORT + ? parseInt(process.env.EMAIL_SMTP_PORT, 10) + : 587, + secure: process.env.EMAIL_SMTP_SECURE === 'true', + auth: { + user: process.env.EMAIL_SMTP_USERNAME, + pass: process.env.EMAIL_SMTP_PASSWORD, + }, + }, + from: { + address: process.env.EMAIL_FROM_ADDRESS, + name: process.env.EMAIL_FROM_NAME || 'Tududi', + }, +}; + +const registrationConfig = { + tokenExpiryHours: process.env.REGISTRATION_TOKEN_EXPIRY_HOURS + ? parseInt(process.env.REGISTRATION_TOKEN_EXPIRY_HOURS, 10) + : 24, +}; + const config = { allowedOrigins: process.env.TUDUDI_ALLOWED_ORIGINS ? process.env.TUDUDI_ALLOWED_ORIGINS.split(',').map((origin) => @@ -53,6 +78,8 @@ const config = { frontendUrl: process.env.FRONTEND_URL || 'http://localhost:8080', + backendUrl: process.env.BACKEND_URL || 'http://localhost:3002', + // Some CI/sandbox environments disallow binding to 0.0.0.0, so force // loopback for tests unless HOST is explicitly provided. host: process.env.HOST || defaultHost, @@ -69,6 +96,10 @@ const config = { credentials, + emailConfig, + + registrationConfig, + uploadPath: process.env.TUDUDI_UPLOAD_PATH || path.join(projectRootPath, 'uploads'), diff --git a/backend/migrations/20251017000000-add-email-verification-to-users.js b/backend/migrations/20251017000000-add-email-verification-to-users.js new file mode 100644 index 0000000..6e33f7c --- /dev/null +++ b/backend/migrations/20251017000000-add-email-verification-to-users.js @@ -0,0 +1,51 @@ +'use strict'; + +const { safeAddColumns } = require('../utils/migration-utils'); + +module.exports = { + async up(queryInterface, Sequelize) { + await safeAddColumns(queryInterface, 'users', [ + { + name: 'email_verified', + definition: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, // Existing users are considered verified + }, + }, + { + name: 'email_verification_token', + definition: { + type: Sequelize.STRING, + allowNull: true, + }, + }, + { + name: 'email_verification_token_expires_at', + definition: { + type: Sequelize.DATE, + allowNull: true, + }, + }, + ]); + + // Add index on verification token for faster lookups + await queryInterface.addIndex('users', ['email_verification_token'], { + name: 'users_email_verification_token_idx', + unique: false, + }); + }, + + async down(queryInterface) { + await queryInterface.removeIndex( + 'users', + 'users_email_verification_token_idx' + ); + await queryInterface.removeColumn('users', 'email_verified'); + await queryInterface.removeColumn('users', 'email_verification_token'); + await queryInterface.removeColumn( + 'users', + 'email_verification_token_expires_at' + ); + }, +}; diff --git a/backend/migrations/20251019000000-create-settings.js b/backend/migrations/20251019000000-create-settings.js new file mode 100644 index 0000000..8f28ec6 --- /dev/null +++ b/backend/migrations/20251019000000-create-settings.js @@ -0,0 +1,51 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('settings', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + key: { + type: Sequelize.STRING, + allowNull: false, + unique: true, + }, + value: { + type: Sequelize.TEXT, + allowNull: false, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + }, + }); + + // Add unique index on key column + await queryInterface.addIndex('settings', ['key'], { + name: 'settings_key_idx', + unique: true, + }); + + // Seed initial registration_enabled setting with default value of false + await queryInterface.bulkInsert('settings', [ + { + key: 'registration_enabled', + value: 'false', + created_at: new Date(), + updated_at: new Date(), + }, + ]); + }, + + async down(queryInterface) { + await queryInterface.removeIndex('settings', 'settings_key_idx'); + await queryInterface.dropTable('settings'); + }, +}; diff --git a/backend/models/index.js b/backend/models/index.js index 1239bee..87ebdd1 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -34,6 +34,7 @@ const Action = require('./action')(sequelize); const Permission = require('./permission')(sequelize); const View = require('./view')(sequelize); const ApiToken = require('./api_token')(sequelize); +const Setting = require('./setting')(sequelize); // Define associations User.hasMany(Area, { foreignKey: 'user_id' }); @@ -158,4 +159,5 @@ module.exports = { Permission, View, ApiToken, + Setting, }; diff --git a/backend/models/setting.js b/backend/models/setting.js new file mode 100644 index 0000000..f263eb8 --- /dev/null +++ b/backend/models/setting.js @@ -0,0 +1,30 @@ +const { DataTypes } = require('sequelize'); + +module.exports = (sequelize) => { + const Setting = sequelize.define( + 'Setting', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + key: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + value: { + type: DataTypes.TEXT, + allowNull: false, + }, + }, + { + tableName: 'settings', + timestamps: true, + underscored: true, + } + ); + + return Setting; +}; diff --git a/backend/models/user.js b/backend/models/user.js index e20857c..ae3751d 100644 --- a/backend/models/user.js +++ b/backend/models/user.js @@ -176,6 +176,19 @@ module.exports = (sequelize) => { }, }, }, + email_verified: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + email_verification_token: { + type: DataTypes.STRING, + allowNull: true, + }, + email_verification_token_expires_at: { + type: DataTypes.DATE, + allowNull: true, + }, }, { tableName: 'users', diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 93386e3..27e8316 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -267,3 +267,25 @@ router.delete('/admin/users/:id', requireAdmin, async (req, res) => { }); } }); + +// POST /api/admin/toggle-registration - toggle registration setting +router.post('/admin/toggle-registration', requireAdmin, async (req, res) => { + try { + const { enabled } = req.body; + if (typeof enabled !== 'boolean') { + return res + .status(400) + .json({ error: 'enabled must be a boolean value' }); + } + + const { + setRegistrationEnabled, + } = require('../services/registrationService'); + await setRegistrationEnabled(enabled); + + res.json({ enabled }); + } catch (err) { + logError('Error toggling registration:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 9d550c8..c39b133 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -2,6 +2,13 @@ const express = require('express'); const { User } = require('../models'); const { isAdmin } = require('../services/rolesService'); const { logError } = require('../services/logService'); +const { getConfig } = require('../config/config'); +const { + isRegistrationEnabled, + createUnverifiedUser, + sendVerificationEmail, + verifyUserEmail, +} = require('../services/registrationService'); const packageJson = require('../../package.json'); const { authLimiter } = require('../middleware/rateLimiter'); const router = express.Router(); @@ -10,6 +17,112 @@ router.get('/version', (req, res) => { res.json({ version: packageJson.version }); }); +// Get registration status +router.get('/registration-status', async (req, res) => { + res.json({ enabled: await isRegistrationEnabled() }); +}); + +// Register new user +router.post('/register', async (req, res) => { + const { sequelize } = require('../models'); + const transaction = await sequelize.transaction(); + + try { + if (!(await isRegistrationEnabled())) { + await transaction.rollback(); + return res + .status(404) + .json({ error: 'Registration is not enabled' }); + } + + const { email, password } = req.body; + + if (!email || !password) { + await transaction.rollback(); + return res + .status(400) + .json({ error: 'Email and password are required' }); + } + + const { user, verificationToken } = await createUnverifiedUser( + email, + password, + transaction + ); + + const emailResult = await sendVerificationEmail( + user, + verificationToken + ); + + if (!emailResult.success) { + await transaction.rollback(); + logError( + new Error(emailResult.reason), + 'Email sending failed during registration, rolling back user creation' + ); + return res.status(500).json({ + error: 'Failed to send verification email. Please try again later.', + }); + } + + await transaction.commit(); + + res.status(201).json({ + message: + 'Registration successful. Please check your email to verify your account.', + }); + } catch (error) { + await transaction.rollback(); + + if (error.message === 'Email already registered') { + return res.status(400).json({ error: error.message }); + } + if ( + error.message === 'Invalid email format' || + error.message === 'Password must be at least 6 characters long' + ) { + return res.status(400).json({ error: error.message }); + } + logError('Registration error:', error); + res.status(500).json({ + error: 'Registration failed. Please try again.', + }); + } +}); + +// Verify email +router.get('/verify-email', async (req, res) => { + try { + const { token } = req.query; + + if (!token) { + return res + .status(400) + .json({ error: 'Verification token is required' }); + } + + await verifyUserEmail(token); + + const config = getConfig(); + res.redirect(`${config.frontendUrl}/login?verified=true`); + } catch (error) { + const config = getConfig(); + let errorParam = 'invalid'; + + if (error.message === 'Email already verified') { + errorParam = 'already_verified'; + } else if (error.message === 'Verification token has expired') { + errorParam = 'expired'; + } + + logError('Email verification error:', error); + res.redirect( + `${config.frontendUrl}/login?verified=false&error=${errorParam}` + ); + } +}); + router.get('/current_user', async (req, res) => { try { if (req.session && req.session.userId) { @@ -71,6 +184,13 @@ router.post('/login', authLimiter, async (req, res) => { return res.status(401).json({ errors: ['Invalid credentials'] }); } + if (!user.email_verified) { + return res.status(403).json({ + error: 'Please verify your email address before logging in.', + email_not_verified: true, + }); + } + req.session.userId = user.id; await new Promise((resolve, reject) => { diff --git a/backend/services/emailService.js b/backend/services/emailService.js new file mode 100644 index 0000000..a5a9b70 --- /dev/null +++ b/backend/services/emailService.js @@ -0,0 +1,143 @@ +const nodemailer = require('nodemailer'); +const { getConfig } = require('../config/config'); +const { logError, logInfo } = require('./logService'); + +let transporter = null; + +const isEmailEnabled = () => { + const config = getConfig(); + return config.emailConfig.enabled; +}; + +const hasValidEmailConfig = () => { + const config = getConfig(); + const { smtp, from } = config.emailConfig; + return !!(smtp.host && smtp.auth.user && smtp.auth.pass && from.address); +}; + +const createTransporter = () => { + if (!isEmailEnabled()) { + return null; + } + + if (!hasValidEmailConfig()) { + logError( + new Error( + 'Email is enabled but configuration is incomplete. Email service will not function.' + ) + ); + return null; + } + + const config = getConfig(); + const { smtp } = config.emailConfig; + + try { + return nodemailer.createTransport({ + host: smtp.host, + port: smtp.port, + secure: smtp.secure, + auth: { + user: smtp.auth.user, + pass: smtp.auth.pass, + }, + }); + } catch (error) { + logError(error, 'Failed to create email transporter'); + return null; + } +}; + +const initializeEmailService = () => { + if (!isEmailEnabled()) { + logInfo('Email service is disabled'); + return; + } + + transporter = createTransporter(); + + if (transporter) { + logInfo('Email service initialized successfully'); + } +}; + +const sendEmail = async ({ to, subject, text, html }) => { + if (!isEmailEnabled()) { + logInfo( + `Email would be sent to ${to} with subject: "${subject}" (email service is disabled)` + ); + return { success: false, reason: 'Email service is disabled' }; + } + + if (!transporter) { + return { success: false, reason: 'Email transporter not initialized' }; + } + + if (!to || !subject) { + return { + success: false, + reason: 'Missing required fields: to, subject', + }; + } + + if (!text && !html) { + return { + success: false, + reason: 'Either text or html content is required', + }; + } + + const config = getConfig(); + const { from } = config.emailConfig; + + const mailOptions = { + from: from.name ? `"${from.name}" <${from.address}>` : from.address, + to, + subject, + text, + html, + }; + + try { + const info = await transporter.sendMail(mailOptions); + return { + success: true, + messageId: info.messageId, + }; + } catch (error) { + logError(error, `Failed to send email to ${to}`); + return { + success: false, + reason: error.message, + }; + } +}; + +const verifyEmailConnection = async () => { + if (!isEmailEnabled()) { + return { success: false, reason: 'Email service is disabled' }; + } + + if (!transporter) { + return { success: false, reason: 'Email transporter not initialized' }; + } + + try { + await transporter.verify(); + return { success: true }; + } catch (error) { + logError(error, 'Email connection verification failed'); + return { + success: false, + reason: error.message, + }; + } +}; + +module.exports = { + initializeEmailService, + sendEmail, + verifyEmailConnection, + isEmailEnabled, + hasValidEmailConfig, +}; diff --git a/backend/services/registrationService.js b/backend/services/registrationService.js new file mode 100644 index 0000000..bd75739 --- /dev/null +++ b/backend/services/registrationService.js @@ -0,0 +1,238 @@ +const crypto = require('crypto'); +const { User, Setting } = require('../models'); +const { getConfig } = require('../config/config'); +const { logError, logInfo } = require('./logService'); +const { sendEmail } = require('./emailService'); +const { validateEmail, validatePassword } = require('./userService'); + +const isRegistrationEnabled = async () => { + const setting = await Setting.findOne({ + where: { key: 'registration_enabled' }, + }); + // Default to false if setting doesn't exist + return setting ? setting.value === 'true' : false; +}; + +const setRegistrationEnabled = async (enabled) => { + await Setting.upsert({ + key: 'registration_enabled', + value: String(enabled), + }); + logInfo(`Registration ${enabled ? 'enabled' : 'disabled'} by admin`); +}; + +const generateVerificationToken = () => { + return crypto.randomBytes(32).toString('hex'); +}; + +const getTokenExpirationDate = () => { + const config = getConfig(); + const hours = config.registrationConfig.tokenExpiryHours; + const expirationDate = new Date(); + expirationDate.setHours(expirationDate.getHours() + hours); + return expirationDate; +}; + +const createUnverifiedUser = async (email, password, transaction = null) => { + if (!validateEmail(email)) { + throw new Error('Invalid email format'); + } + + if (!validatePassword(password)) { + throw new Error('Password must be at least 6 characters long'); + } + + const existingUser = await User.findOne({ + where: { email }, + transaction, + }); + if (existingUser) { + throw new Error('Email already registered'); + } + + const verificationToken = generateVerificationToken(); + const tokenExpiresAt = getTokenExpirationDate(); + + const user = await User.create( + { + email, + password, + email_verified: false, + email_verification_token: verificationToken, + email_verification_token_expires_at: tokenExpiresAt, + }, + { transaction } + ); + + return { + user, + verificationToken, + tokenExpiresAt, + }; +}; + +const isVerificationTokenValid = (token, expiresAt) => { + if (!token || !expiresAt) { + return false; + } + + const now = new Date(); + const expiration = new Date(expiresAt); + return now <= expiration; +}; + +const verifyUserEmail = async (token) => { + if (!token) { + throw new Error('Verification token is required'); + } + + const user = await User.findOne({ + where: { email_verification_token: token }, + }); + + if (!user) { + throw new Error('Invalid verification token'); + } + + if (user.email_verified) { + throw new Error('Email already verified'); + } + + if ( + !isVerificationTokenValid( + token, + user.email_verification_token_expires_at + ) + ) { + throw new Error('Verification token has expired'); + } + + user.email_verified = true; + user.email_verification_token = null; + user.email_verification_token_expires_at = null; + await user.save(); + + return user; +}; + +const sendVerificationEmail = async (user, verificationToken) => { + const config = getConfig(); + const { isEmailEnabled } = require('./emailService'); + + if (!isEmailEnabled()) { + logInfo( + `Email service is disabled. Verification email for ${user.email} not sent. User must be verified manually.` + ); + return { success: false, reason: 'Email service is disabled' }; + } + + const verificationUrl = `${config.backendUrl}/api/verify-email?token=${verificationToken}`; + const tokenExpiryHours = config.registrationConfig.tokenExpiryHours; + + const subject = 'Welcome to Tududi - Verify your email'; + + const text = `Welcome to Tududi! + +Thank you for registering. To complete your registration and start using Tududi, please verify your email address by clicking the link below: + +${verificationUrl} + +This verification link will expire in ${tokenExpiryHours} hours. + +After verification, you'll be able to: +- Create and organize tasks with ease +- Manage projects and areas +- Set up recurring tasks +- Track your productivity + +If you didn't create an account with Tududi, you can safely ignore this email. + +Best regards, +The Tududi Team`; + + const html = ` +
Welcome to Tududi!
+ +Thank you for registering. To complete your registration and start using Tududi, please verify your email address by clicking the button below:
+ + + +Or copy and paste this link into your browser:
+${verificationUrl}
+ +This verification link will expire in ${tokenExpiryHours} hours.
+ +After verification, you'll be able to:
+If you didn't create an account with Tududi, you can safely ignore this email.
+ +Best regards,
The Tududi Team
+ {t( + 'admin.registrationDescription', + 'Allow new users to register via email verification' + )} +
++ {t( + 'auth.registration_email_sent', + 'We have sent a verification link to your email address. Please check your inbox and click the link to verify your account.' + )} +
+ + {t( + 'auth.back_to_login', + 'Back to Login' + )} + +
+
+