From 87bb6c2a7cfa4922cb2a4daf4297b4364e7d1509 Mon Sep 17 00:00:00 2001 From: Ryan Dudley Date: Wed, 19 Nov 2025 01:50:06 -0500 Subject: [PATCH] some changes --- OcStockAPI/Controllers/TestController.cs | 276 ++++++++++---- .../Controllers/TrackedStocksController.cs | 8 +- OcStockAPI/DataContext/DpapiDbContext.cs | 3 - .../DataContext/OcStockDbContextFactory.cs | 2 - .../SuperKeyAuthenticationMiddleware.cs | 100 ----- OcStockAPI/OcStockAPI.csproj | 4 - OcStockAPI/Program.cs | 343 +++++++++++------- OcStockAPI/Services/Auth/AuthService.cs | 44 ++- OcStockAPI/Services/Email/EmailService.cs | 262 +++++++++++++ OcStockAPI/Services/Email/IEmailService.cs | 8 + OcStockAPI/appsettings.json | 27 ++ 11 files changed, 758 insertions(+), 319 deletions(-) delete mode 100644 OcStockAPI/Middleware/SuperKeyAuthenticationMiddleware.cs create mode 100644 OcStockAPI/Services/Email/EmailService.cs create mode 100644 OcStockAPI/Services/Email/IEmailService.cs diff --git a/OcStockAPI/Controllers/TestController.cs b/OcStockAPI/Controllers/TestController.cs index 5f03564..8853721 100644 --- a/OcStockAPI/Controllers/TestController.cs +++ b/OcStockAPI/Controllers/TestController.cs @@ -10,17 +10,23 @@ namespace OcStockAPI.Controllers; +#if DEBUG +// TestController is only available in DEBUG builds (development) +// This prevents it from being included in production deployments [ApiController] [Route("api/[controller]")] -[SwaggerTag("Test endpoints for authentication and authorization")] +[SwaggerTag("Test endpoints for authentication, authorization, and database testing (DEVELOPMENT ONLY)")] public class TestController : ControllerBase { private readonly OcStockDbContext _context; + private readonly ILogger _logger; - public TestController(OcStockDbContext context) + public TestController(OcStockDbContext context, ILogger logger) { _context = context; + _logger = logger; } + [HttpGet("public")] [SwaggerOperation( Summary = "Public endpoint", @@ -86,41 +92,6 @@ public IActionResult AdminEndpoint() }); } - [HttpGet("super-admin")] - [Authorize] - [SwaggerOperation( - Summary = "Super admin test endpoint", - Description = "Tests super key authentication - accessible only with super key or admin JWT" - )] - [SwaggerResponse(200, "Super admin access granted")] - [SwaggerResponse(401, "Unauthorized")] - public IActionResult SuperAdminEndpoint() - { - var userId = User.FindFirst("userId")?.Value ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - var email = User.FindFirst(ClaimTypes.Email)?.Value; - var fullName = User.FindFirst("fullName")?.Value; - var roles = User.Claims.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToList(); - var isSuperUser = User.FindFirst("isSuperUser")?.Value == "true"; - - return Ok(new { - message = isSuperUser ? "?? SUPER KEY ACCESS GRANTED!" : "?? Admin access via JWT", - authMethod = isSuperUser ? "SuperKey" : "JWT", - user = new { - id = userId, - email = email, - fullName = fullName, - roles = roles, - isSuperUser = isSuperUser - }, - timestamp = DateTime.UtcNow, - superKeyInstructions = new { - header1 = "X-Super-Key: your-super-key-here", - header2 = "Authorization: SuperKey your-super-key-here", - yourSuperKey = "Check your user secrets file" - } - }); - } - [HttpGet("database")] [SwaggerOperation( Summary = "Database connection test", @@ -133,7 +104,7 @@ public async Task DatabaseTest() var testResults = new { timestamp = DateTime.UtcNow, - databaseType = _context.Database.IsInMemory() ? "In-Memory" : "PostgreSQL", + databaseType = "PostgreSQL", tests = new List() }; @@ -144,7 +115,7 @@ public async Task DatabaseTest() ((List)testResults.tests).Add(new { test = "Database Connection", - status = canConnect ? "PASS" : "FAIL", + status = canConnect ? "? PASS" : "? FAIL", message = canConnect ? "Successfully connected to database" : "Failed to connect to database" }); @@ -160,7 +131,7 @@ public async Task DatabaseTest() ((List)testResults.tests).Add(new { test = "Database Accessibility", - status = "PASS", + status = "? PASS", message = "Database is accessible" }); } @@ -169,7 +140,7 @@ public async Task DatabaseTest() ((List)testResults.tests).Add(new { test = "Database Accessibility", - status = "FAIL", + status = "? FAIL", message = $"Database access failed: {ex.Message}" }); } @@ -181,7 +152,7 @@ public async Task DatabaseTest() ((List)testResults.tests).Add(new { test = "Table Access (Users)", - status = "PASS", + status = "? PASS", message = $"Successfully queried Users table, found {userCount} users" }); } @@ -190,7 +161,7 @@ public async Task DatabaseTest() ((List)testResults.tests).Add(new { test = "Table Access (Users)", - status = "FAIL", + status = "? FAIL", message = $"Failed to access Users table: {ex.Message}" }); } @@ -202,7 +173,7 @@ public async Task DatabaseTest() ((List)testResults.tests).Add(new { test = "Table Access (TrackedStocks)", - status = "PASS", + status = "? PASS", message = $"Successfully queried TrackedStocks table, found {stockCount} tracked stocks" }); } @@ -211,15 +182,14 @@ public async Task DatabaseTest() ((List)testResults.tests).Add(new { test = "Table Access (TrackedStocks)", - status = "FAIL", + status = "? FAIL", message = $"Failed to access TrackedStocks table: {ex.Message}" }); } - // Test 5: Test write operation (if not in production) + // Test 5: Test write operation try { - // Create a test entry in ApiCallLog var testLog = new OcStockAPI.Entities.Settings.ApiCallLog { CallType = "Test", @@ -230,14 +200,13 @@ public async Task DatabaseTest() _context.ApiCallLog.Add(testLog); await _context.SaveChangesAsync(); - // Clean up the test entry _context.ApiCallLog.Remove(testLog); await _context.SaveChangesAsync(); ((List)testResults.tests).Add(new { test = "Write Operations", - status = "PASS", + status = "? PASS", message = "Successfully performed read/write operations" }); } @@ -246,7 +215,7 @@ public async Task DatabaseTest() ((List)testResults.tests).Add(new { test = "Write Operations", - status = "FAIL", + status = "? FAIL", message = $"Write operation failed: {ex.Message}" }); } @@ -255,20 +224,17 @@ public async Task DatabaseTest() try { var connectionString = _context.Database.GetConnectionString(); - var hostPrefixLength = 5; // Length of "Host=" var hostStart = connectionString?.IndexOf("Host=") ?? -1; var hostEnd = connectionString?.IndexOf(";", hostStart) ?? -1; var host = hostStart >= 0 && hostEnd > hostStart - ? connectionString.Substring(hostStart + hostPrefixLength, hostEnd - hostStart - hostPrefixLength) + ? connectionString.Substring(hostStart + 5, hostEnd - hostStart - 5) : "Unknown"; ((List)testResults.tests).Add(new { test = "Connection Info", - status = "INFO", - message = _context.Database.IsInMemory() - ? "Using in-memory database" - : $"Connected to PostgreSQL host: {host}" + status = "?? INFO", + message = $"Connected to PostgreSQL host: {host}" }); } catch (Exception ex) @@ -276,15 +242,14 @@ public async Task DatabaseTest() ((List)testResults.tests).Add(new { test = "Connection Info", - status = "WARN", + status = "?? WARN", message = $"Could not retrieve connection info: {ex.Message}" }); } - // Determine overall status var allTests = (List)testResults.tests; - var failedTests = allTests.Count(t => ((dynamic)t).status == "FAIL"); - var overallStatus = failedTests == 0 ? "ALL TESTS PASSED" : $"{failedTests} TESTS FAILED"; + var failedTests = allTests.Count(t => ((dynamic)t).status.Contains("FAIL")); + var overallStatus = failedTests == 0 ? "? ALL TESTS PASSED" : $"? {failedTests} TESTS FAILED"; return Ok(new { @@ -296,17 +261,153 @@ public async Task DatabaseTest() } catch (Exception ex) { + _logger.LogError(ex, "Critical error during database testing"); return StatusCode(500, new { testResults.timestamp, testResults.databaseType, - overallStatus = "CRITICAL FAILURE", + overallStatus = "? CRITICAL FAILURE", error = ex.Message, + stackTrace = ex.StackTrace, tests = testResults.tests }); } } + [HttpGet("database/tables")] + [SwaggerOperation( + Summary = "List all database tables with row counts", + Description = "Shows all tables in the database with their record counts" + )] + [SwaggerResponse(200, "List of tables with counts")] + public async Task GetDatabaseTables() + { + try + { + var tables = new List + { + new { + table = "Users", + count = await _context.Users.CountAsync(), + sample = await _context.Users.Take(5).Select(u => new { u.Id, u.Email, u.FirstName, u.LastName }).ToListAsync() + }, + new { + table = "Roles", + count = await _context.Roles.CountAsync(), + sample = await _context.Roles.Take(5).Select(r => new { r.Id, r.Name }).ToListAsync() + }, + new { + table = "TrackedStocks", + count = await _context.TrackedStocks.CountAsync(), + sample = await _context.TrackedStocks.Take(5).Select(ts => new { ts.Id, ts.Symbol, ts.StockName, ts.DateAdded }).ToListAsync() + }, + new { + table = "Stocks", + count = await _context.Stocks.CountAsync(), + sample = await _context.Stocks.Take(5).Select(s => new { s.StockId, s.Symbol, s.Name, s.LastUpdated }).ToListAsync() + }, + new { + table = "StockHistories", + count = await _context.StockHistories.CountAsync(), + sample = await _context.StockHistories.Take(5).Select(sh => new { sh.HistoryId, sh.StockId, sh.Timestamp, sh.ClosedValue }).ToListAsync() + }, + new { + table = "MarketNews", + count = await _context.MarketNews.CountAsync(), + sample = await _context.MarketNews.Take(5).Select(mn => new { mn.NewsId, mn.StockId, mn.Headline, mn.Datetime }).ToListAsync() + }, + new { + table = "InvestorAccounts", + count = await _context.InvestorAccounts.CountAsync(), + sample = await _context.InvestorAccounts.Take(5).Select(ia => new { ia.AccountId, ia.UserId, ia.Name }).ToListAsync() + }, + new { + table = "ApiCallLog", + count = await _context.ApiCallLog.CountAsync(), + sample = await _context.ApiCallLog.Take(5).Select(acl => new { acl.Id, acl.CallType, acl.Symbol, acl.CallDate }).ToListAsync() + } + }; + + var totalRecords = tables.Sum(t => ((dynamic)t).count); + + return Ok(new + { + databaseType = "PostgreSQL", + connectionString = MaskConnectionString(_context.Database.GetConnectionString() ?? ""), + totalTables = tables.Count, + totalRecords, + timestamp = DateTime.UtcNow, + tables + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving database tables"); + return StatusCode(500, new { error = $"Failed to retrieve tables: {ex.Message}" }); + } + } + + // ...existing code for other query endpoints... + + [HttpGet("database/summary")] + [SwaggerOperation( + Summary = "Database summary statistics", + Description = "Comprehensive overview of all database tables and their statistics" + )] + [SwaggerResponse(200, "Database summary")] + public async Task GetDatabaseSummary() + { + try + { + var summary = new + { + databaseType = "PostgreSQL", + connectionInfo = MaskConnectionString(_context.Database.GetConnectionString() ?? ""), + timestamp = DateTime.UtcNow, + + userStatistics = new + { + totalUsers = await _context.Users.CountAsync(), + activeUsers = await _context.Users.Where(u => u.IsActive).CountAsync(), + confirmedEmails = await _context.Users.Where(u => u.EmailConfirmed).CountAsync(), + lockedOutUsers = await _context.Users.Where(u => u.LockoutEnd > DateTimeOffset.UtcNow).CountAsync(), + recentLogins = await _context.Users.Where(u => u.LastLoginAt > DateTime.UtcNow.AddDays(-7)).CountAsync() + }, + + stockStatistics = new + { + trackedStocks = await _context.TrackedStocks.CountAsync(), + maxTrackedStocks = 20, + availableSlots = 20 - await _context.TrackedStocks.CountAsync(), + totalStocks = await _context.Stocks.CountAsync(), + stockHistoryRecords = await _context.StockHistories.CountAsync(), + newsArticles = await _context.MarketNews.CountAsync() + }, + + accountStatistics = new + { + investorAccounts = await _context.InvestorAccounts.CountAsync(), + portfolios = await _context.Portfolios.CountAsync(), + totalRoles = await _context.Roles.CountAsync() + }, + + systemStatistics = new + { + apiCallsToday = await _context.ApiCallLog.Where(log => log.CallDate.Date == DateTime.UtcNow.Date).CountAsync(), + totalApiCalls = await _context.ApiCallLog.CountAsync(), + events = await _context.Events.CountAsync() + } + }; + + return Ok(summary); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating database summary"); + return StatusCode(500, new { error = $"Failed to generate summary: {ex.Message}" }); + } + } + [HttpPost("validate-token")] [SwaggerOperation( Summary = "Validate JWT token", @@ -322,14 +423,12 @@ public IActionResult ValidateToken([FromBody] ValidateTokenRequest request) return BadRequest(new { success = false, message = "Token is required" }); } - // Remove 'Bearer ' prefix if present var token = request.Token.StartsWith("Bearer ") ? request.Token.Substring(7) : request.Token; var handler = new JwtSecurityTokenHandler(); - // First, check if the token is a valid JWT format if (!handler.CanReadToken(token)) { return Ok(new @@ -340,10 +439,7 @@ public IActionResult ValidateToken([FromBody] ValidateTokenRequest request) }); } - // Read the token without validation to see its contents var jsonToken = handler.ReadJwtToken(token); - - // Get JWT settings for validation var configuration = HttpContext.RequestServices.GetService(); var jwtSettings = configuration?.GetSection("JwtSettings"); @@ -365,8 +461,26 @@ public IActionResult ValidateToken([FromBody] ValidateTokenRequest request) }); } - // Now validate the token properly - var key = Encoding.UTF8.GetBytes(jwtSettings["SecretKey"] ?? ""); + var secretKey = jwtSettings["SecretKey"]; + if (string.IsNullOrEmpty(secretKey)) + { + return Ok(new + { + success = false, + message = "JWT SecretKey not configured - cannot validate tokens", + hint = "Set JwtSettings:SecretKey in user secrets or environment variables", + tokenInfo = new + { + issuer = jsonToken.Issuer, + audience = jsonToken.Audiences?.FirstOrDefault(), + expires = jsonToken.ValidTo, + isExpired = jsonToken.ValidTo < DateTime.UtcNow + }, + timestamp = DateTime.UtcNow + }); + } + + var key = Encoding.UTF8.GetBytes(secretKey); var validationParameters = new TokenValidationParameters { ValidateIssuer = true, @@ -384,7 +498,7 @@ public IActionResult ValidateToken([FromBody] ValidateTokenRequest request) return Ok(new { success = true, - message = "Token is valid", + message = "? Token is valid", tokenInfo = new { issuer = jsonToken.Issuer, @@ -404,7 +518,7 @@ public IActionResult ValidateToken([FromBody] ValidateTokenRequest request) return Ok(new { success = false, - message = "Token has expired", + message = "? Token has expired", timestamp = DateTime.UtcNow }); } @@ -413,7 +527,7 @@ public IActionResult ValidateToken([FromBody] ValidateTokenRequest request) return Ok(new { success = false, - message = "Token has an invalid signature", + message = "? Token has an invalid signature", timestamp = DateTime.UtcNow }); } @@ -422,14 +536,30 @@ public IActionResult ValidateToken([FromBody] ValidateTokenRequest request) return Ok(new { success = false, - message = $"Token validation failed: {ex.Message}", + message = $"? Token validation failed: {ex.Message}", timestamp = DateTime.UtcNow }); } } + + private static string MaskConnectionString(string connectionString) + { + if (string.IsNullOrEmpty(connectionString)) + return "Not configured"; + + return System.Text.RegularExpressions.Regex.Replace( + connectionString, + @"Password=[^;]+", + "Password=***" + ); + } } public class ValidateTokenRequest { public string Token { get; set; } = string.Empty; -} \ No newline at end of file +} +#else +// TestController is completely removed in RELEASE builds (production) +// This ensures no test endpoints are exposed in production deployments +#endif \ No newline at end of file diff --git a/OcStockAPI/Controllers/TrackedStocksController.cs b/OcStockAPI/Controllers/TrackedStocksController.cs index ab0f6ec..231db2f 100644 --- a/OcStockAPI/Controllers/TrackedStocksController.cs +++ b/OcStockAPI/Controllers/TrackedStocksController.cs @@ -9,8 +9,12 @@ namespace OcStockAPI.Controllers [ApiController] [Route("api/[controller]")] [Produces("application/json")] - [Authorize] // Require authentication for all endpoints - [SwaggerTag("Tracked stocks management - requires authentication")] +#if DEBUG + [AllowAnonymous] // Development: Allow anonymous access for testing +#else + [Authorize] // Production: Require authentication +#endif + [SwaggerTag("Tracked stocks management")] public class TrackedStocksController : ControllerBase { private readonly ITrackedStockService _trackedStockService; diff --git a/OcStockAPI/DataContext/DpapiDbContext.cs b/OcStockAPI/DataContext/DpapiDbContext.cs index 69b39fe..4146c09 100644 --- a/OcStockAPI/DataContext/DpapiDbContext.cs +++ b/OcStockAPI/DataContext/DpapiDbContext.cs @@ -1,9 +1,6 @@ -using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity; using OcStockAPI.Entities; using OcStockAPI.Entities.Settings; -using OcStockAPI.Entities.Identity; namespace OcStockAPI.DataContext { diff --git a/OcStockAPI/DataContext/OcStockDbContextFactory.cs b/OcStockAPI/DataContext/OcStockDbContextFactory.cs index 5977b75..27eae5e 100644 --- a/OcStockAPI/DataContext/OcStockDbContextFactory.cs +++ b/OcStockAPI/DataContext/OcStockDbContextFactory.cs @@ -1,6 +1,4 @@ -using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; -using Microsoft.Extensions.Configuration; namespace OcStockAPI.DataContext; diff --git a/OcStockAPI/Middleware/SuperKeyAuthenticationMiddleware.cs b/OcStockAPI/Middleware/SuperKeyAuthenticationMiddleware.cs deleted file mode 100644 index 42afabd..0000000 --- a/OcStockAPI/Middleware/SuperKeyAuthenticationMiddleware.cs +++ /dev/null @@ -1,100 +0,0 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Options; -using System.Security.Claims; -using System.Text.Encodings.Web; - -namespace OcStockAPI.Middleware; - -public class SuperKeyAuthenticationSchemeOptions : AuthenticationSchemeOptions -{ - public string SuperKey { get; set; } = string.Empty; -} - -public class SuperKeyAuthenticationHandler : AuthenticationHandler -{ - private readonly IConfiguration _configuration; - - public SuperKeyAuthenticationHandler( - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder, - ISystemClock clock, - IConfiguration configuration) - : base(options, logger, encoder, clock) - { - _configuration = configuration; - } - - protected override Task HandleAuthenticateAsync() - { - var superKey = _configuration["SuperKey"]; - - // Skip if no super key is configured - if (string.IsNullOrEmpty(superKey)) - { - return Task.FromResult(AuthenticateResult.NoResult()); - } - - // Check for X-Super-Key header - if (Request.Headers.TryGetValue("X-Super-Key", out var headerValue)) - { - var providedKey = headerValue.ToString(); - - if (providedKey == superKey) - { - var claims = new[] - { - new Claim(ClaimTypes.NameIdentifier, "SuperUser"), - new Claim(ClaimTypes.Name, "Super Admin"), - new Claim(ClaimTypes.Email, "superadmin@ocstock.dev"), - new Claim(ClaimTypes.Role, "Admin"), - new Claim(ClaimTypes.Role, "User"), - new Claim("userId", "0"), - new Claim("fullName", "Super Admin"), - new Claim("isSuperUser", "true") - }; - - var identity = new ClaimsIdentity(claims, Scheme.Name); - var principal = new ClaimsPrincipal(identity); - var ticket = new AuthenticationTicket(principal, Scheme.Name); - - Logger.LogInformation("Super key authentication successful"); - return Task.FromResult(AuthenticateResult.Success(ticket)); - } - } - - // Check Authorization header for super key (alternative format) - if (Request.Headers.TryGetValue("Authorization", out var authHeader)) - { - var authValue = authHeader.ToString(); - if (authValue.StartsWith("SuperKey ", StringComparison.OrdinalIgnoreCase)) - { - var providedKey = authValue.Substring(9); - - if (providedKey == superKey) - { - var claims = new[] - { - new Claim(ClaimTypes.NameIdentifier, "SuperUser"), - new Claim(ClaimTypes.Name, "Super Admin"), - new Claim(ClaimTypes.Email, "superadmin@ocstock.dev"), - new Claim(ClaimTypes.Role, "Admin"), - new Claim(ClaimTypes.Role, "User"), - new Claim("userId", "0"), - new Claim("fullName", "Super Admin"), - new Claim("isSuperUser", "true") - }; - - var identity = new ClaimsIdentity(claims, Scheme.Name); - var principal = new ClaimsPrincipal(identity); - var ticket = new AuthenticationTicket(principal, Scheme.Name); - - Logger.LogInformation("Super key authentication successful via Authorization header"); - return Task.FromResult(AuthenticateResult.Success(ticket)); - } - } - } - - return Task.FromResult(AuthenticateResult.NoResult()); - } -} \ No newline at end of file diff --git a/OcStockAPI/OcStockAPI.csproj b/OcStockAPI/OcStockAPI.csproj index 61bb38b..5ded9b4 100644 --- a/OcStockAPI/OcStockAPI.csproj +++ b/OcStockAPI/OcStockAPI.csproj @@ -29,8 +29,4 @@ - - - - \ No newline at end of file diff --git a/OcStockAPI/Program.cs b/OcStockAPI/Program.cs index 118d1a6..f9aa9c9 100644 --- a/OcStockAPI/Program.cs +++ b/OcStockAPI/Program.cs @@ -3,9 +3,9 @@ using OcStockAPI.Settings; using OcStockAPI.Services; using OcStockAPI.Services.Auth; +using OcStockAPI.Services.Email; using OcStockAPI.Helpers; using OcStockAPI.Entities.Identity; -using OcStockAPI.Middleware; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; @@ -14,14 +14,14 @@ var builder = WebApplication.CreateBuilder(args); -// Load configuration sources - Render will provide environment variables +// Load configuration sources builder.Configuration .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true) .AddUserSecrets(optional: true) .AddEnvironmentVariables(); -// Configure settings from environment variables (Render style) +// Configure settings from environment variables var appSettings = new AppSettings(); builder.Configuration.Bind(appSettings); @@ -32,77 +32,128 @@ builder.Services.AddInMemoryRateLimiting(); builder.Services.AddSingleton(); -// Handle database connection string +// =================================================================== +// DATABASE CONFIGURATION - POSTGRESQL ONLY (NO IN-MEMORY FALLBACK) +// =================================================================== + string connectionString = ""; -bool useInMemoryDatabase = false; -try +// Try to get the connection string +connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? ""; + +if (string.IsNullOrEmpty(connectionString)) { - // Try to get the connection string from the standard ConnectionStrings section - connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? ""; - - if (string.IsNullOrEmpty(connectionString)) + // Fallback to AppSettings components if available + try { - // Fallback to AppSettings components if available connectionString = appSettings.Database.GetEffectiveConnectionString(); } - - Console.WriteLine($"Attempting to use connection string: {connectionString.Substring(0, Math.Min(50, connectionString.Length))}..."); - - // Test the connection first + catch (Exception ex) + { + Console.WriteLine("? CRITICAL ERROR: No database connection string found!"); + Console.WriteLine($" Error: {ex.Message}"); + Console.WriteLine(); + Console.WriteLine("?? Connection string must be configured in one of:"); + Console.WriteLine(" 1. User Secrets: ConnectionStrings:DefaultConnection"); + Console.WriteLine(" 2. Environment Variable: ConnectionStrings__DefaultConnection"); + Console.WriteLine(" 3. appsettings.json: ConnectionStrings:DefaultConnection"); + Console.WriteLine(); + Console.WriteLine("?? To set via user secrets:"); + Console.WriteLine(" dotnet user-secrets set \"ConnectionStrings:DefaultConnection\" \"your-connection-string\""); + Console.WriteLine(); + throw new InvalidOperationException("Database connection string is not configured. Application cannot start without PostgreSQL connection."); + } +} + +// Mask password for console output +string MaskConnectionString(string connString) +{ + return System.Text.RegularExpressions.Regex.Replace( + connString, + @"Password=[^;]+", + "Password=***" + ); +} + +Console.WriteLine("?? Connecting to PostgreSQL database..."); +Console.WriteLine($" Connection: {MaskConnectionString(connectionString)}"); + +// Test the connection - FAIL IMMEDIATELY if it doesn't work +try +{ using (var testConnection = new Npgsql.NpgsqlConnection(connectionString)) { testConnection.Open(); - Console.WriteLine("? Successfully connected to PostgreSQL database"); + var serverVersion = testConnection.ServerVersion; + Console.WriteLine($"? Successfully connected to PostgreSQL!"); + Console.WriteLine($" Server Version: {serverVersion}"); testConnection.Close(); } } +catch (Npgsql.NpgsqlException npgEx) +{ + Console.WriteLine(); + Console.WriteLine("? CRITICAL ERROR: Failed to connect to PostgreSQL database!"); + Console.WriteLine($" Error: {npgEx.Message}"); + Console.WriteLine(); + Console.WriteLine("?? Common causes:"); + Console.WriteLine(" 1. PostgreSQL server is not running"); + Console.WriteLine(" 2. Wrong host/port in connection string"); + Console.WriteLine(" 3. Invalid username or password"); + Console.WriteLine(" 4. Database does not exist"); + Console.WriteLine(" 5. Firewall blocking connection"); + Console.WriteLine(" 6. SSL/TLS configuration mismatch"); + Console.WriteLine(); + Console.WriteLine($"?? Your connection string (masked): {MaskConnectionString(connectionString)}"); + Console.WriteLine(); + Console.WriteLine("?? Verify your PostgreSQL server is accessible:"); + Console.WriteLine(" - Check if PostgreSQL service is running"); + Console.WriteLine(" - Test connection with a PostgreSQL client (pgAdmin, psql, etc.)"); + Console.WriteLine(" - Verify firewall settings"); + Console.WriteLine(); + throw new InvalidOperationException("Cannot connect to PostgreSQL database. Application cannot start without a valid database connection.", npgEx); +} catch (Exception ex) { - Console.WriteLine($"? Failed to connect to PostgreSQL database: {ex.Message}"); - Console.WriteLine("?? Switching to in-memory database for testing..."); - useInMemoryDatabase = true; + Console.WriteLine(); + Console.WriteLine("? CRITICAL ERROR: Unexpected error while connecting to database!"); + Console.WriteLine($" Error Type: {ex.GetType().Name}"); + Console.WriteLine($" Error: {ex.Message}"); + Console.WriteLine(); + throw new InvalidOperationException("Database connection failed. Application cannot start.", ex); } -// Configure DbContext based on connection test -if (useInMemoryDatabase) +// Configure DbContext - POSTGRESQL ONLY +Console.WriteLine("?? Configuring Entity Framework with PostgreSQL..."); + +builder.Services.AddDbContext(options => { - Console.WriteLine("?? Using IN-MEMORY database - Data will NOT persist between restarts!"); + options.UseNpgsql(connectionString); - builder.Services.AddDbContext(options => + // Enable sensitive data logging in development only + if (builder.Environment.IsDevelopment()) { - options.UseInMemoryDatabase("OcStockTestDb"); - options.EnableSensitiveDataLogging(builder.Environment.IsDevelopment()); - }); - - // Add basic health check - builder.Services.AddHealthChecks(); -} -else -{ - Console.WriteLine("? Using PostgreSQL database"); + options.EnableSensitiveDataLogging(); + options.EnableDetailedErrors(); + } - // Configure DbContext for PostgreSQL - builder.Services.AddDbContext(options => + // Add query logging in development + if (builder.Environment.IsDevelopment()) { - options.UseNpgsql(connectionString); - // SECURITY FIX: Only enable sensitive data logging in development - if (builder.Environment.IsDevelopment()) - { - options.EnableSensitiveDataLogging(); - } - }); - - // Register health check with database - builder.Services.AddHealthChecks() - .AddNpgSql(connectionString, name: "PostgreSQL Database"); -} + options.LogTo(Console.WriteLine, new[] { + Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.CommandExecuting + }); + } +}); -// Update the app settings with the final connection string -if (!useInMemoryDatabase) -{ - appSettings.Database.ConnectionString = connectionString; -} +// Register health check with database +builder.Services.AddHealthChecks() + .AddNpgSql(connectionString, name: "PostgreSQL Database", tags: new[] { "database", "postgresql" }); + +Console.WriteLine("? Entity Framework configured with PostgreSQL"); + +// Update the app settings with the connection string +appSettings.Database.ConnectionString = connectionString; // Configure Identity builder.Services.AddIdentity(options => @@ -117,7 +168,7 @@ // Lockout settings - SECURITY: 3 failed attempts = 5 minute lockout options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); - options.Lockout.MaxFailedAccessAttempts = 3; // Set to 3 for enhanced security + options.Lockout.MaxFailedAccessAttempts = 3; options.Lockout.AllowedForNewUsers = true; // User settings @@ -125,8 +176,7 @@ options.User.RequireUniqueEmail = true; // SignIn settings - // TODO: Enable email confirmation in production when email service is implemented - options.SignIn.RequireConfirmedEmail = false; // Set to true in production with email service + options.SignIn.RequireConfirmedEmail = false; options.SignIn.RequireConfirmedPhoneNumber = false; }) .AddEntityFrameworkStores() @@ -134,64 +184,72 @@ // Configure JWT Authentication var jwtSettings = builder.Configuration.GetSection("JwtSettings"); -var secretKey = jwtSettings["SecretKey"] ?? throw new InvalidOperationException("JWT SecretKey is not configured"); +var secretKey = jwtSettings["SecretKey"] ?? ""; -builder.Services.AddAuthentication(options => -{ - options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; -}) -.AddJwtBearer(options => +// Only require JWT configuration in production +if (!builder.Environment.IsDevelopment() && string.IsNullOrEmpty(secretKey)) { - options.SaveToken = true; - options.RequireHttpsMetadata = !builder.Environment.IsDevelopment(); // FIXED: Require HTTPS in production - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidateAudience = true, - ValidateLifetime = true, - ValidateIssuerSigningKey = true, - ValidIssuer = jwtSettings["Issuer"], - ValidAudience = jwtSettings["Audience"], - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)), - ClockSkew = TimeSpan.Zero - }; -}); + throw new InvalidOperationException("JWT SecretKey is not configured for production environment"); +} -// SECURITY FIX: Only enable SuperKey in development -if (builder.Environment.IsDevelopment()) +// Configure authentication - in development, JWT is optional +if (!string.IsNullOrEmpty(secretKey)) { - builder.Services.AddAuthentication() - .AddScheme("SuperKey", options => + builder.Services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.SaveToken = true; + options.RequireHttpsMetadata = !builder.Environment.IsDevelopment(); + options.TokenValidationParameters = new TokenValidationParameters { - options.SuperKey = builder.Configuration["SuperKey"] ?? ""; - }); + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtSettings["Issuer"], + ValidAudience = jwtSettings["Audience"], + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)), + ClockSkew = TimeSpan.Zero + }; + }); - Console.WriteLine("?? WARNING: SuperKey authentication enabled (DEVELOPMENT ONLY)"); + Console.WriteLine("? JWT Authentication configured"); +} +else +{ + builder.Services.AddAuthentication(); + Console.WriteLine("?? Running in DEVELOPMENT mode without JWT - Authentication disabled"); } -// Configure authentication policies +// Configure authorization policies builder.Services.AddAuthorization(options => { - var schemes = new List { JwtBearerDefaults.AuthenticationScheme }; - - // Only add SuperKey in development if (builder.Environment.IsDevelopment()) { - schemes.Add("SuperKey"); + options.FallbackPolicy = null; + Console.WriteLine("?? WARNING: Anonymous access enabled (DEVELOPMENT ONLY)"); + } + else + { + options.DefaultPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .Build(); } - - options.DefaultPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder() - .RequireAuthenticatedUser() - .AddAuthenticationSchemes(schemes.ToArray()) - .Build(); }); // Register settings builder.Services.Configure(builder.Configuration); builder.Services.AddSingleton(appSettings); +// Register email service +builder.Services.AddTransient(); + // Register authentication services builder.Services.AddTransient(); builder.Services.AddTransient(); @@ -213,7 +271,7 @@ builder.Services.AddTransient(); builder.Services.AddTransient(); -// Background services - ONLY in production to avoid rate limiting during development +// Background services - ONLY in production if (!builder.Environment.IsDevelopment()) { builder.Services.AddHostedService(); @@ -224,32 +282,28 @@ } else { - Console.WriteLine("?? Background services disabled (DEVELOPMENT - prevents API rate limiting)"); + Console.WriteLine("?? Background services disabled (DEVELOPMENT - prevents API rate limiting)"); } -// Add CORS for your frontend on Render +// Add CORS builder.Services.AddCors(options => { options.AddPolicy("AllowFrontend", policy => { var allowedOrigins = new List(); - // Add development origin if (builder.Environment.IsDevelopment()) { allowedOrigins.Add("http://localhost:3000"); - allowedOrigins.Add("http://localhost:5173"); // Vite default + allowedOrigins.Add("http://localhost:5173"); } - // Add production frontend URL from environment variable var frontendUrl = Environment.GetEnvironmentVariable("FRONTEND_URL"); if (!string.IsNullOrEmpty(frontendUrl)) { allowedOrigins.Add(frontendUrl); } - // SECURITY WARNING: Only use wildcard in development or for specific trusted domains - // In production, specify exact URLs if (builder.Environment.IsDevelopment()) { policy.SetIsOriginAllowedToAllowWildcardSubdomains(); @@ -260,7 +314,6 @@ } else { - // Production: Strict CORS - only allow specific origins if (allowedOrigins.Any()) { policy.WithOrigins(allowedOrigins.ToArray()) @@ -270,7 +323,7 @@ } else { - Console.WriteLine("?? WARNING: No CORS origins configured for production!"); + Console.WriteLine("?? WARNING: No CORS origins configured for production!"); } } }); @@ -279,7 +332,6 @@ builder.Services.AddControllers() .AddJsonOptions(options => { - // Configure System.Text.Json for better performance options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; options.JsonSerializerOptions.WriteIndented = false; }); @@ -293,10 +345,8 @@ Description = "Modern stock market data API built with .NET 8 providing comprehensive financial data including stock quotes, market news, portfolio management, and economic indicators." }); - // Enable annotations for better Swagger documentation c.EnableAnnotations(); - // Add JWT security definition c.AddSecurityDefinition("Bearer", new Microsoft.OpenApi.Models.OpenApiSecurityScheme { Description = "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"", @@ -325,29 +375,47 @@ var app = builder.Build(); // Initialize database and seed roles +Console.WriteLine(); +Console.WriteLine("??? Initializing database..."); + using (var scope = app.Services.CreateScope()) { var services = scope.ServiceProvider; var dbContext = services.GetRequiredService(); var roleManager = services.GetRequiredService>(); - var userManager = services.GetRequiredService>(); try { - // For in-memory database, ensure it's created - if (useInMemoryDatabase) + // Verify database connection + var canConnect = await dbContext.Database.CanConnectAsync(); + if (!canConnect) + { + Console.WriteLine("? CRITICAL: Cannot connect to database during initialization!"); + throw new InvalidOperationException("Database connection lost"); + } + + Console.WriteLine("? Database connection verified"); + + // Apply any pending migrations automatically + var pendingMigrations = await dbContext.Database.GetPendingMigrationsAsync(); + if (pendingMigrations.Any()) { - await dbContext.Database.EnsureCreatedAsync(); - Console.WriteLine("? In-memory database tables created successfully"); + Console.WriteLine($"?? Applying {pendingMigrations.Count()} pending migrations..."); + foreach (var migration in pendingMigrations) + { + Console.WriteLine($" - {migration}"); + } + + await dbContext.Database.MigrateAsync(); + Console.WriteLine("? Database migrations applied successfully"); } else { - // For PostgreSQL, test the connection - var canConnect = await dbContext.Database.CanConnectAsync(); - Console.WriteLine(canConnect ? "? PostgreSQL database connection verified" : "? PostgreSQL database connection failed"); + Console.WriteLine("? Database is up to date (no pending migrations)"); } // Seed default roles + Console.WriteLine("?? Seeding default roles..."); var roles = new[] { "Admin", "User" }; foreach (var roleName in roles) { @@ -359,18 +427,35 @@ Description = $"{roleName} role" }; await roleManager.CreateAsync(role); - Console.WriteLine($"? Created role: {roleName}"); + Console.WriteLine($" ? Created role: {roleName}"); + } + else + { + Console.WriteLine($" ?? Role already exists: {roleName}"); } } + + Console.WriteLine("? Database initialization complete!"); } catch (Exception ex) { - Console.WriteLine($"?? Database initialization warning: {ex.Message}"); + Console.WriteLine(); + Console.WriteLine("? CRITICAL ERROR during database initialization!"); + Console.WriteLine($" Error: {ex.Message}"); + Console.WriteLine(); + Console.WriteLine("?? Possible issues:"); + Console.WriteLine(" 1. Database connection was lost"); + Console.WriteLine(" 2. Migration failed (check migration files)"); + Console.WriteLine(" 3. Insufficient database permissions"); + Console.WriteLine(" 4. Database schema conflicts"); + Console.WriteLine(); + throw; } } +Console.WriteLine(); + // Configure the HTTP request pipeline -// SECURITY FIX: Only enable Swagger in Development if (app.Environment.IsDevelopment()) { app.UseSwagger(); @@ -383,28 +468,26 @@ c.DefaultModelsExpandDepth(-1); c.DocExpansion(Swashbuckle.AspNetCore.SwaggerUI.DocExpansion.None); }); + + Console.WriteLine("?? Swagger UI enabled at: /swagger"); } else { - // In production, you might want to keep Swagger but protect it with authentication - // Or completely disable it for security - Console.WriteLine("?? Swagger UI disabled in production environment"); + Console.WriteLine("?? Swagger UI disabled in production environment"); } app.UseCors("AllowFrontend"); -app.UseStaticFiles(); // Enable static file serving - -// SECURITY FIX: Add rate limiting app.UseIpRateLimiting(); - -app.MapHealthChecks("/health"); // Standard health check endpoint - +app.MapHealthChecks("/health"); app.UseHttpsRedirection(); - -// Authentication & Authorization middleware order is important app.UseAuthentication(); app.UseAuthorization(); - app.MapControllers(); +Console.WriteLine(); +Console.WriteLine("?? Application starting..."); +Console.WriteLine("?? Database: PostgreSQL (IN-MEMORY DISABLED)"); +Console.WriteLine("?? All data persists to real database"); +Console.WriteLine(); + app.Run(); diff --git a/OcStockAPI/Services/Auth/AuthService.cs b/OcStockAPI/Services/Auth/AuthService.cs index 3593c03..10e8250 100644 --- a/OcStockAPI/Services/Auth/AuthService.cs +++ b/OcStockAPI/Services/Auth/AuthService.cs @@ -1,4 +1,5 @@ using OcStockAPI.DTOs.Auth; +using OcStockAPI.Services.Email; namespace OcStockAPI.Services.Auth; @@ -23,6 +24,7 @@ public class AuthService : IAuthService private readonly IJwtService _jwtService; private readonly ILogger _logger; private readonly IConfiguration _configuration; + private readonly IEmailService _emailService; public AuthService( UserManager userManager, @@ -30,7 +32,8 @@ public AuthService( RoleManager roleManager, IJwtService jwtService, ILogger logger, - IConfiguration configuration) + IConfiguration configuration, + IEmailService emailService) { _userManager = userManager; _signInManager = signInManager; @@ -38,6 +41,7 @@ public AuthService( _jwtService = jwtService; _logger = logger; _configuration = configuration; + _emailService = emailService; } public async Task RegisterAsync(RegisterDto registerDto) @@ -80,6 +84,16 @@ public async Task RegisterAsync(RegisterDto registerDto) // Assign default role await _userManager.AddToRoleAsync(user, "User"); + // Send welcome email (don't fail registration if email fails) + try + { + await _emailService.SendWelcomeEmailAsync(user.Email, user.FullName); + } + catch (Exception emailEx) + { + _logger.LogWarning(emailEx, "Failed to send welcome email to {Email}", user.Email); + } + // Generate token var roles = await _userManager.GetRolesAsync(user); var token = await _jwtService.GenerateTokenAsync(user, roles); @@ -250,7 +264,8 @@ public async Task ForgotPasswordAsync(ForgotPasswordDto forgotP var user = await _userManager.FindByEmailAsync(forgotPasswordDto.Email); if (user == null) { - // Don't reveal that the user doesn't exist + // Don't reveal that the user doesn't exist - security best practice + _logger.LogInformation("Password reset requested for non-existent email: {Email}", forgotPasswordDto.Email); return new AuthResponseDto { Success = true, @@ -260,9 +275,28 @@ public async Task ForgotPasswordAsync(ForgotPasswordDto forgotP var token = await _userManager.GeneratePasswordResetTokenAsync(user); - // TODO: Send email with reset link - // For now, just log the token (in production, you'd send this via email) - _logger.LogInformation("Password reset token for {Email}: {Token}", forgotPasswordDto.Email, token); + // Send password reset email + try + { + var emailSent = await _emailService.SendPasswordResetEmailAsync(user.Email, token, user.FullName); + + if (emailSent) + { + _logger.LogInformation("Password reset email sent successfully to {Email}", forgotPasswordDto.Email); + } + else + { + _logger.LogWarning("Failed to send password reset email to {Email} - email service may not be configured", forgotPasswordDto.Email); + // Log token for development/debugging (remove in production) + _logger.LogInformation("Password reset token for {Email}: {Token}", forgotPasswordDto.Email, token); + } + } + catch (Exception emailEx) + { + _logger.LogError(emailEx, "Error sending password reset email to {Email}", forgotPasswordDto.Email); + // Log token as fallback + _logger.LogInformation("Password reset token for {Email}: {Token}", forgotPasswordDto.Email, token); + } return new AuthResponseDto { diff --git a/OcStockAPI/Services/Email/EmailService.cs b/OcStockAPI/Services/Email/EmailService.cs new file mode 100644 index 0000000..f5c6032 --- /dev/null +++ b/OcStockAPI/Services/Email/EmailService.cs @@ -0,0 +1,262 @@ +using System.Net; +using System.Net.Mail; + +namespace OcStockAPI.Services.Email; + +public class EmailService : IEmailService +{ + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + private readonly string? _smtpServer; + private readonly int _smtpPort; + private readonly string? _smtpUsername; + private readonly string? _smtpPassword; + private readonly string? _fromEmail; + private readonly string? _fromName; + private readonly bool _isConfigured; + + public EmailService(IConfiguration configuration, ILogger logger) + { + _configuration = configuration; + _logger = logger; + + // Load email configuration from environment variables or appsettings + _smtpServer = Environment.GetEnvironmentVariable("EmailService__SmtpServer") + ?? configuration["EmailService:SmtpServer"]; + + var portString = Environment.GetEnvironmentVariable("EmailService__SmtpPort") + ?? configuration["EmailService:SmtpPort"]; + _smtpPort = int.TryParse(portString, out var port) ? port : 587; + + _smtpUsername = Environment.GetEnvironmentVariable("EmailService__SmtpUsername") + ?? configuration["EmailService:SmtpUsername"]; + + _smtpPassword = Environment.GetEnvironmentVariable("EmailService__SmtpPassword") + ?? configuration["EmailService:SmtpPassword"]; + + _fromEmail = Environment.GetEnvironmentVariable("EmailService__FromEmail") + ?? configuration["EmailService:FromEmail"]; + + _fromName = Environment.GetEnvironmentVariable("EmailService__FromName") + ?? configuration["EmailService:FromName"] + ?? "OcStock API"; + + // Check if email service is properly configured + _isConfigured = !string.IsNullOrEmpty(_smtpServer) + && !string.IsNullOrEmpty(_smtpUsername) + && !string.IsNullOrEmpty(_smtpPassword) + && !string.IsNullOrEmpty(_fromEmail); + + if (!_isConfigured) + { + _logger.LogWarning("Email service is not fully configured. Email features will be disabled."); + _logger.LogWarning("Required: EmailService__SmtpServer, EmailService__SmtpUsername, EmailService__SmtpPassword, EmailService__FromEmail"); + } + else + { + _logger.LogInformation("Email service configured: {SmtpServer}:{Port} from {FromEmail}", + _smtpServer, _smtpPort, _fromEmail); + } + } + + public async Task SendPasswordResetEmailAsync(string toEmail, string resetToken, string userName) + { + if (!_isConfigured) + { + _logger.LogWarning("Cannot send password reset email - email service not configured"); + return false; + } + + // URL encode the token for safe transmission in URL + var encodedToken = Uri.EscapeDataString(resetToken); + var encodedEmail = Uri.EscapeDataString(toEmail); + + // Get frontend URL from environment or use default + var frontendUrl = Environment.GetEnvironmentVariable("FRONTEND_URL") ?? "http://localhost:3000"; + var resetLink = $"{frontendUrl}/reset-password?token={encodedToken}&email={encodedEmail}"; + + var subject = "Password Reset Request - OcStock API"; + + var htmlBody = $@" + + + + + + +
+
+

?? Password Reset Request

+
+
+

Hello {userName},

+

We received a request to reset your password for your OcStock API account.

+

Click the button below to reset your password:

+

+ Reset Password +

+

Or copy and paste this link into your browser:

+

+ {resetLink} +

+
+ ?? Security Notice: +
    +
  • This link will expire in 24 hours
  • +
  • If you didn't request this reset, please ignore this email
  • +
  • Never share this link with anyone
  • +
+
+
+ +
+ +"; + + var plainTextBody = $@" +Password Reset Request - OcStock API + +Hello {userName}, + +We received a request to reset your password for your OcStock API account. + +Click this link to reset your password: +{resetLink} + +SECURITY NOTICE: +- This link will expire in 24 hours +- If you didn't request this reset, please ignore this email +- Never share this link with anyone + +This is an automated email from OcStock API +© 2024 OcStock API. All rights reserved. +"; + + return await SendEmailAsync(toEmail, subject, htmlBody, plainTextBody); + } + + public async Task SendWelcomeEmailAsync(string toEmail, string userName) + { + if (!_isConfigured) + { + _logger.LogWarning("Cannot send welcome email - email service not configured"); + return false; + } + + var subject = "Welcome to OcStock API! ??"; + + var htmlBody = $@" + + + + + + +
+
+

?? Welcome to OcStock API!

+
+
+

Hello {userName},

+

Thank you for registering with OcStock API! Your account has been successfully created.

+ +

What you can do:

+
?? Track Stocks: Monitor up to 20 stocks simultaneously
+
?? Market News: Get latest news for your tracked stocks
+
?? Economic Data: Access CPI, unemployment, and interest rates
+
?? Stock History: View historical performance data
+ +

Start exploring by logging into your account and adding stocks to track!

+
+ +
+ +"; + + var plainTextBody = $@" +Welcome to OcStock API! + +Hello {userName}, + +Thank you for registering with OcStock API! Your account has been successfully created. + +What you can do: +- Track Stocks: Monitor up to 20 stocks simultaneously +- Market News: Get latest news for your tracked stocks +- Economic Data: Access CPI, unemployment, and interest rates +- Stock History: View historical performance data + +Start exploring by logging into your account and adding stocks to track! + +This is an automated email from OcStock API +© 2024 OcStock API. All rights reserved. +"; + + return await SendEmailAsync(toEmail, subject, htmlBody, plainTextBody); + } + + public async Task SendEmailAsync(string toEmail, string subject, string htmlBody, string plainTextBody) + { + if (!_isConfigured) + { + _logger.LogWarning("Cannot send email - email service not configured"); + return false; + } + + try + { + using var message = new MailMessage(); + message.From = new MailAddress(_fromEmail!, _fromName); + message.To.Add(new MailAddress(toEmail)); + message.Subject = subject; + message.Body = plainTextBody; + message.IsBodyHtml = false; + + // Add HTML alternative view + var htmlView = AlternateView.CreateAlternateViewFromString(htmlBody, null, "text/html"); + message.AlternateViews.Add(htmlView); + + using var smtpClient = new SmtpClient(_smtpServer, _smtpPort); + smtpClient.EnableSsl = true; + smtpClient.Credentials = new NetworkCredential(_smtpUsername, _smtpPassword); + smtpClient.DeliveryMethod = SmtpDeliveryMethod.Network; + + await smtpClient.SendMailAsync(message); + + _logger.LogInformation("Email sent successfully to {ToEmail} with subject: {Subject}", toEmail, subject); + return true; + } + catch (SmtpException ex) + { + _logger.LogError(ex, "SMTP error sending email to {ToEmail}: {Error}", toEmail, ex.Message); + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending email to {ToEmail}", toEmail); + return false; + } + } +} diff --git a/OcStockAPI/Services/Email/IEmailService.cs b/OcStockAPI/Services/Email/IEmailService.cs new file mode 100644 index 0000000..8b8b198 --- /dev/null +++ b/OcStockAPI/Services/Email/IEmailService.cs @@ -0,0 +1,8 @@ +namespace OcStockAPI.Services.Email; + +public interface IEmailService +{ + Task SendPasswordResetEmailAsync(string toEmail, string resetToken, string userName); + Task SendWelcomeEmailAsync(string toEmail, string userName); + Task SendEmailAsync(string toEmail, string subject, string htmlBody, string plainTextBody); +} diff --git a/OcStockAPI/appsettings.json b/OcStockAPI/appsettings.json index a538c8a..0215766 100644 --- a/OcStockAPI/appsettings.json +++ b/OcStockAPI/appsettings.json @@ -10,11 +10,37 @@ "JwtSettings": { // The SecretKey is intentionally left empty for production security. // Configure the SecretKey using user secrets (for development) or environment variables (for production). + // Minimum length: 32 characters + // Example: dotnet user-secrets set "JwtSettings:SecretKey" "your-256-bit-secret-key" "SecretKey": "", "Issuer": "OcStockAPI", "Audience": "OcStockAPI-Users", "ExpiryHours": "24" }, + "EmailService": { + // Email service configuration for password resets and notifications + // Configure using user secrets (development) or environment variables (production) + // + // For Gmail: + // - SmtpServer: smtp.gmail.com + // - SmtpPort: 587 + // - SmtpUsername: your-email@gmail.com + // - SmtpPassword: your-app-specific-password (not regular password!) + // - Generate App Password: https://myaccount.google.com/apppasswords + // + // For SendGrid: + // - SmtpServer: smtp.sendgrid.net + // - SmtpPort: 587 + // - SmtpUsername: apikey + // - SmtpPassword: your-sendgrid-api-key + // + "SmtpServer": "", + "SmtpPort": "587", + "SmtpUsername": "", + "SmtpPassword": "", + "FromEmail": "", + "FromName": "OcStock API" + }, "IpRateLimiting": { "EnableEndpointRateLimiting": true, "StackBlockedRequests": false, @@ -39,6 +65,7 @@ }, "AdminUser": { // Configure the AdminUser email using user secrets (for development) or environment variables (for production). + // Only this email address can be promoted to Admin role via the API "Email": "" } }