From b3f266f9a84f75ab8622fb31dbab28d71b0d478d Mon Sep 17 00:00:00 2001 From: David Headrick Date: Sat, 22 Feb 2025 18:45:37 -0600 Subject: [PATCH] Enhance authentication features and refactor codebase Updated `AuthenticationController` with new methods for authentication, token refresh, and password recovery. Improved error handling and response structure. Refactored dependency injection in `Program.cs` and added JWT settings in `appsettings.json`. Removed unused `Class1.cs` files. Introduced new DTOs for authentication requests and updated `IAuthService` and `IUserRepository` interfaces. Enhanced `User` class and added `AuthResult` enum for standardized responses. Expanded `DataAccess` for better database operations and updated `UserRepository` and `AuthService` to implement new authentication logic. Front-end changes include renaming variables for consistency and updating the `ForgotPasswordModal` and `Login.tsx` components to use usernames instead of email addresses. Updated API proxy path in `vite.config.ts` and ensured proper typing for `API_BASE_URL` in global TypeScript definitions. --- .../Controllers/AuthenticationController.cs | 63 ++- Surge365.MassEmailReact.API/Program.cs | 8 +- Surge365.MassEmailReact.API/appsettings.json | 10 +- Surge365.MassEmailReact.Application/Class1.cs | 7 - .../DTOs/GeneratePasswordRecoveryRequest.cs | 13 + .../DTOs/LoginRequest.cs | 14 + .../DTOs/RefreshTokenRequest.cs | 14 + .../Interfaces/IAuthService.cs | 8 + .../Interfaces/IUserRepository.cs | 15 + Surge365.MassEmailReact.Domain/Class1.cs | 7 - .../Entities/User.cs | 34 ++ .../Enums/AuthResult.cs | 18 + .../AuthenticationResponseExtensions.cs | 23 + .../Class1.cs | 7 - .../DataAccess.cs | 414 ++++++++++++++++++ .../Repositories/UserRepository.cs | 140 ++++++ .../Services/AuthService.cs | 94 ++++ ...ge365.MassEmailReact.Infrastructure.csproj | 9 + .../content/js/ytb-massemail-.global.ts | 12 +- .../components/modals/ForgotPasswordModal.tsx | 124 ++---- .../src/components/pages/Login.tsx | 32 +- .../src/config/constants.ts | 2 +- Surge365.MassEmailReact.Web/vite.config.ts | 2 +- 23 files changed, 919 insertions(+), 151 deletions(-) delete mode 100644 Surge365.MassEmailReact.Application/Class1.cs create mode 100644 Surge365.MassEmailReact.Application/DTOs/GeneratePasswordRecoveryRequest.cs create mode 100644 Surge365.MassEmailReact.Application/DTOs/LoginRequest.cs create mode 100644 Surge365.MassEmailReact.Application/DTOs/RefreshTokenRequest.cs create mode 100644 Surge365.MassEmailReact.Application/Interfaces/IAuthService.cs create mode 100644 Surge365.MassEmailReact.Application/Interfaces/IUserRepository.cs delete mode 100644 Surge365.MassEmailReact.Domain/Class1.cs create mode 100644 Surge365.MassEmailReact.Domain/Entities/User.cs create mode 100644 Surge365.MassEmailReact.Domain/Enums/AuthResult.cs create mode 100644 Surge365.MassEmailReact.Domain/Enums/Extensions/AuthenticationResponseExtensions.cs delete mode 100644 Surge365.MassEmailReact.Infrastructure/Class1.cs create mode 100644 Surge365.MassEmailReact.Infrastructure/DataAccess.cs create mode 100644 Surge365.MassEmailReact.Infrastructure/Repositories/UserRepository.cs create mode 100644 Surge365.MassEmailReact.Infrastructure/Services/AuthService.cs diff --git a/Surge365.MassEmailReact.API/Controllers/AuthenticationController.cs b/Surge365.MassEmailReact.API/Controllers/AuthenticationController.cs index af5366e..ea2ac6c 100644 --- a/Surge365.MassEmailReact.API/Controllers/AuthenticationController.cs +++ b/Surge365.MassEmailReact.API/Controllers/AuthenticationController.cs @@ -1,30 +1,51 @@ using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity.Data; using Microsoft.AspNetCore.Mvc; +using Surge365.MassEmailReact.Application.DTOs; +using Surge365.MassEmailReact.Application.Interfaces; namespace Surge365.MassEmailReact.Server.Controllers { - //[Route("api/[controller]")] - //[ApiController] - //public class AuthenticationController : ControllerBase - //{ - // private readonly IAuthService _authService; + [Route("api/[controller]")] + [ApiController] + public class AuthenticationController : ControllerBase + { + private readonly IAuthService _authService; - // public AuthenticationController(IAuthService authService) - // { - // _authService = authService; - // } + public AuthenticationController(IAuthService authService) + { + _authService = authService; + } - // [HttpPost("login")] - // public IActionResult Authenticate([FromBody] LoginRequest request) - // { - // var token = _authService.Authenticate(request.Username, request.Password); - // if (token == null) - // { - // return Unauthorized(new { message = "Invalid credentials" }); - // } + [HttpPost("authenticate")] + public async Task Authenticate([FromBody] LoginRequest request) + { + var authResponse = await _authService.Authenticate(request.Username, request.Password); + if (!authResponse.authenticated) + return Unauthorized(new { message = authResponse.errorMessage }); + else if(authResponse.token == null) + return Unauthorized(new { message = "Invalid credentials" }); - // return Ok(new { token }); - // } - //} + return Ok(new { success = true, authResponse.token.Value.accessToken }); + } + [HttpPost("refreshtoken")] + public IActionResult RefreshToken([FromBody] RefreshTokenRequest request) + { + Guid? userId = Guid.NewGuid();//TODO: Lookup user in session + if (userId == null) + { + return Unauthorized("Invalid refresh token"); + } + + var tokens = _authService.GenerateTokens(userId.Value, request.RefreshToken); + if(tokens == null) + return Unauthorized(); + + return Ok(new { accessToken = tokens.Value.accessToken, refreshToken = tokens.Value.refreshToken }); + } + [HttpPost("generatepasswordrecovery")] + public IActionResult GeneratePasswordRecovery([FromBody] GeneratePasswordRecoveryRequest request) + { + return Ok(new { }); + } + } } \ No newline at end of file diff --git a/Surge365.MassEmailReact.API/Program.cs b/Surge365.MassEmailReact.API/Program.cs index 1b52868..bfec0e0 100644 --- a/Surge365.MassEmailReact.API/Program.cs +++ b/Surge365.MassEmailReact.API/Program.cs @@ -1,3 +1,8 @@ +using Microsoft.AspNetCore.Identity; +using Surge365.MassEmailReact.Application.Interfaces; +using Surge365.MassEmailReact.Infrastructure.Repositories; +using Surge365.MassEmailReact.Infrastructure.Services; + var builder = WebApplication.CreateBuilder(args); // Add services to the container. @@ -5,7 +10,8 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); - +builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); app.UseDefaultFiles(); diff --git a/Surge365.MassEmailReact.API/appsettings.json b/Surge365.MassEmailReact.API/appsettings.json index 10f68b8..c0fe1b5 100644 --- a/Surge365.MassEmailReact.API/appsettings.json +++ b/Surge365.MassEmailReact.API/appsettings.json @@ -5,5 +5,13 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Jwt": { + "Secret": "Z9R5aFml+eRMeb7tyf8N9wCq3tZpS/EM6nGqOxlXPtOw4cJ3zS1AByczrIlD5F9d" + }, + "AppCode": "MassEmailReactApi", + "EnvironmentCode": "UAT", + "ConnectionStrings": { + "Marketing.ConnectionString": "data source=uat.surge365.com;initial catalog=Marketing;User ID=ytb;Password=YTB()nl!n3;Encrypt=yes;TrustServerCertificate=yes;Application Name=##application_name##" + } } diff --git a/Surge365.MassEmailReact.Application/Class1.cs b/Surge365.MassEmailReact.Application/Class1.cs deleted file mode 100644 index 5dcdf7e..0000000 --- a/Surge365.MassEmailReact.Application/Class1.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Surge365.MassEmailReact.Application -{ - public class Class1 - { - - } -} diff --git a/Surge365.MassEmailReact.Application/DTOs/GeneratePasswordRecoveryRequest.cs b/Surge365.MassEmailReact.Application/DTOs/GeneratePasswordRecoveryRequest.cs new file mode 100644 index 0000000..4471073 --- /dev/null +++ b/Surge365.MassEmailReact.Application/DTOs/GeneratePasswordRecoveryRequest.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Surge365.MassEmailReact.Application.DTOs +{ + public class GeneratePasswordRecoveryRequest + { + public required string Username { get; set; } + } +} diff --git a/Surge365.MassEmailReact.Application/DTOs/LoginRequest.cs b/Surge365.MassEmailReact.Application/DTOs/LoginRequest.cs new file mode 100644 index 0000000..2b99c20 --- /dev/null +++ b/Surge365.MassEmailReact.Application/DTOs/LoginRequest.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Surge365.MassEmailReact.Application.DTOs +{ + public class LoginRequest + { + public required string Username { get; set; } + public required string Password { get; set; } + } +} diff --git a/Surge365.MassEmailReact.Application/DTOs/RefreshTokenRequest.cs b/Surge365.MassEmailReact.Application/DTOs/RefreshTokenRequest.cs new file mode 100644 index 0000000..8348f1a --- /dev/null +++ b/Surge365.MassEmailReact.Application/DTOs/RefreshTokenRequest.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Surge365.MassEmailReact.Application.DTOs +{ + public class RefreshTokenRequest + { + public required string RefreshToken { get; set; } + } + +} diff --git a/Surge365.MassEmailReact.Application/Interfaces/IAuthService.cs b/Surge365.MassEmailReact.Application/Interfaces/IAuthService.cs new file mode 100644 index 0000000..a47f7d0 --- /dev/null +++ b/Surge365.MassEmailReact.Application/Interfaces/IAuthService.cs @@ -0,0 +1,8 @@ +namespace Surge365.MassEmailReact.Application.Interfaces +{ + public interface IAuthService + { + Task<(bool authenticated, (string accessToken, string refreshToken)? token, string errorMessage)> Authenticate(string username, string password); + (string accessToken, string refreshToken)? GenerateTokens(Guid userId, string refreshToken); + } +} diff --git a/Surge365.MassEmailReact.Application/Interfaces/IUserRepository.cs b/Surge365.MassEmailReact.Application/Interfaces/IUserRepository.cs new file mode 100644 index 0000000..4f9f46f --- /dev/null +++ b/Surge365.MassEmailReact.Application/Interfaces/IUserRepository.cs @@ -0,0 +1,15 @@ +using Surge365.MassEmailReact.Domain.Entities; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Surge365.MassEmailReact.Application.Interfaces +{ + public interface IUserRepository + { + Task<(User? user, string message)> Authenticate(string username, string password); + bool Authenticate(Guid userId, string refreshToken); + } +} diff --git a/Surge365.MassEmailReact.Domain/Class1.cs b/Surge365.MassEmailReact.Domain/Class1.cs deleted file mode 100644 index d1cb8f1..0000000 --- a/Surge365.MassEmailReact.Domain/Class1.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Surge365.MassEmailReact.Domain -{ - public class Class1 - { - - } -} diff --git a/Surge365.MassEmailReact.Domain/Entities/User.cs b/Surge365.MassEmailReact.Domain/Entities/User.cs new file mode 100644 index 0000000..e5dc397 --- /dev/null +++ b/Surge365.MassEmailReact.Domain/Entities/User.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Surge365.MassEmailReact.Domain.Entities +{ + public class User + { + public int? UserKey { get; private set; } + public Guid UserId { get; private set; } + public string Username { get; private set; } + public string FirstName { get; private set; } + public string MiddleInitial { get; private set; } + public string LastName { get; private set; } + public bool IsActive { get; private set; } + + private User(int userKey, Guid userId, string username, string? firstName, string? middleInitial, string? lastName, bool isActive) + { + UserKey = userKey; + UserId = userId; + Username = username; + Username = firstName ?? ""; + MiddleInitial = middleInitial ?? ""; + LastName = lastName ?? ""; + IsActive = isActive; + } + public static User Create(int userKey, Guid userId, string username, string? firstName, string? middleInitial, string? lastName, bool isActive) + { + return new User(userKey, userId, username, firstName, middleInitial, lastName, isActive); + } + } +} diff --git a/Surge365.MassEmailReact.Domain/Enums/AuthResult.cs b/Surge365.MassEmailReact.Domain/Enums/AuthResult.cs new file mode 100644 index 0000000..c7100d9 --- /dev/null +++ b/Surge365.MassEmailReact.Domain/Enums/AuthResult.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Surge365.MassEmailReact.Domain.Enums +{ + public enum AuthResult + { + Success = 0, + LoginNotFound = 1, + LoginLocked = 2, + InvalidPassword = 3, + UnexpectedError = 4, + NotAllowed = 5 + } +} diff --git a/Surge365.MassEmailReact.Domain/Enums/Extensions/AuthenticationResponseExtensions.cs b/Surge365.MassEmailReact.Domain/Enums/Extensions/AuthenticationResponseExtensions.cs new file mode 100644 index 0000000..71396f4 --- /dev/null +++ b/Surge365.MassEmailReact.Domain/Enums/Extensions/AuthenticationResponseExtensions.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Surge365.MassEmailReact.Domain.Enums.Extensions +{ + public static class AuthResultExtensions + { + public static string GetMessage(this AuthResult response) + { + return response switch + { + AuthResult.Success => "Login successful.", + AuthResult.InvalidPassword or AuthResult.LoginNotFound => "Invalid username and/or password.", + AuthResult.LoginLocked => "User has been locked out.", + AuthResult.NotAllowed => "User has not been granted access to this application.", + AuthResult.UnexpectedError or _ => "Unexpected error." + }; + } + } +} diff --git a/Surge365.MassEmailReact.Infrastructure/Class1.cs b/Surge365.MassEmailReact.Infrastructure/Class1.cs deleted file mode 100644 index bd85d20..0000000 --- a/Surge365.MassEmailReact.Infrastructure/Class1.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Surge365.MassEmailReact.Infrastructure -{ - public class Class1 - { - - } -} diff --git a/Surge365.MassEmailReact.Infrastructure/DataAccess.cs b/Surge365.MassEmailReact.Infrastructure/DataAccess.cs new file mode 100644 index 0000000..19f89e7 --- /dev/null +++ b/Surge365.MassEmailReact.Infrastructure/DataAccess.cs @@ -0,0 +1,414 @@ +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; +using System; +using System.Collections.Generic; +using System.Data; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection.PortableExecutable; +using System.Text; +using System.Threading.Tasks; + + +namespace Surge365.MassEmailReact.Infrastructure +{ + public class DataAccess + { + private string GetConnectionString(string connectionStringName) + { + if (_configuration == null) + return ""; + return _configuration[$"ConnectionStrings:{connectionStringName}"] ?? ""; + } + private string GetAppCode() + { + if (_configuration == null) + return ""; + return _configuration[$"AppCode"] ?? ""; + } + public DataAccess(IConfiguration configuration, string connectionStringName) + { + _configuration = configuration; + _connectionString = GetConnectionString(connectionStringName).Replace("##application_code##", GetAppCode()); + } + + internal IConfiguration? _configuration; + internal Guid _sessionID = Guid.NewGuid(); + internal string _connectionString; + internal int _timeout = 30; + internal SqlTransaction? _transaction; + internal SqlConnection? _connection; + internal bool _transactionStarted; + internal Object? _transactionStarter; + + public string ConnectionString + { + get { return _connectionString; } + set { _connectionString = value; } + } + public int Timeout + { + get { return _timeout; } + set { _timeout = value; } + } + public bool TransactionStarted + { + get { return _transactionStarted; } + internal set { _transactionStarted = value; } + } + public Object? TransactionStarter + { + get { return _transactionStarter; } + internal set { _transactionStarter = value; } + } + + #region Non-Async + internal void OpenConnection() + { + _connection = new SqlConnection(_connectionString); + _connection.Open(); + } + internal void CloseConnection() + { + if (_connection == null) + return; + + _connection.Close(); + _connection.Dispose(); + _connection = null; + } + public SqlTransaction BeginTransaction(Object sender) + { + if (_transaction != null) + return _transaction; + + OpenConnection(); + + ArgumentNullException.ThrowIfNull(_connection); + + _transaction = _connection.BeginTransaction(); + _transactionStarted = true; + _transactionStarter = sender; + return _transaction; + } + public void RollbackTransaction(Object sender) + { + if (_transaction != null && sender == _transactionStarter) + { + _transaction.Rollback(); + _transaction = null; + CloseConnection(); + _transactionStarter = null; + _transactionStarted = false; + } + } + public void CommitTransaction(Object sender) + { + if (_transaction != null && sender == _transactionStarter) + { + _transaction.Commit(); + _transaction = null; + CloseConnection(); + _transactionStarter = null; + _transactionStarted = false; + } + } + + public DataSet CallRetrievalProcedure(List parameters, string proc, int timeoutSeconds = 0) + { + DataSet ds = new DataSet(); + bool createdConnection = false; + if (timeoutSeconds == 0) + timeoutSeconds = _timeout; + try + { + if (_connection == null) + { + createdConnection = true; + OpenConnection(); + } + + using SqlCommand cmd = new SqlCommand(proc, _connection); + cmd.Transaction = _transaction; + if (parameters != null) + { + foreach (SqlParameter p in parameters) + { + if (p != null) + cmd.Parameters.Add(p); + } + } + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandTimeout = timeoutSeconds; + using SqlDataAdapter da = new SqlDataAdapter(cmd); + try + { + da.Fill(ds); + } + catch (Exception ex) + { + string message = String.Format("Unable to retrieve data from proc: {0}", proc); + LogError(ex, message, "CallRetrievalProcedure"); + throw; + } + if (createdConnection) + { + CloseConnection(); + } + } + catch (Exception) + { + if (createdConnection) + { + CloseConnection(); + } + throw; + } + + return ds; + } + public int CallActionProcedure(List parameters, string proc, int timeoutSeconds = 0) + { + int iReturnVal = -1; + if (timeoutSeconds == 0) + timeoutSeconds = _timeout; + bool createdConnection = false; + try + { + if (_connection == null) + { + createdConnection = true; + OpenConnection(); + } + using SqlCommand cmd = new SqlCommand(proc, _connection); + cmd.Transaction = _transaction; + if (parameters != null) + { + foreach (SqlParameter p in parameters) + { + if (p != null) + cmd.Parameters.Add(p); + } + } + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandTimeout = timeoutSeconds; + try + { + iReturnVal = cmd.ExecuteNonQuery(); + } + catch (Exception ex) + { + string message = String.Format("Unable to execute proc: {0}", proc); + LogError(ex, message, "CallActionProcedure"); + throw; + } + } + catch (Exception) + { + if (createdConnection) + { + CloseConnection(); + } + throw; + } + + if (createdConnection) + { + CloseConnection(); + } + return iReturnVal; + } + private static void LogError(Exception ex, string message, string task) + { + Exception ex1 = ex; + if (ex == null) + ex1 = new Exception(message); + else if (!string.IsNullOrEmpty(message)) + ex1 = new Exception(message, ex); + + //Log + } + #endregion + + #region Async + internal async Task OpenConnectionAsync() + { + _connection = new SqlConnection(_connectionString); + await _connection.OpenAsync(); + } + internal async Task CloseConnectionAsync() + { + if (_connection == null) + return; + + await _connection.CloseAsync(); + _connection.Dispose(); + _connection = null; + } + public async Task BeginTransactionAsync(Object sender) + { + if (_transaction != null) + return _transaction; + + await OpenConnectionAsync(); + + ArgumentNullException.ThrowIfNull(_connection); + + _transaction = _connection.BeginTransaction(); + _transactionStarted = true; + _transactionStarter = sender; + return _transaction; + } + public async Task RollbackTransactionAsync(Object sender) + { + if (_transaction != null && sender == _transactionStarter) + { + await _transaction.RollbackAsync(); + _transaction = null; + await CloseConnectionAsync(); + _transactionStarter = null; + _transactionStarted = false; + } + } + public async Task CommitTransactionAsync(Object sender) + { + if (_transaction != null && sender == _transactionStarter) + { + await _transaction.CommitAsync(); + _transaction = null; + await CloseConnectionAsync(); + _transactionStarter = null; + _transactionStarted = false; + } + } + public async Task CallRetrievalProcedureAsync(List parameters, string proc, int timeoutSeconds = 0) + { + DataSet ds = new DataSet(); + bool createdConnection = false; + if (timeoutSeconds == 0) + timeoutSeconds = _timeout; + try + { + if (_connection == null) + { + createdConnection = true; + await OpenConnectionAsync(); + } + + using SqlCommand cmd = new SqlCommand(proc, _connection); + + cmd.Transaction = _transaction; + if (parameters != null) + { + foreach (SqlParameter p in parameters) + { + if (p != null) + cmd.Parameters.Add(p); + } + } + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandTimeout = timeoutSeconds; + + try + { + //using var reader = await cmd.ExecuteReaderAsync(); + using var adapter = new SqlDataAdapter(cmd); + await Task.Run(() => adapter.Fill(ds)); + } + catch (Exception ex) + { + string message = String.Format("Unable to retrieve data from proc: {0}", proc); + await LogErrorAsync(ex, message, "CallRetrievalProcedure"); + throw; + } + if (createdConnection) + { + await CloseConnectionAsync(); + } + } + catch (Exception) + { + if (createdConnection) + { + await CloseConnectionAsync(); + } + throw; + } + + return ds; + } + public async Task CallActionProcedureAsync(List parameters, string proc, int timeoutSeconds = 0) + { + int iReturnVal = -1; + if (timeoutSeconds == 0) + timeoutSeconds = _timeout; + bool createdConnection = false; + try + { + if (_connection == null) + { + createdConnection = true; + await OpenConnectionAsync(); + } + using SqlCommand cmd = new SqlCommand(proc, _connection); + + cmd.Transaction = _transaction; + if (parameters != null) + { + foreach (SqlParameter p in parameters) + { + if (p != null) + cmd.Parameters.Add(p); + } + } + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandTimeout = timeoutSeconds; + try + { + iReturnVal = await cmd.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + string message = String.Format("Unable to execute proc: {0}", proc); + await LogErrorAsync(ex, message, "CallActionProcedure"); + throw; + } + } + catch (Exception) + { + if (createdConnection) + { + await CloseConnectionAsync(); + } + throw; + } + + if (createdConnection) + { + await CloseConnectionAsync(); + } + return iReturnVal; + } + private static async Task LogErrorAsync(Exception ex, string message, string task) + { + Exception ex1 = ex; + if (ex == null) + ex1 = new Exception(message); + else if (!string.IsNullOrEmpty(message)) + ex1 = new Exception(message, ex); + + try + { + using SqlConnection c = new SqlConnection(); + await c.OpenAsync(); + using SqlCommand cmd = new SqlCommand("lg_log_error", c); + await cmd.ExecuteNonQueryAsync(); + } + catch (Exception iex) //Trap all errors, don't do any + { + Console.WriteLine($"Unhandled Exception occurred logging exception: exception={iex}; trying to log exception={ex}"); + } + } + #endregion + } +} diff --git a/Surge365.MassEmailReact.Infrastructure/Repositories/UserRepository.cs b/Surge365.MassEmailReact.Infrastructure/Repositories/UserRepository.cs new file mode 100644 index 0000000..6da0c14 --- /dev/null +++ b/Surge365.MassEmailReact.Infrastructure/Repositories/UserRepository.cs @@ -0,0 +1,140 @@ +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; +using Surge365.MassEmailReact.Application.Interfaces; +using Surge365.MassEmailReact.Domain.Entities; +using Surge365.MassEmailReact.Domain.Enums; +using Surge365.MassEmailReact.Domain.Enums.Extensions; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Surge365.MassEmailReact.Infrastructure.Repositories +{ + public class UserRepository (IConfiguration config) : IUserRepository + { + private IConfiguration _config = config; + private const string _connectionStringName = "Marketing.ConnectionString"; + + //private static readonly List Users = new(); + + public async Task<(User? user, string message)> Authenticate(string username, string password) + { + List pms = new List(); + pms.Add(new SqlParameter("username", username)); + pms.Add(new SqlParameter("password", password)); + pms.Add(new SqlParameter("application_code", "MassEmailWeb")); //TODO: Pull from config + + SqlParameter pmResponseNumber = new SqlParameter("response_number", SqlDbType.SmallInt); + pmResponseNumber.Direction = ParameterDirection.Output; + pms.Add(pmResponseNumber); + + SqlParameter pUserKey = new SqlParameter("login_key", SqlDbType.Int); + pUserKey.Direction = ParameterDirection.Output; + pms.Add(pUserKey); + + DataAccess da = new DataAccess(_config, _connectionStringName); + DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_authenticate_login"); + + var result = (AuthResult)Convert.ToInt16(pmResponseNumber.Value); + + string responseMessage = AuthResultExtensions.GetMessage(result); + if (result == AuthResult.Success) + { + if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0) + return (null, "No user row returned"); + return (LoadFromDataRow(ds.Tables[0].Rows[0]), responseMessage); + } + return (null, responseMessage); + } + public bool Authenticate(Guid userId, string refreshToken) + { + //TODO: Validate refresh token + return true; + } + public async Task GetByUsername(string username) + { + List pms = new List(); + pms.Add(new SqlParameter("username", username)); + + DataAccess da = new DataAccess(_config, _connectionStringName); + DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_get_login_by_username"); + if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0) + return null; + + List users = LoadFromDataRow(ds.Tables[0]); + return users.FirstOrDefault(); + } + public async Task GetByKey(int userKey) + { + List pms = new List(); + pms.Add(new SqlParameter("login_key", userKey)); + + DataAccess da = new DataAccess(_config, _connectionStringName); + DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_get_adm_login_by_key"); + if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0) + return null; + + List users = LoadFromDataRow(ds.Tables[0]); + return users.FirstOrDefault(); + } + public async Task GetById(Guid userId) + { + List pms = new List(); + pms.Add(new SqlParameter("login_id", userId)); + + DataAccess da = new DataAccess(_config, _connectionStringName); + DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_get_adm_login_by_id"); + if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0) + return null; + + List users = LoadFromDataRow(ds.Tables[0]); + return users.FirstOrDefault(); + } + public async Task> GetAll(bool activeOnly = true) + { + List pms = new List(); + pms.Add(new SqlParameter("active_only", activeOnly)); + + DataAccess da = new DataAccess(_config, _connectionStringName); + DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_get_adm_login_all"); + if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0) + return null; + + return LoadFromDataRow(ds.Tables[0]); + } + + + //public void Add(User user) + //{ + // Users.Add(user); + //} + + private List LoadFromDataRow(DataTable dt) + { + ArgumentNullException.ThrowIfNull(dt); + ArgumentNullException.ThrowIfNull(dt.Rows); + + List users = new List(); + foreach (DataRow dr in dt.Rows) + { + users.Add(LoadFromDataRow(dr)); + } + return users; + } + private User LoadFromDataRow(DataRow dr) + { + ArgumentNullException.ThrowIfNull(dr); + return User.Create(dr.Field("login_key"), + dr.Field("login_id"), + dr.Field("username")!, + dr.Field("first_name"), + dr.Field("middle_initial"), + dr.Field("last_name"), + dr.Field("is_active")); + + } + } +} diff --git a/Surge365.MassEmailReact.Infrastructure/Services/AuthService.cs b/Surge365.MassEmailReact.Infrastructure/Services/AuthService.cs new file mode 100644 index 0000000..605665c --- /dev/null +++ b/Surge365.MassEmailReact.Infrastructure/Services/AuthService.cs @@ -0,0 +1,94 @@ +using Surge365.MassEmailReact.Application.Interfaces; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; +using System.IdentityModel.Tokens.Jwt; +using Microsoft.IdentityModel.Tokens; +using Microsoft.Extensions.Configuration; +using Surge365.MassEmailReact.Domain.Entities; +using System.Security.Cryptography; + + +namespace Surge365.MassEmailReact.Infrastructure.Services +{ + public class AuthService : IAuthService + { + private const int TOKEN_MINUTES = 60; + private readonly IUserRepository _userRepository; + private readonly IConfiguration _config; + + public AuthService(IUserRepository userRepository, IConfiguration config) + { + _userRepository = userRepository; + _config = config; + } + + public async Task<(bool authenticated, (string accessToken, string refreshToken)? token, string errorMessage)> Authenticate(string username, string password) + { + var authResponse = await _userRepository.Authenticate(username, password); + if (authResponse.user == null) + { + return (false, null, authResponse.message); + } + // Generate JWT token + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(new[] + { + new Claim(JwtRegisteredClaimNames.Sub, authResponse.user.UserId.ToString()), + new Claim(JwtRegisteredClaimNames.UniqueName, username), + new Claim(ClaimTypes.Role, "User") + }), + Expires = DateTime.UtcNow.AddMinutes(TOKEN_MINUTES), + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(key), + SecurityAlgorithms.HmacSha256Signature + ) + }; + + var token = tokenHandler.CreateToken(tokenDescriptor); + var accessToken = tokenHandler.WriteToken(token); + var refreshToken = Convert.ToBase64String(Guid.NewGuid().ToByteArray()); + //_userRepository.SaveRefreshToken(userId.Value, refreshToken); // TODO: Store refresh token in DB + + return (true, (accessToken, refreshToken), ""); + } + public (string accessToken, string refreshToken)? GenerateTokens(Guid userId, string refreshToken) + { + if (!_userRepository.Authenticate(userId, refreshToken)) + { + return null; + } + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!); + var username = ""; + //TODO: Look update User + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(new[] + { + new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()!), + new Claim(JwtRegisteredClaimNames.UniqueName, username), + new Claim(ClaimTypes.Role, "User") + }), + Expires = DateTime.UtcNow.AddMinutes(TOKEN_MINUTES), + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(key), + SecurityAlgorithms.HmacSha256Signature + ) + }; + + var token = tokenHandler.CreateToken(tokenDescriptor); + var accessToken = tokenHandler.WriteToken(token); + var newRefreshToken = Convert.ToBase64String(Guid.NewGuid().ToByteArray()); + //_userRepository.SaveRefreshToken(userId.Value, newRefreshToken); // TODO: Store refresh token in DB + + return (accessToken, newRefreshToken); + } + } +} diff --git a/Surge365.MassEmailReact.Infrastructure/Surge365.MassEmailReact.Infrastructure.csproj b/Surge365.MassEmailReact.Infrastructure/Surge365.MassEmailReact.Infrastructure.csproj index 852f009..9499a02 100644 --- a/Surge365.MassEmailReact.Infrastructure/Surge365.MassEmailReact.Infrastructure.csproj +++ b/Surge365.MassEmailReact.Infrastructure/Surge365.MassEmailReact.Infrastructure.csproj @@ -11,4 +11,13 @@ + + + + + + + + + diff --git a/Surge365.MassEmailReact.Web/public/content/js/ytb-massemail-.global.ts b/Surge365.MassEmailReact.Web/public/content/js/ytb-massemail-.global.ts index a8e7685..3f486a7 100644 --- a/Surge365.MassEmailReact.Web/public/content/js/ytb-massemail-.global.ts +++ b/Surge365.MassEmailReact.Web/public/content/js/ytb-massemail-.global.ts @@ -51,8 +51,8 @@ function checkLoggedInStatus() { // $("#spanMenuName").html(user.FirstName + ' ' + user.LastName); - // var userid = $.localStorage("session_currentUser").UserID; - // var imgid = userid + "&t=" + new Date().getTime(); + // var userId = $.localStorage("session_currentUser").userId; + // var imgid = userId + "&t=" + new Date().getTime(); /* if (user.HasProfileImage) { $("#imgRightProfile").attr("src", "/webservices/getprofileimage.ashx?id=" + imgid); @@ -391,7 +391,7 @@ function formatSelect2DataForUser(id, data) { for (var x = 0; x < data.length; x++) { var obj = {}; obj.id = data[x][id]; - obj.text = data[x]["FirstName"] + ' ' + data[x]["LastName"] + ' - ' + data[x]["UserID"]; + obj.text = data[x]["FirstName"] + ' ' + data[x]["LastName"] + ' - ' + data[x]["userId"]; newdata.push(obj); } @@ -426,7 +426,7 @@ function fillLocationsDropDown(deferred) { var search = {}; if (!$.userHasRole("Administrators")) { - search.UserID = $.localStorage("session_currentUser").UserID; + search.userId = $.localStorage("session_currentUser").userId; } else { search = null; @@ -531,7 +531,7 @@ function fillEmployeesDropDown(deferred, dropdown) { $.sessionStorage("session_users", json.data); if ($.getBoolean(json.success)) { - var data = formatSelect2DataForUser("UserID", $.sessionStorage("session_users")); + var data = formatSelect2DataForUser("userId", $.sessionStorage("session_users")); dropdown.select2({ placeholder: "Select an employee...", allowClear: true, @@ -601,7 +601,7 @@ function growlSuccess(msg) { function getUser(users, id) { for (var x = 0; x < users.length; x++) { - if (users[x].UserID == id) { + if (users[x].userId == id) { return users[x]; break; } diff --git a/Surge365.MassEmailReact.Web/src/components/modals/ForgotPasswordModal.tsx b/Surge365.MassEmailReact.Web/src/components/modals/ForgotPasswordModal.tsx index 7dd1c33..65e6f04 100644 --- a/Surge365.MassEmailReact.Web/src/components/modals/ForgotPasswordModal.tsx +++ b/Surge365.MassEmailReact.Web/src/components/modals/ForgotPasswordModal.tsx @@ -1,118 +1,84 @@ -import { useState } from 'react'; +import { useState, FormEvent } from 'react'; import { Modal, Button, Form } from 'react-bootstrap'; import { FaExclamationCircle } from 'react-icons/fa'; // For optional font icon -import PropTypes from 'prop-types'; +import utils from '@/ts/utils'; -import utils from '@/ts/utils.ts' +type FormErrors = Record; -const ForgotPasswordModal = ({ show, onClose }) => { - const [email, setEmail] = useState(''); - const [formErrors, setFormErrors] = useState({}); - const [emailNotFound, setEmailNotFound] = useState(false); +type ForgotPasswordModalProps = { + show: boolean; + onClose: () => void; +}; + +const ForgotPasswordModal: React.FC = ({ show, onClose }) => { + const [username, setUsername] = useState(''); + const [formErrors, setFormErrors] = useState({}); + const [usernameNotFound, setUsernameNotFound] = useState(false); const [recoveryStarted, setRecoveryStarted] = useState(false); - const validate = () => { + const validate = (): boolean => { setFormErrors({}); - - const errors = {}; - if (!email.trim()) { - errors.email = 'Email is required'; - } else if (!/\S+@\S+\.\S+/.test(email)) { - errors.email = 'Invalid email address'; + const errors: FormErrors = {}; + if (!username.trim()) { + errors.username = 'Username is required'; } - if (Object.keys(errors).length > 0) { - setFormErrors(errors); + setFormErrors(errors); return false; } else { return true; } - } + }; - const handleStartPasswordRecovery = async (e) => { + const handleStartPasswordRecovery = async (e: FormEvent) => { e.preventDefault(); - setEmailNotFound(false); + setUsernameNotFound(false); if (validate()) { - console.log('Processing forgot password for', email); + console.log('Processing forgot password for', username); await utils.webMethod({ - 'methodPage': 'UserMethods', - 'methodName': 'GeneratePasswordRecovery', - 'parameters': { "emailAddress": email }, - success: function (json) { + methodPage: 'authenticate', + methodName: 'generatepasswordrecovery', + parameters: { username }, + success: (json: any) => { if (utils.getBoolean(json.success)) { setRecoveryStarted(true); + } else { + setUsernameNotFound(true); } - else { - setEmailNotFound(true); - } - } + }, }); } }; - /* - $("#btnResetPassword").click(function (e) { - $("#frmResetPassword").validator('validate'); - e.preventDefault(); - var isValid = !$('#frmResetPassword .has-error').length - - if (isValid) { - $.webMethod({ - 'methodPage': 'UserMethods', - 'methodName': 'ResetPasswordFromToken', - 'parameters': { "token": getParameterByName("guid"), "password": $("#txtNewPassword").val() }, - success: function (json) { - - if ($.getBoolean(json.success)) { - - //redirect user to Dashboard - location.href = "/vehicles"; - - } - else { - //error - - } - } - }); - } - - }) - */ return ( - + Forgot your password? - {emailNotFound && ( - An email has been sent to the address you provided. Please follow the instructions in the email in order to reset your password. + {usernameNotFound && ( + An email has been sent to the address you provided. Please follow the instructions to reset your password. )} {!recoveryStarted && (
- Enter your email address below and we'll send you instructions on how to reset your password... - Email Addresss + Enter your email address below and we'll send you instructions on how to reset your password... + Email setEmail(e.target.value)} + type="username" + placeholder="Username" + value={username} + isInvalid={!!formErrors.username} + onChange={(e) => setUsername(e.target.value)} required autoFocus size="lg" /> - {/* Validation Icon */} - {formErrors.email && ( - + {formErrors.username && ( + )} - {/* Validation Message */} - {formErrors.email} + {formErrors.username} @@ -129,9 +95,5 @@ const ForgotPasswordModal = ({ show, onClose }) => { ); }; -ForgotPasswordModal.propTypes = { - show: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, -}; - export default ForgotPasswordModal; + diff --git a/Surge365.MassEmailReact.Web/src/components/pages/Login.tsx b/Surge365.MassEmailReact.Web/src/components/pages/Login.tsx index 526f4b5..f9c6f06 100644 --- a/Surge365.MassEmailReact.Web/src/components/pages/Login.tsx +++ b/Surge365.MassEmailReact.Web/src/components/pages/Login.tsx @@ -12,7 +12,7 @@ function Login() { const [isLoading, setIsLoading] = useState(false); const [spinners, setSpinnersState] = useState({}); const [formErrors, setFormErrors] = useState({}); - const [email, setEmail] = useState(''); + const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [showForgotPasswordModal, setShowForgotPasswordModal] = useState(false); const [user, setUser] = useState(null); @@ -40,17 +40,14 @@ function Login() { setShowForgotPasswordModal(false); }; - const handleEmailBlur = () => { - }; - const validateLoginForm = () => { setFormErrors({}); const errors: FormErrors = {}; - if (!email.trim()) { - errors.email = 'Email is required'; - } else if (!/\S+@\S+\.\S+/.test(email)) { - errors.email = 'Invalid email address'; + if (!username.trim()) { + errors.username = 'Username is required'; + //} else if (!/\S+@\S+\.\S+/.test(email)) { + // errors.email = 'Invalid email address'; } if (!password.trim()) { errors.password = 'Password is required'; @@ -75,9 +72,9 @@ function Login() { let loggedInUser: any = null; await utils.webMethod({ - methodPage: 'UserMethods', - methodName: 'AuthenticateUser', - parameters: { emailAddress: email, password: password }, + methodPage: 'authentication', + methodName: 'authenticate', + parameters: { username: username, password: password }, success: (json: any) => { if (utils.getBoolean(json.success)) { loggedInUser = json.data; @@ -132,14 +129,13 @@ function Login() { {loginError && ( Login error )} - - Email address + + Username setEmail(e.target.value)} - onBlur={handleEmailBlur} + type="username" + placeholder="Username" + value={username} + onChange={(e) => setUsername(e.target.value)} required autoFocus size="sm" diff --git a/Surge365.MassEmailReact.Web/src/config/constants.ts b/Surge365.MassEmailReact.Web/src/config/constants.ts index 9e15a61..72c5b1f 100644 --- a/Surge365.MassEmailReact.Web/src/config/constants.ts +++ b/Surge365.MassEmailReact.Web/src/config/constants.ts @@ -9,7 +9,7 @@ export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; // Add global values to window object for use in static JS declare global { interface Window { - API_BASE_URL: object + API_BASE_URL: string } } if (typeof window !== 'undefined') { diff --git a/Surge365.MassEmailReact.Web/vite.config.ts b/Surge365.MassEmailReact.Web/vite.config.ts index 2d99bc1..af8b534 100644 --- a/Surge365.MassEmailReact.Web/vite.config.ts +++ b/Surge365.MassEmailReact.Web/vite.config.ts @@ -47,7 +47,7 @@ export default defineConfig({ }, server: { proxy: { - '^/weatherforecast': { + '^/api': { target, secure: false }