Integrate Azure Key Vault and refactor authentication

Updated the application to use Azure Key Vault for managing secrets, including the addition of a new `SurgeKeyVaultSecretManager` class. Modified configuration files to include `KeyVaultName` and removed sensitive `Jwt` sections for enhanced security.

Refactored `IAuthService` and `IUserRepository` to utilize DTOs instead of the `User` entity. Removed the `UserMap` class and updated the `UserRepository` to focus on DTOs for user management.

The `AuthService` class now communicates with an external API for authentication, and new classes for API requests and responses have been added. Updated project dependencies to support Azure Key Vault integration.
This commit is contained in:
David Headrick 2025-04-24 16:32:26 -05:00
parent 712dbb5046
commit 3bd334f239
17 changed files with 212 additions and 347 deletions

View File

@ -1,6 +1,8 @@
using Azure.Identity;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Surge365.MassEmailReact.Application.Interfaces; using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities; using Surge365.MassEmailReact.Domain.Entities;
using Surge365.MassEmailReact.Infrastructure;
using Surge365.MassEmailReact.Infrastructure.DapperMaps; using Surge365.MassEmailReact.Infrastructure.DapperMaps;
using Surge365.MassEmailReact.Infrastructure.Repositories; using Surge365.MassEmailReact.Infrastructure.Repositories;
using Surge365.MassEmailReact.Infrastructure.Services; using Surge365.MassEmailReact.Infrastructure.Services;
@ -10,6 +12,18 @@ using System.Security.Authentication;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var keyVaultName = builder.Configuration["KeyVaultName"] ?? "";
if (!string.IsNullOrEmpty(keyVaultName))
{
var keyVaultUri = $"https://{keyVaultName}.vault.azure.net/";
builder.Configuration.AddAzureKeyVault(
new Uri(keyVaultUri),
new DefaultAzureCredential(),
new SurgeKeyVaultSecretManager()
);
}
builder.Services.AddHttpClient("SendGridClient", client => builder.Services.AddHttpClient("SendGridClient", client =>
{ {
client.BaseAddress = new Uri("https://api.sendgrid.com/"); // Optional, for clarity client.BaseAddress = new Uri("https://api.sendgrid.com/"); // Optional, for clarity
@ -22,7 +36,6 @@ builder.Services.AddHttpClient("SendGridClient", client =>
builder.Services.AddControllers(); builder.Services.AddControllers();
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<ITargetService, TargetService>(); builder.Services.AddScoped<ITargetService, TargetService>();
builder.Services.AddScoped<ITargetRepository, TargetRepository>(); builder.Services.AddScoped<ITargetRepository, TargetRepository>();

View File

@ -1,4 +1,5 @@
{ {
"KeyVaultName": "surge365-keyvault-dev",
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
@ -6,13 +7,11 @@
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"Jwt": {
"Secret": "Z9R5aFml+eRMeb7tyf8N9wCq3tZpS/EM6nGqOxlXPtOw4cJ3zS1AByczrIlD5F9d"
},
"EnvironmentCode": "UAT", "EnvironmentCode": "UAT",
"ConnectionStrings": { "ConnectionStrings": {
"Marketing.ConnectionString": "data source=uat.surge365.com;initial catalog=Marketing;User ID=ytb;Password=YTB()nl!n3;Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=3;Application Name=##application_name##;", //TODO: Move this to development.json, on server should go somewhere secure. GET IT OUT OF GIT },
"MassEmail.ConnectionString": "data source=uat.surge365.com;initial catalog=MassEmail;User ID=ytb;Password=YTB()nl!n3;Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=3;Application Name=##application_name##;" //TODO: Move this to development.json, on server should go somewhere secure. GET IT OUT OF GIT "AdminAuth": {
"Url": "https://uat.aauth.surge365.com"
}, },
"DefaultUnsubscribeUrl": "https://uat.emailopentracking.surge365.com/unsubscribe.htm" "DefaultUnsubscribeUrl": "https://uat.emailopentracking.surge365.com/unsubscribe.htm"
} }

View File

@ -1,4 +1,5 @@
{ {
"KeyVaultName": "surge365-keyvault-uat",
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
@ -6,13 +7,9 @@
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"Jwt": {
"Secret": "1bXgXk7v/W9XksGoNiqWvM7+9/BERZonShxqoCVvdi8Ew47M1VFzJGA9sPMgkmn/HRmuZ83iytNsHXI6GkAb8g=="
},
"EnvironmentCode": "UAT", "EnvironmentCode": "UAT",
"ConnectionStrings": { "AdminAuth": {
"Marketing.ConnectionString": "data source=localhost;initial catalog=Marketing;User ID=ytb;Password=YTB()nl!n3;Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=3;Application Name=##application_name##;", //TODO: Move this to development.json, on server should go somewhere secure. GET IT OUT OF GIT "Url": "https://uat.aauth.surge365.com"
"MassEmail.ConnectionString": "data source=localhost;initial catalog=MassEmail;User ID=ytb;Password=YTB()nl!n3;Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=3;Application Name=##application_name##;" //TODO: Move this to development.json, on server should go somewhere secure. GET IT OUT OF GIT
}, },
"DefaultUnsubscribeUrl": "https://uat.emailopentracking.surge365.com/unsubscribe.htm" "DefaultUnsubscribeUrl": "https://uat.emailopentracking.surge365.com/unsubscribe.htm"
} }

View File

@ -1,4 +1,5 @@
{ {
"KeyVaultName": "surge365-keyvault-prod",
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
@ -6,15 +7,11 @@
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"Jwt": {
"Secret": "4r1AJ0riBpEhgaTxhTWMIPs5rv9AlVZjTqrGUoU3DUz4i/Dx9ZfGciIubNODQRO0z3qJZq6VqxGXdsFRJgSb6Q=="
},
"AppCode": "MassEmailReactApi", "AppCode": "MassEmailReactApi",
"AuthAppCode": "MassEmailWeb", "AuthAppCode": "MassEmailWeb",
"EnvironmentCode": "PRODUCTION", "EnvironmentCode": "PRODUCTION",
"ConnectionStrings": { "AdminAuth": {
"Marketing.ConnectionString": "data source=localhost;initial catalog=Marketing;User ID=ytb;Password=YTB()nl!n3;Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=3;Application Name=##application_name##;", //TODO: Move this to development.json, on server should go somewhere secure. GET IT OUT OF GIT "Url": "https://aauth.surge365.com"
"MassEmail.ConnectionString": "data source=localhost;initial catalog=MassEmail;User ID=ytb;Password=YTB()nl!n3;Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=3;Application Name=##application_name##;" //TODO: Move this to development.json, on server should go somewhere secure. GET IT OUT OF GIT
}, },
"TestTargetSql": "CREATE TABLE #columns\r\n(\r\n primary_key INT NOT NULL IDENTITY(1,1) PRIMARY KEY,\r\n name VARCHAR(255),\r\n data_type CHAR(1)\r\n)\r\nSELECT TOP 10 *\r\nINTO #list\r\nFROM ##database_name##..##view_name##\r\n##filter##\r\n\r\nDECLARE @row_count INT\r\nSELECT @row_count = COUNT(*)\r\nFROM ##database_name##..##view_name##\r\n##filter##\r\n\r\nDECLARE c_curs CURSOR FOR \r\nSELECT c.name AS column_name, t.name AS data_type\r\nFROM tempdb.sys.columns c\r\nINNER JOIN tempdb.sys.types t ON c.user_type_id = t.user_type_id\r\n AND t.name NOT IN ('text','ntext','image','binary','varbinary','image','cursor','timestamp','hierarchyid','sql_variant','xml','table')\r\nWHERE object_id = object_id('tempdb..#list') \r\n AND ((t.name IN ('char','varchar') AND c.max_length <= 255)\r\n OR (t.name IN ('nchar','nvarchar') AND c.max_length <= 510)\r\n OR (t.name NOT IN ('char','varchar','nchar','nvarchar')))\r\n \r\nOPEN c_curs\r\nDECLARE @column_name VARCHAR(255), @column_type VARCHAR(255)\r\n\r\nFETCH NEXT FROM c_curs INTO @column_name, @column_type\r\nWHILE(@@FETCH_STATUS = 0)\r\nBEGIN \r\n DECLARE @data_type CHAR(1) = 'S'\r\n IF(@column_type IN ('date','datetime','datetime2','datetimeoffset','smalldatetime','time'))\r\n BEGIN\r\n SET @data_type = 'D'\r\n END\r\n ELSE IF(@column_type IN ('bit'))\r\n BEGIN\r\n SET @data_type = 'B'\r\n END\r\n ELSE IF(@column_type IN ('bigint','numeric','smallint','decimal','smallmoney','int','tinyint','money','float','real'))\r\n BEGIN\r\n SET @data_type = 'N'\r\n END\r\n INSERT INTO #columns(name, data_type) VALUES(@column_name, @data_type)\r\n FETCH NEXT FROM c_curs INTO @column_name, @column_type\r\nEND\r\nCLOSE c_curs\r\nDEALLOCATE c_curs\r\nSELECT * FROM #columns ORDER BY primary_key\r\nSELECT * FROM #list\r\nSELECT @row_count AS row_count\r\nDROP TABLE #columns\r\nDROP TABLE #list", "TestTargetSql": "CREATE TABLE #columns\r\n(\r\n primary_key INT NOT NULL IDENTITY(1,1) PRIMARY KEY,\r\n name VARCHAR(255),\r\n data_type CHAR(1)\r\n)\r\nSELECT TOP 10 *\r\nINTO #list\r\nFROM ##database_name##..##view_name##\r\n##filter##\r\n\r\nDECLARE @row_count INT\r\nSELECT @row_count = COUNT(*)\r\nFROM ##database_name##..##view_name##\r\n##filter##\r\n\r\nDECLARE c_curs CURSOR FOR \r\nSELECT c.name AS column_name, t.name AS data_type\r\nFROM tempdb.sys.columns c\r\nINNER JOIN tempdb.sys.types t ON c.user_type_id = t.user_type_id\r\n AND t.name NOT IN ('text','ntext','image','binary','varbinary','image','cursor','timestamp','hierarchyid','sql_variant','xml','table')\r\nWHERE object_id = object_id('tempdb..#list') \r\n AND ((t.name IN ('char','varchar') AND c.max_length <= 255)\r\n OR (t.name IN ('nchar','nvarchar') AND c.max_length <= 510)\r\n OR (t.name NOT IN ('char','varchar','nchar','nvarchar')))\r\n \r\nOPEN c_curs\r\nDECLARE @column_name VARCHAR(255), @column_type VARCHAR(255)\r\n\r\nFETCH NEXT FROM c_curs INTO @column_name, @column_type\r\nWHILE(@@FETCH_STATUS = 0)\r\nBEGIN \r\n DECLARE @data_type CHAR(1) = 'S'\r\n IF(@column_type IN ('date','datetime','datetime2','datetimeoffset','smalldatetime','time'))\r\n BEGIN\r\n SET @data_type = 'D'\r\n END\r\n ELSE IF(@column_type IN ('bit'))\r\n BEGIN\r\n SET @data_type = 'B'\r\n END\r\n ELSE IF(@column_type IN ('bigint','numeric','smallint','decimal','smallmoney','int','tinyint','money','float','real'))\r\n BEGIN\r\n SET @data_type = 'N'\r\n END\r\n INSERT INTO #columns(name, data_type) VALUES(@column_name, @data_type)\r\n FETCH NEXT FROM c_curs INTO @column_name, @column_type\r\nEND\r\nCLOSE c_curs\r\nDEALLOCATE c_curs\r\nSELECT * FROM #columns ORDER BY primary_key\r\nSELECT * FROM #list\r\nSELECT @row_count AS row_count\r\nDROP TABLE #columns\r\nDROP TABLE #list",
"ConnectionStringTemplate": "data source=##server_name##,##port##;initial catalog=##database_name##;User ID=##username##;Password=##password##;persist security info=False;packet size=4096;TrustServerCertificate=True;", "ConnectionStringTemplate": "data source=##server_name##,##port##;initial catalog=##database_name##;User ID=##username##;Password=##password##;persist security info=False;packet size=4096;TrustServerCertificate=True;",

View File

@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Application.DTOs.AuthApi
{
public class AuthResponse
{
public string accessToken { get; set; } = "";
public string refreshToken { get; set; } = "";
public User? user { get; set; }
public string message { get; set; } = "";
}
}

View File

@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Application.DTOs.AuthApi
{
public class AuthenticateApiRequest
{
public required string appCode { get; set; }
public required string username { get; set; }
public required string password { get; set; }
}
}

View File

@ -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.AuthApi
{
public class RefreshTokenApiRequest
{
public string appCode { get; set; } = "";
public string refreshToken { get; set; } = "";
}
}

View File

@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Application.DTOs.AuthApi
{
public class User
{
public int? UserKey { get; set; }
public Guid UserId { get; set; }
public string Username { get; set; } = "";
public string FirstName { get; set; } = "";
public string MiddleInitial { get; set; } = "";
public string LastName { get; set; } = "";
public bool IsActive { get; set; }
public List<string> Roles { get; set; } = new List<string>();
public User() { }
}
}

View File

@ -1,4 +1,4 @@
using Surge365.MassEmailReact.Domain.Entities; using Surge365.MassEmailReact.Application.DTOs.AuthApi;
namespace Surge365.MassEmailReact.Application.Interfaces namespace Surge365.MassEmailReact.Application.Interfaces
{ {

View File

@ -1,21 +0,0 @@
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);
Task<User?> Authenticate(string refreshToken);
Task<User?> GetByUsername(string username);
Task<User?> GetByKey(int userKey);
Task<User?> GetById(Guid userId);
Task<List<User>> GetAll(bool activeOnly = true);
Task<bool> SaveRefreshToken(Guid userId, string refreshToken, string? previousToken = "");
}
}

View File

@ -1,37 +0,0 @@
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; }
public List<string> Roles { get; private set; } = new List<string>();
public User() { }
//private User(int userKey, Guid userId, string username, string? firstName, string? middleInitial, string? lastName, bool isActive, List<string> roles)
//{
// UserKey = userKey;
// UserId = userId;
// Username = username;
// FirstName = firstName ?? "";
// MiddleInitial = middleInitial ?? "";
// LastName = lastName ?? "";
// IsActive = isActive;
// Roles = roles;
//}
//public static User Create(int userKey, Guid userId, string username, string? firstName, string? middleInitial, string? lastName, bool isActive, List<string> roles)
//{
// return new User(userKey, userId, username, firstName, middleInitial, lastName, isActive, roles);
//}
}
}

View File

@ -30,7 +30,6 @@ namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
config.AddMap(new MailingTemplateMap()); config.AddMap(new MailingTemplateMap());
config.AddMap(new MailingTargetMap()); config.AddMap(new MailingTargetMap());
config.AddMap(new MailingStatisticMap()); config.AddMap(new MailingStatisticMap());
config.AddMap(new UserMap());
}); });
} }
} }

View File

@ -1,25 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Dapper.FluentMap.Mapping;
using Surge365.MassEmailReact.Domain.Entities;
namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
{
public class UserMap : EntityMap<User>
{
public UserMap()
{
Map(u => u.UserKey).ToColumn("login_key");
Map(u => u.UserId).ToColumn("login_id");
Map(u => u.Username).ToColumn("username");
Map(u => u.FirstName).ToColumn("first_name");
Map(u => u.MiddleInitial).ToColumn("middle_initial");
Map(u => u.LastName).ToColumn("last_name");
Map(u => u.IsActive).ToColumn("is_active");
Map(u => u.Roles).ToColumn("roles");
}
}
}

View File

@ -0,0 +1,36 @@
using Azure.Extensions.AspNetCore.Configuration.Secrets;
using Azure.Security.KeyVault.Secrets;
namespace Surge365.MassEmailReact.Infrastructure
{
public class SurgeKeyVaultSecretManager : KeyVaultSecretManager
{
public override bool Load(SecretProperties secret)
{
return true;
}
public override string GetKey(KeyVaultSecret secret)
{
// Transform secret name back to configuration key
// Replace double hyphens (--) with colons (:) and single hyphens (-) with periods (.)
/*
* Example:
* AppSettings.json =
{
"AdminAuth": {
"ApiKey": "abcde"
},
"AdminAuth.ConnectionString":"abc"
}
* _config path = "AdminAuth:ApiKey"
* Environment Variable = "AdminAuth__ApiKey"
* Azure Key Vault = "AdminAuth--ApiKey"
*
* _config path = "AdminAuth.ConnectionString"
* Environment Variable = "AdminAuth.ConnectionString"
* Azure Key Vault = "AdminAuth-ConnectionString"
*/
return secret.Name.Replace("--", ":").Replace("-", ".");
}
}
}

View File

@ -1,167 +0,0 @@
using Dapper;
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.Threading.Tasks;
namespace Surge365.MassEmailReact.Infrastructure.Repositories
{
public class UserRepository : IUserRepository
{
private readonly IConfiguration _config;
private const string _connectionStringName = "Marketing.ConnectionString";
private const string _refreshTokenConnectionStringName = "MassEmail.ConnectionString";
public UserRepository(IConfiguration config)
{
_config = config ?? throw new ArgumentNullException(nameof(config));
}
public string AppCode
{
get
{
return _config?["AuthAppCode"] ?? Utilities.GetAppCode(_config);
}
}
private string ConnectionString => _config.GetConnectionString(_connectionStringName) ?? "";
private string RefreshTokenConnectionString => _config.GetConnectionString(_refreshTokenConnectionStringName) ?? "";
public async Task<(User? user, string message)> Authenticate(string username, string password)
{
using var connection = new SqlConnection(ConnectionString);
var parameters = new DynamicParameters();
parameters.Add("username", username);
parameters.Add("password", password);
parameters.Add("application_code", AppCode);
parameters.Add("response_number", dbType: DbType.Int16, direction: ParameterDirection.Output);
parameters.Add("login_key", dbType: DbType.Int32, direction: ParameterDirection.Output);
var result = await connection.QueryAsync<User>(
"adm_authenticate_login",
parameters,
commandType: CommandType.StoredProcedure
);
var responseNumber = parameters.Get<short>("response_number");
var authResult = (AuthResult)responseNumber;
string responseMessage = authResult.GetMessage();
if (authResult == AuthResult.Success)
{
var user = result.FirstOrDefault();
return (user, responseMessage);
}
return (null, responseMessage);
}
public async Task<User?> Authenticate(string refreshToken)
{
using var connection = new SqlConnection(RefreshTokenConnectionString);
var parameters = new DynamicParameters();
parameters.Add("@@token_hash", HashToken(refreshToken));
parameters.Add("@valid", dbType: DbType.Boolean, direction: ParameterDirection.Output);
parameters.Add("@user_guid", dbType: DbType.Guid, direction: ParameterDirection.Output);
await connection.ExecuteAsync(
"mem_validate_user_refresh_token",
parameters,
commandType: CommandType.StoredProcedure
);
if (!parameters.Get<bool>("@valid"))
return null;
return await GetById(parameters.Get<Guid>("@user_guid"));
}
public async Task<User?> GetByUsername(string username)
{
using var connection = new SqlConnection(ConnectionString);
var parameters = new { username, application_code = AppCode };
return await connection.QueryFirstOrDefaultAsync<User>(
"adm_get_login_by_username",
parameters,
commandType: CommandType.StoredProcedure
);
}
public async Task<User?> GetByKey(int userKey)
{
using var connection = new SqlConnection(ConnectionString);
var parameters = new { login_key = userKey, application_code = AppCode };
return await connection.QueryFirstOrDefaultAsync<User>(
"adm_get_adm_login_by_key",
parameters,
commandType: CommandType.StoredProcedure
);
}
public async Task<User?> GetById(Guid userId)
{
using var connection = new SqlConnection(ConnectionString);
var parameters = new { login_id = userId, application_code = AppCode };
return await connection.QueryFirstOrDefaultAsync<User>(
"adm_get_adm_login_by_id",
parameters,
commandType: CommandType.StoredProcedure
);
}
public async Task<List<User>> GetAll(bool activeOnly = true)
{
using var connection = new SqlConnection(ConnectionString);
var parameters = new { active_only = activeOnly, application_code = AppCode };
var users = await connection.QueryAsync<User>(
"adm_get_adm_login_all",
parameters,
commandType: CommandType.StoredProcedure
);
return users.AsList();
}
public async Task<bool> SaveRefreshToken(Guid userId, string token, string? previousToken = "")
{
using var connection = new SqlConnection(RefreshTokenConnectionString);
var parameters = new DynamicParameters();
parameters.Add("@user_guid", userId);
parameters.Add("@token_hash", HashToken(token));
parameters.Add("@previous_token_hash", HashToken(previousToken ?? ""));
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
await connection.ExecuteAsync(
"mem_save_user_refresh_token",
parameters,
commandType: CommandType.StoredProcedure
);
return parameters.Get<bool>("@success");
}
private string HashToken(string token)
{
if (string.IsNullOrEmpty(token))
{
return "";
}
using (var sha256 = System.Security.Cryptography.SHA256.Create())
{
// Convert the token string to bytes
byte[] tokenBytes = System.Text.Encoding.UTF8.GetBytes(token);
// Compute the hash
byte[] hashBytes = sha256.ComputeHash(tokenBytes);
// Convert the hash to a hexadecimal string
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
}
}
}

View File

@ -8,108 +8,114 @@ using System.Threading.Tasks;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Surge365.MassEmailReact.Domain.Entities; using System.Net.Http.Json;
using System.Security.Cryptography; using Surge365.MassEmailReact.Application.DTOs.AuthApi;
using System.Data;
namespace Surge365.MassEmailReact.Infrastructure.Services namespace Surge365.MassEmailReact.Infrastructure.Services
{ {
public class AuthService : IAuthService public class AuthService : IAuthService
{ {
private const int TOKEN_MINUTES = 5;
private readonly IUserRepository _userRepository;
private readonly IConfiguration _config; private readonly IConfiguration _config;
string ApiUrl
public AuthService(IUserRepository userRepository, IConfiguration config) {
get
{
var apiUrl = _config["AdminAuth:Url"];
if (string.IsNullOrEmpty(apiUrl))
throw new Exception("Auth URL is not configured");
return apiUrl;
}
}
string ApiKey
{
get
{
var apiKey = _config["AdminAuth:ApiKey"];
if (string.IsNullOrEmpty(apiKey))
throw new Exception("Api Key is not configured");
return apiKey;
}
}
string AuthAppCode
{
get
{
var appCode = _config["AuthAppCode"];
if (string.IsNullOrEmpty(appCode))
throw new Exception("Auth App Code is not configured");
return appCode;
}
}
public AuthService(IConfiguration config)
{ {
_userRepository = userRepository;
_config = config; _config = config;
} }
public async Task<(bool authenticated, (User user, string accessToken, string refreshToken)? data, string errorMessage)> Authenticate(string refreshToken) public async Task<(bool authenticated, (User user, string accessToken, string refreshToken)? data, string errorMessage)> Authenticate(string refreshToken)
{ {
var user = await _userRepository.Authenticate(refreshToken); var authResponse = await AuthenticateAtApi(refreshToken);
if (user == null) if (!authResponse.success) return (false, null, authResponse.authResponse.message ?? "Authentication failed");
return (false, null, "Not authenticated"); if (string.IsNullOrWhiteSpace(authResponse.authResponse.accessToken) || string.IsNullOrWhiteSpace(authResponse.authResponse.refreshToken) || authResponse.authResponse.user == null)
return (false, null, authResponse.authResponse.message ?? "Authentication failed");
var tokenResponse = await GenerateTokens(user.UserId, refreshToken); return (true, (authResponse.authResponse.user, authResponse.authResponse.accessToken, authResponse.authResponse.refreshToken), "");
if(tokenResponse == null)
return (false, null, "Error generating tokens");
return (true, tokenResponse, "");
} }
public async Task<(bool authenticated, (User user, string accessToken, string refreshToken)? data, string errorMessage)> Authenticate(string username, string password) public async Task<(bool authenticated, (User user, string accessToken, string refreshToken)? data, string errorMessage)> Authenticate(string username, string password)
{ {
var authResponse = await _userRepository.Authenticate(username, password); var authResponse = await AuthenticateAtApi(username, password);
if (authResponse.user == null) if (!authResponse.success) return (false, null, "Authentication failed");
{
return (false, null, authResponse.message);
}
// Generate JWT token
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!);
var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, authResponse.user.UserId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
claims.AddRange(authResponse.user.Roles.Select(role => new Claim(ClaimTypes.Role, role)));
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddMinutes(TOKEN_MINUTES),
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(key),
SecurityAlgorithms.HmacSha256Signature
)
};
var token = tokenHandler.CreateToken(tokenDescriptor); if (string.IsNullOrWhiteSpace(authResponse.authResponse.accessToken) || string.IsNullOrWhiteSpace(authResponse.authResponse.refreshToken) || authResponse.authResponse.user == null)
var accessToken = tokenHandler.WriteToken(token); return (false, null, authResponse.authResponse.message ?? "Authentication failed");
string refreshToken = Convert.ToBase64String(Guid.NewGuid().ToByteArray());
if (!await _userRepository.SaveRefreshToken(authResponse.user.UserId, refreshToken))
return (false, null, "Error saving token");
return (true, (authResponse.user, accessToken, refreshToken), ""); return (true, (authResponse.authResponse.user, authResponse.authResponse.accessToken, authResponse.authResponse.refreshToken), "");
}
private async Task<(User user, string accessToken, string refreshToken)?> GenerateTokens(Guid userId, string previousRefreshToken)
{
var user = await _userRepository.GetById(userId);
if (user == null)
{
return null;
} }
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!);
var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, user.UserId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, user.Username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
claims.AddRange(user.Roles.Select(role => new Claim(ClaimTypes.Role, role)));
var tokenDescriptor = new SecurityTokenDescriptor async Task<(bool success, AuthResponse authResponse)> AuthenticateAtApi(string refreshToken)
{ {
Subject = new ClaimsIdentity(claims), var httpClient = new HttpClient();
Expires = DateTime.UtcNow.AddMinutes(TOKEN_MINUTES), httpClient.BaseAddress = new Uri(ApiUrl);
SigningCredentials = new SigningCredentials( httpClient.DefaultRequestHeaders.Add("X-Api-Key", ApiKey);
new SymmetricSecurityKey(key), RefreshTokenApiRequest request = new RefreshTokenApiRequest()
SecurityAlgorithms.HmacSha256Signature {
) appCode = AuthAppCode,
refreshToken = refreshToken
}; };
var token = tokenHandler.CreateToken(tokenDescriptor); var response = await httpClient.PostAsJsonAsync("authentication/refreshtoken", request);
var accessToken = tokenHandler.WriteToken(token);
var newRefreshToken = Convert.ToBase64String(Guid.NewGuid().ToByteArray());
if (!await _userRepository.SaveRefreshToken(user.UserId, newRefreshToken, previousRefreshToken))
return null;
return (user, accessToken, newRefreshToken); if (!response.IsSuccessStatusCode) return (false, new AuthResponse() { message = "Authentication failed" });
var authResponse = await response.Content.ReadFromJsonAsync<AuthResponse>();
if (authResponse?.user == null) return (false, new AuthResponse() { message = "Authentication failed" });
return (true, authResponse);
}
async Task<(bool success, AuthResponse authResponse)> AuthenticateAtApi(string username, string password)
{
var httpClient = new HttpClient();
httpClient.BaseAddress = new Uri(ApiUrl);
httpClient.DefaultRequestHeaders.Add("X-Api-Key", ApiKey);
AuthenticateApiRequest request = new AuthenticateApiRequest()
{
appCode = AuthAppCode,
username = username,
password = password
};
var response = await httpClient.PostAsJsonAsync("authentication/authenticate", request);
if (!response.IsSuccessStatusCode) return (false, new AuthResponse() { message = "Authentication failed" });
var authResponse = await response.Content.ReadFromJsonAsync<AuthResponse>();
if (authResponse?.user == null) return (false, new AuthResponse() { message = "Authentication failed" });
return (true, authResponse);
} }
} }
} }

View File

@ -12,6 +12,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.4.0" />
<PackageReference Include="Dapper.FluentMap" Version="2.0.0" /> <PackageReference Include="Dapper.FluentMap" Version="2.0.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.1" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.4" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.4" />