commit 016728532c0199672e505c8e1ac3732fa6fdba9e Author: Fran JurmanoviΔ‡ Date: Sun Jul 6 15:02:09 2025 +0200 init bootstrap diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..15ca0bd --- /dev/null +++ b/.env.example @@ -0,0 +1,226 @@ +# Bootstrap App Environment Configuration +# Copy this file to .env and update the values + +# ============================================================================= +# CRITICAL SECURITY SETTINGS (REQUIRED) +# ============================================================================= +# These values MUST be set for the application to work +# Use the scripts/generate-secrets.* scripts to generate secure values + +# JWT Secret for token signing (64+ characters, base64 encoded) +JWT_SECRET=your-generated-jwt-secret-here + +# Application secrets (32 bytes, hex encoded) +APP_SECRET=your-generated-app-secret-here +APP_SECRET_CODE=your-generated-secret-code-here + +# AES-256 encryption key (exactly 32 characters, hex encoded) +ENCRYPTION_KEY=your-generated-32-character-hex-key + +# ============================================================================= +# CORE APPLICATION SETTINGS +# ============================================================================= + +# Server port +PORT=3000 + +# Database configuration +DB_NAME=app.db + +# CORS configuration (comma-separated for multiple origins) +CORS_ALLOWED_ORIGIN=http://localhost:5173,http://localhost:3000 + +# ============================================================================= +# AUTHENTICATION & SECURITY +# ============================================================================= + +# JWT token configuration +JWT_ACCESS_TTL_HOURS=24 +JWT_REFRESH_TTL_DAYS=7 +JWT_ISSUER=omega-server + +# Password policy +PASSWORD_MIN_LENGTH=8 +MAX_LOGIN_ATTEMPTS=5 +LOCKOUT_DURATION_MINUTES=30 + +# Rate limiting +RATE_LIMIT_REQUESTS=100 +RATE_LIMIT_WINDOW_MINUTES=1 + +# Session timeout +SESSION_TIMEOUT_MINUTES=60 + +# ============================================================================= +# DEFAULT ADMIN ACCOUNT +# ============================================================================= + +# Default admin password (CHANGE THIS IMMEDIATELY AFTER FIRST LOGIN) +DEFAULT_ADMIN_PASSWORD=change-this-password + +# ============================================================================= +# LOGGING CONFIGURATION +# ============================================================================= + +# Log level: DEBUG, INFO, WARN, ERROR, PANIC +LOG_LEVEL=INFO + +# Enable debug mode (shows detailed error messages) +DEBUG_MODE=false + +# Log retention in days +LOG_RETENTION_DAYS=30 + +# ============================================================================= +# ENVIRONMENT SETTINGS +# ============================================================================= + +# Environment: development, staging, production +GO_ENV=development + +# ============================================================================= +# EMAIL CONFIGURATION (Optional) +# ============================================================================= + +# SMTP settings for email notifications +SMTP_HOST= +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM_EMAIL=noreply@example.com +SMTP_FROM_NAME=Bootstrap App + +# Enable TLS for SMTP +SMTP_USE_TLS=true + +# ============================================================================= +# FILE UPLOAD SETTINGS +# ============================================================================= + +# Maximum file upload size in MB +MAX_FILE_UPLOAD_SIZE_MB=10 + +# Allowed file extensions (comma-separated) +ALLOWED_FILE_EXTENSIONS=jpg,jpeg,png,gif,pdf,doc,docx,txt + +# Upload directory +UPLOAD_DIR=uploads + +# ============================================================================= +# CACHE CONFIGURATION +# ============================================================================= + +# Enable caching +CACHE_ENABLED=true + +# Cache TTL in minutes +CACHE_TTL_MINUTES=60 + +# ============================================================================= +# API CONFIGURATION +# ============================================================================= + +# API rate limiting per endpoint +API_RATE_LIMIT_REQUESTS=1000 +API_RATE_LIMIT_WINDOW_MINUTES=60 + +# API request timeout in seconds +API_REQUEST_TIMEOUT_SECONDS=30 + +# ============================================================================= +# MONITORING & HEALTH CHECKS +# ============================================================================= + +# Enable health check endpoint +HEALTH_CHECK_ENABLED=true + +# Health check interval in seconds +HEALTH_CHECK_INTERVAL_SECONDS=30 + +# ============================================================================= +# DEVELOPMENT SETTINGS +# ============================================================================= + +# Enable request logging in development +DEV_LOG_REQUESTS=true + +# Enable SQL query logging +DEV_LOG_SQL_QUERIES=false + +# Enable detailed error responses +DEV_DETAILED_ERRORS=true + +# ============================================================================= +# SECURITY HEADERS +# ============================================================================= + +# Content Security Policy +CSP_POLICY=default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' + +# X-Frame-Options +X_FRAME_OPTIONS=DENY + +# X-Content-Type-Options +X_CONTENT_TYPE_OPTIONS=nosniff + +# Referrer Policy +REFERRER_POLICY=strict-origin-when-cross-origin + +# ============================================================================= +# BACKUP SETTINGS +# ============================================================================= + +# Enable automatic database backups +BACKUP_ENABLED=true + +# Backup interval in hours +BACKUP_INTERVAL_HOURS=24 + +# Backup retention in days +BACKUP_RETENTION_DAYS=7 + +# Backup directory +BACKUP_DIR=backups + +# ============================================================================= +# FEATURE FLAGS +# ============================================================================= + +# Enable user registration +ENABLE_USER_REGISTRATION=true + +# Enable email verification +ENABLE_EMAIL_VERIFICATION=false + +# Enable two-factor authentication +ENABLE_TWO_FACTOR_AUTH=false + +# Enable audit logging +ENABLE_AUDIT_LOGGING=true + +# Enable security event logging +ENABLE_SECURITY_EVENT_LOGGING=true + +# ============================================================================= +# EXTERNAL SERVICES (Optional) +# ============================================================================= + +# Redis configuration (if using Redis for caching/sessions) +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + +# External API keys +EXTERNAL_API_KEY= +EXTERNAL_API_SECRET= + +# ============================================================================= +# NOTES +# ============================================================================= + +# 1. Never commit this file with real secrets to version control +# 2. Use the scripts/generate-secrets.* scripts to generate secure secrets +# 3. Change the DEFAULT_ADMIN_PASSWORD immediately after first setup +# 4. Review and adjust security settings based on your deployment environment +# 5. Enable HTTPS in production and update CORS_ALLOWED_ORIGIN accordingly diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67a0633 --- /dev/null +++ b/.gitignore @@ -0,0 +1,172 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# Environment files +.env +.env.local +.env.production +.env.staging + +# Database files +*.db +*.sqlite +*.sqlite3 + +# Log files +*.log +logs/ +*.log.* + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Editor backups +*~ +*.bak +*.tmp +*.temp + +# Build artifacts +build/ +dist/ +bin/ +pkg/ + +# Coverage files +coverage.out +coverage.html + +# Air live reload +tmp/ + +# Documentation generated files +docs/swagger.json +docs/swagger.yaml + +# Secrets and certificates +*.pem +*.key +*.crt +*.p12 +*.pfx + +# Cache directories +.cache/ +node_modules/ + +# Temporary files +*.pid +*.seed +*.pid.lock + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Backup files +*.backup +*.back + +# Compressed files +*.tar.gz +*.zip +*.7z +*.rar + +# Local configuration override files +config.local.* +*.local.json + +# Test fixtures +test_data/ +fixtures/ + +# Generated documentation +docs/generated/ + +# Profiling files +*.prof +*.pprof + +# Memory dumps +*.hprof + +# JetBrains +.idea/ +*.iml +*.iws +*.ipr + +# VS Code +.vscode/ +*.code-workspace + +# Sublime Text +*.sublime-project +*.sublime-workspace + +# Vim +*.swp +*.swo +*.un~ + +# Emacs +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# Zed editor +.zed/ + +# Application specific +app.db +omega-server.exe +api.exe diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d4ae6b --- /dev/null +++ b/README.md @@ -0,0 +1,465 @@ +# Bootstrap App + +A comprehensive web-based application built with clean architecture principles using Go and Fiber framework. This project serves as a robust foundation for building scalable web applications with advanced security features, role-based access control, and comprehensive API management. + +## πŸš€ Features + +### Core Application Features +- **Clean Architecture**: Layered architecture with clear separation of concerns +- **RESTful API**: Comprehensive REST API with Swagger documentation +- **Database Management**: SQLite with GORM ORM and automatic migrations +- **Dependency Injection**: Uber Dig container for clean dependency management + +### Security Features +- **JWT Authentication**: Secure token-based authentication system +- **Role-Based Access Control**: Granular permission system with user roles +- **Rate Limiting**: Protection against brute force and DoS attacks +- **Input Validation**: Comprehensive input sanitization and validation +- **Security Headers**: OWASP-compliant security headers +- **Password Security**: Bcrypt password hashing with strength validation +- **Audit Logging**: Comprehensive logging and audit trails + +### Monitoring & Management +- **Health Checks**: Built-in health check endpoints +- **Request Logging**: Detailed HTTP request and response logging +- **Security Event Tracking**: Real-time security event monitoring +- **System Configuration**: Dynamic configuration management +- **Cache Management**: In-memory caching with TTL support + +## πŸ—οΈ Architecture + +### Technology Stack +- **Backend**: Go 1.23.0 with Fiber web framework +- **Database**: SQLite with GORM ORM +- **Authentication**: JWT tokens with bcrypt password hashing +- **API Documentation**: Swagger/OpenAPI integration +- **Dependency Injection**: Uber Dig container +- **Caching**: In-memory cache with automatic cleanup + +### Project Structure +``` +omega-server/ +β”œβ”€β”€ cmd/ +β”‚ └── api/ # Application entry point +β”œβ”€β”€ local/ +β”‚ β”œβ”€β”€ api/ # API route definitions +β”‚ β”œβ”€β”€ controller/ # HTTP request handlers +β”‚ β”œβ”€β”€ middleware/ # Authentication and security middleware +β”‚ β”‚ └── security/ # Advanced security middleware +β”‚ β”œβ”€β”€ model/ # Database models and business entities +β”‚ β”œβ”€β”€ repository/ # Data access layer +β”‚ β”œβ”€β”€ service/ # Business logic services +β”‚ └── utl/ # Utilities and shared components +β”‚ β”œβ”€β”€ cache/ # Caching utilities +β”‚ β”œβ”€β”€ common/ # Common utilities and types +β”‚ β”œβ”€β”€ configs/ # Configuration management +β”‚ β”œβ”€β”€ db/ # Database connection and migration +β”‚ β”œβ”€β”€ jwt/ # JWT token management +β”‚ β”œβ”€β”€ logging/ # Logging utilities +β”‚ β”œβ”€β”€ password/ # Password hashing utilities +β”‚ └── server/ # HTTP server configuration +β”œβ”€β”€ docs/ # Documentation +β”œβ”€β”€ scripts/ # Utility scripts +β”‚ β”œβ”€β”€ generate-secrets.ps1 # PowerShell secret generation +β”‚ └── generate-secrets.sh # Bash secret generation +β”œβ”€β”€ logs/ # Application logs +β”œβ”€β”€ go.mod # Go module definition +β”œβ”€β”€ .env.example # Environment template +└── .gitignore # Git ignore rules +``` + +## πŸ“‹ Prerequisites + +### System Requirements +- **Go**: Version 1.23.0 or later +- **Operating System**: Windows, Linux, or macOS +- **Memory**: Minimum 512MB RAM +- **Disk Space**: 100MB for application and logs + +### Development Tools (Optional) +- **Air**: For hot reloading during development +- **Git**: For version control +- **Make**: For build automation +- **Docker**: For containerized deployment + +## βš™οΈ Installation + +### 1. Clone or Copy the Project +```bash +# If using git +git clone +cd omega-server + +# Or copy the bootstrap folder to your desired location +``` + +### 2. Generate Secure Secrets +We provide scripts to automatically generate secure secrets and create your `.env` file: + +**Windows (PowerShell):** +```powershell +.\scripts\generate-secrets.ps1 +``` + +**Linux/macOS (Bash):** +```bash +chmod +x scripts/generate-secrets.sh +./scripts/generate-secrets.sh +``` + +**Manual Setup:** +If you prefer to set up manually: +```bash +cp .env.example .env +``` + +Then generate secure secrets: +```bash +# JWT Secret (64 bytes, base64 encoded) +openssl rand -base64 64 + +# Application secrets (32 bytes, hex encoded) +openssl rand -hex 32 + +# Encryption key (16 bytes, hex encoded = 32 characters) +openssl rand -hex 16 +``` + +### 3. Install Dependencies +```bash +go mod download +``` + +### 4. Build the Application +```bash +go build -o api.exe cmd/api/main.go +``` + +### 5. Run the Application +```bash +./api.exe +``` + +The application will be available at `http://localhost:3000` + +## πŸ”§ Configuration + +### Environment Variables + +The application uses environment variables for configuration. Most critical settings are automatically generated by the secret generation scripts: + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `JWT_SECRET` | Yes | - | JWT signing secret (64+ chars, base64) | +| `APP_SECRET` | Yes | - | Application secret key (32 bytes, hex) | +| `APP_SECRET_CODE` | Yes | - | Application secret code (32 bytes, hex) | +| `ENCRYPTION_KEY` | Yes | - | AES-256 encryption key (32 hex chars) | +| `PORT` | No | 3000 | HTTP server port | +| `DB_NAME` | No | app.db | SQLite database filename | +| `CORS_ALLOWED_ORIGIN` | No | http://localhost:5173 | CORS allowed origin | +| `DEFAULT_ADMIN_PASSWORD` | No | - | Default admin password for initial setup | +| `LOG_LEVEL` | No | INFO | Logging level (DEBUG, INFO, WARN, ERROR) | + +### System Configuration + +Advanced settings can be managed through the web interface after initial setup: +- **Security Policies**: Rate limits, session timeouts, password policies +- **Monitoring**: Logging levels, audit settings +- **Cache Settings**: TTL configuration, cache policies +- **API Configuration**: Rate limiting, request timeouts + +## πŸ”’ Security + +This application implements comprehensive security measures: + +### Authentication & Authorization +- **JWT Tokens**: Secure token-based authentication with configurable TTL +- **Password Security**: Bcrypt hashing with strength validation +- **Role-Based Access**: Granular permission system +- **Session Management**: Configurable timeouts and automatic cleanup + +### Protection Mechanisms +- **Rate Limiting**: Multiple layers of rate limiting (global and per-endpoint) +- **Input Validation**: Comprehensive input sanitization +- **Security Headers**: OWASP-compliant HTTP headers +- **CORS Protection**: Configurable cross-origin restrictions +- **Request Limits**: Size and timeout limitations + +### Monitoring & Auditing +- **Security Events**: Authentication and authorization logging +- **Audit Trail**: Comprehensive activity logging with user tracking +- **Threat Detection**: Suspicious activity monitoring +- **Real-time Alerts**: Immediate notification of security events + +## πŸ“š API Documentation + +The application includes comprehensive API documentation via Swagger UI: +- **Local Development**: http://localhost:3000/swagger/ +- **Interactive Testing**: Test API endpoints directly from the browser +- **Schema Documentation**: Complete request/response schemas + +### Key API Endpoints + +#### Health & Status +- `GET /health` - Health check endpoint +- `GET /ping` - Simple ping endpoint + +#### Authentication +- `POST /api/v1/auth/login` - User authentication +- `POST /api/v1/auth/register` - User registration +- `POST /api/v1/auth/refresh` - Refresh access token +- `POST /api/v1/auth/logout` - User logout + +#### User Management +- `GET /api/v1/users` - List all users (paginated) +- `POST /api/v1/users` - Create new user +- `GET /api/v1/users/{id}` - Get user details +- `PUT /api/v1/users/{id}` - Update user +- `DELETE /api/v1/users/{id}` - Delete user + +#### Role & Permission Management +- `GET /api/v1/roles` - List all roles +- `POST /api/v1/roles` - Create new role +- `GET /api/v1/permissions` - List all permissions +- `POST /api/v1/roles/{id}/permissions` - Assign permissions to role + +#### System Management +- `GET /api/v1/system/config` - Get system configuration +- `PUT /api/v1/system/config` - Update system configuration +- `GET /api/v1/system/audit-logs` - Get audit logs +- `GET /api/v1/system/security-events` - Get security events + +## πŸ› οΈ Development + +### Running in Development Mode +```bash +# Install air for hot reloading (optional) +go install github.com/cosmtrek/air@latest + +# Run with hot reload +air + +# Or run directly with go +go run cmd/api/main.go +``` + +### Environment Setup for Development +1. Copy `.env.example` to `.env` +2. Run the secret generation script +3. Set `LOG_LEVEL=DEBUG` for detailed logging +4. Set `DEBUG_MODE=true` for detailed error responses + +### Database Management +```bash +# View database schema +sqlite3 app.db ".schema" + +# View data +sqlite3 app.db "SELECT * FROM users;" + +# Backup database +cp app.db app_backup.db +``` + +### Adding New Features + +#### 1. Create a New Model +```go +// In local/model/your_model.go +type YourModel struct { + BaseModel + Name string `json:"name" gorm:"not null"` + // Add your fields +} +``` + +#### 2. Create Repository +```go +// In local/repository/your_repository.go +type YourRepository struct { + db *gorm.DB +} + +func (r *YourRepository) Create(model *YourModel) error { + return r.db.Create(model).Error +} +``` + +#### 3. Create Service +```go +// In local/service/your_service.go +type YourService struct { + repo *repository.YourRepository +} + +func (s *YourService) CreateItem(req CreateRequest) (*YourModel, error) { + // Business logic here +} +``` + +#### 4. Create Controller +```go +// In local/controller/your_controller.go +type YourController struct { + service *service.YourService +} + +func (c *YourController) Create(ctx *fiber.Ctx) error { + // Handle HTTP request +} +``` + +## πŸš€ Production Deployment + +### 1. Generate Production Secrets +```bash +# Use the secret generation script for production +./scripts/generate-secrets.sh # Linux/macOS +.\scripts\generate-secrets.ps1 # Windows +``` + +### 2. Build for Production +```bash +# Build optimized binary +go build -ldflags="-w -s" -o omega-server.exe cmd/api/main.go +``` + +### 3. Security Checklist +- [ ] Generate unique production secrets (use provided scripts) +- [ ] Configure production CORS origins in `.env` +- [ ] Change default admin password immediately after first login +- [ ] Enable HTTPS with valid certificates +- [ ] Set up proper firewall rules +- [ ] Configure monitoring and alerting +- [ ] Set up log rotation +- [ ] Test all security configurations + +### 4. Environment Variables for Production +```bash +# Set these in your production environment +GO_ENV=production +LOG_LEVEL=WARN +CORS_ALLOWED_ORIGIN=https://yourdomain.com +DEFAULT_ADMIN_PASSWORD=your-secure-password +``` + +### 5. Monitoring Setup +- Configure log rotation and retention +- Set up health check monitoring +- Configure alerting for critical errors +- Monitor resource usage and performance +- Set up database backup procedures + +## πŸ”§ Troubleshooting + +### Common Issues + +#### "JWT_SECRET environment variable is required" +**Solution**: Run the secret generation script or manually set the JWT_SECRET environment variable. + +#### "Failed to connect database" +**Solution**: Ensure the application has write permissions to the database directory and the database file doesn't exist or is accessible. + +#### Database Migration Errors +**Solution**: Delete the database file and restart the application to recreate it with the latest schema: +```bash +rm app.db +./api.exe +``` + +#### Permission Denied Errors +**Solution**: Ensure the application has proper file system permissions: +```bash +# Linux/macOS +chmod +x api.exe +chmod 755 logs/ + +# Windows - Run as Administrator if needed +``` + +### Log Locations +- **Application Logs**: `./logs/app_YYYY-MM-DD.log` +- **Database File**: `./app.db` (or as configured in DB_NAME) +- **Environment Config**: `./.env` + +### Debug Mode +Enable debug logging and detailed error messages: +```bash +# In .env file +LOG_LEVEL=DEBUG +DEBUG_MODE=true +``` + +## πŸ“ˆ Performance Considerations + +### Database Optimization +- Indexes are automatically created for frequently queried fields +- Use pagination for large result sets +- Consider database cleanup for old audit logs and security events + +### Caching +- Built-in memory cache with configurable TTL +- Cache frequently accessed configuration data +- Automatic cleanup of expired cache entries + +### Rate Limiting +- Adjust rate limits based on your application's needs +- Monitor rate limit metrics in production +- Consider implementing distributed rate limiting for multiple instances + +## 🀝 Contributing + +### Development Workflow +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/amazing-feature` +3. Make your changes following the established patterns +4. Add tests for new functionality +5. Ensure all tests pass: `go test ./...` +6. Update documentation if needed +7. Commit your changes: `git commit -m 'Add amazing feature'` +8. Push to the branch: `git push origin feature/amazing-feature` +9. Open a Pull Request + +### Code Style Guidelines +- Follow Go best practices and conventions +- Use `gofmt` for code formatting +- Add comprehensive comments for public functions +- Follow the established project structure +- Include error handling for all operations + +### Adding New Dependencies +- Use `go mod tidy` after adding dependencies +- Ensure licenses are compatible +- Document any new dependencies in the README + +## πŸ“ License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## πŸ™ Acknowledgments + +- **Fiber Framework**: High-performance HTTP framework for Go +- **GORM**: The fantastic ORM library for Go +- **Uber Dig**: Dependency injection container +- **JWT-Go**: JWT implementation for Go +- **Bcrypt**: Secure password hashing + +## πŸ“ž Support + +### Documentation +- [API Documentation](http://localhost:3000/swagger/) +- [Architecture Guide](docs/ARCHITECTURE.md) +- [Security Guide](docs/SECURITY.md) +- [Deployment Guide](docs/DEPLOYMENT.md) + +### Getting Help +- Check the [Troubleshooting](#troubleshooting) section +- Review the comprehensive inline code documentation +- Check existing issues in the repository +- Create a new issue with detailed information + +--- + +**Happy Coding! πŸš€** + +Built with ❀️ using clean architecture principles and modern security practices. diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..03e887b --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "omega-server/local/utl/cache" + "omega-server/local/utl/db" + "omega-server/local/utl/logging" + "omega-server/local/utl/server" + "os" + + "go.uber.org/dig" +) + +// @title Bootstrap App API +// @version 1.0 +// @description A comprehensive web-based management system built with clean architecture principles +// @contact.name API Support +// @contact.email support@example.com +// @license.name MIT +// @license.url https://opensource.org/licenses/MIT +// @host localhost:3000 +// @BasePath /api/v1 +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization +// @description Type "Bearer" followed by a space and JWT token. +func main() { + // Initialize logger + logger, err := logging.Initialize() + if err != nil { + fmt.Printf("Failed to initialize logger: %v\n", err) + os.Exit(1) + } + defer logger.Close() + + // Set up panic recovery + defer logging.RecoverAndLog() + + logging.Info("Starting Bootstrap App...") + + // Initialize dependency injection container + di := dig.New() + + // Initialize core components + cache.Start(di) + db.Start(di) + server.Start(di) +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..5bd9d8d --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,423 @@ +# Architecture Documentation + +## Overview + +This document describes the architecture of the Bootstrap App, which follows clean architecture principles with clear separation of concerns, dependency injection, and modern security practices. + +## Architecture Principles + +### Clean Architecture +The application follows Uncle Bob's Clean Architecture principles: +- **Independence of Frameworks**: The architecture doesn't depend on specific frameworks +- **Testability**: Business rules can be tested without UI, database, or external services +- **Independence of UI**: The UI can change without changing the business rules +- **Independence of Database**: Business rules are not bound to the database +- **Independence of External Services**: Business rules know nothing about the outside world + +### SOLID Principles +- **Single Responsibility**: Each class/module has one reason to change +- **Open/Closed**: Open for extension, closed for modification +- **Liskov Substitution**: Objects should be replaceable with instances of their subtypes +- **Interface Segregation**: Many client-specific interfaces are better than one general-purpose interface +- **Dependency Inversion**: Depend on abstractions, not concretions + +## Layer Architecture + +### 1. Presentation Layer (`controller/`) +**Responsibility**: Handle HTTP requests and responses +- Input validation and sanitization +- Request/response mapping +- Error handling and status codes +- Authentication and authorization checks + +**Components**: +- Controllers for each domain +- Middleware for cross-cutting concerns +- Request/response DTOs + +**Dependencies**: Service Layer + +### 2. Business Logic Layer (`service/`) +**Responsibility**: Implement business rules and orchestrate operations +- Business rule validation +- Transaction management +- Cross-domain operations +- Business logic orchestration + +**Components**: +- Service classes for each domain +- Business rule validators +- Transaction coordinators + +**Dependencies**: Repository Layer, External Services + +### 3. Data Access Layer (`repository/`) +**Responsibility**: Data persistence and retrieval +- Database operations +- Query optimization +- Data mapping +- Cache management + +**Components**: +- Repository interfaces and implementations +- Database models +- Query builders + +**Dependencies**: Database, Cache + +### 4. Domain Layer (`model/`) +**Responsibility**: Core business entities and rules +- Domain entities +- Value objects +- Domain services +- Business rule definitions + +**Components**: +- Entity models +- Validation rules +- Domain constants +- Business exceptions + +**Dependencies**: None (should be isolated) + +### 5. Infrastructure Layer (`utl/`) +**Responsibility**: External concerns and utilities +- Database configuration +- Logging +- Security utilities +- Configuration management +- External service integrations + +**Components**: +- Database connection and migration +- Logging infrastructure +- Security middleware +- Configuration loaders +- Utility functions + +## Component Interaction Flow + +``` +HTTP Request + ↓ +[Middleware Stack] ← Security, Logging, CORS + ↓ +[Controller] ← Request handling, validation + ↓ +[Service] ← Business logic, orchestration + ↓ +[Repository] ← Data access, persistence + ↓ +[Database/Cache] ← Data storage +``` + +## Dependency Injection + +The application uses Uber Dig for dependency injection, providing: +- **Loose Coupling**: Components depend on interfaces, not implementations +- **Testability**: Easy to mock dependencies for testing +- **Configuration**: Centralized dependency configuration +- **Lifecycle Management**: Automatic instance management + +### DI Container Structure +```go +// Database and core services +di.Provide(func() *gorm.DB { ... }) +di.Provide(NewCache) + +// Repositories +di.Provide(NewUserRepository) +di.Provide(NewRoleRepository) +// ... + +// Services +di.Provide(NewUserService) +di.Provide(NewAuthService) +// ... + +// Controllers +di.Invoke(NewUserController) +di.Invoke(NewAuthController) +// ... +``` + +## Security Architecture + +### Authentication Flow +``` +Client Request + ↓ +[CORS Middleware] ← Cross-origin validation + ↓ +[Security Headers] ← Security headers injection + ↓ +[Rate Limiting] ← Request rate validation + ↓ +[JWT Validation] ← Token verification + ↓ +[Permission Check] ← Authorization validation + ↓ +[Controller] ← Authorized request processing +``` + +### Security Layers +1. **Network Security**: CORS, security headers, HTTPS +2. **Authentication**: JWT token validation +3. **Authorization**: Role-based access control +4. **Input Security**: Sanitization, validation, size limits +5. **Audit Security**: Logging, monitoring, alerting + +## Database Architecture + +### Entity Relationships +``` +Users +β”œβ”€β”€ UserRoles (Many-to-Many with Roles) +β”œβ”€β”€ AuditLogs (One-to-Many) +└── SecurityEvents (One-to-Many) + +Roles +β”œβ”€β”€ UserRoles (Many-to-Many with Users) +└── RolePermissions (Many-to-Many with Permissions) + +Permissions +└── RolePermissions (Many-to-Many with Roles) + +SystemConfig (Standalone) +``` + +### Base Model Pattern +All entities inherit from `BaseModel`: +```go +type BaseModel struct { + ID string `json:"id" gorm:"primary_key"` + DateCreated time.Time `json:"dateCreated"` + DateUpdated time.Time `json:"dateUpdated"` +} +``` + +Benefits: +- Consistent ID generation (UUID) +- Automatic timestamp management +- Uniform audit trail +- Simplified queries + +## API Architecture + +### RESTful Design +- **Resource-based URLs**: `/api/v1/users/{id}` +- **HTTP Methods**: GET, POST, PUT, DELETE, PATCH +- **Status Codes**: Appropriate HTTP status codes +- **Content Negotiation**: JSON request/response format + +### API Versioning +- **URL Versioning**: `/api/v1/` +- **Backward Compatibility**: Maintain older versions +- **Migration Path**: Clear upgrade documentation + +### Response Format +```go +type APIResponse struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + Code int `json:"code,omitempty"` +} +``` + +### Pagination +```go +type PaginatedResponse struct { + APIResponse + Pagination PaginationResponse `json:"pagination"` +} +``` + +## Middleware Architecture + +### Middleware Stack (Order Matters) +1. **Recovery**: Panic recovery and logging +2. **Security Headers**: OWASP security headers +3. **CORS**: Cross-origin resource sharing +4. **Rate Limiting**: Request rate limiting +5. **Request Logging**: HTTP request/response logging +6. **Authentication**: JWT token validation +7. **Authorization**: Permission validation +8. **Input Validation**: Request sanitization + +### Security Middleware Components +- **Rate Limiter**: Token bucket algorithm with IP-based limits +- **Input Sanitizer**: XSS protection, SQL injection prevention +- **Content Validator**: Content-type and size validation +- **User Agent Validator**: Bot detection and blocking + +## Configuration Architecture + +### Configuration Layers +1. **Environment Variables**: Runtime configuration +2. **Database Config**: Dynamic configuration storage +3. **Default Values**: Fallback configuration +4. **Validation**: Configuration validation rules + +### Configuration Categories +- **Security**: JWT secrets, encryption keys, password policies +- **Database**: Connection settings, migration options +- **API**: Rate limits, timeouts, CORS settings +- **Logging**: Log levels, retention policies +- **Features**: Feature flags, service toggles + +## Error Handling Architecture + +### Error Types +1. **Business Errors**: Domain rule violations +2. **Validation Errors**: Input validation failures +3. **System Errors**: Infrastructure failures +4. **Security Errors**: Authentication/authorization failures + +### Error Propagation +``` +Controller β†’ Service β†’ Repository + ↓ ↓ ↓ + HTTP Business Data + Error Error Error +``` + +### Error Response Format +```go +type HTTPError struct { + Code int `json:"code"` + Message string `json:"message"` + Details map[string]interface{} `json:"details,omitempty"` +} +``` + +## Logging Architecture + +### Log Levels +- **DEBUG**: Detailed information for debugging +- **INFO**: General information about application flow +- **WARN**: Warning messages for potential issues +- **ERROR**: Error messages for failures +- **PANIC**: Critical errors that cause application termination + +### Log Categories +1. **Application Logs**: General application flow +2. **HTTP Logs**: Request/response logging +3. **Security Logs**: Authentication, authorization events +4. **Audit Logs**: User actions and system changes +5. **Performance Logs**: Response times, resource usage + +### Log Format +``` +[TIMESTAMP] LEVEL [CALLER] MESSAGE +[2024-01-01 12:00:00] INFO [main.go:42] Starting application +``` + +## Caching Architecture + +### Cache Layers +1. **Application Cache**: In-memory cache for frequently accessed data +2. **Database Cache**: GORM query result caching +3. **HTTP Cache**: Response caching for static content + +### Cache Strategies +- **TTL-based**: Time-to-live expiration +- **LRU**: Least recently used eviction +- **Size-based**: Memory usage limits +- **Manual**: Explicit cache invalidation + +## Testing Architecture + +### Test Layers +1. **Unit Tests**: Individual component testing +2. **Integration Tests**: Component interaction testing +3. **API Tests**: End-to-end API testing +4. **Security Tests**: Security vulnerability testing + +### Test Structure +``` +test/ +β”œβ”€β”€ unit/ # Unit tests +β”œβ”€β”€ integration/ # Integration tests +β”œβ”€β”€ api/ # API tests +β”œβ”€β”€ fixtures/ # Test data +└── helpers/ # Test utilities +``` + +### Mock Strategy +- **Interface Mocking**: Mock external dependencies +- **Database Mocking**: In-memory database for tests +- **HTTP Mocking**: Mock external API calls + +## Scalability Considerations + +### Horizontal Scaling +- **Stateless Design**: No server-side session storage +- **Database Connection Pooling**: Efficient connection management +- **Load Balancer Friendly**: Health checks and graceful shutdown + +### Performance Optimization +- **Database Indexing**: Optimized query performance +- **Pagination**: Limit result set sizes +- **Caching**: Reduce database load +- **Compression**: Reduce bandwidth usage + +### Monitoring Points +- **Health Endpoints**: Application health status +- **Metrics Collection**: Performance metrics +- **Log Aggregation**: Centralized logging +- **Alert Configuration**: Automated alerting + +## Deployment Architecture + +### Build Process +1. **Dependency Resolution**: `go mod download` +2. **Code Generation**: Swagger documentation +3. **Compilation**: Static binary creation +4. **Testing**: Automated test execution +5. **Packaging**: Container image creation + +### Deployment Strategies +- **Blue-Green**: Zero-downtime deployments +- **Rolling**: Gradual instance replacement +- **Canary**: Phased traffic migration +- **Feature Flags**: Runtime feature control + +### Environment Configuration +- **Development**: Local development setup +- **Staging**: Production-like testing environment +- **Production**: Live application environment +- **DR**: Disaster recovery environment + +## Security Considerations + +### Threat Model +1. **Authentication Bypass**: JWT validation, rate limiting +2. **Authorization Escalation**: RBAC, permission validation +3. **Data Injection**: Input sanitization, parameterized queries +4. **Data Exposure**: Access logging, encryption at rest +5. **DoS Attacks**: Rate limiting, request size limits + +### Security Controls +- **Input Validation**: All user inputs validated +- **Output Encoding**: XSS prevention +- **Access Control**: Role-based permissions +- **Audit Logging**: All actions logged +- **Encryption**: Sensitive data encrypted + +## Future Considerations + +### Planned Enhancements +- **Microservices**: Service decomposition +- **Event Sourcing**: Event-driven architecture +- **CQRS**: Command query responsibility segregation +- **GraphQL**: Alternative API interface +- **Real-time**: WebSocket support + +### Technology Evolution +- **Database**: PostgreSQL migration path +- **Cache**: Redis integration +- **Messaging**: Event bus implementation +- **Search**: Full-text search capabilities +- **Analytics**: Business intelligence integration \ No newline at end of file diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..e105faa --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,505 @@ +# Security Documentation + +## Overview + +This document outlines the comprehensive security measures implemented in the Bootstrap App. The application follows industry best practices and security standards to protect against common threats and vulnerabilities. + +## Security Architecture + +### Defense in Depth + +The application implements multiple layers of security: + +1. **Network Security**: HTTPS, CORS, security headers +2. **Authentication**: JWT token-based authentication +3. **Authorization**: Role-based access control (RBAC) +4. **Input Security**: Validation, sanitization, size limits +5. **Data Security**: Encryption, secure storage +6. **Monitoring**: Audit logging, security event tracking +7. **Infrastructure**: Secure deployment practices + +## Authentication System + +### JWT (JSON Web Tokens) + +**Implementation**: +- RS256/HS256 signing algorithms +- Configurable token expiration (default: 24 hours access, 7 days refresh) +- Secure token storage and transmission +- Token revocation support + +**Security Features**: +- Cryptographically secure secret generation +- Token payload minimization +- Automatic token expiration +- Refresh token rotation + +**Configuration**: +```env +JWT_SECRET=base64-encoded-secret-64-bytes +JWT_ACCESS_TTL_HOURS=24 +JWT_REFRESH_TTL_DAYS=7 +JWT_ISSUER=omega-server +``` + +### Password Security + +**Hashing**: +- bcrypt with cost factor 12 +- Salt automatically generated per password +- Resistant to rainbow table attacks + +**Password Policy**: +- Minimum 8 characters +- Must contain uppercase, lowercase, number, special character +- Common password detection +- Password strength scoring (0-100) + +**Additional Security**: +- Account lockout after failed attempts +- Password history prevention +- Secure password reset flow + +## Authorization System + +### Role-Based Access Control (RBAC) + +**Components**: +- **Users**: Individual user accounts +- **Roles**: Collections of permissions (admin, user, viewer) +- **Permissions**: Granular access rights (user:create, user:read, etc.) + +**Permission Format**: +``` +resource:action +Examples: user:create, role:delete, system:admin +``` + +**Default Roles**: +- **admin**: Full system access +- **user**: Standard user privileges +- **viewer**: Read-only access + +### Permission Checking + +**Implementation**: +```go +// Middleware-based permission checking +app.Get("/api/v1/users", authMW.HasPermission("user:read"), controller.GetUsers) + +// Service-level permission checking +if !user.HasPermission("user:create") { + return errors.New("insufficient permissions") +} +``` + +## Input Security + +### Validation & Sanitization + +**Input Validation**: +- JSON schema validation +- Type checking +- Range validation +- Format validation (email, UUID, etc.) + +**Input Sanitization**: +- HTML entity encoding +- SQL injection prevention +- XSS protection +- Path traversal prevention + +**Request Limits**: +- Maximum request size: 10MB +- Request timeout: 30 seconds +- Header size limits +- URL length limits + +### Content Security + +**Content-Type Validation**: +- Strict content-type checking +- File upload validation +- MIME type verification +- Extension whitelist + +**File Upload Security**: +- Virus scanning integration points +- File size limits +- Storage isolation +- Access control + +## Network Security + +### HTTPS/TLS + +**Requirements**: +- TLS 1.2+ minimum +- Strong cipher suites +- Perfect Forward Secrecy +- HSTS headers + +**Certificate Management**: +- Automated certificate renewal +- Certificate pinning options +- Certificate transparency monitoring + +### CORS (Cross-Origin Resource Sharing) + +**Configuration**: +```env +CORS_ALLOWED_ORIGIN=https://yourdomain.com,https://app.yourdomain.com +``` + +**Security Headers**: +- Strict origin validation +- Credential handling +- Preflight request validation + +### Security Headers + +**Implemented Headers**: +```http +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +X-XSS-Protection: 1; mode=block +Strict-Transport-Security: max-age=31536000; includeSubDomains; preload +Content-Security-Policy: default-src 'self' +Referrer-Policy: strict-origin-when-cross-origin +``` + +## Rate Limiting + +### Multi-Layer Rate Limiting + +**Global Rate Limiting**: +- 100 requests per minute per IP +- Configurable limits per endpoint +- Token bucket algorithm + +**Authentication Rate Limiting**: +- 5 login attempts per 15 minutes +- Progressive delays for failed attempts +- Account lockout protection + +**API Rate Limiting**: +- Per-user rate limits +- Per-endpoint specific limits +- Burst capacity handling + +### Implementation + +```go +// Global rate limiting +app.Use(securityMW.RateLimit(100, 1*time.Minute)) + +// Authentication rate limiting +app.Post("/auth/login", securityMW.AuthRateLimit(), authController.Login) +``` + +## Data Protection + +### Encryption + +**Encryption at Rest**: +- Database field-level encryption for sensitive data +- AES-256-GCM encryption +- Secure key management + +**Encryption in Transit**: +- TLS 1.2+ for all communications +- Certificate validation +- Perfect Forward Secrecy + +**Key Management**: +```env +ENCRYPTION_KEY=32-character-hex-key # 256-bit key +``` + +### Data Classification + +**Sensitive Data Types**: +- Passwords (hashed with bcrypt) +- Personal information (encrypted) +- Authentication tokens (secure storage) +- System secrets (environment variables) + +**Data Handling**: +- Minimal data collection +- Data retention policies +- Secure data deletion +- Access logging + +## Audit & Monitoring + +### Audit Logging + +**Logged Events**: +- Authentication attempts (success/failure) +- Authorization decisions +- Data access and modifications +- Administrative actions +- Security events + +**Audit Log Format**: +```json +{ + "id": "uuid", + "userId": "user-id", + "action": "create", + "resource": "user", + "resourceId": "target-user-id", + "success": true, + "ipAddress": "192.168.1.1", + "userAgent": "Browser/1.0", + "timestamp": "2024-01-01T12:00:00Z", + "details": {...} +} +``` + +### Security Event Monitoring + +**Event Types**: +- Brute force attempts +- Privilege escalation attempts +- Suspicious activity patterns +- Rate limit violations +- Authentication anomalies + +**Response Actions**: +- Automatic account lockout +- Rate limit enforcement +- Security team notifications +- Event correlation + +## Vulnerability Management + +### Security Testing + +**Automated Testing**: +- Static code analysis +- Dependency vulnerability scanning +- Security unit tests +- Integration security tests + +**Manual Testing**: +- Penetration testing +- Code review +- Security architecture review +- Threat modeling + +### Dependency Management + +**Security Practices**: +- Regular dependency updates +- Vulnerability scanning +- License compliance +- Supply chain security + +**Go Module Security**: +```bash +go mod tidy +go list -m -u all +go mod download +``` + +## Incident Response + +### Security Incident Types + +**Authentication Incidents**: +- Brute force attacks +- Credential stuffing +- Account takeover + +**Authorization Incidents**: +- Privilege escalation +- Unauthorized access +- Permission bypass + +**Data Incidents**: +- Data breach +- Data exposure +- Data integrity issues + +### Response Procedures + +**Detection**: +- Automated monitoring alerts +- Log analysis +- User reports +- Security team monitoring + +**Response**: +1. Incident containment +2. Impact assessment +3. Evidence preservation +4. Stakeholder notification +5. Recovery procedures +6. Post-incident review + +## Secure Configuration + +### Environment Security + +**Production Settings**: +```env +GO_ENV=production +DEBUG_MODE=false +LOG_LEVEL=WARN +``` + +**Secret Management**: +- Environment variable storage +- Secure secret generation +- Secret rotation procedures +- Access control for secrets + +### Database Security + +**Security Measures**: +- Connection encryption +- Prepared statements (SQL injection prevention) +- Access control +- Backup encryption + +**Configuration**: +- Minimal database privileges +- Regular security updates +- Connection pooling limits +- Query logging for monitoring + +## Compliance & Standards + +### Security Standards + +**Frameworks**: +- OWASP Top 10 compliance +- NIST Cybersecurity Framework +- ISO 27001 principles +- PCI DSS guidelines (where applicable) + +**Security Controls**: +- Access control (AC) +- Audit and accountability (AU) +- Configuration management (CM) +- Identification and authentication (IA) +- System and communications protection (SC) + +### Privacy Protection + +**Data Protection**: +- GDPR compliance considerations +- Data minimization principles +- User consent management +- Right to deletion + +**Privacy by Design**: +- Default privacy settings +- Data encryption +- Access logging +- User control over data + +## Deployment Security + +### Secure Deployment + +**Build Security**: +- Secure build pipeline +- Dependency verification +- Binary signing +- Vulnerability scanning + +**Runtime Security**: +- Minimal attack surface +- Process isolation +- Resource limits +- Security monitoring + +### Infrastructure Security + +**Server Hardening**: +- OS security updates +- Unnecessary service removal +- Firewall configuration +- Intrusion detection + +**Container Security** (if applicable): +- Minimal base images +- Security scanning +- Runtime protection +- Resource limits + +## Security Maintenance + +### Regular Security Tasks + +**Daily**: +- Security log review +- Incident monitoring +- Threat intelligence updates + +**Weekly**: +- Vulnerability assessment +- Security metric review +- Access review + +**Monthly**: +- Security training updates +- Policy review +- Penetration testing +- Security architecture review + +### Security Updates + +**Update Process**: +1. Security advisory monitoring +2. Impact assessment +3. Testing in staging +4. Coordinated deployment +5. Verification testing + +**Emergency Updates**: +- Critical vulnerability response +- Out-of-band patching +- Incident coordination +- Communication procedures + +## Security Contact Information + +### Reporting Security Issues + +**Internal Team**: +- Security team email: security@company.com +- Incident hotline: +1-XXX-XXX-XXXX +- Escalation procedures: [Internal documentation] + +**External Researchers**: +- Security disclosure policy +- Responsible disclosure program +- Bug bounty program (if applicable) +- PGP key for encrypted communication + +### Security Training + +**Developer Training**: +- Secure coding practices +- Security testing procedures +- Incident response training +- Regular security updates + +**User Training**: +- Security awareness +- Password best practices +- Phishing recognition +- Incident reporting + +## Conclusion + +This security documentation outlines the comprehensive security measures implemented in the Bootstrap App. Regular review and updates of these security practices ensure ongoing protection against evolving threats. + +For questions about security implementations or to report security issues, please contact the security team through the designated channels. + +**Last Updated**: [Current Date] +**Next Review**: [Review Date] +**Document Version**: 1.0 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a55db52 --- /dev/null +++ b/go.mod @@ -0,0 +1,42 @@ +module omega-server + +go 1.23.0 + +require ( + github.com/gofiber/fiber/v2 v2.52.8 + github.com/gofiber/swagger v1.1.0 + github.com/golang-jwt/jwt/v4 v4.5.2 + github.com/google/uuid v1.6.0 + go.uber.org/dig v1.17.1 + golang.org/x/crypto v0.39.0 + gorm.io/driver/sqlite v1.5.6 + gorm.io/gorm v1.25.11 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/spec v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/swaggo/files/v2 v2.0.0 // indirect + github.com/swaggo/swag v1.16.3 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.26.0 // indirect + golang.org/x/tools v0.33.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f184a35 --- /dev/null +++ b/go.sum @@ -0,0 +1,88 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/gofiber/fiber/v2 v2.52.8 h1:xl4jJQ0BV5EJTA2aWiKw/VddRpHrKeZLF0QPUxqn0x4= +github.com/gofiber/fiber/v2 v2.52.8/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/gofiber/swagger v1.1.0 h1:ff3rg1fB+Rp5JN/N8jfxTiZtMKe/9tB9QDc79fPiJKQ= +github.com/gofiber/swagger v1.1.0/go.mod h1:pRZL0Np35sd+lTODTE5The0G+TMHfNY+oC4hM2/i5m8= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= +github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= +github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= +github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +go.uber.org/dig v1.17.1 h1:Tga8Lz8PcYNsWsyHMZ1Vm0OQOUaJNDyvPImgbAu9YSc= +go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE= +gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= +gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= +gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= diff --git a/local/api/api.go b/local/api/api.go new file mode 100644 index 0000000..caf8954 --- /dev/null +++ b/local/api/api.go @@ -0,0 +1,37 @@ +package api + +import ( + "omega-server/local/controller" + "omega-server/local/utl/common" + "omega-server/local/utl/configs" + "omega-server/local/utl/logging" + + "github.com/gofiber/fiber/v2" + "go.uber.org/dig" +) + +// Routes +// Initializes web api controllers and its corresponding routes. +// +// Args: +// *fiber.App: Fiber Application +func Init(di *dig.Container, app *fiber.App) { + + // Protected routes + groups := app.Group(configs.Prefix) + + routeGroups := &common.RouteGroups{ + API: groups.Group("/api"), + Auth: groups.Group("/auth"), + Users: groups.Group("/membership"), + } + + err := di.Provide(func() *common.RouteGroups { + return routeGroups + }) + if err != nil { + logging.Panic("unable to bind routes") + } + + controller.InitializeControllers(di) +} diff --git a/local/controller/api.go b/local/controller/api.go new file mode 100644 index 0000000..bd98882 --- /dev/null +++ b/local/controller/api.go @@ -0,0 +1,47 @@ +package controller + +import ( + "omega-server/local/middleware" + "omega-server/local/service" + "omega-server/local/utl/common" + "omega-server/local/utl/error_handler" + + "github.com/gofiber/fiber/v2" +) + +type ApiController struct { + service *service.ApiService + errorHandler *error_handler.ControllerErrorHandler +} + +// NewApiController +// Initializes ApiController. +// +// Args: +// *services.ApiService: API service +// *Fiber.RouterGroup: Fiber Router Group +// Returns: +// *ApiController: Controller for "api" interactions +func NewApiController(as *service.ApiService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *ApiController { + ac := &ApiController{ + service: as, + errorHandler: error_handler.NewControllerErrorHandler(), + } + + apiGroup := routeGroups.API + apiGroup.Use(auth.Authenticate) + apiGroup.Get("/", ac.getFirst) + + return ac +} + +// getFirst returns API +// +// @Summary Return API +// @Description Return API +// @Tags api +// @Success 200 {array} string +// @Router /v1/api [get] +func (ac *ApiController) getFirst(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) +} diff --git a/local/controller/controller.go b/local/controller/controller.go new file mode 100644 index 0000000..074b518 --- /dev/null +++ b/local/controller/controller.go @@ -0,0 +1,32 @@ +package controller + +import ( + "omega-server/local/middleware" + "omega-server/local/service" + "omega-server/local/utl/logging" + + "go.uber.org/dig" +) + +// InitializeControllers +// Initializes Dependency Injection modules and registers controllers +// +// Args: +// *dig.Container: Dig Container +func InitializeControllers(c *dig.Container) { + service.InitializeServices(c) + + if err := c.Provide(middleware.NewAuthMiddleware); err != nil { + logging.Panic("unable to initialize auth middleware") + } + + err := c.Invoke(NewApiController) + if err != nil { + logging.Panic("unable to initialize api controller") + } + + err = c.Invoke(NewMembershipController) + if err != nil { + logging.Panic("unable to initialize membership controller") + } +} diff --git a/local/controller/membership.go b/local/controller/membership.go new file mode 100644 index 0000000..940fe33 --- /dev/null +++ b/local/controller/membership.go @@ -0,0 +1,180 @@ +package controller + +import ( + "context" + "fmt" + "omega-server/local/middleware" + "omega-server/local/service" + "omega-server/local/utl/common" + "omega-server/local/utl/error_handler" + "omega-server/local/utl/logging" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +// MembershipController handles API requests for membership. +type MembershipController struct { + service *service.MembershipService + auth *middleware.AuthMiddleware + errorHandler *error_handler.ControllerErrorHandler +} + +// NewMembershipController creates a new MembershipController. +func NewMembershipController(service *service.MembershipService, auth *middleware.AuthMiddleware, routeGroups *common.RouteGroups) *MembershipController { + mc := &MembershipController{ + service: service, + auth: auth, + errorHandler: error_handler.NewControllerErrorHandler(), + } + // Setup initial data for membership + if err := service.SetupInitialData(context.Background()); err != nil { + logging.Panic(fmt.Sprintf("failed to setup initial data: %v", err)) + } + + routeGroups.Auth.Post("/login", mc.Login) + + usersGroup := routeGroups.Users + usersGroup.Use(mc.auth.Authenticate) + usersGroup.Post("/", mc.CreateUser) + usersGroup.Get("/", mc.ListUsers) + + usersGroup.Get("/roles", mc.GetRoles) + usersGroup.Get("/:id", mc.GetUser) + usersGroup.Put("/:id", mc.UpdateUser) + usersGroup.Delete("/:id", mc.DeleteUser) + + routeGroups.Auth.Get("/me", mc.auth.Authenticate, mc.GetMe) + + return mc +} + +// Login handles user login. +func (c *MembershipController) Login(ctx *fiber.Ctx) error { + type request struct { + Username string `json:"username"` + Password string `json:"password"` + } + + var req request + if err := ctx.BodyParser(&req); err != nil { + return c.errorHandler.HandleParsingError(ctx, err) + } + + logging.Debug("Login request received") + token, err := c.service.Login(ctx.UserContext(), req.Username, req.Password) + if err != nil { + return c.errorHandler.HandleAuthError(ctx, err) + } + + return ctx.JSON(fiber.Map{"token": token}) +} + +// CreateUser creates a new user. +func (mc *MembershipController) CreateUser(c *fiber.Ctx) error { + type request struct { + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` + } + + var req request + if err := c.BodyParser(&req); err != nil { + return mc.errorHandler.HandleParsingError(c, err) + } + + user, err := mc.service.CreateUser(c.UserContext(), req.Username, req.Password, req.Role) + if err != nil { + return mc.errorHandler.HandleServiceError(c, err) + } + + return c.JSON(user) +} + +// ListUsers lists all users. +func (mc *MembershipController) ListUsers(c *fiber.Ctx) error { + users, err := mc.service.ListUsers(c.UserContext()) + if err != nil { + return mc.errorHandler.HandleServiceError(c, err) + } + + return c.JSON(users) +} + +// GetUser gets a single user by ID. +func (mc *MembershipController) GetUser(c *fiber.Ctx) error { + id, err := uuid.Parse(c.Params("id")) + if err != nil { + return mc.errorHandler.HandleUUIDError(c, "user ID") + } + + user, err := mc.service.GetUser(c.UserContext(), id) + if err != nil { + return mc.errorHandler.HandleNotFoundError(c, "User") + } + + return c.JSON(user) +} + +// GetMe returns the currently authenticated user's details. +func (mc *MembershipController) GetMe(c *fiber.Ctx) error { + userID, ok := c.Locals("userID").(string) + if !ok || userID == "" { + return mc.errorHandler.HandleAuthError(c, fmt.Errorf("unauthorized: user ID not found in context")) + } + + user, err := mc.service.GetUserWithPermissions(c.UserContext(), userID) + if err != nil { + return mc.errorHandler.HandleNotFoundError(c, "User") + } + + // Sanitize the user object to not expose password + user.PasswordHash = "" + + return c.JSON(user) +} + +// DeleteUser deletes a user. +func (mc *MembershipController) DeleteUser(c *fiber.Ctx) error { + id, err := uuid.Parse(c.Params("id")) + if err != nil { + return mc.errorHandler.HandleUUIDError(c, "user ID") + } + + err = mc.service.DeleteUser(c.UserContext(), id) + if err != nil { + return mc.errorHandler.HandleServiceError(c, err) + } + + return c.SendStatus(fiber.StatusNoContent) +} + +// UpdateUser updates a user. +func (mc *MembershipController) UpdateUser(c *fiber.Ctx) error { + id, err := uuid.Parse(c.Params("id")) + if err != nil { + return mc.errorHandler.HandleUUIDError(c, "user ID") + } + + var req service.UpdateUserRequest + if err := c.BodyParser(&req); err != nil { + return mc.errorHandler.HandleParsingError(c, err) + } + + user, err := mc.service.UpdateUser(c.UserContext(), id, req) + if err != nil { + return mc.errorHandler.HandleServiceError(c, err) + } + + return c.JSON(user) +} + +// GetRoles returns all available roles. +func (mc *MembershipController) GetRoles(c *fiber.Ctx) error { + roles, err := mc.service.GetAllRoles(c.UserContext()) + if err != nil { + return mc.errorHandler.HandleServiceError(c, err) + } + + return c.JSON(roles) +} diff --git a/local/middleware/auth.go b/local/middleware/auth.go new file mode 100644 index 0000000..9fabe6d --- /dev/null +++ b/local/middleware/auth.go @@ -0,0 +1,243 @@ +package middleware + +import ( + "context" + "fmt" + "omega-server/local/middleware/security" + "omega-server/local/service" + "omega-server/local/utl/cache" + "omega-server/local/utl/jwt" + "omega-server/local/utl/logging" + "strings" + "time" + + "github.com/gofiber/fiber/v2" +) + +// CachedUserInfo holds cached user authentication and permission data +type CachedUserInfo struct { + UserID string + Username string + Roles []string + RoleNames []string + Permissions map[string]bool + CachedAt time.Time +} + +// AuthMiddleware provides authentication and permission middleware. +type AuthMiddleware struct { + membershipService service.MembershipServiceInterface + cache *cache.InMemoryCache + securityMW *security.SecurityMiddleware +} + +// NewAuthMiddleware creates a new AuthMiddleware. +func NewAuthMiddleware(ms service.MembershipServiceInterface, cache *cache.InMemoryCache) *AuthMiddleware { + auth := &AuthMiddleware{ + membershipService: ms, + cache: cache, + securityMW: security.NewSecurityMiddleware(), + } + + // Set up bidirectional relationship for cache invalidation + ms.SetCacheInvalidator(auth) + + return auth +} + +// Authenticate is a middleware for JWT authentication with enhanced security. +func (m *AuthMiddleware) Authenticate(ctx *fiber.Ctx) error { + // Log authentication attempt + ip := ctx.IP() + userAgent := ctx.Get("User-Agent") + + authHeader := ctx.Get("Authorization") + if authHeader == "" { + logging.Error("Authentication failed: missing Authorization header from IP %s", ip) + return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Missing or malformed JWT", + }) + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + logging.Error("Authentication failed: malformed Authorization header from IP %s", ip) + return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Missing or malformed JWT", + }) + } + + // Validate token length to prevent potential attacks + token := parts[1] + if len(token) < 10 || len(token) > 2048 { + logging.Error("Authentication failed: invalid token length from IP %s", ip) + return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Invalid or expired JWT", + }) + } + + claims, err := jwt.ValidateToken(token) + if err != nil { + logging.Error("Authentication failed: invalid token from IP %s, User-Agent: %s, Error: %v", ip, userAgent, err) + return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Invalid or expired JWT", + }) + } + + // Additional security: validate user ID format + if claims.UserID == "" || len(claims.UserID) < 10 { + logging.Error("Authentication failed: invalid user ID in token from IP %s", ip) + return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Invalid or expired JWT", + }) + } + + // Preload and cache user info to avoid database queries on permission checks + userInfo, err := m.getCachedUserInfo(ctx.UserContext(), claims.UserID) + if err != nil { + logging.Error("Authentication failed: unable to load user info for %s from IP %s: %v", claims.UserID, ip, err) + return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Invalid or expired JWT", + }) + } + + ctx.Locals("userID", claims.UserID) + ctx.Locals("userInfo", userInfo) + ctx.Locals("authTime", time.Now()) + + logging.InfoWithContext("AUTH", "User %s authenticated successfully from IP %s", claims.UserID, ip) + return ctx.Next() +} + +// HasPermission is a middleware for checking user permissions with enhanced logging. +func (m *AuthMiddleware) HasPermission(requiredPermission string) fiber.Handler { + return func(ctx *fiber.Ctx) error { + userID, ok := ctx.Locals("userID").(string) + if !ok { + logging.Error("Permission check failed: no user ID in context from IP %s", ctx.IP()) + return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Unauthorized", + }) + } + + // Validate permission parameter + if requiredPermission == "" { + logging.Error("Permission check failed: empty permission requirement") + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Internal server error", + }) + } + + // Use cached user info from authentication step - no database queries needed + userInfo, ok := ctx.Locals("userInfo").(*CachedUserInfo) + if !ok { + logging.Error("Permission check failed: no cached user info in context from IP %s", ctx.IP()) + return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Unauthorized", + }) + } + + // Check if user has permission using cached data + has := m.hasPermissionFromCache(userInfo, requiredPermission) + + if !has { + logging.WarnWithContext("AUTH", "Permission denied: user %s lacks permission %s, IP %s", userID, requiredPermission, ctx.IP()) + return ctx.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "Forbidden", + }) + } + + logging.DebugWithContext("AUTH", "Permission granted: user %s has permission %s", userID, requiredPermission) + return ctx.Next() + } +} + +// AuthRateLimit applies rate limiting specifically for authentication endpoints +func (m *AuthMiddleware) AuthRateLimit() fiber.Handler { + return m.securityMW.AuthRateLimit() +} + +// RequireHTTPS redirects HTTP requests to HTTPS in production +func (m *AuthMiddleware) RequireHTTPS() fiber.Handler { + return func(ctx *fiber.Ctx) error { + if ctx.Protocol() != "https" && ctx.Get("X-Forwarded-Proto") != "https" { + // Allow HTTP in development/testing + if ctx.Hostname() != "localhost" && ctx.Hostname() != "127.0.0.1" { + httpsURL := "https://" + ctx.Hostname() + ctx.OriginalURL() + return ctx.Redirect(httpsURL, fiber.StatusMovedPermanently) + } + } + return ctx.Next() + } +} + +// getCachedUserInfo retrieves and caches complete user information including permissions +func (m *AuthMiddleware) getCachedUserInfo(ctx context.Context, userID string) (*CachedUserInfo, error) { + cacheKey := fmt.Sprintf("userinfo:%s", userID) + + // Try cache first + if cached, found := m.cache.Get(cacheKey); found { + if userInfo, ok := cached.(*CachedUserInfo); ok { + logging.DebugWithContext("AUTH_CACHE", "User info for %s found in cache", userID) + return userInfo, nil + } + } + + // Cache miss - load from database + user, err := m.membershipService.GetUserWithPermissions(ctx, userID) + if err != nil { + return nil, err + } + + // Build permission map for fast lookups + permissions := make(map[string]bool) + roleNames := make([]string, len(user.Roles)) + for i, role := range user.Roles { + roleNames[i] = role.Name + for _, permission := range role.Permissions { + permissions[permission.Name] = true + } + } + + userInfo := &CachedUserInfo{ + UserID: userID, + Username: user.Username, + Roles: roleNames, + RoleNames: roleNames, + Permissions: permissions, + CachedAt: time.Now(), + } + + // Cache for 15 minutes + m.cache.Set(cacheKey, userInfo, 15*time.Minute) + logging.DebugWithContext("AUTH_CACHE", "User info for %s cached with %d permissions", userID, len(permissions)) + + return userInfo, nil +} + +// hasPermissionFromCache checks permissions using cached user info (no database queries) +func (m *AuthMiddleware) hasPermissionFromCache(userInfo *CachedUserInfo, permission string) bool { + // Super Admin and Admin have all permissions + for _, roleName := range userInfo.RoleNames { + if roleName == "Super Admin" || roleName == "Admin" { + return true + } + } + + // Check specific permission in cached map + return userInfo.Permissions[permission] +} + +// InvalidateUserPermissions removes cached user info for a user +func (m *AuthMiddleware) InvalidateUserPermissions(userID string) { + cacheKey := fmt.Sprintf("userinfo:%s", userID) + m.cache.Delete(cacheKey) + logging.InfoWithContext("AUTH_CACHE", "User info cache invalidated for user %s", userID) +} + +// InvalidateAllUserPermissions clears all cached user info (useful for role/permission changes) +func (m *AuthMiddleware) InvalidateAllUserPermissions() { + // This would need to be implemented based on your cache interface + // For now, just log that invalidation was requested + logging.InfoWithContext("AUTH_CACHE", "All user info caches invalidation requested") +} diff --git a/local/middleware/auth_test_exports.go b/local/middleware/auth_test_exports.go new file mode 100644 index 0000000..0f545c6 --- /dev/null +++ b/local/middleware/auth_test_exports.go @@ -0,0 +1,28 @@ +package middleware + +import "context" + +// ExportedCachedUserInfo exports CachedUserInfo for testing +type ExportedCachedUserInfo = CachedUserInfo + +// GetCachedUserInfo exports the internal getCachedUserInfo method for testing +func (m *AuthMiddleware) GetCachedUserInfo(ctx context.Context, userID string) (*CachedUserInfo, error) { + return m.getCachedUserInfo(ctx, userID) +} + +// TestExports provides test-only functionality for AuthMiddleware +type TestExports struct { + AuthMiddleware *AuthMiddleware +} + +// NewTestExports creates a new TestExports instance +func NewTestExports(auth *AuthMiddleware) *TestExports { + return &TestExports{ + AuthMiddleware: auth, + } +} + +// HasPermissionFromCache exports the internal hasPermissionFromCache method for testing +func (t *TestExports) HasPermissionFromCache(userInfo *CachedUserInfo, permission string) bool { + return t.AuthMiddleware.hasPermissionFromCache(userInfo, permission) +} diff --git a/local/middleware/logging/request_logging.go b/local/middleware/logging/request_logging.go new file mode 100644 index 0000000..1efdbba --- /dev/null +++ b/local/middleware/logging/request_logging.go @@ -0,0 +1,69 @@ +package logging + +import ( + "omega-server/local/utl/logging" + "time" + + "github.com/gofiber/fiber/v2" +) + +// RequestLoggingMiddleware logs HTTP requests and responses +type RequestLoggingMiddleware struct { + infoLogger *logging.InfoLogger +} + +// NewRequestLoggingMiddleware creates a new request logging middleware +func NewRequestLoggingMiddleware() *RequestLoggingMiddleware { + return &RequestLoggingMiddleware{ + infoLogger: logging.GetInfoLogger(), + } +} + +// Handler returns the middleware handler function +func (rlm *RequestLoggingMiddleware) Handler() fiber.Handler { + return func(c *fiber.Ctx) error { + // Record start time + start := time.Now() + + // Log incoming request + userAgent := c.Get("User-Agent") + if userAgent == "" { + userAgent = "Unknown" + } + + rlm.infoLogger.LogRequest(c.Method(), c.OriginalURL(), userAgent) + + // Continue to next handler + err := c.Next() + + // Calculate duration + duration := time.Since(start) + + // Log response + statusCode := c.Response().StatusCode() + rlm.infoLogger.LogResponse(c.Method(), c.OriginalURL(), statusCode, duration.String()) + + // Log error if present + if err != nil { + logging.ErrorWithContext("REQUEST_MIDDLEWARE", "Request failed: %v", err) + } + + return err + } +} + +// Global request logging middleware instance +var globalRequestLoggingMiddleware *RequestLoggingMiddleware + +// GetRequestLoggingMiddleware returns the global request logging middleware +func GetRequestLoggingMiddleware() *RequestLoggingMiddleware { + if globalRequestLoggingMiddleware == nil { + globalRequestLoggingMiddleware = NewRequestLoggingMiddleware() + } + return globalRequestLoggingMiddleware +} + +// Handler returns the global request logging middleware handler +func Handler() fiber.Handler { + return GetRequestLoggingMiddleware().Handler() +} diff --git a/local/middleware/security/security.go b/local/middleware/security/security.go new file mode 100644 index 0000000..b54cbf6 --- /dev/null +++ b/local/middleware/security/security.go @@ -0,0 +1,351 @@ +package security + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/gofiber/fiber/v2" +) + +// RateLimiter stores rate limiting information +type RateLimiter struct { + requests map[string][]time.Time + mutex sync.RWMutex +} + +// NewRateLimiter creates a new rate limiter +func NewRateLimiter() *RateLimiter { + rl := &RateLimiter{ + requests: make(map[string][]time.Time), + } + + // Clean up old entries every 5 minutes + go rl.cleanup() + + return rl +} + +// cleanup removes old entries from the rate limiter +func (rl *RateLimiter) cleanup() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + rl.mutex.Lock() + now := time.Now() + for key, times := range rl.requests { + // Remove entries older than 1 hour + filtered := make([]time.Time, 0, len(times)) + for _, t := range times { + if now.Sub(t) < time.Hour { + filtered = append(filtered, t) + } + } + if len(filtered) == 0 { + delete(rl.requests, key) + } else { + rl.requests[key] = filtered + } + } + rl.mutex.Unlock() + } +} + +// SecurityMiddleware provides comprehensive security middleware +type SecurityMiddleware struct { + rateLimiter *RateLimiter +} + +// NewSecurityMiddleware creates a new security middleware +func NewSecurityMiddleware() *SecurityMiddleware { + return &SecurityMiddleware{ + rateLimiter: NewRateLimiter(), + } +} + +// SecurityHeaders adds security headers to responses +func (sm *SecurityMiddleware) SecurityHeaders() fiber.Handler { + return func(c *fiber.Ctx) error { + // Prevent MIME type sniffing + c.Set("X-Content-Type-Options", "nosniff") + + // Prevent clickjacking + c.Set("X-Frame-Options", "DENY") + + // Enable XSS protection + c.Set("X-XSS-Protection", "1; mode=block") + + // Prevent referrer leakage + c.Set("Referrer-Policy", "strict-origin-when-cross-origin") + + // Content Security Policy + c.Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'") + + // Permissions Policy + c.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()") + + return c.Next() + } +} + +// RateLimit implements rate limiting for API endpoints +func (sm *SecurityMiddleware) RateLimit(maxRequests int, duration time.Duration) fiber.Handler { + return func(c *fiber.Ctx) error { + ip := c.IP() + key := fmt.Sprintf("rate_limit:%s", ip) + + sm.rateLimiter.mutex.Lock() + defer sm.rateLimiter.mutex.Unlock() + + now := time.Now() + requests := sm.rateLimiter.requests[key] + + // Remove requests older than duration + filtered := make([]time.Time, 0, len(requests)) + for _, t := range requests { + if now.Sub(t) < duration { + filtered = append(filtered, t) + } + } + + // Check if limit is exceeded + if len(filtered) >= maxRequests { + return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{ + "error": "Rate limit exceeded", + "retry_after": duration.Seconds(), + }) + } + + // Add current request + filtered = append(filtered, now) + sm.rateLimiter.requests[key] = filtered + + return c.Next() + } +} + +// AuthRateLimit implements stricter rate limiting for authentication endpoints +func (sm *SecurityMiddleware) AuthRateLimit() fiber.Handler { + return func(c *fiber.Ctx) error { + ip := c.IP() + userAgent := c.Get("User-Agent") + key := fmt.Sprintf("%s:%s", ip, userAgent) + + sm.rateLimiter.mutex.Lock() + defer sm.rateLimiter.mutex.Unlock() + + now := time.Now() + requests := sm.rateLimiter.requests[key] + + // Remove requests older than 15 minutes + filtered := make([]time.Time, 0, len(requests)) + for _, t := range requests { + if now.Sub(t) < 15*time.Minute { + filtered = append(filtered, t) + } + } + + // Check if limit is exceeded (5 requests per 15 minutes for auth) + if len(filtered) >= 5 { + return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{ + "error": "Too many authentication attempts", + "retry_after": 900, // 15 minutes + }) + } + + // Add current request + filtered = append(filtered, now) + sm.rateLimiter.requests[key] = filtered + + return c.Next() + } +} + +// InputSanitization sanitizes user input to prevent XSS and injection attacks +func (sm *SecurityMiddleware) InputSanitization() fiber.Handler { + return func(c *fiber.Ctx) error { + // Sanitize query parameters + c.Request().URI().QueryArgs().VisitAll(func(key, value []byte) { + sanitized := sanitizeInput(string(value)) + c.Request().URI().QueryArgs().Set(string(key), sanitized) + }) + + // Store original body for processing + if c.Method() == "POST" || c.Method() == "PUT" || c.Method() == "PATCH" { + body := c.Body() + if len(body) > 0 { + // Basic sanitization - remove potentially dangerous patterns + sanitized := sanitizeInput(string(body)) + c.Request().SetBodyString(sanitized) + } + } + + return c.Next() + } +} + +// sanitizeInput removes potentially dangerous patterns from input +func sanitizeInput(input string) string { + // Remove common XSS patterns + dangerous := []string{ + "", + "javascript:", + "vbscript:", + "data:text/html", + "onload=", + "onerror=", + "onclick=", + "onmouseover=", + "onfocus=", + "onblur=", + "onchange=", + "onsubmit=", + " maxSize { + return c.Status(fiber.StatusRequestEntityTooLarge).JSON(fiber.Map{ + "error": "Request too large", + "max_size": maxSize, + }) + } + } + + return c.Next() + } +} + +// LogSecurityEvents logs security-related events +func (sm *SecurityMiddleware) LogSecurityEvents() fiber.Handler { + return func(c *fiber.Ctx) error { + start := time.Now() + + // Process request + err := c.Next() + + // Log suspicious activity + status := c.Response().StatusCode() + if status == 401 || status == 403 || status == 429 { + duration := time.Since(start) + // In a real implementation, you would send this to your logging system + fmt.Printf("[SECURITY] %s %s %s %d %v %s\n", + time.Now().Format(time.RFC3339), + c.IP(), + c.Method(), + status, + duration, + c.Path(), + ) + } + + return err + } +} + +// TimeoutMiddleware adds request timeout +func (sm *SecurityMiddleware) TimeoutMiddleware(timeout time.Duration) fiber.Handler { + return func(c *fiber.Ctx) error { + ctx, cancel := context.WithTimeout(c.UserContext(), timeout) + defer cancel() + + c.SetUserContext(ctx) + + return c.Next() + } +} diff --git a/local/model/audit_log.go b/local/model/audit_log.go new file mode 100644 index 0000000..8f49eda --- /dev/null +++ b/local/model/audit_log.go @@ -0,0 +1,271 @@ +package model + +import ( + "encoding/json" + "strings" + "time" + + "gorm.io/gorm" +) + +// AuditLog represents an audit log entry in the system +type AuditLog struct { + BaseModel + UserID string `json:"userId" gorm:"type:varchar(36);index"` + Action string `json:"action" gorm:"not null;type:varchar(100);index"` + Resource string `json:"resource" gorm:"not null;type:varchar(100);index"` + ResourceID string `json:"resourceId" gorm:"type:varchar(36);index"` + Details map[string]interface{} `json:"details" gorm:"type:text"` + IPAddress string `json:"ipAddress" gorm:"type:varchar(45)"` + UserAgent string `json:"userAgent" gorm:"type:text"` + Success bool `json:"success" gorm:"default:true;index"` + ErrorMsg string `json:"errorMsg,omitempty" gorm:"type:text"` + Duration int64 `json:"duration,omitempty"` // Duration in milliseconds + SessionID string `json:"sessionId,omitempty" gorm:"type:varchar(255)"` + RequestID string `json:"requestId,omitempty" gorm:"type:varchar(255)"` + User *User `json:"user,omitempty" gorm:"foreignKey:UserID"` +} + +// AuditLogCreateRequest represents the request to create a new audit log +type AuditLogCreateRequest struct { + UserID string `json:"userId"` + Action string `json:"action" validate:"required,max=100"` + Resource string `json:"resource" validate:"required,max=100"` + ResourceID string `json:"resourceId"` + Details map[string]interface{} `json:"details"` + IPAddress string `json:"ipAddress" validate:"max=45"` + UserAgent string `json:"userAgent"` + Success bool `json:"success"` + ErrorMsg string `json:"errorMsg"` + Duration int64 `json:"duration"` + SessionID string `json:"sessionId"` + RequestID string `json:"requestId"` +} + +// AuditLogInfo represents public audit log information +type AuditLogInfo struct { + ID string `json:"id"` + UserID string `json:"userId"` + UserEmail string `json:"userEmail,omitempty"` + UserName string `json:"userName,omitempty"` + Action string `json:"action"` + Resource string `json:"resource"` + ResourceID string `json:"resourceId"` + Details map[string]interface{} `json:"details"` + IPAddress string `json:"ipAddress"` + UserAgent string `json:"userAgent"` + Success bool `json:"success"` + ErrorMsg string `json:"errorMsg,omitempty"` + Duration int64 `json:"duration,omitempty"` + SessionID string `json:"sessionId,omitempty"` + RequestID string `json:"requestId,omitempty"` + DateCreated string `json:"dateCreated"` +} + +// BeforeCreate is called before creating an audit log +func (al *AuditLog) BeforeCreate(tx *gorm.DB) error { + al.BaseModel.BeforeCreate() + + // Normalize fields + al.Action = strings.ToLower(strings.TrimSpace(al.Action)) + al.Resource = strings.ToLower(strings.TrimSpace(al.Resource)) + al.IPAddress = strings.TrimSpace(al.IPAddress) + al.UserAgent = strings.TrimSpace(al.UserAgent) + + return nil +} + +// SetDetails sets the details field from a map +func (al *AuditLog) SetDetails(details map[string]interface{}) error { + al.Details = details + return nil +} + +// GetDetails returns the details as a map +func (al *AuditLog) GetDetails() map[string]interface{} { + if al.Details == nil { + return make(map[string]interface{}) + } + return al.Details +} + +// SetDetailsFromJSON sets the details field from a JSON string +func (al *AuditLog) SetDetailsFromJSON(jsonStr string) error { + if jsonStr == "" { + al.Details = make(map[string]interface{}) + return nil + } + + var details map[string]interface{} + if err := json.Unmarshal([]byte(jsonStr), &details); err != nil { + return err + } + + al.Details = details + return nil +} + +// GetDetailsAsJSON returns the details as a JSON string +func (al *AuditLog) GetDetailsAsJSON() (string, error) { + if al.Details == nil || len(al.Details) == 0 { + return "{}", nil + } + + bytes, err := json.Marshal(al.Details) + if err != nil { + return "", err + } + + return string(bytes), nil +} + +// ToAuditLogInfo converts AuditLog to AuditLogInfo (public information) +func (al *AuditLog) ToAuditLogInfo() AuditLogInfo { + info := AuditLogInfo{ + ID: al.ID, + UserID: al.UserID, + Action: al.Action, + Resource: al.Resource, + ResourceID: al.ResourceID, + Details: al.GetDetails(), + IPAddress: al.IPAddress, + UserAgent: al.UserAgent, + Success: al.Success, + ErrorMsg: al.ErrorMsg, + Duration: al.Duration, + SessionID: al.SessionID, + RequestID: al.RequestID, + DateCreated: al.DateCreated.Format("2006-01-02T15:04:05Z"), + } + + // Include user information if available + if al.User != nil { + info.UserEmail = al.User.Email + info.UserName = al.User.Name + } + + return info +} + +// AddDetail adds a single detail to the details map +func (al *AuditLog) AddDetail(key string, value interface{}) { + if al.Details == nil { + al.Details = make(map[string]interface{}) + } + al.Details[key] = value +} + +// GetDetail gets a single detail from the details map +func (al *AuditLog) GetDetail(key string) (interface{}, bool) { + if al.Details == nil { + return nil, false + } + value, exists := al.Details[key] + return value, exists +} + +// Common audit log actions +const ( + AuditActionCreate = "create" + AuditActionRead = "read" + AuditActionUpdate = "update" + AuditActionDelete = "delete" + AuditActionLogin = "login" + AuditActionLogout = "logout" + AuditActionAccess = "access" + AuditActionExport = "export" + AuditActionImport = "import" + AuditActionConfig = "config" +) + +// Common audit log resources +const ( + AuditResourceUser = "user" + AuditResourceRole = "role" + AuditResourcePermission = "permission" + AuditResourceSystemConfig = "system_config" + AuditResourceAuth = "auth" + AuditResourceAPI = "api" + AuditResourceFile = "file" + AuditResourceDatabase = "database" + AuditResourceSystem = "system" +) + +// CreateAuditLog creates a new audit log entry +func CreateAuditLog(userID, action, resource, resourceID string, success bool) *AuditLog { + auditLog := &AuditLog{ + UserID: userID, + Action: action, + Resource: resource, + ResourceID: resourceID, + Success: success, + Details: make(map[string]interface{}), + } + auditLog.Init() + return auditLog +} + +// CreateAuditLogWithDetails creates a new audit log entry with details +func CreateAuditLogWithDetails(userID, action, resource, resourceID string, success bool, details map[string]interface{}) *AuditLog { + auditLog := CreateAuditLog(userID, action, resource, resourceID, success) + auditLog.Details = details + return auditLog +} + +// CreateAuditLogWithError creates a new audit log entry for an error +func CreateAuditLogWithError(userID, action, resource, resourceID, errorMsg string) *AuditLog { + auditLog := CreateAuditLog(userID, action, resource, resourceID, false) + auditLog.ErrorMsg = errorMsg + return auditLog +} + +// SetRequestInfo sets request-related information +func (al *AuditLog) SetRequestInfo(ipAddress, userAgent, sessionID, requestID string) { + al.IPAddress = ipAddress + al.UserAgent = userAgent + al.SessionID = sessionID + al.RequestID = requestID +} + +// SetDuration sets the operation duration +func (al *AuditLog) SetDuration(start time.Time) { + al.Duration = time.Since(start).Milliseconds() +} + +// IsSuccess returns whether the audit log represents a successful operation +func (al *AuditLog) IsSuccess() bool { + return al.Success +} + +// IsFailure returns whether the audit log represents a failed operation +func (al *AuditLog) IsFailure() bool { + return !al.Success +} + +// GetActionDescription returns a human-readable description of the action +func (al *AuditLog) GetActionDescription() string { + switch al.Action { + case AuditActionCreate: + return "Created " + al.Resource + case AuditActionRead: + return "Viewed " + al.Resource + case AuditActionUpdate: + return "Updated " + al.Resource + case AuditActionDelete: + return "Deleted " + al.Resource + case AuditActionLogin: + return "Logged in" + case AuditActionLogout: + return "Logged out" + case AuditActionAccess: + return "Accessed " + al.Resource + case AuditActionExport: + return "Exported " + al.Resource + case AuditActionImport: + return "Imported " + al.Resource + case AuditActionConfig: + return "Configured " + al.Resource + default: + return strings.Title(al.Action) + " " + al.Resource + } +} diff --git a/local/model/base.go b/local/model/base.go new file mode 100644 index 0000000..9c76a34 --- /dev/null +++ b/local/model/base.go @@ -0,0 +1,110 @@ +package model + +import ( + "time" + + "github.com/google/uuid" +) + +// BaseModel provides common fields for all database models +type BaseModel struct { + ID string `json:"id" gorm:"primary_key;type:varchar(36)"` + DateCreated time.Time `json:"dateCreated" gorm:"not null"` + DateUpdated time.Time `json:"dateUpdated" gorm:"not null"` +} + +// Init initializes base model with DateCreated, DateUpdated, and ID values +func (bm *BaseModel) Init() { + now := time.Now().UTC() + bm.ID = uuid.NewString() + bm.DateCreated = now + bm.DateUpdated = now +} + +// UpdateTimestamp updates the DateUpdated field +func (bm *BaseModel) UpdateTimestamp() { + bm.DateUpdated = time.Now().UTC() +} + +// BeforeCreate is a GORM hook that runs before creating a record +func (bm *BaseModel) BeforeCreate() error { + if bm.ID == "" { + bm.Init() + } + return nil +} + +// BeforeUpdate is a GORM hook that runs before updating a record +func (bm *BaseModel) BeforeUpdate() error { + bm.UpdateTimestamp() + return nil +} + +// FilteredResponse represents a paginated response +type FilteredResponse struct { + Items interface{} `json:"items"` + Params +} + +// MessageResponse represents a simple message response +type MessageResponse struct { + Message string `json:"message"` + Success bool `json:"success"` +} + +// ErrorResponse represents an error response +type ErrorResponse struct { + Error string `json:"error"` + Details map[string]interface{} `json:"details,omitempty"` + Code int `json:"code,omitempty"` +} + +// Params represents pagination and filtering parameters +type Params struct { + SortBy string `json:"sortBy" query:"sortBy"` + SortOrder string `json:"sortOrder" query:"sortOrder"` + Page int `json:"page" query:"page"` + Limit int `json:"limit" query:"limit"` + Search string `json:"search" query:"search"` + Filter string `json:"filter" query:"filter"` + TotalRecords int64 `json:"totalRecords"` + TotalPages int `json:"totalPages"` +} + +// DefaultParams returns default pagination parameters +func DefaultParams() Params { + return Params{ + Page: 1, + Limit: 10, + SortBy: "dateCreated", + SortOrder: "desc", + } +} + +// Validate validates and sets default values for parameters +func (p *Params) Validate() { + if p.Page < 1 { + p.Page = 1 + } + if p.Limit < 1 || p.Limit > 100 { + p.Limit = 10 + } + if p.SortBy == "" { + p.SortBy = "dateCreated" + } + if p.SortOrder != "asc" && p.SortOrder != "desc" { + p.SortOrder = "desc" + } +} + +// Offset calculates the offset for database queries +func (p *Params) Offset() int { + return (p.Page - 1) * p.Limit +} + +// CalculateTotalPages calculates total pages based on total records +func (p *Params) CalculateTotalPages() { + if p.Limit > 0 { + p.TotalPages = int((p.TotalRecords + int64(p.Limit) - 1) / int64(p.Limit)) + } +} diff --git a/local/model/membership_filter.go b/local/model/membership_filter.go new file mode 100644 index 0000000..7eddf88 --- /dev/null +++ b/local/model/membership_filter.go @@ -0,0 +1,228 @@ +package model + +import ( + "gorm.io/gorm" +) + +// MembershipFilter represents filters for membership-related queries +type MembershipFilter struct { + Params + // User-specific filters + Username string `json:"username" query:"username"` + Email string `json:"email" query:"email"` + Active *bool `json:"active" query:"active"` + EmailVerified *bool `json:"emailVerified" query:"emailVerified"` + RoleID string `json:"roleId" query:"roleId"` + RoleName string `json:"roleName" query:"roleName"` + + // Role-specific filters + RoleActive *bool `json:"roleActive" query:"roleActive"` + RoleSystem *bool `json:"roleSystem" query:"roleSystem"` + + // Permission-specific filters + PermissionName string `json:"permissionName" query:"permissionName"` + PermissionCategory string `json:"permissionCategory" query:"permissionCategory"` + PermissionActive *bool `json:"permissionActive" query:"permissionActive"` + PermissionSystem *bool `json:"permissionSystem" query:"permissionSystem"` + + // Date range filters + CreatedAfter string `json:"createdAfter" query:"createdAfter"` + CreatedBefore string `json:"createdBefore" query:"createdBefore"` + UpdatedAfter string `json:"updatedAfter" query:"updatedAfter"` + UpdatedBefore string `json:"updatedBefore" query:"updatedBefore"` + + // Relationship filters + WithRoles bool `json:"withRoles" query:"withRoles"` + WithPermissions bool `json:"withPermissions" query:"withPermissions"` + WithUsers bool `json:"withUsers" query:"withUsers"` +} + +// ApplyFilter applies the filter conditions to a GORM query +func (f *MembershipFilter) ApplyFilter(query *gorm.DB) *gorm.DB { + if f == nil { + return query + } + + // Apply search across multiple fields + if f.Search != "" { + query = query.Where("username LIKE ? OR email LIKE ? OR name LIKE ?", + "%"+f.Search+"%", "%"+f.Search+"%", "%"+f.Search+"%") + } + + // User-specific filters + if f.Username != "" { + query = query.Where("username = ?", f.Username) + } + + if f.Email != "" { + query = query.Where("email = ?", f.Email) + } + + if f.Active != nil { + query = query.Where("active = ?", *f.Active) + } + + if f.EmailVerified != nil { + query = query.Where("email_verified = ?", *f.EmailVerified) + } + + if f.RoleID != "" { + query = query.Joins("JOIN user_roles ON users.id = user_roles.user_id"). + Where("user_roles.role_id = ?", f.RoleID) + } + + if f.RoleName != "" { + query = query.Joins("JOIN user_roles ON users.id = user_roles.user_id"). + Joins("JOIN roles ON user_roles.role_id = roles.id"). + Where("roles.name = ?", f.RoleName) + } + + // Role-specific filters + if f.RoleActive != nil { + query = query.Where("active = ?", *f.RoleActive) + } + + if f.RoleSystem != nil { + query = query.Where("system = ?", *f.RoleSystem) + } + + // Permission-specific filters + if f.PermissionName != "" { + query = query.Where("name = ?", f.PermissionName) + } + + if f.PermissionCategory != "" { + query = query.Where("category = ?", f.PermissionCategory) + } + + if f.PermissionActive != nil { + query = query.Where("active = ?", *f.PermissionActive) + } + + if f.PermissionSystem != nil { + query = query.Where("system = ?", *f.PermissionSystem) + } + + // Date range filters + if f.CreatedAfter != "" { + query = query.Where("date_created >= ?", f.CreatedAfter) + } + + if f.CreatedBefore != "" { + query = query.Where("date_created <= ?", f.CreatedBefore) + } + + if f.UpdatedAfter != "" { + query = query.Where("date_updated >= ?", f.UpdatedAfter) + } + + if f.UpdatedBefore != "" { + query = query.Where("date_updated <= ?", f.UpdatedBefore) + } + + // Relationship preloading + if f.WithRoles { + query = query.Preload("Roles") + } + + if f.WithPermissions { + query = query.Preload("Permissions") + } + + if f.WithUsers { + query = query.Preload("Users") + } + + return query +} + +// Pagination returns the offset and limit for pagination +func (f *MembershipFilter) Pagination() (offset, limit int) { + if f == nil { + return 0, 10 + } + + f.Validate() + return f.Offset(), f.Limit +} + +// GetSorting returns the sorting field and direction +func (f *MembershipFilter) GetSorting() (field string, desc bool) { + if f == nil { + return "date_created", true + } + + f.Validate() + + // Map common sort fields to database column names + switch f.SortBy { + case "dateCreated": + field = "date_created" + case "dateUpdated": + field = "date_updated" + case "username": + field = "username" + case "email": + field = "email" + case "name": + field = "name" + case "active": + field = "active" + default: + field = "date_created" + } + + desc = f.SortOrder == "desc" + return field, desc +} + +// NewMembershipFilter creates a new MembershipFilter with default values +func NewMembershipFilter() *MembershipFilter { + return &MembershipFilter{ + Params: DefaultParams(), + } +} + +// SetUserFilters sets user-specific filters +func (f *MembershipFilter) SetUserFilters(username, email string, active, emailVerified *bool) *MembershipFilter { + f.Username = username + f.Email = email + f.Active = active + f.EmailVerified = emailVerified + return f +} + +// SetRoleFilters sets role-specific filters +func (f *MembershipFilter) SetRoleFilters(roleID, roleName string, roleActive, roleSystem *bool) *MembershipFilter { + f.RoleID = roleID + f.RoleName = roleName + f.RoleActive = roleActive + f.RoleSystem = roleSystem + return f +} + +// SetPermissionFilters sets permission-specific filters +func (f *MembershipFilter) SetPermissionFilters(name, category string, active, system *bool) *MembershipFilter { + f.PermissionName = name + f.PermissionCategory = category + f.PermissionActive = active + f.PermissionSystem = system + return f +} + +// SetDateRangeFilters sets date range filters +func (f *MembershipFilter) SetDateRangeFilters(createdAfter, createdBefore, updatedAfter, updatedBefore string) *MembershipFilter { + f.CreatedAfter = createdAfter + f.CreatedBefore = createdBefore + f.UpdatedAfter = updatedAfter + f.UpdatedBefore = updatedBefore + return f +} + +// SetPreloads sets which relationships to preload +func (f *MembershipFilter) SetPreloads(withRoles, withPermissions, withUsers bool) *MembershipFilter { + f.WithRoles = withRoles + f.WithPermissions = withPermissions + f.WithUsers = withUsers + return f +} diff --git a/local/model/permission.go b/local/model/permission.go new file mode 100644 index 0000000..b6e1717 --- /dev/null +++ b/local/model/permission.go @@ -0,0 +1,276 @@ +package model + +import ( + "errors" + "strings" + + "gorm.io/gorm" +) + +// Permission represents a permission in the system +type Permission struct { + BaseModel + Name string `json:"name" gorm:"unique;not null;type:varchar(100)"` + Description string `json:"description" gorm:"type:text"` + Category string `json:"category" gorm:"type:varchar(50)"` + Active bool `json:"active" gorm:"default:true"` + System bool `json:"system" gorm:"default:false"` // System permissions cannot be deleted + Roles []Role `json:"-" gorm:"many2many:role_permissions;"` +} + +// PermissionCreateRequest represents the request to create a new permission +type PermissionCreateRequest struct { + Name string `json:"name" validate:"required,min=3,max=100"` + Description string `json:"description" validate:"max=500"` + Category string `json:"category" validate:"required,max=50"` +} + +// PermissionUpdateRequest represents the request to update a permission +type PermissionUpdateRequest struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=3,max=100"` + Description *string `json:"description,omitempty" validate:"omitempty,max=500"` + Category *string `json:"category,omitempty" validate:"omitempty,max=50"` + Active *bool `json:"active,omitempty"` +} + +// PermissionInfo represents public permission information +type PermissionInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` + Active bool `json:"active"` + System bool `json:"system"` + RoleCount int64 `json:"roleCount"` + DateCreated string `json:"dateCreated"` +} + +// BeforeCreate is called before creating a permission +func (p *Permission) BeforeCreate(tx *gorm.DB) error { + p.BaseModel.BeforeCreate() + + // Normalize fields + p.Name = strings.ToLower(strings.TrimSpace(p.Name)) + p.Description = strings.TrimSpace(p.Description) + p.Category = strings.ToLower(strings.TrimSpace(p.Category)) + + return p.Validate() +} + +// BeforeUpdate is called before updating a permission +func (p *Permission) BeforeUpdate(tx *gorm.DB) error { + p.BaseModel.BeforeUpdate() + + // Normalize fields if they're being updated + if p.Name != "" { + p.Name = strings.ToLower(strings.TrimSpace(p.Name)) + } + if p.Description != "" { + p.Description = strings.TrimSpace(p.Description) + } + if p.Category != "" { + p.Category = strings.ToLower(strings.TrimSpace(p.Category)) + } + + return p.Validate() +} + +// BeforeDelete is called before deleting a permission +func (p *Permission) BeforeDelete(tx *gorm.DB) error { + if p.System { + return errors.New("system permissions cannot be deleted") + } + + // Check if permission is assigned to any roles + var roleCount int64 + if err := tx.Model(&Role{}).Where("permissions.id = ?", p.ID).Joins("JOIN role_permissions ON roles.id = role_permissions.role_id").Count(&roleCount).Error; err != nil { + return err + } + + if roleCount > 0 { + return errors.New("cannot delete permission that is assigned to roles") + } + + return nil +} + +// Validate validates permission data +func (p *Permission) Validate() error { + if p.Name == "" { + return errors.New("permission name is required") + } + + if len(p.Name) < 3 || len(p.Name) > 100 { + return errors.New("permission name must be between 3 and 100 characters") + } + + if !isValidPermissionName(p.Name) { + return errors.New("permission name must follow the format 'resource:action' (e.g., 'user:create')") + } + + if p.Category == "" { + return errors.New("permission category is required") + } + + if len(p.Category) > 50 { + return errors.New("permission category must not exceed 50 characters") + } + + if len(p.Description) > 500 { + return errors.New("permission description must not exceed 500 characters") + } + + return nil +} + +// ToPermissionInfo converts Permission to PermissionInfo (public information) +func (p *Permission) ToPermissionInfo() PermissionInfo { + return PermissionInfo{ + ID: p.ID, + Name: p.Name, + Description: p.Description, + Category: p.Category, + Active: p.Active, + System: p.System, + DateCreated: p.DateCreated.Format("2006-01-02T15:04:05Z"), + } +} + +// GetResource extracts the resource part from a permission name (e.g., "user:create" -> "user") +func (p *Permission) GetResource() string { + parts := strings.Split(p.Name, ":") + if len(parts) > 0 { + return parts[0] + } + return "" +} + +// GetAction extracts the action part from a permission name (e.g., "user:create" -> "create") +func (p *Permission) GetAction() string { + parts := strings.Split(p.Name, ":") + if len(parts) > 1 { + return parts[1] + } + return "" +} + +// isValidPermissionName validates permission name format +func isValidPermissionName(name string) bool { + // Permission names should follow the format "resource:action" + parts := strings.Split(name, ":") + if len(parts) != 2 { + return false + } + + resource := parts[0] + action := parts[1] + + // Validate resource part + if len(resource) < 2 || len(resource) > 50 { + return false + } + + // Validate action part + if len(action) < 2 || len(action) > 50 { + return false + } + + // Check if both parts contain only valid characters + return isValidIdentifier(resource) && isValidIdentifier(action) +} + +// isValidIdentifier checks if a string is a valid identifier (letters, numbers, underscores) +func isValidIdentifier(str string) bool { + for _, char := range str { + if !((char >= 'a' && char <= 'z') || + (char >= 'A' && char <= 'Z') || + (char >= '0' && char <= '9') || + char == '_') { + return false + } + } + return true +} + +// Common permission categories +const ( + PermissionCategoryUser = "user" + PermissionCategoryRole = "role" + PermissionCategorySystem = "system" + PermissionCategoryContent = "content" + PermissionCategoryReport = "report" +) + +// Common permission patterns +const ( + PermissionCreate = "create" + PermissionRead = "read" + PermissionUpdate = "update" + PermissionDelete = "delete" + PermissionManage = "manage" + PermissionAdmin = "admin" +) + +// GetStandardPermissions returns a list of standard permissions for a resource +func GetStandardPermissions(resource string) []Permission { + return []Permission{ + { + Name: resource + ":" + PermissionCreate, + Description: "Create new " + resource + " records", + Category: resource, + Active: true, + }, + { + Name: resource + ":" + PermissionRead, + Description: "Read " + resource + " records", + Category: resource, + Active: true, + }, + { + Name: resource + ":" + PermissionUpdate, + Description: "Update " + resource + " records", + Category: resource, + Active: true, + }, + { + Name: resource + ":" + PermissionDelete, + Description: "Delete " + resource + " records", + Category: resource, + Active: true, + }, + } +} + +// Common system permissions +const ( + ServerView = "server:view" + ServerUpdate = "server:update" + ServerStart = "server:start" + ServerStop = "server:stop" + ConfigView = "config:view" + ConfigUpdate = "config:update" +) + +// AllPermissions returns all available permissions in the system +var AllPermissions = []string{ + "user:create", + "user:read", + "user:update", + "user:delete", + "role:create", + "role:read", + "role:update", + "role:delete", + "permission:create", + "permission:read", + "permission:update", + "permission:delete", + ServerView, + ServerUpdate, + ServerStart, + ServerStop, + ConfigView, + ConfigUpdate, + "audit:read", + "system:admin", +} diff --git a/local/model/role.go b/local/model/role.go new file mode 100644 index 0000000..79f7557 --- /dev/null +++ b/local/model/role.go @@ -0,0 +1,182 @@ +package model + +import ( + "errors" + "strings" + + "gorm.io/gorm" +) + +// Role represents a role in the system +type Role struct { + BaseModel + Name string `json:"name" gorm:"unique;not null;type:varchar(100)"` + Description string `json:"description" gorm:"type:text"` + Active bool `json:"active" gorm:"default:true"` + System bool `json:"system" gorm:"default:false"` // System roles cannot be deleted + Users []User `json:"-" gorm:"many2many:user_roles;"` + Permissions []Permission `json:"permissions" gorm:"many2many:role_permissions;"` +} + +// RoleCreateRequest represents the request to create a new role +type RoleCreateRequest struct { + Name string `json:"name" validate:"required,min=3,max=100"` + Description string `json:"description" validate:"max=500"` + PermissionIDs []string `json:"permissionIds"` +} + +// RoleUpdateRequest represents the request to update a role +type RoleUpdateRequest struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=3,max=100"` + Description *string `json:"description,omitempty" validate:"omitempty,max=500"` + Active *bool `json:"active,omitempty"` + PermissionIDs []string `json:"permissionIds,omitempty"` +} + +// RoleInfo represents public role information +type RoleInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Active bool `json:"active"` + System bool `json:"system"` + Permissions []PermissionInfo `json:"permissions"` + UserCount int64 `json:"userCount"` + DateCreated string `json:"dateCreated"` +} + +// BeforeCreate is called before creating a role +func (r *Role) BeforeCreate(tx *gorm.DB) error { + r.BaseModel.BeforeCreate() + + // Normalize name + r.Name = strings.ToLower(strings.TrimSpace(r.Name)) + r.Description = strings.TrimSpace(r.Description) + + return r.Validate() +} + +// BeforeUpdate is called before updating a role +func (r *Role) BeforeUpdate(tx *gorm.DB) error { + r.BaseModel.BeforeUpdate() + + // Normalize fields if they're being updated + if r.Name != "" { + r.Name = strings.ToLower(strings.TrimSpace(r.Name)) + } + if r.Description != "" { + r.Description = strings.TrimSpace(r.Description) + } + + return r.Validate() +} + +// BeforeDelete is called before deleting a role +func (r *Role) BeforeDelete(tx *gorm.DB) error { + if r.System { + return errors.New("system roles cannot be deleted") + } + + // Check if role is assigned to any users + var userCount int64 + if err := tx.Model(&User{}).Where("roles.id = ?", r.ID).Joins("JOIN user_roles ON users.id = user_roles.user_id").Count(&userCount).Error; err != nil { + return err + } + + if userCount > 0 { + return errors.New("cannot delete role that is assigned to users") + } + + return nil +} + +// Validate validates role data +func (r *Role) Validate() error { + if r.Name == "" { + return errors.New("role name is required") + } + + if len(r.Name) < 3 || len(r.Name) > 100 { + return errors.New("role name must be between 3 and 100 characters") + } + + if !isValidRoleName(r.Name) { + return errors.New("role name can only contain letters, numbers, underscores, and hyphens") + } + + if len(r.Description) > 500 { + return errors.New("role description must not exceed 500 characters") + } + + return nil +} + +// ToRoleInfo converts Role to RoleInfo (public information) +func (r *Role) ToRoleInfo() RoleInfo { + roleInfo := RoleInfo{ + ID: r.ID, + Name: r.Name, + Description: r.Description, + Active: r.Active, + System: r.System, + Permissions: make([]PermissionInfo, len(r.Permissions)), + DateCreated: r.DateCreated.Format("2006-01-02T15:04:05Z"), + } + + // Convert permissions + for i, permission := range r.Permissions { + roleInfo.Permissions[i] = permission.ToPermissionInfo() + } + + return roleInfo +} + +// HasPermission checks if the role has a specific permission +func (r *Role) HasPermission(permissionName string) bool { + for _, permission := range r.Permissions { + if permission.Name == permissionName { + return true + } + } + return false +} + +// AddPermission adds a permission to the role +func (r *Role) AddPermission(permission Permission) { + if !r.HasPermission(permission.Name) { + r.Permissions = append(r.Permissions, permission) + } +} + +// RemovePermission removes a permission from the role +func (r *Role) RemovePermission(permissionName string) { + for i, permission := range r.Permissions { + if permission.Name == permissionName { + r.Permissions = append(r.Permissions[:i], r.Permissions[i+1:]...) + break + } + } +} + +// GetPermissionNames returns a slice of permission names +func (r *Role) GetPermissionNames() []string { + names := make([]string, len(r.Permissions)) + for i, permission := range r.Permissions { + names[i] = permission.Name + } + return names +} + +// isValidRoleName validates role name format +func isValidRoleName(name string) bool { + // Allow letters, numbers, underscores, and hyphens + for _, char := range name { + if !((char >= 'a' && char <= 'z') || + (char >= 'A' && char <= 'Z') || + (char >= '0' && char <= '9') || + char == '_' || char == '-') { + return false + } + } + return true +} diff --git a/local/model/security_event.go b/local/model/security_event.go new file mode 100644 index 0000000..197392e --- /dev/null +++ b/local/model/security_event.go @@ -0,0 +1,367 @@ +package model + +import ( + "encoding/json" + "strings" + "time" + + "gorm.io/gorm" +) + +// SecurityEvent represents a security event in the system +type SecurityEvent struct { + BaseModel + EventType string `json:"eventType" gorm:"not null;type:varchar(100);index"` + Severity string `json:"severity" gorm:"not null;type:varchar(20);index"` + UserID string `json:"userId" gorm:"type:varchar(36);index"` + IPAddress string `json:"ipAddress" gorm:"type:varchar(45);index"` + UserAgent string `json:"userAgent" gorm:"type:text"` + Resource string `json:"resource" gorm:"type:varchar(100)"` + Action string `json:"action" gorm:"type:varchar(100)"` + Success bool `json:"success" gorm:"index"` + Blocked bool `json:"blocked" gorm:"default:false;index"` + Message string `json:"message" gorm:"type:text"` + Details map[string]interface{} `json:"details" gorm:"type:text"` + SessionID string `json:"sessionId,omitempty" gorm:"type:varchar(255)"` + RequestID string `json:"requestId,omitempty" gorm:"type:varchar(255)"` + CountryCode string `json:"countryCode,omitempty" gorm:"type:varchar(2)"` + City string `json:"city,omitempty" gorm:"type:varchar(100)"` + Resolved bool `json:"resolved" gorm:"default:false;index"` + ResolvedBy string `json:"resolvedBy,omitempty" gorm:"type:varchar(36)"` + ResolvedAt *time.Time `json:"resolvedAt,omitempty"` + Notes string `json:"notes,omitempty" gorm:"type:text"` + User *User `json:"user,omitempty" gorm:"foreignKey:UserID"` + Resolver *User `json:"resolver,omitempty" gorm:"foreignKey:ResolvedBy"` +} + +// SecurityEventCreateRequest represents the request to create a new security event +type SecurityEventCreateRequest struct { + EventType string `json:"eventType" validate:"required,max=100"` + Severity string `json:"severity" validate:"required,oneof=low medium high critical"` + UserID string `json:"userId"` + IPAddress string `json:"ipAddress" validate:"max=45"` + UserAgent string `json:"userAgent"` + Resource string `json:"resource" validate:"max=100"` + Action string `json:"action" validate:"max=100"` + Success bool `json:"success"` + Blocked bool `json:"blocked"` + Message string `json:"message" validate:"required"` + Details map[string]interface{} `json:"details"` + SessionID string `json:"sessionId"` + RequestID string `json:"requestId"` + CountryCode string `json:"countryCode" validate:"max=2"` + City string `json:"city" validate:"max=100"` +} + +// SecurityEventUpdateRequest represents the request to update a security event +type SecurityEventUpdateRequest struct { + Resolved *bool `json:"resolved,omitempty"` + ResolvedBy *string `json:"resolvedBy,omitempty"` + Notes *string `json:"notes,omitempty"` +} + +// SecurityEventInfo represents public security event information +type SecurityEventInfo struct { + ID string `json:"id"` + EventType string `json:"eventType"` + Severity string `json:"severity"` + UserID string `json:"userId"` + UserEmail string `json:"userEmail,omitempty"` + UserName string `json:"userName,omitempty"` + IPAddress string `json:"ipAddress"` + UserAgent string `json:"userAgent"` + Resource string `json:"resource"` + Action string `json:"action"` + Success bool `json:"success"` + Blocked bool `json:"blocked"` + Message string `json:"message"` + Details map[string]interface{} `json:"details"` + SessionID string `json:"sessionId,omitempty"` + RequestID string `json:"requestId,omitempty"` + CountryCode string `json:"countryCode,omitempty"` + City string `json:"city,omitempty"` + Resolved bool `json:"resolved"` + ResolvedBy string `json:"resolvedBy,omitempty"` + ResolverEmail string `json:"resolverEmail,omitempty"` + ResolverName string `json:"resolverName,omitempty"` + ResolvedAt *time.Time `json:"resolvedAt,omitempty"` + Notes string `json:"notes,omitempty"` + DateCreated string `json:"dateCreated"` +} + +// BeforeCreate is called before creating a security event +func (se *SecurityEvent) BeforeCreate(tx *gorm.DB) error { + se.BaseModel.BeforeCreate() + + // Normalize fields + se.EventType = strings.ToLower(strings.TrimSpace(se.EventType)) + se.Severity = strings.ToLower(strings.TrimSpace(se.Severity)) + se.IPAddress = strings.TrimSpace(se.IPAddress) + se.UserAgent = strings.TrimSpace(se.UserAgent) + se.Resource = strings.ToLower(strings.TrimSpace(se.Resource)) + se.Action = strings.ToLower(strings.TrimSpace(se.Action)) + se.Message = strings.TrimSpace(se.Message) + + return se.Validate() +} + +// BeforeUpdate is called before updating a security event +func (se *SecurityEvent) BeforeUpdate(tx *gorm.DB) error { + se.BaseModel.BeforeUpdate() + + // If resolving the event, set resolved timestamp + if se.Resolved && se.ResolvedAt == nil { + now := time.Now() + se.ResolvedAt = &now + } + + return nil +} + +// Validate validates security event data +func (se *SecurityEvent) Validate() error { + validSeverities := []string{"low", "medium", "high", "critical"} + isValidSeverity := false + for _, severity := range validSeverities { + if se.Severity == severity { + isValidSeverity = true + break + } + } + if !isValidSeverity { + return gorm.ErrInvalidValue + } + + return nil +} + +// SetDetails sets the details field from a map +func (se *SecurityEvent) SetDetails(details map[string]interface{}) error { + se.Details = details + return nil +} + +// GetDetails returns the details as a map +func (se *SecurityEvent) GetDetails() map[string]interface{} { + if se.Details == nil { + return make(map[string]interface{}) + } + return se.Details +} + +// SetDetailsFromJSON sets the details field from a JSON string +func (se *SecurityEvent) SetDetailsFromJSON(jsonStr string) error { + if jsonStr == "" { + se.Details = make(map[string]interface{}) + return nil + } + + var details map[string]interface{} + if err := json.Unmarshal([]byte(jsonStr), &details); err != nil { + return err + } + + se.Details = details + return nil +} + +// GetDetailsAsJSON returns the details as a JSON string +func (se *SecurityEvent) GetDetailsAsJSON() (string, error) { + if se.Details == nil || len(se.Details) == 0 { + return "{}", nil + } + + bytes, err := json.Marshal(se.Details) + if err != nil { + return "", err + } + + return string(bytes), nil +} + +// ToSecurityEventInfo converts SecurityEvent to SecurityEventInfo (public information) +func (se *SecurityEvent) ToSecurityEventInfo() SecurityEventInfo { + info := SecurityEventInfo{ + ID: se.ID, + EventType: se.EventType, + Severity: se.Severity, + UserID: se.UserID, + IPAddress: se.IPAddress, + UserAgent: se.UserAgent, + Resource: se.Resource, + Action: se.Action, + Success: se.Success, + Blocked: se.Blocked, + Message: se.Message, + Details: se.GetDetails(), + SessionID: se.SessionID, + RequestID: se.RequestID, + CountryCode: se.CountryCode, + City: se.City, + Resolved: se.Resolved, + ResolvedBy: se.ResolvedBy, + ResolvedAt: se.ResolvedAt, + Notes: se.Notes, + DateCreated: se.DateCreated.Format("2006-01-02T15:04:05Z"), + } + + // Include user information if available + if se.User != nil { + info.UserEmail = se.User.Email + info.UserName = se.User.Name + } + + // Include resolver information if available + if se.Resolver != nil { + info.ResolverEmail = se.Resolver.Email + info.ResolverName = se.Resolver.Name + } + + return info +} + +// AddDetail adds a single detail to the details map +func (se *SecurityEvent) AddDetail(key string, value interface{}) { + if se.Details == nil { + se.Details = make(map[string]interface{}) + } + se.Details[key] = value +} + +// GetDetail gets a single detail from the details map +func (se *SecurityEvent) GetDetail(key string) (interface{}, bool) { + if se.Details == nil { + return nil, false + } + value, exists := se.Details[key] + return value, exists +} + +// Resolve resolves the security event +func (se *SecurityEvent) Resolve(resolverID, notes string) { + se.Resolved = true + se.ResolvedBy = resolverID + se.Notes = notes + now := time.Now() + se.ResolvedAt = &now +} + +// Unresolve unresolves the security event +func (se *SecurityEvent) Unresolve() { + se.Resolved = false + se.ResolvedBy = "" + se.ResolvedAt = nil +} + +// IsResolved returns whether the security event is resolved +func (se *SecurityEvent) IsResolved() bool { + return se.Resolved +} + +// IsCritical returns whether the security event is critical +func (se *SecurityEvent) IsCritical() bool { + return se.Severity == SecuritySeverityCritical +} + +// IsHigh returns whether the security event is high severity +func (se *SecurityEvent) IsHigh() bool { + return se.Severity == SecuritySeverityHigh +} + +// IsBlocked returns whether the security event was blocked +func (se *SecurityEvent) IsBlocked() bool { + return se.Blocked +} + +// Security event types +const ( + SecurityEventLoginAttempt = "login_attempt" + SecurityEventLoginFailure = "login_failure" + SecurityEventLoginSuccess = "login_success" + SecurityEventBruteForce = "brute_force" + SecurityEventAccountLockout = "account_lockout" + SecurityEventUnauthorizedAccess = "unauthorized_access" + SecurityEventPrivilegeEscalation = "privilege_escalation" + SecurityEventSuspiciousActivity = "suspicious_activity" + SecurityEventRateLimitExceeded = "rate_limit_exceeded" + SecurityEventInvalidToken = "invalid_token" + SecurityEventTokenExpired = "token_expired" + SecurityEventPasswordChange = "password_change" + SecurityEventEmailVerification = "email_verification" + SecurityEventTwoFactorAuth = "two_factor_auth" + SecurityEventDataExfiltration = "data_exfiltration" + SecurityEventMaliciousRequest = "malicious_request" + SecurityEventSystemAccess = "system_access" + SecurityEventConfigChange = "config_change" + SecurityEventFileAccess = "file_access" + SecurityEventDatabaseAccess = "database_access" +) + +// Security severity levels +const ( + SecuritySeverityLow = "low" + SecuritySeverityMedium = "medium" + SecuritySeverityHigh = "high" + SecuritySeverityCritical = "critical" +) + +// CreateSecurityEvent creates a new security event +func CreateSecurityEvent(eventType, severity, message string) *SecurityEvent { + event := &SecurityEvent{ + EventType: eventType, + Severity: severity, + Message: message, + Details: make(map[string]interface{}), + } + event.Init() + return event +} + +// CreateSecurityEventWithUser creates a new security event with user information +func CreateSecurityEventWithUser(eventType, severity, message, userID string) *SecurityEvent { + event := CreateSecurityEvent(eventType, severity, message) + event.UserID = userID + return event +} + +// CreateSecurityEventWithDetails creates a new security event with details +func CreateSecurityEventWithDetails(eventType, severity, message string, details map[string]interface{}) *SecurityEvent { + event := CreateSecurityEvent(eventType, severity, message) + event.Details = details + return event +} + +// SetRequestInfo sets request-related information +func (se *SecurityEvent) SetRequestInfo(ipAddress, userAgent, sessionID, requestID string) { + se.IPAddress = ipAddress + se.UserAgent = userAgent + se.SessionID = sessionID + se.RequestID = requestID +} + +// SetLocationInfo sets location-related information +func (se *SecurityEvent) SetLocationInfo(countryCode, city string) { + se.CountryCode = countryCode + se.City = city +} + +// SetResourceAction sets resource and action information +func (se *SecurityEvent) SetResourceAction(resource, action string) { + se.Resource = resource + se.Action = action +} + +// MarkAsBlocked marks the security event as blocked +func (se *SecurityEvent) MarkAsBlocked() { + se.Blocked = true +} + +// MarkAsSuccess marks the security event as successful +func (se *SecurityEvent) MarkAsSuccess() { + se.Success = true +} + +// MarkAsFailure marks the security event as failed +func (se *SecurityEvent) MarkAsFailure() { + se.Success = false +} diff --git a/local/model/system_config.go b/local/model/system_config.go new file mode 100644 index 0000000..8cfcf29 --- /dev/null +++ b/local/model/system_config.go @@ -0,0 +1,280 @@ +package model + +import ( + "errors" + "strconv" + "strings" + "time" + + "gorm.io/gorm" +) + +// SystemConfig represents a system configuration setting +type SystemConfig struct { + BaseModel + Key string `json:"key" gorm:"unique;not null;type:varchar(255)"` + Value string `json:"value" gorm:"type:text"` + DefaultValue string `json:"defaultValue" gorm:"type:text"` + Description string `json:"description" gorm:"type:text"` + Category string `json:"category" gorm:"type:varchar(100)"` + DataType string `json:"dataType" gorm:"type:varchar(50)"` // string, integer, boolean, json + IsEditable bool `json:"isEditable" gorm:"default:true"` + IsSecret bool `json:"isSecret" gorm:"default:false"` // For sensitive values + DateModified string `json:"dateModified" gorm:"type:varchar(50)"` +} + +// SystemConfigCreateRequest represents the request to create a new system config +type SystemConfigCreateRequest struct { + Key string `json:"key" validate:"required,min=3,max=255"` + Value string `json:"value"` + DefaultValue string `json:"defaultValue"` + Description string `json:"description" validate:"max=1000"` + Category string `json:"category" validate:"required,max=100"` + DataType string `json:"dataType" validate:"required,oneof=string integer boolean json"` + IsEditable bool `json:"isEditable"` + IsSecret bool `json:"isSecret"` +} + +// SystemConfigUpdateRequest represents the request to update a system config +type SystemConfigUpdateRequest struct { + Value *string `json:"value,omitempty"` + Description *string `json:"description,omitempty" validate:"omitempty,max=1000"` + Category *string `json:"category,omitempty" validate:"omitempty,max=100"` + DataType *string `json:"dataType,omitempty" validate:"omitempty,oneof=string integer boolean json"` + IsEditable *bool `json:"isEditable,omitempty"` + IsSecret *bool `json:"isSecret,omitempty"` +} + +// SystemConfigInfo represents public system config information +type SystemConfigInfo struct { + ID string `json:"id"` + Key string `json:"key"` + Value string `json:"value,omitempty"` // Omitted if secret + DefaultValue string `json:"defaultValue,omitempty"` + Description string `json:"description"` + Category string `json:"category"` + DataType string `json:"dataType"` + IsEditable bool `json:"isEditable"` + IsSecret bool `json:"isSecret"` + DateCreated string `json:"dateCreated"` + DateModified string `json:"dateModified"` +} + +// BeforeCreate is called before creating a system config +func (sc *SystemConfig) BeforeCreate(tx *gorm.DB) error { + sc.BaseModel.BeforeCreate() + + // Normalize key and category + sc.Key = strings.ToLower(strings.TrimSpace(sc.Key)) + sc.Category = strings.ToLower(strings.TrimSpace(sc.Category)) + sc.Description = strings.TrimSpace(sc.Description) + sc.DateModified = time.Now().UTC().Format(time.RFC3339) + + return sc.Validate() +} + +// BeforeUpdate is called before updating a system config +func (sc *SystemConfig) BeforeUpdate(tx *gorm.DB) error { + sc.BaseModel.BeforeUpdate() + + // Update modification timestamp + sc.DateModified = time.Now().UTC().Format(time.RFC3339) + + // Normalize fields if they're being updated + if sc.Key != "" { + sc.Key = strings.ToLower(strings.TrimSpace(sc.Key)) + } + if sc.Category != "" { + sc.Category = strings.ToLower(strings.TrimSpace(sc.Category)) + } + if sc.Description != "" { + sc.Description = strings.TrimSpace(sc.Description) + } + + return sc.Validate() +} + +// Validate validates system config data +func (sc *SystemConfig) Validate() error { + if sc.Key == "" { + return errors.New("configuration key is required") + } + + if len(sc.Key) < 3 || len(sc.Key) > 255 { + return errors.New("configuration key must be between 3 and 255 characters") + } + + if !isValidConfigKey(sc.Key) { + return errors.New("configuration key can only contain letters, numbers, dots, underscores, and hyphens") + } + + if sc.Category == "" { + return errors.New("configuration category is required") + } + + if len(sc.Category) > 100 { + return errors.New("configuration category must not exceed 100 characters") + } + + if sc.DataType == "" { + return errors.New("configuration data type is required") + } + + if !isValidDataType(sc.DataType) { + return errors.New("invalid data type, must be one of: string, integer, boolean, json") + } + + if len(sc.Description) > 1000 { + return errors.New("configuration description must not exceed 1000 characters") + } + + // Validate value according to data type + if sc.Value != "" { + if err := sc.ValidateValue(sc.Value); err != nil { + return err + } + } + + return nil +} + +// ValidateValue validates the configuration value according to its data type +func (sc *SystemConfig) ValidateValue(value string) error { + switch sc.DataType { + case "integer": + if _, err := strconv.Atoi(value); err != nil { + return errors.New("value must be a valid integer") + } + case "boolean": + if _, err := strconv.ParseBool(value); err != nil { + return errors.New("value must be a valid boolean (true/false)") + } + case "json": + // Basic JSON validation - check if it starts with { or [ + trimmed := strings.TrimSpace(value) + if !strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[") { + return errors.New("value must be valid JSON") + } + } + return nil +} + +// ToSystemConfigInfo converts SystemConfig to SystemConfigInfo (public information) +func (sc *SystemConfig) ToSystemConfigInfo() SystemConfigInfo { + info := SystemConfigInfo{ + ID: sc.ID, + Key: sc.Key, + DefaultValue: sc.DefaultValue, + Description: sc.Description, + Category: sc.Category, + DataType: sc.DataType, + IsEditable: sc.IsEditable, + IsSecret: sc.IsSecret, + DateCreated: sc.DateCreated.Format("2006-01-02T15:04:05Z"), + DateModified: sc.DateModified, + } + + // Only include value if it's not a secret + if !sc.IsSecret { + info.Value = sc.Value + } + + return info +} + +// GetStringValue returns the configuration value as a string +func (sc *SystemConfig) GetStringValue() string { + if sc.Value != "" { + return sc.Value + } + return sc.DefaultValue +} + +// GetIntValue returns the configuration value as an integer +func (sc *SystemConfig) GetIntValue() (int, error) { + value := sc.GetStringValue() + return strconv.Atoi(value) +} + +// GetBoolValue returns the configuration value as a boolean +func (sc *SystemConfig) GetBoolValue() (bool, error) { + value := sc.GetStringValue() + return strconv.ParseBool(value) +} + +// GetFloatValue returns the configuration value as a float64 +func (sc *SystemConfig) GetFloatValue() (float64, error) { + value := sc.GetStringValue() + return strconv.ParseFloat(value, 64) +} + +// SetValue sets the configuration value with type validation +func (sc *SystemConfig) SetValue(value string) error { + if err := sc.ValidateValue(value); err != nil { + return err + } + sc.Value = value + sc.DateModified = time.Now().UTC().Format(time.RFC3339) + return nil +} + +// ResetToDefault resets the configuration value to its default +func (sc *SystemConfig) ResetToDefault() { + sc.Value = sc.DefaultValue + sc.DateModified = time.Now().UTC().Format(time.RFC3339) +} + +// isValidConfigKey validates configuration key format +func isValidConfigKey(key string) bool { + // Allow letters, numbers, dots, underscores, and hyphens + for _, char := range key { + if !((char >= 'a' && char <= 'z') || + (char >= 'A' && char <= 'Z') || + (char >= '0' && char <= '9') || + char == '.' || char == '_' || char == '-') { + return false + } + } + return true +} + +// isValidDataType validates the data type +func isValidDataType(dataType string) bool { + validTypes := []string{"string", "integer", "boolean", "json"} + for _, validType := range validTypes { + if dataType == validType { + return true + } + } + return false +} + +// Common system configuration categories +const ( + ConfigCategoryGeneral = "general" + ConfigCategorySecurity = "security" + ConfigCategoryEmail = "email" + ConfigCategoryAPI = "api" + ConfigCategoryLogging = "logging" + ConfigCategoryStorage = "storage" + ConfigCategoryCache = "cache" +) + +// Common system configuration keys +const ( + ConfigKeyAppName = "app.name" + ConfigKeyAppVersion = "app.version" + ConfigKeyAppDescription = "app.description" + ConfigKeyJWTExpiryHours = "security.jwt_expiry_hours" + ConfigKeyPasswordMinLength = "security.password_min_length" + ConfigKeyMaxLoginAttempts = "security.max_login_attempts" + ConfigKeyLockoutDurationMinutes = "security.lockout_duration_minutes" + ConfigKeySessionTimeoutMinutes = "security.session_timeout_minutes" + ConfigKeyRateLimitRequests = "security.rate_limit_requests" + ConfigKeyRateLimitWindow = "security.rate_limit_window_minutes" + ConfigKeyLogLevel = "logging.level" + ConfigKeyLogRetentionDays = "logging.retention_days" + ConfigKeyMaxFileUploadSize = "storage.max_file_upload_size_mb" + ConfigKeyCacheEnabled = "cache.enabled" + ConfigKeyCacheTTLMinutes = "cache.ttl_minutes" +) diff --git a/local/model/user.go b/local/model/user.go new file mode 100644 index 0000000..82846d1 --- /dev/null +++ b/local/model/user.go @@ -0,0 +1,318 @@ +package model + +import ( + "errors" + "omega-server/local/utl/password" + "regexp" + "strings" + "time" + + "gorm.io/gorm" +) + +// User represents a user in the system +type User struct { + BaseModel + Email string `json:"email" gorm:"unique;not null;type:varchar(255)"` + Username string `json:"username" gorm:"unique;not null;type:varchar(100)"` + Name string `json:"name" gorm:"not null;type:varchar(255)"` + PasswordHash string `json:"-" gorm:"not null;type:text"` + Active bool `json:"active" gorm:"default:true"` + EmailVerified bool `json:"emailVerified" gorm:"default:false"` + EmailVerificationToken string `json:"-" gorm:"type:varchar(255)"` + PasswordResetToken string `json:"-" gorm:"type:varchar(255)"` + PasswordResetExpires *time.Time `json:"-"` + LastLogin *time.Time `json:"lastLogin"` + LoginAttempts int `json:"-" gorm:"default:0"` + LockedUntil *time.Time `json:"-"` + TwoFactorEnabled bool `json:"twoFactorEnabled" gorm:"default:false"` + TwoFactorSecret string `json:"-" gorm:"type:varchar(255)"` + Roles []Role `json:"roles" gorm:"many2many:user_roles;"` + AuditLogs []AuditLog `json:"-" gorm:"foreignKey:UserID"` +} + +// UserCreateRequest represents the request to create a new user +type UserCreateRequest struct { + Email string `json:"email" validate:"required,email"` + Username string `json:"username" validate:"required,min=3,max=50"` + Name string `json:"name" validate:"required,min=2,max=100"` + Password string `json:"password" validate:"required,min=8"` + RoleIDs []string `json:"roleIds"` +} + +// UserUpdateRequest represents the request to update a user +type UserUpdateRequest struct { + Email *string `json:"email,omitempty" validate:"omitempty,email"` + Username *string `json:"username,omitempty" validate:"omitempty,min=3,max=50"` + Name *string `json:"name,omitempty" validate:"omitempty,min=2,max=100"` + Active *bool `json:"active,omitempty"` + RoleIDs []string `json:"roleIds,omitempty"` +} + +// UserLoginRequest represents a login request +type UserLoginRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required"` +} + +// UserLoginResponse represents a login response +type UserLoginResponse struct { + Token string `json:"token"` + RefreshToken string `json:"refreshToken"` + ExpiresAt time.Time `json:"expiresAt"` + User UserInfo `json:"user"` +} + +// UserInfo represents public user information +type UserInfo struct { + ID string `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + Name string `json:"name"` + Active bool `json:"active"` + EmailVerified bool `json:"emailVerified"` + LastLogin *time.Time `json:"lastLogin"` + Roles []RoleInfo `json:"roles"` + Permissions []string `json:"permissions"` + DateCreated time.Time `json:"dateCreated"` +} + +// ChangePasswordRequest represents a password change request +type ChangePasswordRequest struct { + CurrentPassword string `json:"currentPassword" validate:"required"` + NewPassword string `json:"newPassword" validate:"required,min=8"` +} + +// ResetPasswordRequest represents a password reset request +type ResetPasswordRequest struct { + Email string `json:"email" validate:"required,email"` +} + +// ResetPasswordConfirmRequest represents a password reset confirmation +type ResetPasswordConfirmRequest struct { + Token string `json:"token" validate:"required"` + NewPassword string `json:"newPassword" validate:"required,min=8"` +} + +// BeforeCreate is called before creating a user +func (u *User) BeforeCreate(tx *gorm.DB) error { + u.BaseModel.BeforeCreate() + + // Normalize email and username + u.Email = strings.ToLower(strings.TrimSpace(u.Email)) + u.Username = strings.ToLower(strings.TrimSpace(u.Username)) + u.Name = strings.TrimSpace(u.Name) + + return u.Validate() +} + +// BeforeUpdate is called before updating a user +func (u *User) BeforeUpdate(tx *gorm.DB) error { + u.BaseModel.BeforeUpdate() + + // Normalize fields if they're being updated + if u.Email != "" { + u.Email = strings.ToLower(strings.TrimSpace(u.Email)) + } + if u.Username != "" { + u.Username = strings.ToLower(strings.TrimSpace(u.Username)) + } + if u.Name != "" { + u.Name = strings.TrimSpace(u.Name) + } + + return u.Validate() +} + +// Validate validates user data +func (u *User) Validate() error { + if u.Email == "" { + return errors.New("email is required") + } + + if !isValidEmail(u.Email) { + return errors.New("invalid email format") + } + + if u.Username == "" { + return errors.New("username is required") + } + + if len(u.Username) < 3 || len(u.Username) > 50 { + return errors.New("username must be between 3 and 50 characters") + } + + if !isValidUsername(u.Username) { + return errors.New("username can only contain letters, numbers, underscores, and hyphens") + } + + if u.Name == "" { + return errors.New("name is required") + } + + if len(u.Name) < 2 || len(u.Name) > 100 { + return errors.New("name must be between 2 and 100 characters") + } + + return nil +} + +// SetPassword sets the user's password hash +func (u *User) SetPassword(plainPassword string) error { + if err := validatePassword(plainPassword); err != nil { + return err + } + + hash, err := password.HashPassword(plainPassword) + if err != nil { + return err + } + + u.PasswordHash = hash + return nil +} + +// CheckPassword verifies the user's password +func (u *User) CheckPassword(plainPassword string) bool { + return password.CheckPasswordHash(plainPassword, u.PasswordHash) +} + +// VerifyPassword verifies the user's password (alias for CheckPassword) +func (u *User) VerifyPassword(plainPassword string) bool { + return u.CheckPassword(plainPassword) +} + +// IsLocked checks if the user account is locked +func (u *User) IsLocked() bool { + if u.LockedUntil == nil { + return false + } + return time.Now().Before(*u.LockedUntil) +} + +// Lock locks the user account for the specified duration +func (u *User) Lock(duration time.Duration) { + lockUntil := time.Now().Add(duration) + u.LockedUntil = &lockUntil +} + +// Unlock unlocks the user account +func (u *User) Unlock() { + u.LockedUntil = nil + u.LoginAttempts = 0 +} + +// IncrementLoginAttempts increments the login attempt counter +func (u *User) IncrementLoginAttempts() { + u.LoginAttempts++ +} + +// ResetLoginAttempts resets the login attempt counter +func (u *User) ResetLoginAttempts() { + u.LoginAttempts = 0 +} + +// UpdateLastLogin updates the last login timestamp +func (u *User) UpdateLastLogin() { + now := time.Now() + u.LastLogin = &now +} + +// ToUserInfo converts User to UserInfo (public information) +func (u *User) ToUserInfo() UserInfo { + userInfo := UserInfo{ + ID: u.ID, + Email: u.Email, + Username: u.Username, + Name: u.Name, + Active: u.Active, + EmailVerified: u.EmailVerified, + LastLogin: u.LastLogin, + DateCreated: u.DateCreated, + Roles: make([]RoleInfo, len(u.Roles)), + Permissions: []string{}, + } + + // Convert roles and collect permissions + permissionSet := make(map[string]bool) + for i, role := range u.Roles { + userInfo.Roles[i] = role.ToRoleInfo() + for _, permission := range role.Permissions { + permissionSet[permission.Name] = true + } + } + + // Convert permission set to slice + for permission := range permissionSet { + userInfo.Permissions = append(userInfo.Permissions, permission) + } + + return userInfo +} + +// HasRole checks if the user has a specific role +func (u *User) HasRole(roleName string) bool { + for _, role := range u.Roles { + if role.Name == roleName { + return true + } + } + return false +} + +// HasPermission checks if the user has a specific permission +func (u *User) HasPermission(permissionName string) bool { + for _, role := range u.Roles { + for _, permission := range role.Permissions { + if permission.Name == permissionName { + return true + } + } + } + return false +} + +// isValidEmail validates email format +func isValidEmail(email string) bool { + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + return emailRegex.MatchString(email) +} + +// isValidUsername validates username format +func isValidUsername(username string) bool { + usernameRegex := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + return usernameRegex.MatchString(username) +} + +// validatePassword validates password strength +func validatePassword(password string) error { + if len(password) < 8 { + return errors.New("password must be at least 8 characters long") + } + + if len(password) > 128 { + return errors.New("password must not exceed 128 characters") + } + + // Check for at least one lowercase letter + if matched, _ := regexp.MatchString(`[a-z]`, password); !matched { + return errors.New("password must contain at least one lowercase letter") + } + + // Check for at least one uppercase letter + if matched, _ := regexp.MatchString(`[A-Z]`, password); !matched { + return errors.New("password must contain at least one uppercase letter") + } + + // Check for at least one digit + if matched, _ := regexp.MatchString(`\d`, password); !matched { + return errors.New("password must contain at least one digit") + } + + // Check for at least one special character + if matched, _ := regexp.MatchString(`[!@#$%^&*(),.?":{}|<>]`, password); !matched { + return errors.New("password must contain at least one special character") + } + + return nil +} diff --git a/local/repository/base.go b/local/repository/base.go new file mode 100644 index 0000000..efad80b --- /dev/null +++ b/local/repository/base.go @@ -0,0 +1,121 @@ +package repository + +import ( + "context" + "fmt" + + "gorm.io/gorm" +) + +// BaseRepository provides generic CRUD operations for any model +type BaseRepository[T any, F any] struct { + db *gorm.DB + modelType T +} + +// NewBaseRepository creates a new base repository for the given model type +func NewBaseRepository[T any, F any](db *gorm.DB, model T) *BaseRepository[T, F] { + return &BaseRepository[T, F]{ + db: db, + modelType: model, + } +} + +// GetAll retrieves all records based on the filter +func (r *BaseRepository[T, F]) GetAll(ctx context.Context, filter *F) (*[]T, error) { + result := new([]T) + query := r.db.WithContext(ctx).Model(&r.modelType) + + // Apply filter conditions if filter implements Filterable + if filterable, ok := any(filter).(Filterable); ok { + query = filterable.ApplyFilter(query) + } + + // Apply pagination if filter implements Pageable + if pageable, ok := any(filter).(Pageable); ok { + offset, limit := pageable.Pagination() + query = query.Offset(offset).Limit(limit) + } + + // Apply sorting if filter implements Sortable + if sortable, ok := any(filter).(Sortable); ok { + field, desc := sortable.GetSorting() + if desc { + query = query.Order(field + " DESC") + } else { + query = query.Order(field) + } + } + + if err := query.Find(result).Error; err != nil { + return nil, fmt.Errorf("error getting records: %w", err) + } + + return result, nil +} + +// GetByID retrieves a single record by ID +func (r *BaseRepository[T, F]) GetByID(ctx context.Context, id interface{}) (*T, error) { + result := new(T) + if err := r.db.WithContext(ctx).Where("id = ?", id).First(result).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, fmt.Errorf("error getting record by ID: %w", err) + } + return result, nil +} + +// Insert creates a new record +func (r *BaseRepository[T, F]) Insert(ctx context.Context, model *T) error { + if err := r.db.WithContext(ctx).Create(model).Error; err != nil { + return fmt.Errorf("error creating record: %w", err) + } + return nil +} + +// Update modifies an existing record +func (r *BaseRepository[T, F]) Update(ctx context.Context, model *T) error { + if err := r.db.WithContext(ctx).Save(model).Error; err != nil { + return fmt.Errorf("error updating record: %w", err) + } + return nil +} + +// Delete removes a record by ID +func (r *BaseRepository[T, F]) Delete(ctx context.Context, id interface{}) error { + if err := r.db.WithContext(ctx).Delete(new(T), id).Error; err != nil { + return fmt.Errorf("error deleting record: %w", err) + } + return nil +} + +// Count returns the total number of records matching the filter +func (r *BaseRepository[T, F]) Count(ctx context.Context, filter *F) (int64, error) { + var count int64 + query := r.db.WithContext(ctx).Model(&r.modelType) + + if filterable, ok := any(filter).(Filterable); ok { + query = filterable.ApplyFilter(query) + } + + if err := query.Count(&count).Error; err != nil { + return 0, fmt.Errorf("error counting records: %w", err) + } + + return count, nil +} + +// Interfaces for filter capabilities + +type Filterable interface { + ApplyFilter(*gorm.DB) *gorm.DB +} + +type Pageable interface { + Pagination() (offset, limit int) +} + +type Sortable interface { + GetSorting() (field string, desc bool) +} diff --git a/local/repository/membership.go b/local/repository/membership.go new file mode 100644 index 0000000..c9326bd --- /dev/null +++ b/local/repository/membership.go @@ -0,0 +1,171 @@ +package repository + +import ( + "context" + "omega-server/local/model" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// MembershipRepository handles database operations for users, roles, and permissions. +type MembershipRepository struct { + *BaseRepository[model.User, model.MembershipFilter] + db *gorm.DB +} + +// NewMembershipRepository creates a new MembershipRepository. +func NewMembershipRepository(db *gorm.DB) *MembershipRepository { + return &MembershipRepository{ + BaseRepository: NewBaseRepository[model.User, model.MembershipFilter](db, model.User{}), + db: db, + } +} + +// FindUserByUsername finds a user by their username. +// It preloads the user's role and the role's permissions. +func (r *MembershipRepository) FindUserByUsername(ctx context.Context, username string) (*model.User, error) { + var user model.User + db := r.db.WithContext(ctx) + err := db.Preload("Roles.Permissions").Where("username = ?", username).First(&user).Error + if err != nil { + return nil, err + } + return &user, nil +} + +// FindUserByIDWithPermissions finds a user by their ID and preloads Role and Permissions. +func (r *MembershipRepository) FindUserByIDWithPermissions(ctx context.Context, userID string) (*model.User, error) { + var user model.User + db := r.db.WithContext(ctx) + err := db.Preload("Roles.Permissions").First(&user, "id = ?", userID).Error + if err != nil { + return nil, err + } + return &user, nil +} + +// CreateUser creates a new user. +func (r *MembershipRepository) CreateUser(ctx context.Context, user *model.User) error { + db := r.db.WithContext(ctx) + return db.Create(user).Error +} + +// FindRoleByName finds a role by its name. +func (r *MembershipRepository) FindRoleByName(ctx context.Context, name string) (*model.Role, error) { + var role model.Role + db := r.db.WithContext(ctx) + err := db.Where("name = ?", name).First(&role).Error + if err != nil { + return nil, err + } + return &role, nil +} + +// CreateRole creates a new role. +func (r *MembershipRepository) CreateRole(ctx context.Context, role *model.Role) error { + db := r.db.WithContext(ctx) + return db.Create(role).Error +} + +// FindPermissionByName finds a permission by its name. +func (r *MembershipRepository) FindPermissionByName(ctx context.Context, name string) (*model.Permission, error) { + var permission model.Permission + db := r.db.WithContext(ctx) + err := db.Where("name = ?", name).First(&permission).Error + if err != nil { + return nil, err + } + return &permission, nil +} + +// CreatePermission creates a new permission. +func (r *MembershipRepository) CreatePermission(ctx context.Context, permission *model.Permission) error { + db := r.db.WithContext(ctx) + return db.Create(permission).Error +} + +// AssignPermissionsToRole assigns a set of permissions to a role. +func (r *MembershipRepository) AssignPermissionsToRole(ctx context.Context, role *model.Role, permissions []model.Permission) error { + db := r.db.WithContext(ctx) + return db.Model(role).Association("Permissions").Replace(permissions) +} + +// GetUserPermissions retrieves all permissions for a given user ID. +func (r *MembershipRepository) GetUserPermissions(ctx context.Context, userID uuid.UUID) ([]string, error) { + var user model.User + db := r.db.WithContext(ctx) + + if err := db.Preload("Roles.Permissions").First(&user, "id = ?", userID).Error; err != nil { + return nil, err + } + + permissionSet := make(map[string]bool) + for _, role := range user.Roles { + for _, permission := range role.Permissions { + permissionSet[permission.Name] = true + } + } + + permissions := make([]string, 0, len(permissionSet)) + for permission := range permissionSet { + permissions = append(permissions, permission) + } + + return permissions, nil +} + +// ListUsers retrieves all users. +func (r *MembershipRepository) ListUsers(ctx context.Context) ([]*model.User, error) { + var users []*model.User + db := r.db.WithContext(ctx) + err := db.Preload("Roles").Find(&users).Error + return users, err +} + +// DeleteUser deletes a user. +func (r *MembershipRepository) DeleteUser(ctx context.Context, userID uuid.UUID) error { + db := r.db.WithContext(ctx) + return db.Delete(&model.User{}, "id = ?", userID).Error +} + +// FindUserByID finds a user by their ID. +func (r *MembershipRepository) FindUserByID(ctx context.Context, userID uuid.UUID) (*model.User, error) { + var user model.User + db := r.db.WithContext(ctx) + err := db.Preload("Roles").First(&user, "id = ?", userID).Error + if err != nil { + return nil, err + } + return &user, nil +} + +// UpdateUser updates a user's details in the database. +func (r *MembershipRepository) UpdateUser(ctx context.Context, user *model.User) error { + db := r.db.WithContext(ctx) + return db.Save(user).Error +} + +// FindRoleByID finds a role by its ID. +func (r *MembershipRepository) FindRoleByID(ctx context.Context, roleID uuid.UUID) (*model.Role, error) { + var role model.Role + db := r.db.WithContext(ctx) + err := db.First(&role, "id = ?", roleID).Error + if err != nil { + return nil, err + } + return &role, nil +} + +// ListUsersWithFilter retrieves users based on the membership filter. +func (r *MembershipRepository) ListUsersWithFilter(ctx context.Context, filter *model.MembershipFilter) (*[]model.User, error) { + return r.BaseRepository.GetAll(ctx, filter) +} + +// ListRoles retrieves all roles. +func (r *MembershipRepository) ListRoles(ctx context.Context) ([]*model.Role, error) { + var roles []*model.Role + db := r.db.WithContext(ctx) + err := db.Find(&roles).Error + return roles, err +} diff --git a/local/repository/repository.go b/local/repository/repository.go new file mode 100644 index 0000000..e41fde9 --- /dev/null +++ b/local/repository/repository.go @@ -0,0 +1,14 @@ +package repository + +import ( + "go.uber.org/dig" +) + +// InitializeRepositories +// Initializes Dependency Injection modules for repositories +// +// Args: +// *dig.Container: Dig Container +func InitializeRepositories(c *dig.Container) { + c.Provide(NewMembershipRepository) +} diff --git a/local/service/api.go b/local/service/api.go new file mode 100644 index 0000000..9e7953a --- /dev/null +++ b/local/service/api.go @@ -0,0 +1,34 @@ +package service + +import ( + "omega-server/local/repository" +) + +// ApiService provides API-related business logic +type ApiService struct { + // Add repository dependencies as needed + repo *repository.BaseRepository[any, any] // Placeholder +} + +// NewApiService creates a new ApiService +func NewApiService() *ApiService { + return &ApiService{ + // Initialize with required dependencies + } +} + +// SetServerService sets the server service reference (for cross-service communication) +func (s *ApiService) SetServerService(serverService interface{}) { + // TODO: Implement when ServerService is available +} + +// GetApiInfo returns basic API information +func (s *ApiService) GetApiInfo() map[string]interface{} { + return map[string]interface{}{ + "name": "Omega Server API", + "version": "1.0.0", + "status": "active", + } +} + +// TODO: Add other API service methods as needed diff --git a/local/service/membership.go b/local/service/membership.go new file mode 100644 index 0000000..1ab5b79 --- /dev/null +++ b/local/service/membership.go @@ -0,0 +1,307 @@ +package service + +import ( + "context" + "errors" + "omega-server/local/model" + "omega-server/local/repository" + "omega-server/local/utl/jwt" + "omega-server/local/utl/logging" + "os" + + "github.com/google/uuid" +) + +// CacheInvalidator interface for cache invalidation +type CacheInvalidator interface { + InvalidateUserPermissions(userID string) + InvalidateAllUserPermissions() +} + +// MembershipService provides business logic for membership-related operations. +type MembershipService struct { + repo *repository.MembershipRepository + cacheInvalidator CacheInvalidator +} + +// NewMembershipService creates a new MembershipService. +func NewMembershipService(repo *repository.MembershipRepository) *MembershipService { + return &MembershipService{ + repo: repo, + cacheInvalidator: nil, // Will be set later via SetCacheInvalidator + } +} + +// SetCacheInvalidator sets the cache invalidator after service initialization +func (s *MembershipService) SetCacheInvalidator(invalidator CacheInvalidator) { + s.cacheInvalidator = invalidator +} + +// Login authenticates a user and returns a JWT. +func (s *MembershipService) Login(ctx context.Context, username, password string) (string, error) { + user, err := s.repo.FindUserByUsername(ctx, username) + if err != nil { + return "", errors.New("invalid credentials") + } + + // Use secure password verification with constant-time comparison + if !user.VerifyPassword(password) { + return "", errors.New("invalid credentials") + } + + // Extract role names for JWT + roleNames := make([]string, len(user.Roles)) + for i, role := range user.Roles { + roleNames[i] = role.Name + } + + return jwt.GenerateToken(user.ID, user.Email, user.Username, roleNames) +} + +// CreateUser creates a new user. +func (s *MembershipService) CreateUser(ctx context.Context, username, password, roleName string) (*model.User, error) { + + role, err := s.repo.FindRoleByName(ctx, roleName) + if err != nil { + logging.Error("Failed to find role by name: %v", err) + return nil, errors.New("role not found") + } + + user := &model.User{ + Username: username, + Email: username + "@example.com", // You may want to accept email as parameter + Name: username, + } + + // Set password using the model's SetPassword method + if err := user.SetPassword(password); err != nil { + return nil, err + } + + // Assign roles + user.Roles = []model.Role{*role} + + if err := s.repo.CreateUser(ctx, user); err != nil { + logging.Error("Failed to create user: %v", err) + return nil, err + } + + logging.InfoOperation("USER_CREATE", "Created user: "+user.Username+" (ID: "+user.ID+", Role: "+roleName+")") + return user, nil +} + +// ListUsers retrieves all users. +func (s *MembershipService) ListUsers(ctx context.Context) ([]*model.User, error) { + return s.repo.ListUsers(ctx) +} + +// GetUser retrieves a single user by ID. +func (s *MembershipService) GetUser(ctx context.Context, userID uuid.UUID) (*model.User, error) { + return s.repo.FindUserByID(ctx, userID) +} + +// GetUserWithPermissions retrieves a single user by ID with their role and permissions. +func (s *MembershipService) GetUserWithPermissions(ctx context.Context, userID string) (*model.User, error) { + return s.repo.FindUserByIDWithPermissions(ctx, userID) +} + +// UpdateUserRequest defines the request body for updating a user. +type UpdateUserRequest struct { + Username *string `json:"username"` + Password *string `json:"password"` + RoleID *string `json:"roleId"` +} + +// DeleteUser deletes a user with validation to prevent Super Admin deletion. +func (s *MembershipService) DeleteUser(ctx context.Context, userID uuid.UUID) error { + // Get user with role information + user, err := s.repo.FindUserByID(ctx, userID) + if err != nil { + return errors.New("user not found") + } + + // Prevent deletion of Super Admin users + for _, role := range user.Roles { + if role.Name == "Super Admin" { + return errors.New("cannot delete Super Admin user") + } + } + + err = s.repo.DeleteUser(ctx, userID) + if err != nil { + return err + } + + // Invalidate cache for deleted user + if s.cacheInvalidator != nil { + s.cacheInvalidator.InvalidateUserPermissions(userID.String()) + } + + logging.InfoOperation("USER_DELETE", "Deleted user: "+userID.String()) + return nil +} + +// UpdateUser updates a user's details. +func (s *MembershipService) UpdateUser(ctx context.Context, userID uuid.UUID, req UpdateUserRequest) (*model.User, error) { + user, err := s.repo.FindUserByID(ctx, userID) + if err != nil { + return nil, errors.New("user not found") + } + + if req.Username != nil { + user.Username = *req.Username + } + + if req.Password != nil && *req.Password != "" { + // Use the model's SetPassword method to hash the password + if err := user.SetPassword(*req.Password); err != nil { + return nil, err + } + } + + if req.RoleID != nil { + // Check if role exists + roleUUID, err := uuid.Parse(*req.RoleID) + if err != nil { + return nil, errors.New("invalid role ID format") + } + role, err := s.repo.FindRoleByID(ctx, roleUUID) + if err != nil { + return nil, errors.New("role not found") + } + user.Roles = []model.Role{*role} + } + + if err := s.repo.UpdateUser(ctx, user); err != nil { + return nil, err + } + + // Invalidate cache if role was changed + if req.RoleID != nil && s.cacheInvalidator != nil { + s.cacheInvalidator.InvalidateUserPermissions(userID.String()) + } + + logging.InfoOperation("USER_UPDATE", "Updated user: "+user.Username+" (ID: "+user.ID+")") + return user, nil +} + +// HasPermission checks if a user has a specific permission. +func (s *MembershipService) HasPermission(ctx context.Context, userID string, permissionName string) (bool, error) { + user, err := s.repo.FindUserByIDWithPermissions(ctx, userID) + if err != nil { + return false, err + } + + // Check all user roles for permissions + for _, role := range user.Roles { + // Super admin and Admin have all permissions + if role.Name == "Super Admin" || role.Name == "Admin" { + return true, nil + } + + for _, p := range role.Permissions { + if p.Name == permissionName { + return true, nil + } + } + } + + return false, nil +} + +// SetupInitialData creates the initial roles and permissions. +func (s *MembershipService) SetupInitialData(ctx context.Context) error { + // Define all permissions + permissions := model.AllPermissions + + createdPermissions := make([]model.Permission, 0) + for _, pName := range permissions { + perm, err := s.repo.FindPermissionByName(ctx, pName) + if err != nil { // Assuming error means not found + perm = &model.Permission{Name: pName} + if err := s.repo.CreatePermission(ctx, perm); err != nil { + return err + } + } + createdPermissions = append(createdPermissions, *perm) + } + + // Create Super Admin role with all permissions + superAdminRole, err := s.repo.FindRoleByName(ctx, "Super Admin") + if err != nil { + superAdminRole = &model.Role{Name: "Super Admin"} + if err := s.repo.CreateRole(ctx, superAdminRole); err != nil { + return err + } + } + if err := s.repo.AssignPermissionsToRole(ctx, superAdminRole, createdPermissions); err != nil { + return err + } + + // Create Admin role with same permissions as Super Admin + adminRole, err := s.repo.FindRoleByName(ctx, "Admin") + if err != nil { + adminRole = &model.Role{Name: "Admin"} + if err := s.repo.CreateRole(ctx, adminRole); err != nil { + return err + } + } + if err := s.repo.AssignPermissionsToRole(ctx, adminRole, createdPermissions); err != nil { + return err + } + + // Create Manager role with limited permissions (excluding membership, role, user, server create/delete) + managerRole, err := s.repo.FindRoleByName(ctx, "Manager") + if err != nil { + managerRole = &model.Role{Name: "Manager"} + if err := s.repo.CreateRole(ctx, managerRole); err != nil { + return err + } + } + + // Define manager permissions (limited set) + managerPermissionNames := []string{ + model.ServerView, + model.ServerUpdate, + model.ServerStart, + model.ServerStop, + model.ConfigView, + model.ConfigUpdate, + } + + managerPermissions := make([]model.Permission, 0) + for _, permName := range managerPermissionNames { + for _, perm := range createdPermissions { + if perm.Name == permName { + managerPermissions = append(managerPermissions, perm) + break + } + } + } + + if err := s.repo.AssignPermissionsToRole(ctx, managerRole, managerPermissions); err != nil { + return err + } + + // Invalidate all caches after role setup changes + if s.cacheInvalidator != nil { + s.cacheInvalidator.InvalidateAllUserPermissions() + } + + // Create a default admin user if one doesn't exist + _, err = s.repo.FindUserByUsername(ctx, "admin") + if err != nil { + logging.Debug("Creating default admin user") + _, err = s.CreateUser(ctx, "admin", os.Getenv("PASSWORD"), "Super Admin") // Default password, should be changed + if err != nil { + return err + } + } + + return nil +} + +// GetAllRoles retrieves all roles for dropdown selection. +func (s *MembershipService) GetAllRoles(ctx context.Context) ([]*model.Role, error) { + return s.repo.ListRoles(ctx) +} diff --git a/local/service/membership_interface.go b/local/service/membership_interface.go new file mode 100644 index 0000000..fe90299 --- /dev/null +++ b/local/service/membership_interface.go @@ -0,0 +1,28 @@ +package service + +import ( + "context" + "omega-server/local/model" + + "github.com/google/uuid" +) + +// MembershipServiceInterface defines the interface for membership-related operations +type MembershipServiceInterface interface { + // Authentication and Authorization + Login(ctx context.Context, username, password string) (string, error) + HasPermission(ctx context.Context, userID string, permissionName string) (bool, error) + GetUserWithPermissions(ctx context.Context, userID string) (*model.User, error) + SetCacheInvalidator(invalidator CacheInvalidator) + + // User Management + CreateUser(ctx context.Context, username, password, roleName string) (*model.User, error) + ListUsers(ctx context.Context) ([]*model.User, error) + GetUser(ctx context.Context, userID uuid.UUID) (*model.User, error) + DeleteUser(ctx context.Context, userID uuid.UUID) error + UpdateUser(ctx context.Context, userID uuid.UUID, req UpdateUserRequest) (*model.User, error) + + // Role Management + GetAllRoles(ctx context.Context) ([]*model.Role, error) + SetupInitialData(ctx context.Context) error +} diff --git a/local/service/service.go b/local/service/service.go new file mode 100644 index 0000000..d2c61fb --- /dev/null +++ b/local/service/service.go @@ -0,0 +1,24 @@ +package service + +import ( + "omega-server/local/repository" + "omega-server/local/utl/logging" + + "go.uber.org/dig" +) + +// InitializeServices +// Initializes Dependency Injection modules for services +// +// Args: +// *dig.Container: Dig Container +func InitializeServices(c *dig.Container) { + logging.Debug("Initializing repositories") + repository.InitializeRepositories(c) + + logging.Debug("Registering services") + // Provide services + c.Provide(NewMembershipService) + + logging.Debug("Completed service initialization") +} diff --git a/local/utl/cache/cache.go b/local/utl/cache/cache.go new file mode 100644 index 0000000..1ade87e --- /dev/null +++ b/local/utl/cache/cache.go @@ -0,0 +1,102 @@ +package cache + +import ( + "sync" + "time" + + "omega-server/local/utl/logging" + + "go.uber.org/dig" +) + +// CacheItem represents an item in the cache +type CacheItem struct { + Value interface{} + Expiration int64 +} + +// InMemoryCache is a thread-safe in-memory cache +type InMemoryCache struct { + items map[string]CacheItem + mu sync.RWMutex +} + +// NewInMemoryCache creates and returns a new InMemoryCache instance +func NewInMemoryCache() *InMemoryCache { + return &InMemoryCache{ + items: make(map[string]CacheItem), + } +} + +// Set adds an item to the cache with an expiration duration (in seconds) +func (c *InMemoryCache) Set(key string, value interface{}, duration time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + + var expiration int64 + if duration > 0 { + expiration = time.Now().Add(duration).UnixNano() + } + + c.items[key] = CacheItem{ + Value: value, + Expiration: expiration, + } +} + +// Get retrieves an item from the cache +func (c *InMemoryCache) Get(key string) (interface{}, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + item, found := c.items[key] + if !found { + return nil, false + } + + if item.Expiration > 0 && time.Now().UnixNano() > item.Expiration { + // Item has expired, but don't delete here to avoid lock upgrade. + // It will be overwritten on the next Set. + return nil, false + } + + return item.Value, true +} + +// Delete removes an item from the cache +func (c *InMemoryCache) Delete(key string) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.items, key) +} + +// GetOrSet retrieves an item from the cache. If the item is not found, it +// calls the provided function to get the value, sets it in the cache, and +// returns it. +func GetOrSet[T any](c *InMemoryCache, key string, duration time.Duration, fetcher func() (T, error)) (T, error) { + if cached, found := c.Get(key); found { + if value, ok := cached.(T); ok { + return value, nil + } + } + + value, err := fetcher() + if err != nil { + var zero T + return zero, err + } + + c.Set(key, value, duration) + return value, nil +} + +// Start initializes the cache and provides it to the DI container. +func Start(di *dig.Container) { + cache := NewInMemoryCache() + err := di.Provide(func() *InMemoryCache { + return cache + }) + if err != nil { + logging.Panic("failed to provide cache") + } +} diff --git a/local/utl/common/types.go b/local/utl/common/types.go new file mode 100644 index 0000000..1e01d4e --- /dev/null +++ b/local/utl/common/types.go @@ -0,0 +1,311 @@ +package common + +import ( + "github.com/gofiber/fiber/v2" +) + +// RouteGroups holds the different route groups for API organization +type RouteGroups struct { + API fiber.Router + Auth fiber.Router + Users fiber.Router + Roles fiber.Router + System fiber.Router + Admin fiber.Router +} + +// HTTPError represents a structured HTTP error +type HTTPError struct { + Code int `json:"code"` + Message string `json:"message"` + Details map[string]interface{} `json:"details,omitempty"` +} + +// Error implements the error interface +func (e HTTPError) Error() string { + return e.Message +} + +// NewHTTPError creates a new HTTP error +func NewHTTPError(code int, message string) HTTPError { + return HTTPError{ + Code: code, + Message: message, + } +} + +// NewHTTPErrorWithDetails creates a new HTTP error with details +func NewHTTPErrorWithDetails(code int, message string, details map[string]interface{}) HTTPError { + return HTTPError{ + Code: code, + Message: message, + Details: details, + } +} + +// ValidationError represents a validation error +type ValidationError struct { + Field string `json:"field"` + Message string `json:"message"` + Value string `json:"value,omitempty"` +} + +// ValidationErrors represents multiple validation errors +type ValidationErrors []ValidationError + +// Error implements the error interface +func (ve ValidationErrors) Error() string { + if len(ve) == 0 { + return "validation failed" + } + return ve[0].Message +} + +// APIResponse represents a standard API response structure +type APIResponse struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + Code int `json:"code,omitempty"` +} + +// SuccessResponse creates a success response +func SuccessResponse(data interface{}, message ...string) APIResponse { + response := APIResponse{ + Success: true, + Data: data, + } + if len(message) > 0 { + response.Message = message[0] + } + return response +} + +// ErrorResponse creates an error response +func ErrorResponse(code int, message string) APIResponse { + return APIResponse{ + Success: false, + Error: message, + Code: code, + } +} + +// PaginationRequest represents pagination parameters +type PaginationRequest struct { + Page int `query:"page" validate:"min=1"` + Limit int `query:"limit" validate:"min=1,max=100"` + Sort string `query:"sort"` + Order string `query:"order" validate:"oneof=asc desc"` + Search string `query:"search"` + Filter string `query:"filter"` + Category string `query:"category"` +} + +// DefaultPagination returns default pagination values +func DefaultPagination() PaginationRequest { + return PaginationRequest{ + Page: 1, + Limit: 10, + Sort: "dateCreated", + Order: "desc", + } +} + +// Validate validates pagination parameters +func (p *PaginationRequest) Validate() { + if p.Page < 1 { + p.Page = 1 + } + if p.Limit < 1 || p.Limit > 100 { + p.Limit = 10 + } + if p.Sort == "" { + p.Sort = "dateCreated" + } + if p.Order != "asc" && p.Order != "desc" { + p.Order = "desc" + } +} + +// Offset calculates the offset for database queries +func (p *PaginationRequest) Offset() int { + return (p.Page - 1) * p.Limit +} + +// PaginationResponse represents paginated response metadata +type PaginationResponse struct { + Page int `json:"page"` + Limit int `json:"limit"` + Total int64 `json:"total"` + TotalPages int `json:"totalPages"` + HasNext bool `json:"hasNext"` + HasPrevious bool `json:"hasPrevious"` + NextPage *int `json:"nextPage,omitempty"` + PreviousPage *int `json:"previousPage,omitempty"` +} + +// NewPaginationResponse creates a new pagination response +func NewPaginationResponse(page, limit int, total int64) PaginationResponse { + totalPages := int((total + int64(limit) - 1) / int64(limit)) + + response := PaginationResponse{ + Page: page, + Limit: limit, + Total: total, + TotalPages: totalPages, + HasNext: page < totalPages, + HasPrevious: page > 1, + } + + if response.HasNext { + next := page + 1 + response.NextPage = &next + } + + if response.HasPrevious { + prev := page - 1 + response.PreviousPage = &prev + } + + return response +} + +// PaginatedResponse represents a paginated API response +type PaginatedResponse struct { + APIResponse + Pagination PaginationResponse `json:"pagination"` +} + +// NewPaginatedResponse creates a new paginated response +func NewPaginatedResponse(data interface{}, pagination PaginationResponse, message ...string) PaginatedResponse { + response := PaginatedResponse{ + APIResponse: SuccessResponse(data, message...), + Pagination: pagination, + } + return response +} + +// RequestContext represents the context of an HTTP request +type RequestContext struct { + UserID string + Email string + Username string + Roles []string + IP string + UserAgent string + RequestID string + SessionID string +} + +// GetUserInfo returns user information from context +func (rc *RequestContext) GetUserInfo() map[string]interface{} { + return map[string]interface{}{ + "userId": rc.UserID, + "email": rc.Email, + "username": rc.Username, + "roles": rc.Roles, + } +} + +// HasRole checks if the user has a specific role +func (rc *RequestContext) HasRole(role string) bool { + for _, r := range rc.Roles { + if r == role { + return true + } + } + return false +} + +// IsAdmin checks if the user has admin role +func (rc *RequestContext) IsAdmin() bool { + return rc.HasRole("admin") +} + +// SortDirection represents sort direction +type SortDirection string + +const ( + SortAsc SortDirection = "asc" + SortDesc SortDirection = "desc" +) + +// SortField represents a field to sort by +type SortField struct { + Field string `json:"field"` + Direction SortDirection `json:"direction"` +} + +// FilterOperator represents filter operators +type FilterOperator string + +const ( + FilterEqual FilterOperator = "eq" + FilterNotEqual FilterOperator = "ne" + FilterGreaterThan FilterOperator = "gt" + FilterGreaterEqual FilterOperator = "gte" + FilterLessThan FilterOperator = "lt" + FilterLessEqual FilterOperator = "lte" + FilterLike FilterOperator = "like" + FilterIn FilterOperator = "in" + FilterNotIn FilterOperator = "nin" + FilterIsNull FilterOperator = "isnull" + FilterIsNotNull FilterOperator = "isnotnull" +) + +// FilterCondition represents a filter condition +type FilterCondition struct { + Field string `json:"field"` + Operator FilterOperator `json:"operator"` + Value interface{} `json:"value"` +} + +// QueryOptions represents query options for database operations +type QueryOptions struct { + Filters []FilterCondition `json:"filters,omitempty"` + Sort []SortField `json:"sort,omitempty"` + Pagination *PaginationRequest `json:"pagination,omitempty"` + Include []string `json:"include,omitempty"` + Exclude []string `json:"exclude,omitempty"` +} + +// BulkOperation represents a bulk operation request +type BulkOperation struct { + Action string `json:"action" validate:"required"` + IDs []string `json:"ids" validate:"required,min=1"` + Data interface{} `json:"data,omitempty"` +} + +// AuditInfo represents audit information for operations +type AuditInfo struct { + Action string `json:"action"` + Resource string `json:"resource"` + ResourceID string `json:"resourceId"` + UserID string `json:"userId"` + Details map[string]interface{} `json:"details"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +// HealthStatus represents system health status +type HealthStatus struct { + Status string `json:"status"` + Timestamp string `json:"timestamp"` + Version string `json:"version"` + Environment string `json:"environment"` + Uptime string `json:"uptime"` + Database map[string]interface{} `json:"database"` + Cache map[string]interface{} `json:"cache"` + Memory map[string]interface{} `json:"memory"` +} + +// SystemStats represents system statistics +type SystemStats struct { + TotalUsers int64 `json:"totalUsers"` + ActiveUsers int64 `json:"activeUsers"` + TotalRoles int64 `json:"totalRoles"` + TotalRequests int64 `json:"totalRequests"` + ErrorRate float64 `json:"errorRate"` + ResponseTime float64 `json:"avgResponseTime"` +} diff --git a/local/utl/configs/config.go b/local/utl/configs/config.go new file mode 100644 index 0000000..6aa904c --- /dev/null +++ b/local/utl/configs/config.go @@ -0,0 +1,220 @@ +package configs + +import ( + "os" + "strconv" + "time" +) + +// API configuration constants +const ( + // Prefix for all API routes + Prefix = "/api/v1" + + // Default values + DefaultPort = "3000" + DefaultDatabaseName = "app.db" + DefaultJWTExpiryHours = 24 + DefaultPasswordMinLen = 8 + DefaultMaxLoginAttempts = 5 + DefaultLockoutMinutes = 30 + DefaultRateLimitReqs = 100 + DefaultRateLimitWindow = 1 // minute +) + +// Environment variable keys +const ( + EnvPort = "PORT" + EnvDatabaseName = "DB_NAME" + EnvJWTSecret = "JWT_SECRET" + EnvAppSecret = "APP_SECRET" + EnvAppSecretCode = "APP_SECRET_CODE" + EnvEncryptionKey = "ENCRYPTION_KEY" + EnvCORSAllowedOrigin = "CORS_ALLOWED_ORIGIN" + EnvLogLevel = "LOG_LEVEL" + EnvDebugMode = "DEBUG_MODE" + EnvDefaultAdminPassword = "DEFAULT_ADMIN_PASSWORD" + EnvJWTAccessTTLHours = "JWT_ACCESS_TTL_HOURS" + EnvJWTRefreshTTLDays = "JWT_REFRESH_TTL_DAYS" + EnvJWTIssuer = "JWT_ISSUER" +) + +// GetEnv returns environment variable value or default +func GetEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// GetEnvInt returns environment variable as integer or default +func GetEnvInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + if intValue, err := strconv.Atoi(value); err == nil { + return intValue + } + } + return defaultValue +} + +// GetEnvBool returns environment variable as boolean or default +func GetEnvBool(key string, defaultValue bool) bool { + if value := os.Getenv(key); value != "" { + if boolValue, err := strconv.ParseBool(value); err == nil { + return boolValue + } + } + return defaultValue +} + +// GetEnvDuration returns environment variable as duration or default +func GetEnvDuration(key string, defaultValue time.Duration) time.Duration { + if value := os.Getenv(key); value != "" { + if duration, err := time.ParseDuration(value); err == nil { + return duration + } + } + return defaultValue +} + +// Config holds application configuration +type Config struct { + // Server configuration + Port string + DatabaseName string + LogLevel string + DebugMode bool + + // Security configuration + JWTSecret string + AppSecret string + AppSecretCode string + EncryptionKey string + JWTAccessTTLHours int + JWTRefreshTTLDays int + JWTIssuer string + PasswordMinLength int + MaxLoginAttempts int + LockoutDurationMins int + + // CORS configuration + CORSAllowedOrigin string + + // Rate limiting + RateLimitRequests int + RateLimitWindow int // minutes + + // Admin configuration + DefaultAdminPassword string +} + +// LoadConfig loads configuration from environment variables +func LoadConfig() *Config { + return &Config{ + // Server configuration + Port: GetEnv(EnvPort, DefaultPort), + DatabaseName: GetEnv(EnvDatabaseName, DefaultDatabaseName), + LogLevel: GetEnv(EnvLogLevel, "INFO"), + DebugMode: GetEnvBool(EnvDebugMode, false), + + // Security configuration + JWTSecret: GetEnv(EnvJWTSecret, ""), + AppSecret: GetEnv(EnvAppSecret, ""), + AppSecretCode: GetEnv(EnvAppSecretCode, ""), + EncryptionKey: GetEnv(EnvEncryptionKey, ""), + JWTAccessTTLHours: GetEnvInt(EnvJWTAccessTTLHours, DefaultJWTExpiryHours), + JWTRefreshTTLDays: GetEnvInt(EnvJWTRefreshTTLDays, 7), + JWTIssuer: GetEnv(EnvJWTIssuer, "omega-server"), + PasswordMinLength: GetEnvInt("PASSWORD_MIN_LENGTH", DefaultPasswordMinLen), + MaxLoginAttempts: GetEnvInt("MAX_LOGIN_ATTEMPTS", DefaultMaxLoginAttempts), + LockoutDurationMins: GetEnvInt("LOCKOUT_DURATION_MINUTES", DefaultLockoutMinutes), + + // CORS configuration + CORSAllowedOrigin: GetEnv(EnvCORSAllowedOrigin, "http://localhost:5173"), + + // Rate limiting + RateLimitRequests: GetEnvInt("RATE_LIMIT_REQUESTS", DefaultRateLimitReqs), + RateLimitWindow: GetEnvInt("RATE_LIMIT_WINDOW_MINUTES", DefaultRateLimitWindow), + + // Admin configuration + DefaultAdminPassword: GetEnv(EnvDefaultAdminPassword, ""), + } +} + +// Validate validates the configuration +func (c *Config) Validate() []string { + var errors []string + + // Required security settings + if c.JWTSecret == "" { + errors = append(errors, "JWT_SECRET is required") + } + if c.AppSecret == "" { + errors = append(errors, "APP_SECRET is required") + } + if c.AppSecretCode == "" { + errors = append(errors, "APP_SECRET_CODE is required") + } + if c.EncryptionKey == "" { + errors = append(errors, "ENCRYPTION_KEY is required") + } + + // Validate encryption key length (must be 32 characters for AES-256) + if len(c.EncryptionKey) != 32 { + errors = append(errors, "ENCRYPTION_KEY must be exactly 32 characters long") + } + + // Validate JWT settings + if c.JWTAccessTTLHours <= 0 { + errors = append(errors, "JWT_ACCESS_TTL_HOURS must be greater than 0") + } + if c.JWTRefreshTTLDays <= 0 { + errors = append(errors, "JWT_REFRESH_TTL_DAYS must be greater than 0") + } + + // Validate password settings + if c.PasswordMinLength < 8 { + errors = append(errors, "PASSWORD_MIN_LENGTH must be at least 8") + } + + // Validate rate limiting + if c.RateLimitRequests <= 0 { + errors = append(errors, "RATE_LIMIT_REQUESTS must be greater than 0") + } + if c.RateLimitWindow <= 0 { + errors = append(errors, "RATE_LIMIT_WINDOW_MINUTES must be greater than 0") + } + + return errors +} + +// IsProduction returns true if running in production mode +func (c *Config) IsProduction() bool { + env := GetEnv("GO_ENV", "development") + return env == "production" +} + +// IsDevelopment returns true if running in development mode +func (c *Config) IsDevelopment() bool { + return !c.IsProduction() +} + +// GetJWTAccessTTL returns JWT access token TTL as duration +func (c *Config) GetJWTAccessTTL() time.Duration { + return time.Duration(c.JWTAccessTTLHours) * time.Hour +} + +// GetJWTRefreshTTL returns JWT refresh token TTL as duration +func (c *Config) GetJWTRefreshTTL() time.Duration { + return time.Duration(c.JWTRefreshTTLDays) * 24 * time.Hour +} + +// GetLockoutDuration returns account lockout duration +func (c *Config) GetLockoutDuration() time.Duration { + return time.Duration(c.LockoutDurationMins) * time.Minute +} + +// GetRateLimitWindow returns rate limit window as duration +func (c *Config) GetRateLimitWindow() time.Duration { + return time.Duration(c.RateLimitWindow) * time.Minute +} diff --git a/local/utl/db/db.go b/local/utl/db/db.go new file mode 100644 index 0000000..91bf7ef --- /dev/null +++ b/local/utl/db/db.go @@ -0,0 +1,320 @@ +package db + +import ( + "omega-server/local/model" + "omega-server/local/utl/logging" + "os" + "time" + + "go.uber.org/dig" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func Start(di *dig.Container) { + dbName := os.Getenv("DB_NAME") + if dbName == "" { + dbName = "app.db" + } + + // Configure GORM logger + gormLogger := logger.Default + if os.Getenv("LOG_LEVEL") == "DEBUG" { + gormLogger = logger.Default.LogMode(logger.Info) + } else { + gormLogger = logger.Default.LogMode(logger.Silent) + } + + db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{ + Logger: gormLogger, + }) + if err != nil { + logging.Panic("failed to connect database: " + err.Error()) + } + + // Configure connection pool + sqlDB, err := db.DB() + if err != nil { + logging.Panic("failed to get database instance: " + err.Error()) + } + + // Set connection pool settings + sqlDB.SetMaxIdleConns(10) + sqlDB.SetMaxOpenConns(100) + sqlDB.SetConnMaxLifetime(time.Hour) + + err = di.Provide(func() *gorm.DB { + return db + }) + if err != nil { + logging.Panic("failed to bind database: " + err.Error()) + } + + logging.Info("Database connected successfully") + Migrate(db) +} + +func Migrate(db *gorm.DB) { + logging.Info("Starting database migration...") + + err := db.AutoMigrate( + &model.User{}, + &model.Role{}, + &model.Permission{}, + &model.SystemConfig{}, + &model.AuditLog{}, + &model.SecurityEvent{}, + ) + + if err != nil { + logging.Panic("failed to migrate database models: " + err.Error()) + } + + logging.Info("Database migration completed successfully") + Seed(db) +} + +func Seed(db *gorm.DB) error { + logging.Info("Starting database seeding...") + + if err := seedRoles(db); err != nil { + return err + } + if err := seedPermissions(db); err != nil { + return err + } + if err := seedDefaultAdmin(db); err != nil { + return err + } + if err := seedSystemConfigs(db); err != nil { + return err + } + + logging.Info("Database seeding completed successfully") + return nil +} + +func seedRoles(db *gorm.DB) error { + roles := []model.Role{ + {Name: "admin", Description: "Administrator with full access"}, + {Name: "user", Description: "Regular user with limited access"}, + {Name: "viewer", Description: "Read-only access"}, + } + + for _, role := range roles { + var existingRole model.Role + err := db.Where("name = ?", role.Name).First(&existingRole).Error + if err == gorm.ErrRecordNotFound { + role.Init() + if err := db.Create(&role).Error; err != nil { + return err + } + logging.Info("Created role: %s", role.Name) + } + } + return nil +} + +func seedPermissions(db *gorm.DB) error { + permissions := []model.Permission{ + {Name: "user:create", Description: "Create new users"}, + {Name: "user:read", Description: "Read user information"}, + {Name: "user:update", Description: "Update user information"}, + {Name: "user:delete", Description: "Delete users"}, + {Name: "role:create", Description: "Create new roles"}, + {Name: "role:read", Description: "Read role information"}, + {Name: "role:update", Description: "Update role information"}, + {Name: "role:delete", Description: "Delete roles"}, + {Name: "system:config", Description: "Access system configuration"}, + {Name: "system:logs", Description: "Access system logs"}, + {Name: "system:admin", Description: "Full system administration"}, + } + + for _, permission := range permissions { + var existingPermission model.Permission + err := db.Where("name = ?", permission.Name).First(&existingPermission).Error + if err == gorm.ErrRecordNotFound { + permission.Init() + if err := db.Create(&permission).Error; err != nil { + return err + } + logging.Info("Created permission: %s", permission.Name) + } + } + + // Assign all permissions to admin role + var adminRole model.Role + if err := db.Where("name = ?", "admin").First(&adminRole).Error; err != nil { + return err + } + + var allPermissions []model.Permission + if err := db.Find(&allPermissions).Error; err != nil { + return err + } + + if err := db.Model(&adminRole).Association("Permissions").Replace(allPermissions); err != nil { + return err + } + + // Assign basic permissions to user role + var userRole model.Role + if err := db.Where("name = ?", "user").First(&userRole).Error; err != nil { + return err + } + + var userPermissions []model.Permission + userPermissionNames := []string{"user:read", "role:read"} + if err := db.Where("name IN ?", userPermissionNames).Find(&userPermissions).Error; err != nil { + return err + } + + if err := db.Model(&userRole).Association("Permissions").Replace(userPermissions); err != nil { + return err + } + + // Assign read permissions to viewer role + var viewerRole model.Role + if err := db.Where("name = ?", "viewer").First(&viewerRole).Error; err != nil { + return err + } + + var viewerPermissions []model.Permission + viewerPermissionNames := []string{"user:read", "role:read"} + if err := db.Where("name IN ?", viewerPermissionNames).Find(&viewerPermissions).Error; err != nil { + return err + } + + if err := db.Model(&viewerRole).Association("Permissions").Replace(viewerPermissions); err != nil { + return err + } + + return nil +} + +func seedDefaultAdmin(db *gorm.DB) error { + // Check if admin user already exists + var existingAdmin model.User + err := db.Where("email = ?", "admin@example.com").First(&existingAdmin).Error + if err != gorm.ErrRecordNotFound { + return nil // Admin already exists or other error + } + + // Get admin role + var adminRole model.Role + if err := db.Where("name = ?", "admin").First(&adminRole).Error; err != nil { + return err + } + + // Create default admin user + defaultPassword := os.Getenv("DEFAULT_ADMIN_PASSWORD") + if defaultPassword == "" { + defaultPassword = "admin123" + } + + admin := model.User{ + Email: "admin@example.com", + Username: "admin", + Name: "System Administrator", + Active: true, + } + admin.Init() + + if err := admin.SetPassword(defaultPassword); err != nil { + return err + } + + if err := db.Create(&admin).Error; err != nil { + return err + } + + // Assign admin role + if err := db.Model(&admin).Association("Roles").Append(&adminRole); err != nil { + return err + } + + logging.Info("Created default admin user with email: %s", admin.Email) + logging.Warn("Default admin password is: %s - PLEASE CHANGE THIS IMMEDIATELY!", defaultPassword) + + return nil +} + +func seedSystemConfigs(db *gorm.DB) error { + configs := []model.SystemConfig{ + { + Key: "app.name", + Value: "Bootstrap App", + DefaultValue: "Bootstrap App", + Description: "Application name", + Category: "general", + DataType: "string", + IsEditable: true, + DateModified: time.Now().UTC().Format(time.RFC3339), + }, + { + Key: "app.version", + Value: "1.0.0", + DefaultValue: "1.0.0", + Description: "Application version", + Category: "general", + DataType: "string", + IsEditable: false, + DateModified: time.Now().UTC().Format(time.RFC3339), + }, + { + Key: "security.jwt_expiry_hours", + Value: "24", + DefaultValue: "24", + Description: "JWT token expiry time in hours", + Category: "security", + DataType: "integer", + IsEditable: true, + DateModified: time.Now().UTC().Format(time.RFC3339), + }, + { + Key: "security.password_min_length", + Value: "8", + DefaultValue: "8", + Description: "Minimum password length", + Category: "security", + DataType: "integer", + IsEditable: true, + DateModified: time.Now().UTC().Format(time.RFC3339), + }, + { + Key: "security.max_login_attempts", + Value: "5", + DefaultValue: "5", + Description: "Maximum login attempts before lockout", + Category: "security", + DataType: "integer", + IsEditable: true, + DateModified: time.Now().UTC().Format(time.RFC3339), + }, + { + Key: "security.lockout_duration_minutes", + Value: "30", + DefaultValue: "30", + Description: "Account lockout duration in minutes", + Category: "security", + DataType: "integer", + IsEditable: true, + DateModified: time.Now().UTC().Format(time.RFC3339), + }, + } + + for _, config := range configs { + var existingConfig model.SystemConfig + err := db.Where("key = ?", config.Key).First(&existingConfig).Error + if err == gorm.ErrRecordNotFound { + config.Init() + if err := db.Create(&config).Error; err != nil { + return err + } + logging.Info("Created system config: %s", config.Key) + } + } + + return nil +} diff --git a/local/utl/error_handler/controller_error_handler.go b/local/utl/error_handler/controller_error_handler.go new file mode 100644 index 0000000..b9f6149 --- /dev/null +++ b/local/utl/error_handler/controller_error_handler.go @@ -0,0 +1,184 @@ +package error_handler + +import ( + "fmt" + "omega-server/local/utl/logging" + "runtime" + "strings" + + "github.com/gofiber/fiber/v2" +) + +// ControllerErrorHandler provides centralized error handling for controllers +type ControllerErrorHandler struct { + errorLogger *logging.ErrorLogger +} + +// NewControllerErrorHandler creates a new controller error handler instance +func NewControllerErrorHandler() *ControllerErrorHandler { + return &ControllerErrorHandler{ + errorLogger: logging.GetErrorLogger(), + } +} + +// ErrorResponse represents a standardized error response +type ErrorResponse struct { + Error string `json:"error"` + Code int `json:"code,omitempty"` + Details map[string]string `json:"details,omitempty"` +} + +// HandleError handles controller errors with logging and standardized responses +func (ceh *ControllerErrorHandler) HandleError(c *fiber.Ctx, err error, statusCode int, context ...string) error { + if err == nil { + return nil + } + + // Get caller information for logging + _, file, line, _ := runtime.Caller(1) + file = strings.TrimPrefix(file, "acc-server-manager/") + + // Build context string + contextStr := "" + if len(context) > 0 { + contextStr = fmt.Sprintf("[%s] ", strings.Join(context, "|")) + } + + // Clean error message (remove null bytes) + cleanErrorMsg := strings.ReplaceAll(err.Error(), "\x00", "") + + // Log the error with context + ceh.errorLogger.LogWithContext( + fmt.Sprintf("CONTROLLER_ERROR [%s:%d]", file, line), + "%s%s", + contextStr, + cleanErrorMsg, + ) + + // Create standardized error response + errorResponse := ErrorResponse{ + Error: cleanErrorMsg, + Code: statusCode, + } + + // Add request details if available + if c != nil { + if errorResponse.Details == nil { + errorResponse.Details = make(map[string]string) + } + errorResponse.Details["method"] = c.Method() + errorResponse.Details["path"] = c.Path() + errorResponse.Details["ip"] = c.IP() + } + + // Return appropriate response based on status code + if statusCode >= 500 { + // For server errors, don't expose internal details + return c.Status(statusCode).JSON(ErrorResponse{ + Error: "Internal server error", + Code: statusCode, + }) + } + + return c.Status(statusCode).JSON(errorResponse) +} + +// HandleValidationError handles validation errors specifically +func (ceh *ControllerErrorHandler) HandleValidationError(c *fiber.Ctx, err error, field string) error { + return ceh.HandleError(c, err, fiber.StatusBadRequest, "VALIDATION", field) +} + +// HandleDatabaseError handles database-related errors +func (ceh *ControllerErrorHandler) HandleDatabaseError(c *fiber.Ctx, err error) error { + return ceh.HandleError(c, err, fiber.StatusInternalServerError, "DATABASE") +} + +// HandleAuthError handles authentication/authorization errors +func (ceh *ControllerErrorHandler) HandleAuthError(c *fiber.Ctx, err error) error { + return ceh.HandleError(c, err, fiber.StatusUnauthorized, "AUTH") +} + +// HandleNotFoundError handles resource not found errors +func (ceh *ControllerErrorHandler) HandleNotFoundError(c *fiber.Ctx, resource string) error { + err := fmt.Errorf("%s not found", resource) + return ceh.HandleError(c, err, fiber.StatusNotFound, "NOT_FOUND") +} + +// HandleBusinessLogicError handles business logic errors +func (ceh *ControllerErrorHandler) HandleBusinessLogicError(c *fiber.Ctx, err error) error { + return ceh.HandleError(c, err, fiber.StatusBadRequest, "BUSINESS_LOGIC") +} + +// HandleServiceError handles service layer errors +func (ceh *ControllerErrorHandler) HandleServiceError(c *fiber.Ctx, err error) error { + return ceh.HandleError(c, err, fiber.StatusInternalServerError, "SERVICE") +} + +// HandleParsingError handles request parsing errors +func (ceh *ControllerErrorHandler) HandleParsingError(c *fiber.Ctx, err error) error { + return ceh.HandleError(c, err, fiber.StatusBadRequest, "PARSING") +} + +// HandleUUIDError handles UUID parsing errors +func (ceh *ControllerErrorHandler) HandleUUIDError(c *fiber.Ctx, field string) error { + err := fmt.Errorf("invalid %s format", field) + return ceh.HandleError(c, err, fiber.StatusBadRequest, "UUID_VALIDATION", field) +} + +// Global controller error handler instance +var globalErrorHandler *ControllerErrorHandler + +// GetControllerErrorHandler returns the global controller error handler instance +func GetControllerErrorHandler() *ControllerErrorHandler { + if globalErrorHandler == nil { + globalErrorHandler = NewControllerErrorHandler() + } + return globalErrorHandler +} + +// Convenience functions using the global error handler + +// HandleError handles controller errors using the global error handler +func HandleError(c *fiber.Ctx, err error, statusCode int, context ...string) error { + return GetControllerErrorHandler().HandleError(c, err, statusCode, context...) +} + +// HandleValidationError handles validation errors using the global error handler +func HandleValidationError(c *fiber.Ctx, err error, field string) error { + return GetControllerErrorHandler().HandleValidationError(c, err, field) +} + +// HandleDatabaseError handles database errors using the global error handler +func HandleDatabaseError(c *fiber.Ctx, err error) error { + return GetControllerErrorHandler().HandleDatabaseError(c, err) +} + +// HandleAuthError handles auth errors using the global error handler +func HandleAuthError(c *fiber.Ctx, err error) error { + return GetControllerErrorHandler().HandleAuthError(c, err) +} + +// HandleNotFoundError handles not found errors using the global error handler +func HandleNotFoundError(c *fiber.Ctx, resource string) error { + return GetControllerErrorHandler().HandleNotFoundError(c, resource) +} + +// HandleBusinessLogicError handles business logic errors using the global error handler +func HandleBusinessLogicError(c *fiber.Ctx, err error) error { + return GetControllerErrorHandler().HandleBusinessLogicError(c, err) +} + +// HandleServiceError handles service errors using the global error handler +func HandleServiceError(c *fiber.Ctx, err error) error { + return GetControllerErrorHandler().HandleServiceError(c, err) +} + +// HandleParsingError handles parsing errors using the global error handler +func HandleParsingError(c *fiber.Ctx, err error) error { + return GetControllerErrorHandler().HandleParsingError(c, err) +} + +// HandleUUIDError handles UUID errors using the global error handler +func HandleUUIDError(c *fiber.Ctx, field string) error { + return GetControllerErrorHandler().HandleUUIDError(c, field) +} diff --git a/local/utl/jwt/jwt.go b/local/utl/jwt/jwt.go new file mode 100644 index 0000000..5595d09 --- /dev/null +++ b/local/utl/jwt/jwt.go @@ -0,0 +1,365 @@ +package jwt + +import ( + "errors" + "os" + "strconv" + "time" + + "github.com/golang-jwt/jwt/v4" +) + +// Claims represents the JWT claims +type Claims struct { + UserID string `json:"userId"` + Email string `json:"email"` + Username string `json:"username"` + Roles []string `json:"roles"` + jwt.RegisteredClaims +} + +// TokenPair represents access and refresh tokens +type TokenPair struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt time.Time `json:"expiresAt"` + TokenType string `json:"tokenType"` +} + +var ( + jwtSecret []byte + accessTokenTTL time.Duration + refreshTokenTTL time.Duration + issuer string + ErrInvalidToken = errors.New("invalid token") + ErrExpiredToken = errors.New("token has expired") + ErrInvalidClaims = errors.New("invalid token claims") +) + +// Initialize initializes the JWT package with configuration +func Initialize() error { + // Get JWT secret from environment + secret := os.Getenv("JWT_SECRET") + if secret == "" { + return errors.New("JWT_SECRET environment variable is required") + } + jwtSecret = []byte(secret) + + // Get token TTL from environment or use defaults + accessTTLStr := os.Getenv("JWT_ACCESS_TTL_HOURS") + if accessTTLStr == "" { + accessTTLStr = "24" // 24 hours default + } + accessHours, err := strconv.Atoi(accessTTLStr) + if err != nil { + accessHours = 24 + } + accessTokenTTL = time.Duration(accessHours) * time.Hour + + refreshTTLStr := os.Getenv("JWT_REFRESH_TTL_DAYS") + if refreshTTLStr == "" { + refreshTTLStr = "7" // 7 days default + } + refreshDays, err := strconv.Atoi(refreshTTLStr) + if err != nil { + refreshDays = 7 + } + refreshTokenTTL = time.Duration(refreshDays) * 24 * time.Hour + + // Get issuer from environment + issuer = os.Getenv("JWT_ISSUER") + if issuer == "" { + issuer = "omega-server" + } + + return nil +} + +// GenerateTokenPair generates both access and refresh tokens +func GenerateTokenPair(userID, email, username string, roles []string) (*TokenPair, error) { + if len(jwtSecret) == 0 { + if err := Initialize(); err != nil { + return nil, err + } + } + + now := time.Now() + accessExpiresAt := now.Add(accessTokenTTL) + refreshExpiresAt := now.Add(refreshTokenTTL) + + // Create access token claims + accessClaims := &Claims{ + UserID: userID, + Email: email, + Username: username, + Roles: roles, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(accessExpiresAt), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + Issuer: issuer, + Subject: userID, + ID: generateJTI(), + }, + } + + // Create refresh token claims (minimal data) + refreshClaims := &Claims{ + UserID: userID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(refreshExpiresAt), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + Issuer: issuer, + Subject: userID, + ID: generateJTI(), + }, + } + + // Generate access token + accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) + accessTokenString, err := accessToken.SignedString(jwtSecret) + if err != nil { + return nil, err + } + + // Generate refresh token + refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) + refreshTokenString, err := refreshToken.SignedString(jwtSecret) + if err != nil { + return nil, err + } + + return &TokenPair{ + AccessToken: accessTokenString, + RefreshToken: refreshTokenString, + ExpiresAt: accessExpiresAt, + TokenType: "Bearer", + }, nil +} + +// ValidateToken validates a JWT token and returns the claims +func ValidateToken(tokenString string) (*Claims, error) { + if len(jwtSecret) == 0 { + if err := Initialize(); err != nil { + return nil, err + } + } + + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + // Validate signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("invalid signing method") + } + return jwtSecret, nil + }) + + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, ErrExpiredToken + } + return nil, ErrInvalidToken + } + + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, ErrInvalidClaims + } + + // Additional validation + if claims.UserID == "" { + return nil, ErrInvalidClaims + } + + return claims, nil +} + +// RefreshToken generates a new access token from a valid refresh token +func RefreshToken(refreshTokenString string) (*TokenPair, error) { + // Validate refresh token + claims, err := ValidateToken(refreshTokenString) + if err != nil { + return nil, err + } + + // For refresh tokens, we only have minimal user data + // In a real application, you might want to fetch fresh user data from the database + return GenerateTokenPair(claims.UserID, claims.Email, claims.Username, claims.Roles) +} + +// ExtractTokenFromHeader extracts JWT token from Authorization header +func ExtractTokenFromHeader(authHeader string) (string, error) { + if authHeader == "" { + return "", errors.New("authorization header is required") + } + + const bearerPrefix = "Bearer " + if len(authHeader) < len(bearerPrefix) || authHeader[:len(bearerPrefix)] != bearerPrefix { + return "", errors.New("invalid authorization header format") + } + + token := authHeader[len(bearerPrefix):] + if token == "" { + return "", errors.New("token is required") + } + + return token, nil +} + +// IsTokenExpired checks if a token is expired without validating signature +func IsTokenExpired(tokenString string) bool { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + return jwtSecret, nil + }) + + if err != nil { + return true + } + + claims, ok := token.Claims.(*Claims) + if !ok { + return true + } + + return claims.ExpiresAt.Before(time.Now()) +} + +// GetTokenClaims extracts claims from token without validating signature (use carefully) +func GetTokenClaims(tokenString string) (*Claims, error) { + token, _, err := new(jwt.Parser).ParseUnverified(tokenString, &Claims{}) + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(*Claims) + if !ok { + return nil, ErrInvalidClaims + } + + return claims, nil +} + +// RevokeToken adds token to revocation list (implement with your chosen storage) +func RevokeToken(tokenString string) error { + // In a real implementation, you would store revoked tokens in a database or cache + // with their JTI (JWT ID) and expiration time for efficient cleanup + + claims, err := GetTokenClaims(tokenString) + if err != nil { + return err + } + + // Store the JTI in your revocation storage + // Example: revokedTokens[claims.ID] = claims.ExpiresAt + + _ = claims // Placeholder to avoid unused variable error + + return nil +} + +// IsTokenRevoked checks if a token has been revoked (implement with your chosen storage) +func IsTokenRevoked(tokenString string) bool { + // In a real implementation, check if the token's JTI is in the revocation list + + claims, err := GetTokenClaims(tokenString) + if err != nil { + return true + } + + // Check revocation storage + // Example: _, revoked := revokedTokens[claims.ID] + // return revoked + + _ = claims // Placeholder to avoid unused variable error + + return false +} + +// generateJTI generates a unique JWT ID +func generateJTI() string { + // In a real implementation, use a proper UUID generator + return strconv.FormatInt(time.Now().UnixNano(), 36) +} + +// GetTokenExpirationTime returns the expiration time of a token +func GetTokenExpirationTime(tokenString string) (time.Time, error) { + claims, err := GetTokenClaims(tokenString) + if err != nil { + return time.Time{}, err + } + + if claims.ExpiresAt == nil { + return time.Time{}, errors.New("token has no expiration time") + } + + return claims.ExpiresAt.Time, nil +} + +// GetTokenRemainingTime returns the remaining time before token expires +func GetTokenRemainingTime(tokenString string) (time.Duration, error) { + expirationTime, err := GetTokenExpirationTime(tokenString) + if err != nil { + return 0, err + } + + remaining := time.Until(expirationTime) + if remaining < 0 { + return 0, ErrExpiredToken + } + + return remaining, nil +} + +// HasRole checks if the token claims contain a specific role +func (c *Claims) HasRole(role string) bool { + for _, r := range c.Roles { + if r == role { + return true + } + } + return false +} + +// HasAnyRole checks if the token claims contain any of the specified roles +func (c *Claims) HasAnyRole(roles []string) bool { + for _, role := range roles { + if c.HasRole(role) { + return true + } + } + return false +} + +// HasAllRoles checks if the token claims contain all of the specified roles +func (c *Claims) HasAllRoles(roles []string) bool { + for _, role := range roles { + if !c.HasRole(role) { + return false + } + } + return true +} + +// IsAdmin checks if the user has admin role +func (c *Claims) IsAdmin() bool { + return c.HasRole("admin") +} + +// GetUserInfo returns basic user information from claims +func (c *Claims) GetUserInfo() map[string]interface{} { + return map[string]interface{}{ + "userId": c.UserID, + "email": c.Email, + "username": c.Username, + "roles": c.Roles, + } +} + +// GenerateToken generates a JWT token for a user (backward compatibility) +func GenerateToken(userID, email, username string, roles []string) (string, error) { + tokenPair, err := GenerateTokenPair(userID, email, username, roles) + if err != nil { + return "", err + } + return tokenPair.AccessToken, nil +} diff --git a/local/utl/logging/base.go b/local/utl/logging/base.go new file mode 100644 index 0000000..03ef1fa --- /dev/null +++ b/local/utl/logging/base.go @@ -0,0 +1,167 @@ +package logging + +import ( + "fmt" + "io" + "log" + "os" + "path/filepath" + "runtime" + "sync" + "time" +) + +var ( + timeFormat = "2006-01-02 15:04:05.000" +) + +// BaseLogger provides the core logging functionality +type BaseLogger struct { + file *os.File + logger *log.Logger + mu sync.RWMutex + initialized bool +} + +// LogLevel represents different logging levels +type LogLevel string + +const ( + LogLevelError LogLevel = "ERROR" + LogLevelWarn LogLevel = "WARN" + LogLevelInfo LogLevel = "INFO" + LogLevelDebug LogLevel = "DEBUG" + LogLevelPanic LogLevel = "PANIC" +) + +// Initialize creates a new base logger instance +func InitializeBase(tp string) (*BaseLogger, error) { + return newBaseLogger(tp) +} + +func newBaseLogger(tp string) (*BaseLogger, error) { + // Ensure logs directory exists + if err := os.MkdirAll("logs", 0755); err != nil { + return nil, fmt.Errorf("failed to create logs directory: %v", err) + } + + // Open log file with date in name + logPath := filepath.Join("logs", fmt.Sprintf("acc-server-%s-%s.log", time.Now().Format("2006-01-02"), tp)) + file, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666) + if err != nil { + return nil, fmt.Errorf("failed to open log file: %v", err) + } + + // Create multi-writer for both file and console + multiWriter := io.MultiWriter(file, os.Stdout) + + // Create base logger + logger := &BaseLogger{ + file: file, + logger: log.New(multiWriter, "", 0), + initialized: true, + } + + return logger, nil +} + +// GetBaseLogger creates and returns a new base logger instance +func GetBaseLogger(tp string) *BaseLogger { + baseLogger, _ := InitializeBase(tp) + return baseLogger +} + +// Close closes the log file +func (bl *BaseLogger) Close() error { + bl.mu.Lock() + defer bl.mu.Unlock() + + if bl.file != nil { + return bl.file.Close() + } + return nil +} + +// Log writes a log entry with the specified level +func (bl *BaseLogger) Log(level LogLevel, format string, v ...interface{}) { + if bl == nil || !bl.initialized { + return + } + + bl.mu.RLock() + defer bl.mu.RUnlock() + + // Get caller info (skip 2 frames: this function and the calling Log function) + _, file, line, _ := runtime.Caller(2) + file = filepath.Base(file) + + // Format message + msg := fmt.Sprintf(format, v...) + + // Format final log line + logLine := fmt.Sprintf("[%s] [%s] [%s:%d] %s", + time.Now().Format(timeFormat), + string(level), + file, + line, + msg, + ) + + bl.logger.Println(logLine) +} + +// LogWithCaller writes a log entry with custom caller depth +func (bl *BaseLogger) LogWithCaller(level LogLevel, callerDepth int, format string, v ...interface{}) { + if bl == nil || !bl.initialized { + return + } + + bl.mu.RLock() + defer bl.mu.RUnlock() + + // Get caller info with custom depth + _, file, line, _ := runtime.Caller(callerDepth) + file = filepath.Base(file) + + // Format message + msg := fmt.Sprintf(format, v...) + + // Format final log line + logLine := fmt.Sprintf("[%s] [%s] [%s:%d] %s", + time.Now().Format(timeFormat), + string(level), + file, + line, + msg, + ) + + bl.logger.Println(logLine) +} + +// IsInitialized returns whether the base logger is initialized +func (bl *BaseLogger) IsInitialized() bool { + if bl == nil { + return false + } + bl.mu.RLock() + defer bl.mu.RUnlock() + return bl.initialized +} + +// RecoverAndLog recovers from panics and logs them +func RecoverAndLog() { + baseLogger := GetBaseLogger("panic") + if baseLogger != nil && baseLogger.IsInitialized() { + if r := recover(); r != nil { + // Get stack trace + buf := make([]byte, 4096) + n := runtime.Stack(buf, false) + stackTrace := string(buf[:n]) + + baseLogger.LogWithCaller(LogLevelPanic, 2, "Recovered from panic: %v\nStack Trace:\n%s", r, stackTrace) + + // Re-panic to maintain original behavior if needed + panic(r) + } + } +} diff --git a/local/utl/logging/debug.go b/local/utl/logging/debug.go new file mode 100644 index 0000000..0422ad6 --- /dev/null +++ b/local/utl/logging/debug.go @@ -0,0 +1,159 @@ +package logging + +import ( + "fmt" + "runtime" + "sync" +) + +// DebugLogger handles debug-level logging +type DebugLogger struct { + base *BaseLogger +} + +// NewDebugLogger creates a new debug logger instance +func NewDebugLogger() *DebugLogger { + base, _ := InitializeBase("debug") + return &DebugLogger{ + base: base, + } +} + +// Log writes a debug-level log entry +func (dl *DebugLogger) Log(format string, v ...interface{}) { + if dl.base != nil { + dl.base.Log(LogLevelDebug, format, v...) + } +} + +// LogWithContext writes a debug-level log entry with additional context +func (dl *DebugLogger) LogWithContext(context string, format string, v ...interface{}) { + if dl.base != nil { + contextualFormat := fmt.Sprintf("[%s] %s", context, format) + dl.base.Log(LogLevelDebug, contextualFormat, v...) + } +} + +// LogFunction logs function entry and exit for debugging +func (dl *DebugLogger) LogFunction(functionName string, args ...interface{}) { + if dl.base != nil { + if len(args) > 0 { + dl.base.Log(LogLevelDebug, "FUNCTION [%s] called with args: %+v", functionName, args) + } else { + dl.base.Log(LogLevelDebug, "FUNCTION [%s] called", functionName) + } + } +} + +// LogVariable logs variable values for debugging +func (dl *DebugLogger) LogVariable(varName string, value interface{}) { + if dl.base != nil { + dl.base.Log(LogLevelDebug, "VARIABLE [%s]: %+v", varName, value) + } +} + +// LogState logs application state information +func (dl *DebugLogger) LogState(component string, state interface{}) { + if dl.base != nil { + dl.base.Log(LogLevelDebug, "STATE [%s]: %+v", component, state) + } +} + +// LogSQL logs SQL queries for debugging +func (dl *DebugLogger) LogSQL(query string, args ...interface{}) { + if dl.base != nil { + if len(args) > 0 { + dl.base.Log(LogLevelDebug, "SQL: %s | Args: %+v", query, args) + } else { + dl.base.Log(LogLevelDebug, "SQL: %s", query) + } + } +} + +// LogMemory logs memory usage information +func (dl *DebugLogger) LogMemory() { + if dl.base != nil { + var m runtime.MemStats + runtime.ReadMemStats(&m) + dl.base.Log(LogLevelDebug, "MEMORY: Alloc = %d KB, TotalAlloc = %d KB, Sys = %d KB, NumGC = %d", + bToKb(m.Alloc), bToKb(m.TotalAlloc), bToKb(m.Sys), m.NumGC) + } +} + +// LogGoroutines logs current number of goroutines +func (dl *DebugLogger) LogGoroutines() { + if dl.base != nil { + dl.base.Log(LogLevelDebug, "GOROUTINES: %d active", runtime.NumGoroutine()) + } +} + +// LogTiming logs timing information for performance debugging +func (dl *DebugLogger) LogTiming(operation string, duration interface{}) { + if dl.base != nil { + dl.base.Log(LogLevelDebug, "TIMING [%s]: %v", operation, duration) + } +} + +// Helper function to convert bytes to kilobytes +func bToKb(b uint64) uint64 { + return b / 1024 +} + +// Global debug logger instance +var ( + debugLogger *DebugLogger + debugOnce sync.Once +) + +// GetDebugLogger returns the global debug logger instance +func GetDebugLogger() *DebugLogger { + debugOnce.Do(func() { + debugLogger = NewDebugLogger() + }) + return debugLogger +} + +// Debug logs a debug-level message using the global debug logger +func Debug(format string, v ...interface{}) { + GetDebugLogger().Log(format, v...) +} + +// DebugWithContext logs a debug-level message with context using the global debug logger +func DebugWithContext(context string, format string, v ...interface{}) { + GetDebugLogger().LogWithContext(context, format, v...) +} + +// DebugFunction logs function entry and exit using the global debug logger +func DebugFunction(functionName string, args ...interface{}) { + GetDebugLogger().LogFunction(functionName, args...) +} + +// DebugVariable logs variable values using the global debug logger +func DebugVariable(varName string, value interface{}) { + GetDebugLogger().LogVariable(varName, value) +} + +// DebugState logs application state information using the global debug logger +func DebugState(component string, state interface{}) { + GetDebugLogger().LogState(component, state) +} + +// DebugSQL logs SQL queries using the global debug logger +func DebugSQL(query string, args ...interface{}) { + GetDebugLogger().LogSQL(query, args...) +} + +// DebugMemory logs memory usage information using the global debug logger +func DebugMemory() { + GetDebugLogger().LogMemory() +} + +// DebugGoroutines logs current number of goroutines using the global debug logger +func DebugGoroutines() { + GetDebugLogger().LogGoroutines() +} + +// DebugTiming logs timing information using the global debug logger +func DebugTiming(operation string, duration interface{}) { + GetDebugLogger().LogTiming(operation, duration) +} diff --git a/local/utl/logging/error.go b/local/utl/logging/error.go new file mode 100644 index 0000000..d347bbd --- /dev/null +++ b/local/utl/logging/error.go @@ -0,0 +1,106 @@ +package logging + +import ( + "fmt" + "runtime" + "sync" +) + +// ErrorLogger handles error-level logging +type ErrorLogger struct { + base *BaseLogger +} + +// NewErrorLogger creates a new error logger instance +func NewErrorLogger() *ErrorLogger { + base, _ := InitializeBase("error") + return &ErrorLogger{ + base: base, + } +} + +// Log writes an error-level log entry +func (el *ErrorLogger) Log(format string, v ...interface{}) { + if el.base != nil { + el.base.Log(LogLevelError, format, v...) + } +} + +// LogWithContext writes an error-level log entry with additional context +func (el *ErrorLogger) LogWithContext(context string, format string, v ...interface{}) { + if el.base != nil { + contextualFormat := fmt.Sprintf("[%s] %s", context, format) + el.base.Log(LogLevelError, contextualFormat, v...) + } +} + +// LogError logs an error object with optional message +func (el *ErrorLogger) LogError(err error, message ...string) { + if el.base != nil && err != nil { + if len(message) > 0 { + el.base.Log(LogLevelError, "%s: %v", message[0], err) + } else { + el.base.Log(LogLevelError, "Error: %v", err) + } + } +} + +// LogWithStackTrace logs an error with stack trace +func (el *ErrorLogger) LogWithStackTrace(format string, v ...interface{}) { + if el.base != nil { + // Get stack trace + buf := make([]byte, 4096) + n := runtime.Stack(buf, false) + stackTrace := string(buf[:n]) + + msg := fmt.Sprintf(format, v...) + el.base.Log(LogLevelError, "%s\nStack Trace:\n%s", msg, stackTrace) + } +} + +// LogFatal logs a fatal error and exits the program +func (el *ErrorLogger) LogFatal(format string, v ...interface{}) { + if el.base != nil { + el.base.Log(LogLevelError, "[FATAL] "+format, v...) + panic(fmt.Sprintf(format, v...)) + } +} + +// Global error logger instance +var ( + errorLogger *ErrorLogger + errorOnce sync.Once +) + +// GetErrorLogger returns the global error logger instance +func GetErrorLogger() *ErrorLogger { + errorOnce.Do(func() { + errorLogger = NewErrorLogger() + }) + return errorLogger +} + +// Error logs an error-level message using the global error logger +func Error(format string, v ...interface{}) { + GetErrorLogger().Log(format, v...) +} + +// ErrorWithContext logs an error-level message with context using the global error logger +func ErrorWithContext(context string, format string, v ...interface{}) { + GetErrorLogger().LogWithContext(context, format, v...) +} + +// LogError logs an error object using the global error logger +func LogError(err error, message ...string) { + GetErrorLogger().LogError(err, message...) +} + +// ErrorWithStackTrace logs an error with stack trace using the global error logger +func ErrorWithStackTrace(format string, v ...interface{}) { + GetErrorLogger().LogWithStackTrace(format, v...) +} + +// Fatal logs a fatal error and exits the program using the global error logger +func Fatal(format string, v ...interface{}) { + GetErrorLogger().LogFatal(format, v...) +} diff --git a/local/utl/logging/info.go b/local/utl/logging/info.go new file mode 100644 index 0000000..17553f0 --- /dev/null +++ b/local/utl/logging/info.go @@ -0,0 +1,130 @@ +package logging + +import ( + "fmt" + "sync" +) + +// InfoLogger handles info-level logging +type InfoLogger struct { + base *BaseLogger +} + +// NewInfoLogger creates a new info logger instance +func NewInfoLogger() *InfoLogger { + base, _ := InitializeBase("info") + return &InfoLogger{ + base: base, + } +} + +// Log writes an info-level log entry +func (il *InfoLogger) Log(format string, v ...interface{}) { + if il.base != nil { + il.base.Log(LogLevelInfo, format, v...) + } +} + +// LogWithContext writes an info-level log entry with additional context +func (il *InfoLogger) LogWithContext(context string, format string, v ...interface{}) { + if il.base != nil { + contextualFormat := fmt.Sprintf("[%s] %s", context, format) + il.base.Log(LogLevelInfo, contextualFormat, v...) + } +} + +// LogStartup logs application startup information +func (il *InfoLogger) LogStartup(component string, message string) { + if il.base != nil { + il.base.Log(LogLevelInfo, "STARTUP [%s]: %s", component, message) + } +} + +// LogShutdown logs application shutdown information +func (il *InfoLogger) LogShutdown(component string, message string) { + if il.base != nil { + il.base.Log(LogLevelInfo, "SHUTDOWN [%s]: %s", component, message) + } +} + +// LogOperation logs general operation information +func (il *InfoLogger) LogOperation(operation string, details string) { + if il.base != nil { + il.base.Log(LogLevelInfo, "OPERATION [%s]: %s", operation, details) + } +} + +// LogStatus logs status changes or updates +func (il *InfoLogger) LogStatus(component string, status string) { + if il.base != nil { + il.base.Log(LogLevelInfo, "STATUS [%s]: %s", component, status) + } +} + +// LogRequest logs incoming requests +func (il *InfoLogger) LogRequest(method string, path string, userAgent string) { + if il.base != nil { + il.base.Log(LogLevelInfo, "REQUEST [%s %s] User-Agent: %s", method, path, userAgent) + } +} + +// LogResponse logs outgoing responses +func (il *InfoLogger) LogResponse(method string, path string, statusCode int, duration string) { + if il.base != nil { + il.base.Log(LogLevelInfo, "RESPONSE [%s %s] Status: %d, Duration: %s", method, path, statusCode, duration) + } +} + +// Global info logger instance +var ( + infoLogger *InfoLogger + infoOnce sync.Once +) + +// GetInfoLogger returns the global info logger instance +func GetInfoLogger() *InfoLogger { + infoOnce.Do(func() { + infoLogger = NewInfoLogger() + }) + return infoLogger +} + +// Info logs an info-level message using the global info logger +func Info(format string, v ...interface{}) { + GetInfoLogger().Log(format, v...) +} + +// InfoWithContext logs an info-level message with context using the global info logger +func InfoWithContext(context string, format string, v ...interface{}) { + GetInfoLogger().LogWithContext(context, format, v...) +} + +// InfoStartup logs application startup information using the global info logger +func InfoStartup(component string, message string) { + GetInfoLogger().LogStartup(component, message) +} + +// InfoShutdown logs application shutdown information using the global info logger +func InfoShutdown(component string, message string) { + GetInfoLogger().LogShutdown(component, message) +} + +// InfoOperation logs general operation information using the global info logger +func InfoOperation(operation string, details string) { + GetInfoLogger().LogOperation(operation, details) +} + +// InfoStatus logs status changes or updates using the global info logger +func InfoStatus(component string, status string) { + GetInfoLogger().LogStatus(component, status) +} + +// InfoRequest logs incoming requests using the global info logger +func InfoRequest(method string, path string, userAgent string) { + GetInfoLogger().LogRequest(method, path, userAgent) +} + +// InfoResponse logs outgoing responses using the global info logger +func InfoResponse(method string, path string, statusCode int, duration string) { + GetInfoLogger().LogResponse(method, path, statusCode, duration) +} diff --git a/local/utl/logging/logger.go b/local/utl/logging/logger.go new file mode 100644 index 0000000..258c0a9 --- /dev/null +++ b/local/utl/logging/logger.go @@ -0,0 +1,213 @@ +package logging + +import ( + "fmt" + "sync" +) + +var ( + // Legacy logger for backward compatibility + logger *Logger + once sync.Once +) + +// Logger maintains backward compatibility with existing code +type Logger struct { + base *BaseLogger + errorLogger *ErrorLogger + warnLogger *WarnLogger + infoLogger *InfoLogger + debugLogger *DebugLogger +} + +// Initialize creates or gets the singleton logger instance +// This maintains backward compatibility with existing code +func Initialize() (*Logger, error) { + var err error + once.Do(func() { + logger, err = newLogger() + }) + return logger, err +} + +func newLogger() (*Logger, error) { + // Initialize the base logger + baseLogger, err := InitializeBase("log") + if err != nil { + return nil, err + } + + // Create the legacy logger wrapper + logger := &Logger{ + base: baseLogger, + errorLogger: GetErrorLogger(), + warnLogger: GetWarnLogger(), + infoLogger: GetInfoLogger(), + debugLogger: GetDebugLogger(), + } + + return logger, nil +} + +// Close closes the logger +func (l *Logger) Close() error { + if l.base != nil { + return l.base.Close() + } + return nil +} + +// Legacy methods for backward compatibility +func (l *Logger) log(level, format string, v ...interface{}) { + if l.base != nil { + l.base.LogWithCaller(LogLevel(level), 3, format, v...) + } +} + +func (l *Logger) Info(format string, v ...interface{}) { + if l.infoLogger != nil { + l.infoLogger.Log(format, v...) + } +} + +func (l *Logger) Error(format string, v ...interface{}) { + if l.errorLogger != nil { + l.errorLogger.Log(format, v...) + } +} + +func (l *Logger) Warn(format string, v ...interface{}) { + if l.warnLogger != nil { + l.warnLogger.Log(format, v...) + } +} + +func (l *Logger) Debug(format string, v ...interface{}) { + if l.debugLogger != nil { + l.debugLogger.Log(format, v...) + } +} + +func (l *Logger) Panic(format string) { + if l.errorLogger != nil { + l.errorLogger.LogFatal(format) + } +} + +// Global convenience functions for backward compatibility +// These are now implemented in individual logger files to avoid redeclaration +func LegacyInfo(format string, v ...interface{}) { + if logger != nil { + logger.Info(format, v...) + } else { + // Fallback to direct logger if legacy logger not initialized + GetInfoLogger().Log(format, v...) + } +} + +func LegacyError(format string, v ...interface{}) { + if logger != nil { + logger.Error(format, v...) + } else { + // Fallback to direct logger if legacy logger not initialized + GetErrorLogger().Log(format, v...) + } +} + +func LegacyWarn(format string, v ...interface{}) { + if logger != nil { + logger.Warn(format, v...) + } else { + // Fallback to direct logger if legacy logger not initialized + GetWarnLogger().Log(format, v...) + } +} + +func LegacyDebug(format string, v ...interface{}) { + if logger != nil { + logger.Debug(format, v...) + } else { + // Fallback to direct logger if legacy logger not initialized + GetDebugLogger().Log(format, v...) + } +} + +func Panic(format string) { + if logger != nil { + logger.Panic(format) + } else { + // Fallback to direct logger if legacy logger not initialized + GetErrorLogger().LogFatal(format) + } +} + +// Enhanced logging convenience functions +// These provide direct access to specialized logging functions + +// LogStartup logs application startup information +func LogStartup(component string, message string) { + GetInfoLogger().LogStartup(component, message) +} + +// LogShutdown logs application shutdown information +func LogShutdown(component string, message string) { + GetInfoLogger().LogShutdown(component, message) +} + +// LogOperation logs general operation information +func LogOperation(operation string, details string) { + GetInfoLogger().LogOperation(operation, details) +} + +// LogRequest logs incoming HTTP requests +func LogRequest(method string, path string, userAgent string) { + GetInfoLogger().LogRequest(method, path, userAgent) +} + +// LogResponse logs outgoing HTTP responses +func LogResponse(method string, path string, statusCode int, duration string) { + GetInfoLogger().LogResponse(method, path, statusCode, duration) +} + +// LogSQL logs SQL queries for debugging +func LogSQL(query string, args ...interface{}) { + GetDebugLogger().LogSQL(query, args...) +} + +// LogMemory logs memory usage information +func LogMemory() { + GetDebugLogger().LogMemory() +} + +// LogTiming logs timing information for performance debugging +func LogTiming(operation string, duration interface{}) { + GetDebugLogger().LogTiming(operation, duration) +} + +// GetLegacyLogger returns the legacy logger instance for backward compatibility +func GetLegacyLogger() *Logger { + if logger == nil { + logger, _ = Initialize() + } + return logger +} + +// InitializeLogging initializes all logging components +func InitializeLogging() error { + // Initialize legacy logger for backward compatibility + _, err := Initialize() + if err != nil { + return fmt.Errorf("failed to initialize legacy logger: %v", err) + } + + // Pre-initialize all logger types to ensure separate log files + GetErrorLogger() + GetWarnLogger() + GetInfoLogger() + GetDebugLogger() + + // Log successful initialization + Info("Logging system initialized successfully") + + return nil +} diff --git a/local/utl/logging/warn.go b/local/utl/logging/warn.go new file mode 100644 index 0000000..376d0ea --- /dev/null +++ b/local/utl/logging/warn.go @@ -0,0 +1,98 @@ +package logging + +import ( + "fmt" + "sync" +) + +// WarnLogger handles warn-level logging +type WarnLogger struct { + base *BaseLogger +} + +// NewWarnLogger creates a new warn logger instance +func NewWarnLogger() *WarnLogger { + base, _ := InitializeBase("warn") + return &WarnLogger{ + base: base, + } +} + +// Log writes a warn-level log entry +func (wl *WarnLogger) Log(format string, v ...interface{}) { + if wl.base != nil { + wl.base.Log(LogLevelWarn, format, v...) + } +} + +// LogWithContext writes a warn-level log entry with additional context +func (wl *WarnLogger) LogWithContext(context string, format string, v ...interface{}) { + if wl.base != nil { + contextualFormat := fmt.Sprintf("[%s] %s", context, format) + wl.base.Log(LogLevelWarn, contextualFormat, v...) + } +} + +// LogDeprecation logs a deprecation warning +func (wl *WarnLogger) LogDeprecation(feature string, alternative string) { + if wl.base != nil { + if alternative != "" { + wl.base.Log(LogLevelWarn, "DEPRECATED: %s is deprecated, use %s instead", feature, alternative) + } else { + wl.base.Log(LogLevelWarn, "DEPRECATED: %s is deprecated", feature) + } + } +} + +// LogConfiguration logs configuration-related warnings +func (wl *WarnLogger) LogConfiguration(setting string, message string) { + if wl.base != nil { + wl.base.Log(LogLevelWarn, "CONFIG WARNING [%s]: %s", setting, message) + } +} + +// LogPerformance logs performance-related warnings +func (wl *WarnLogger) LogPerformance(operation string, threshold string, actual string) { + if wl.base != nil { + wl.base.Log(LogLevelWarn, "PERFORMANCE WARNING [%s]: exceeded threshold %s, actual: %s", operation, threshold, actual) + } +} + +// Global warn logger instance +var ( + warnLogger *WarnLogger + warnOnce sync.Once +) + +// GetWarnLogger returns the global warn logger instance +func GetWarnLogger() *WarnLogger { + warnOnce.Do(func() { + warnLogger = NewWarnLogger() + }) + return warnLogger +} + +// Warn logs a warn-level message using the global warn logger +func Warn(format string, v ...interface{}) { + GetWarnLogger().Log(format, v...) +} + +// WarnWithContext logs a warn-level message with context using the global warn logger +func WarnWithContext(context string, format string, v ...interface{}) { + GetWarnLogger().LogWithContext(context, format, v...) +} + +// WarnDeprecation logs a deprecation warning using the global warn logger +func WarnDeprecation(feature string, alternative string) { + GetWarnLogger().LogDeprecation(feature, alternative) +} + +// WarnConfiguration logs configuration-related warnings using the global warn logger +func WarnConfiguration(setting string, message string) { + GetWarnLogger().LogConfiguration(setting, message) +} + +// WarnPerformance logs performance-related warnings using the global warn logger +func WarnPerformance(operation string, threshold string, actual string) { + GetWarnLogger().LogPerformance(operation, threshold, actual) +} diff --git a/local/utl/password/password.go b/local/utl/password/password.go new file mode 100644 index 0000000..0a9d664 --- /dev/null +++ b/local/utl/password/password.go @@ -0,0 +1,336 @@ +package password + +import ( + "errors" + "regexp" + "unicode" + + "golang.org/x/crypto/bcrypt" +) + +const ( + // MinPasswordLength minimum password length + MinPasswordLength = 8 + // MaxPasswordLength maximum password length + MaxPasswordLength = 128 + // DefaultCost default bcrypt cost + DefaultCost = 12 +) + +// HashPassword hashes a plain text password using bcrypt +func HashPassword(password string) (string, error) { + if err := ValidatePasswordStrength(password); err != nil { + return "", err + } + + bytes, err := bcrypt.GenerateFromPassword([]byte(password), DefaultCost) + if err != nil { + return "", err + } + + return string(bytes), nil +} + +// CheckPasswordHash compares a plain text password with its hash +func CheckPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +// ValidatePasswordStrength validates password strength requirements +func ValidatePasswordStrength(password string) error { + if len(password) < MinPasswordLength { + return errors.New("password must be at least 8 characters long") + } + + if len(password) > MaxPasswordLength { + return errors.New("password must not exceed 128 characters") + } + + var ( + hasUpper = false + hasLower = false + hasNumber = false + hasSpecial = false + ) + + for _, char := range password { + switch { + case unicode.IsUpper(char): + hasUpper = true + case unicode.IsLower(char): + hasLower = true + case unicode.IsNumber(char): + hasNumber = true + case unicode.IsPunct(char) || unicode.IsSymbol(char): + hasSpecial = true + } + } + + if !hasUpper { + return errors.New("password must contain at least one uppercase letter") + } + + if !hasLower { + return errors.New("password must contain at least one lowercase letter") + } + + if !hasNumber { + return errors.New("password must contain at least one digit") + } + + if !hasSpecial { + return errors.New("password must contain at least one special character") + } + + // Check for common patterns + if isCommonPassword(password) { + return errors.New("password is too common, please choose a stronger password") + } + + return nil +} + +// isCommonPassword checks if password is in list of common passwords +func isCommonPassword(password string) bool { + commonPasswords := []string{ + "password", "123456", "password123", "admin", "qwerty", + "letmein", "welcome", "monkey", "1234567890", "password1", + "123456789", "welcome123", "admin123", "root", "test", + "guest", "password12", "changeme", "default", "temp", + } + + for _, common := range commonPasswords { + if password == common { + return true + } + } + + return false +} + +// ValidatePasswordComplexity validates password against additional complexity rules +func ValidatePasswordComplexity(password string) error { + if err := ValidatePasswordStrength(password); err != nil { + return err + } + + // Check for repeated characters (more than 3 consecutive) + if hasRepeatedChars(password, 3) { + return errors.New("password must not contain more than 3 consecutive identical characters") + } + + // Check for sequential characters (like "1234" or "abcd") + if hasSequentialChars(password, 4) { + return errors.New("password must not contain sequential characters") + } + + // Check for keyboard patterns + if hasKeyboardPattern(password) { + return errors.New("password must not contain keyboard patterns") + } + + return nil +} + +// hasRepeatedChars checks for repeated consecutive characters +func hasRepeatedChars(password string, maxRepeat int) bool { + if len(password) < maxRepeat+1 { + return false + } + + count := 1 + for i := 1; i < len(password); i++ { + if password[i] == password[i-1] { + count++ + if count > maxRepeat { + return true + } + } else { + count = 1 + } + } + + return false +} + +// hasSequentialChars checks for sequential characters +func hasSequentialChars(password string, minSequence int) bool { + if len(password) < minSequence { + return false + } + + for i := 0; i <= len(password)-minSequence; i++ { + isSequential := true + isReverseSequential := true + + for j := 1; j < minSequence; j++ { + if int(password[i+j]) != int(password[i+j-1])+1 { + isSequential = false + } + if int(password[i+j]) != int(password[i+j-1])-1 { + isReverseSequential = false + } + } + + if isSequential || isReverseSequential { + return true + } + } + + return false +} + +// hasKeyboardPattern checks for common keyboard patterns +func hasKeyboardPattern(password string) bool { + keyboardPatterns := []string{ + "qwerty", "asdf", "zxcv", "qwertyuiop", "asdfghjkl", "zxcvbnm", + "1234567890", "qazwsx", "wsxedc", "rfvtgb", "nhyujm", "iklop", + } + + lowerPassword := regexp.MustCompile(`[^a-zA-Z0-9]`).ReplaceAllString(password, "") + lowerPassword = regexp.MustCompile(`[A-Z]`).ReplaceAllStringFunc(lowerPassword, func(s string) string { + return string(rune(s[0]) + 32) + }) + + for _, pattern := range keyboardPatterns { + if len(lowerPassword) >= len(pattern) { + for i := 0; i <= len(lowerPassword)-len(pattern); i++ { + if lowerPassword[i:i+len(pattern)] == pattern { + return true + } + } + } + } + + return false +} + +// GenerateRandomPassword generates a random password with specified length +func GenerateRandomPassword(length int) (string, error) { + if length < MinPasswordLength { + length = MinPasswordLength + } + if length > MaxPasswordLength { + length = MaxPasswordLength + } + + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;:,.<>?" + + // Use crypto/rand for secure random generation + password := make([]byte, length) + for i := range password { + // Simple implementation - in production, use crypto/rand + password[i] = charset[i%len(charset)] + } + + // Ensure password meets complexity requirements + result := string(password) + if err := ValidatePasswordStrength(result); err != nil { + // Fallback to a known good password pattern if generation fails + return generateFallbackPassword(length), nil + } + + return result, nil +} + +// generateFallbackPassword generates a password that meets all requirements +func generateFallbackPassword(length int) string { + if length < MinPasswordLength { + length = MinPasswordLength + } + + // Start with a base that meets all requirements + base := "Aa1!" + remaining := length - len(base) + + // Fill remaining with mixed characters + charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*" + for i := 0; i < remaining; i++ { + base += string(charset[i%len(charset)]) + } + + return base +} + +// GetPasswordStrengthScore returns a score from 0-100 indicating password strength +func GetPasswordStrengthScore(password string) int { + score := 0 + + // Length score (0-25 points) + if len(password) >= 8 { + score += 5 + } + if len(password) >= 12 { + score += 10 + } + if len(password) >= 16 { + score += 10 + } + + // Character variety (0-40 points) + var hasUpper, hasLower, hasNumber, hasSpecial bool + for _, char := range password { + if unicode.IsUpper(char) { + hasUpper = true + } else if unicode.IsLower(char) { + hasLower = true + } else if unicode.IsNumber(char) { + hasNumber = true + } else if unicode.IsPunct(char) || unicode.IsSymbol(char) { + hasSpecial = true + } + } + + if hasUpper { + score += 10 + } + if hasLower { + score += 10 + } + if hasNumber { + score += 10 + } + if hasSpecial { + score += 10 + } + + // Complexity bonus (0-35 points) + if !isCommonPassword(password) { + score += 10 + } + if !hasRepeatedChars(password, 2) { + score += 10 + } + if !hasSequentialChars(password, 3) { + score += 10 + } + if !hasKeyboardPattern(password) { + score += 5 + } + + // Cap at 100 + if score > 100 { + score = 100 + } + + return score +} + +// GetPasswordStrengthLevel returns a human-readable strength level +func GetPasswordStrengthLevel(password string) string { + score := GetPasswordStrengthScore(password) + + switch { + case score >= 80: + return "Very Strong" + case score >= 60: + return "Strong" + case score >= 40: + return "Medium" + case score >= 20: + return "Weak" + default: + return "Very Weak" + } +} diff --git a/local/utl/server/server.go b/local/utl/server/server.go new file mode 100644 index 0000000..7dd04bf --- /dev/null +++ b/local/utl/server/server.go @@ -0,0 +1,157 @@ +package server + +import ( + "omega-server/local/api" + "omega-server/local/middleware/security" + "omega-server/local/utl/logging" + "os" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/helmet" + "github.com/gofiber/fiber/v2/middleware/recover" + "github.com/gofiber/swagger" + "go.uber.org/dig" +) + +func Start(di *dig.Container) *fiber.App { + app := fiber.New(fiber.Config{ + AppName: "Omega Project Management", + ServerHeader: "omega-server", + EnablePrintRoutes: true, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + BodyLimit: 10 * 1024 * 1024, // 10MB + Prefork: false, + CaseSensitive: false, + StrictRouting: false, + DisableKeepalive: false, + ErrorHandler: func(c *fiber.Ctx, err error) error { + // Custom error handler + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + + // Log error + logging.Error("HTTP Error: %v, Path: %s, Method: %s, IP: %s", + err, c.Path(), c.Method(), c.IP()) + + // Return JSON error response + return c.Status(code).JSON(fiber.Map{ + "error": err.Error(), + "code": code, + }) + }, + }) + + // Initialize security middleware + securityMW := security.NewSecurityMiddleware() + + // Add recovery middleware first + app.Use(recover.New(recover.Config{ + EnableStackTrace: true, + })) + + // Add security middleware stack + app.Use(securityMW.SecurityHeaders()) + app.Use(securityMW.LogSecurityEvents()) + app.Use(securityMW.TimeoutMiddleware(30 * time.Second)) + app.Use(securityMW.RequestSizeLimit(10 * 1024 * 1024)) // 10MB + app.Use(securityMW.ValidateUserAgent()) + app.Use(securityMW.ValidateContentType("application/json", "application/x-www-form-urlencoded", "multipart/form-data")) + app.Use(securityMW.InputSanitization()) + app.Use(securityMW.RateLimit(100, 1*time.Minute)) // 100 requests per minute global + + // Add Helmet middleware for security headers + app.Use(helmet.New(helmet.Config{ + XSSProtection: "1; mode=block", + ContentTypeNosniff: "nosniff", + XFrameOptions: "DENY", + HSTSMaxAge: 31536000, + HSTSPreloadEnabled: true, + ContentSecurityPolicy: "default-src 'self'", + ReferrerPolicy: "strict-origin-when-cross-origin", + })) + + // Configure CORS + allowedOrigin := os.Getenv("CORS_ALLOWED_ORIGIN") + if allowedOrigin == "" { + allowedOrigin = "http://localhost:5173" + } + + app.Use(cors.New(cors.Config{ + AllowOrigins: allowedOrigin, + AllowHeaders: "Origin, Content-Type, Accept, Authorization, X-Requested-With", + AllowMethods: "GET, POST, PUT, DELETE, OPTIONS, PATCH", + AllowCredentials: true, + MaxAge: 86400, // 24 hours + })) + + // Add request logging middleware + app.Use(func(c *fiber.Ctx) error { + start := time.Now() + + // Process request + err := c.Next() + + // Log request + duration := time.Since(start) + logging.InfoResponse( + c.Method(), + c.Path(), + c.Response().StatusCode(), + duration.String(), + ) + + return err + }) + + // Swagger documentation + app.Get("/swagger/*", swagger.HandlerDefault) + + // Health check endpoint + app.Get("/health", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "healthy", + "timestamp": time.Now().UTC().Format(time.RFC3339), + "version": "1.0.0", + }) + }) + + // Ping endpoint + app.Get("/ping", func(c *fiber.Ctx) error { + return c.SendString("pong") + }) + + // Initialize API routes + api.Init(di, app) + + // 404 handler + app.Use(func(c *fiber.Ctx) error { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "Route not found", + "path": c.Path(), + }) + }) + + // Get port from environment + port := os.Getenv("PORT") + if port == "" { + port = "3000" // Default port + } + + logging.Info("Starting server on port %s", port) + logging.Info("Swagger documentation available at: http://localhost:%s/swagger/", port) + logging.Info("Health check available at: http://localhost:%s/health", port) + + // Start server + if err := app.Listen(":" + port); err != nil { + logging.Error("Failed to start server: %v", err) + os.Exit(1) + } + + return app +} diff --git a/scripts/generate-secrets.ps1 b/scripts/generate-secrets.ps1 new file mode 100644 index 0000000..7a4c576 --- /dev/null +++ b/scripts/generate-secrets.ps1 @@ -0,0 +1,176 @@ +# Bootstrap App - Secret Generation Script +# This script generates cryptographically secure secrets for the Bootstrap App + +Write-Host "Bootstrap App - Secret Generation Script" -ForegroundColor Green +Write-Host "=========================================" -ForegroundColor Green +Write-Host "" + +# Function to generate random bytes and convert to hex +function Generate-HexString { + param([int]$Length) + $bytes = New-Object byte[] $Length + $rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::new() + $rng.GetBytes($bytes) + $rng.Dispose() + return [System.BitConverter]::ToString($bytes) -replace '-', '' +} + +# Function to generate random bytes and convert to base64 +function Generate-Base64String { + param([int]$Length) + $bytes = New-Object byte[] $Length + $rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::new() + $rng.GetBytes($bytes) + $rng.Dispose() + return [System.Convert]::ToBase64String($bytes) +} + +# Generate secrets +Write-Host "Generating cryptographically secure secrets..." -ForegroundColor Yellow +Write-Host "" + +$jwtSecret = Generate-Base64String -Length 64 +$appSecret = Generate-HexString -Length 32 +$appSecretCode = Generate-HexString -Length 32 +$encryptionKey = Generate-HexString -Length 16 + +# Display generated secrets +Write-Host "Generated Secrets:" -ForegroundColor Cyan +Write-Host "==================" -ForegroundColor Cyan +Write-Host "" +Write-Host "JWT_SECRET=" -NoNewline -ForegroundColor White +Write-Host $jwtSecret -ForegroundColor Yellow +Write-Host "" +Write-Host "APP_SECRET=" -NoNewline -ForegroundColor White +Write-Host $appSecret -ForegroundColor Yellow +Write-Host "" +Write-Host "APP_SECRET_CODE=" -NoNewline -ForegroundColor White +Write-Host $appSecretCode -ForegroundColor Yellow +Write-Host "" +Write-Host "ENCRYPTION_KEY=" -NoNewline -ForegroundColor White +Write-Host $encryptionKey -ForegroundColor Yellow +Write-Host "" + +# Check if .env file exists +$envFile = ".env" +$envExists = Test-Path $envFile + +if ($envExists) { + Write-Host "Warning: .env file already exists!" -ForegroundColor Red + $overwrite = Read-Host "Do you want to update it with new secrets? (y/N)" + if ($overwrite -eq "y" -or $overwrite -eq "Y") { + $updateFile = $true + } else { + $updateFile = $false + Write-Host "Secrets generated but not written to file." -ForegroundColor Yellow + } +} else { + $createFile = Read-Host "Create .env file with these secrets? (Y/n)" + if ($createFile -eq "" -or $createFile -eq "y" -or $createFile -eq "Y") { + $updateFile = $true + } else { + $updateFile = $false + Write-Host "Secrets generated but not written to file." -ForegroundColor Yellow + } +} + +if ($updateFile) { + # Create or update .env file + if ($envExists) { + # Backup existing file + $backupFile = ".env.backup." + (Get-Date -Format "yyyyMMdd-HHmmss") + Copy-Item $envFile $backupFile + Write-Host "Backed up existing .env to $backupFile" -ForegroundColor Green + + # Read existing content and update secrets + $content = Get-Content $envFile + $newContent = @() + + foreach ($line in $content) { + if ($line -match "^JWT_SECRET=") { + $newContent += "JWT_SECRET=$jwtSecret" + } elseif ($line -match "^APP_SECRET=") { + $newContent += "APP_SECRET=$appSecret" + } elseif ($line -match "^APP_SECRET_CODE=") { + $newContent += "APP_SECRET_CODE=$appSecretCode" + } elseif ($line -match "^ENCRYPTION_KEY=") { + $newContent += "ENCRYPTION_KEY=$encryptionKey" + } else { + $newContent += $line + } + } + + $newContent | Out-File -FilePath $envFile -Encoding UTF8 + Write-Host "Updated .env file with new secrets" -ForegroundColor Green + } else { + # Create new .env file from template + if (Test-Path ".env.example") { + $template = Get-Content ".env.example" + $newContent = @() + + foreach ($line in $template) { + if ($line -match "^JWT_SECRET=") { + $newContent += "JWT_SECRET=$jwtSecret" + } elseif ($line -match "^APP_SECRET=") { + $newContent += "APP_SECRET=$appSecret" + } elseif ($line -match "^APP_SECRET_CODE=") { + $newContent += "APP_SECRET_CODE=$appSecretCode" + } elseif ($line -match "^ENCRYPTION_KEY=") { + $newContent += "ENCRYPTION_KEY=$encryptionKey" + } else { + $newContent += $line + } + } + + $newContent | Out-File -FilePath $envFile -Encoding UTF8 + Write-Host "Created .env file from template with generated secrets" -ForegroundColor Green + } else { + # Create minimal .env file + $minimalEnv = @( + "# Bootstrap App Environment Configuration", + "# Generated on $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')", + "", + "# CRITICAL SECURITY SETTINGS (REQUIRED)", + "JWT_SECRET=$jwtSecret", + "APP_SECRET=$appSecret", + "APP_SECRET_CODE=$appSecretCode", + "ENCRYPTION_KEY=$encryptionKey", + "", + "# CORE APPLICATION SETTINGS", + "DB_NAME=app.db", + "PORT=3000", + "CORS_ALLOWED_ORIGIN=http://localhost:5173", + "DEFAULT_ADMIN_PASSWORD=change-this-password" + ) + + $minimalEnv | Out-File -FilePath $envFile -Encoding UTF8 + Write-Host "Created minimal .env file with generated secrets" -ForegroundColor Green + } + } +} + +Write-Host "" +Write-Host "Security Notes:" -ForegroundColor Red +Write-Host "===============" -ForegroundColor Red +Write-Host "1. Keep these secrets secure and never commit them to version control" -ForegroundColor Yellow +Write-Host "2. Use different secrets for each environment (dev, staging, production)" -ForegroundColor Yellow +Write-Host "3. Rotate secrets regularly in production environments" -ForegroundColor Yellow +Write-Host "4. The ENCRYPTION_KEY is exactly 32 characters as required for AES-256" -ForegroundColor Yellow +Write-Host "5. Change the DEFAULT_ADMIN_PASSWORD immediately after first login" -ForegroundColor Yellow +Write-Host "" + +# Verify encryption key length +if ($encryptionKey.Length -eq 32) { # 32 characters = 32 bytes when converted to []byte in Go + Write-Host "βœ“ Encryption key length verified (32 characters = 32 bytes for AES-256)" -ForegroundColor Green +} else { + Write-Host "βœ— Warning: Encryption key length is incorrect! Got $($encryptionKey.Length) chars, expected 32" -ForegroundColor Red +} + +Write-Host "" +Write-Host "Next steps:" -ForegroundColor Cyan +Write-Host "1. Review and customize the .env file if needed" -ForegroundColor White +Write-Host "2. Build and run the application: go run cmd/api/main.go" -ForegroundColor White +Write-Host "3. Change the default admin password on first login" -ForegroundColor White +Write-Host "4. Access the API documentation at: http://localhost:3000/swagger/" -ForegroundColor White +Write-Host "" +Write-Host "Happy coding! πŸš€" -ForegroundColor Green diff --git a/scripts/generate-secrets.sh b/scripts/generate-secrets.sh new file mode 100644 index 0000000..0026982 --- /dev/null +++ b/scripts/generate-secrets.sh @@ -0,0 +1,163 @@ +#!/bin/bash + +# Bootstrap App - Secret Generation Script +# This script generates cryptographically secure secrets for the Bootstrap App + +echo "Bootstrap App - Secret Generation Script" +echo "========================================" +echo "" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +WHITE='\033[1;37m' +NC='\033[0m' # No Color + +# Function to generate hex string +generate_hex_string() { + local length=$1 + openssl rand -hex $length 2>/dev/null || xxd -l $length -p /dev/urandom | tr -d '\n' +} + +# Function to generate base64 string +generate_base64_string() { + local length=$1 + openssl rand -base64 $length 2>/dev/null || dd if=/dev/urandom bs=$length count=1 2>/dev/null | base64 | tr -d '\n' +} + +# Check if required tools are available +check_dependencies() { + if ! command -v openssl >/dev/null 2>&1; then + if ! command -v xxd >/dev/null 2>&1; then + echo -e "${RED}Error: Neither openssl nor xxd is available. Please install one of them.${NC}" + exit 1 + fi + fi +} + +# Generate secrets +echo -e "${YELLOW}Generating cryptographically secure secrets...${NC}" +echo "" + +check_dependencies + +JWT_SECRET=$(generate_base64_string 64) +APP_SECRET=$(generate_hex_string 32) +APP_SECRET_CODE=$(generate_hex_string 32) +ENCRYPTION_KEY=$(generate_hex_string 16) + +# Display generated secrets +echo -e "${CYAN}Generated Secrets:${NC}" +echo -e "${CYAN}==================${NC}" +echo "" +echo -e "${WHITE}JWT_SECRET=${NC}${YELLOW}$JWT_SECRET${NC}" +echo "" +echo -e "${WHITE}APP_SECRET=${NC}${YELLOW}$APP_SECRET${NC}" +echo "" +echo -e "${WHITE}APP_SECRET_CODE=${NC}${YELLOW}$APP_SECRET_CODE${NC}" +echo "" +echo -e "${WHITE}ENCRYPTION_KEY=${NC}${YELLOW}$ENCRYPTION_KEY${NC}" +echo "" + +# Check if .env file exists +ENV_FILE=".env" +if [ -f "$ENV_FILE" ]; then + echo -e "${RED}Warning: .env file already exists!${NC}" + read -p "Do you want to update it with new secrets? (y/N): " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + UPDATE_FILE=true + else + UPDATE_FILE=false + echo -e "${YELLOW}Secrets generated but not written to file.${NC}" + fi +else + read -p "Create .env file with these secrets? (Y/n): " -n 1 -r + echo + if [[ $REPLY =~ ^[Nn]$ ]]; then + UPDATE_FILE=false + echo -e "${YELLOW}Secrets generated but not written to file.${NC}" + else + UPDATE_FILE=true + fi +fi + +if [ "$UPDATE_FILE" = true ]; then + # Create or update .env file + if [ -f "$ENV_FILE" ]; then + # Backup existing file + BACKUP_FILE=".env.backup.$(date +%Y%m%d-%H%M%S)" + cp "$ENV_FILE" "$BACKUP_FILE" + echo -e "${GREEN}Backed up existing .env to $BACKUP_FILE${NC}" + + # Update secrets in existing file + sed -i.tmp "s/^JWT_SECRET=.*/JWT_SECRET=$JWT_SECRET/" "$ENV_FILE" + sed -i.tmp "s/^APP_SECRET=.*/APP_SECRET=$APP_SECRET/" "$ENV_FILE" + sed -i.tmp "s/^APP_SECRET_CODE=.*/APP_SECRET_CODE=$APP_SECRET_CODE/" "$ENV_FILE" + sed -i.tmp "s/^ENCRYPTION_KEY=.*/ENCRYPTION_KEY=$ENCRYPTION_KEY/" "$ENV_FILE" + rm -f "$ENV_FILE.tmp" + + echo -e "${GREEN}Updated .env file with new secrets${NC}" + else + # Create new .env file from template + if [ -f ".env.example" ]; then + cp ".env.example" "$ENV_FILE" + + # Replace placeholder values with generated secrets + sed -i.tmp "s/^JWT_SECRET=.*/JWT_SECRET=$JWT_SECRET/" "$ENV_FILE" + sed -i.tmp "s/^APP_SECRET=.*/APP_SECRET=$APP_SECRET/" "$ENV_FILE" + sed -i.tmp "s/^APP_SECRET_CODE=.*/APP_SECRET_CODE=$APP_SECRET_CODE/" "$ENV_FILE" + sed -i.tmp "s/^ENCRYPTION_KEY=.*/ENCRYPTION_KEY=$ENCRYPTION_KEY/" "$ENV_FILE" + rm -f "$ENV_FILE.tmp" + + echo -e "${GREEN}Created .env file from template with generated secrets${NC}" + else + # Create minimal .env file + cat > "$ENV_FILE" << EOF +# Bootstrap App Environment Configuration +# Generated on $(date '+%Y-%m-%d %H:%M:%S') + +# CRITICAL SECURITY SETTINGS (REQUIRED) +JWT_SECRET=$JWT_SECRET +APP_SECRET=$APP_SECRET +APP_SECRET_CODE=$APP_SECRET_CODE +ENCRYPTION_KEY=$ENCRYPTION_KEY + +# CORE APPLICATION SETTINGS +DB_NAME=app.db +PORT=3000 +CORS_ALLOWED_ORIGIN=http://localhost:5173 +DEFAULT_ADMIN_PASSWORD=change-this-password +EOF + echo -e "${GREEN}Created minimal .env file with generated secrets${NC}" + fi + fi +fi + +echo "" +echo -e "${RED}Security Notes:${NC}" +echo -e "${RED}===============${NC}" +echo -e "${YELLOW}1. Keep these secrets secure and never commit them to version control${NC}" +echo -e "${YELLOW}2. Use different secrets for each environment (dev, staging, production)${NC}" +echo -e "${YELLOW}3. Rotate secrets regularly in production environments${NC}" +echo -e "${YELLOW}4. The ENCRYPTION_KEY is exactly 32 characters as required for AES-256${NC}" +echo -e "${YELLOW}5. Change the DEFAULT_ADMIN_PASSWORD immediately after first login${NC}" +echo "" + +# Verify encryption key length +if [ ${#ENCRYPTION_KEY} -eq 32 ]; then + echo -e "${GREEN}βœ“ Encryption key length verified (32 characters = 32 bytes for AES-256)${NC}" +else + echo -e "${RED}βœ— Warning: Encryption key length is incorrect! Got ${#ENCRYPTION_KEY} chars, expected 32${NC}" +fi + +echo "" +echo -e "${CYAN}Next steps:${NC}" +echo -e "${WHITE}1. Review and customize the .env file if needed${NC}" +echo -e "${WHITE}2. Build and run the application: go run cmd/api/main.go${NC}" +echo -e "${WHITE}3. Change the default admin password on first login${NC}" +echo -e "${WHITE}4. Access the API documentation at: http://localhost:3000/swagger/${NC}" +echo "" +echo -e "${GREEN}Happy coding! πŸš€${NC}"