Add JWT, OTP, and caching features

Upgraded projects to .NET 9.0 and added new projects `KBR.Cache`, `KBR.Shared`, and `KBR.Shared.Lite` to the solution. Introduced JWT authentication and OTP handling with new models, services, and configuration options. Updated database schema with new entities `Users` and `RefreshTokens`, and added migrations for schema changes. Implemented caching strategies using `AppDistributedCache` with support for in-memory, SQL Server, and Redis. Enhanced email handling with `MailHelpers` for domain replacement. Updated controllers, repositories, and configuration files to support new features.
This commit is contained in:
2025-11-22 06:52:59 +05:30
parent 912457755e
commit 3a7edb23c7
68 changed files with 13506 additions and 147 deletions
+96
View File
@@ -0,0 +1,96 @@
using KBR.Shared.Cache;
using Microsoft.Extensions.Caching.Distributed;
namespace KBR.Cache
{
internal class AppDistributedCache : IAppDistributedCache
{
private readonly IDistributedCache _cache;
private readonly DistributedCacheEntryOptions _entryOptions;
public AppDistributedCache(IDistributedCache cache)
{
this._cache = cache;
this._entryOptions = new DistributedCacheEntryOptions();
}
public T Get<T>(string key)
where T : class
{
if (this._cache.Get(key) is var result && result != null)
{
return result.FromByteArray<T>();
}
return default;
}
public async Task<T> GetAsync<T>(string key, CancellationToken cancellationToken = default)
where T : class
{
var result = await this._cache.GetAsync(key, cancellationToken);
if (result != null)
{
return result.FromByteArray<T>();
}
return default;
}
public void Set<T>(string key, T obj, DistributedCacheEntryOptions? entryOptions = default)
{
this._cache.Set(key, obj.ToByteArray(), entryOptions ?? this._entryOptions);
}
public async Task SetAsync<T>(string key, T obj, DistributedCacheEntryOptions? entryOptions = default, CancellationToken cancellationToken = default)
{
await this._cache.SetAsync(key, obj.ToByteArray(), entryOptions ?? this._entryOptions, cancellationToken);
}
public void Remove(string key)
{
this._cache.Remove(key);
}
public async Task RemoveAsync(string key, CancellationToken cancellationToken = default)
{
await this._cache.RemoveAsync(key, cancellationToken);
}
public TItem GetOrCreate<TItem>(string key, Func<TItem> factory)
where TItem : class
{
byte[] result = this._cache.Get(key);
var resultData = result?.FromByteArray<TItem>();
if (result == null || resultData == null)
{
var data = factory();
this._cache.Set(key, data.ToByteArray(), this._entryOptions);
return data;
}
return resultData;
}
public async Task<TItem> GetOrCreateAsync<TItem>(string key, Func<Task<TItem>> factory, CancellationToken cancellationToken = default)
where TItem : class
{
byte[] result = await this._cache.GetAsync(key, cancellationToken);
var resultData = result?.FromByteArray<TItem>();
if (result == null || resultData == null)
{
var data = await factory();
if (data != null)
{
await this._cache.SetAsync(key, data.ToByteArray(), this._entryOptions, cancellationToken);
}
return data;
}
return resultData;
}
}
}
+24
View File
@@ -0,0 +1,24 @@
using KBR.Shared.Cache.Models;
using Microsoft.Extensions.Caching.Distributed;
namespace KBR.Cache
{
public static class CacheHelpers
{
private static readonly string _userOtpSecrets = nameof(_userOtpSecrets);
private static string UserOtpSecretKey(string key) => $"{_userOtpSecrets}-{key}";
public static async Task SetUserSecretsCacheAsync(IAppDistributedCache cache, string userId, string secret, CancellationToken cancellationToken = default)
{
await cache.SetAsync(UserOtpSecretKey(userId), secret, new DistributedCacheEntryOptions { AbsoluteExpiration = DateTime.UtcNow.AddMinutes(3) }, cancellationToken);
}
public static async Task<string> GetUserOtpSecretAsync(IAppDistributedCache cache, string userId, CancellationToken cancellationToken = default)
{
var key = await cache.GetAsync<string>(UserOtpSecretKey(userId), cancellationToken);
return key;
}
}
}
@@ -0,0 +1,11 @@
namespace KBR.Cache.Constants
{
public static class CacheProviders
{
public const string InMemory = nameof(InMemory);
public const string SqlServer = nameof(SqlServer);
public const string Redis = nameof(Redis);
}
}
@@ -0,0 +1,47 @@
using KBR.Cache.Constants;
using KBR.Cache.Options;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using System.Diagnostics.CodeAnalysis;
namespace KBR.Cache.Extensions
{
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddCache([NotNull] this IServiceCollection services, IConfiguration configuration)
{
// Ensure the 'Microsoft.Extensions.Options.ConfigurationExtensions' package is referenced in your project
var cacheOptions = configuration.GetSection("Cache").Get<CacheOptions>();
if (cacheOptions == null || cacheOptions.Provider == CacheProviders.InMemory)
{
services.AddDistributedMemoryCache();
}
else if (cacheOptions.Provider == CacheProviders.SqlServer)
{
services.AddDistributedMySqlCache(options =>
{
options.ConnectionString = cacheOptions.ConnectionString;
options.TableName = "Cache";
});
}
else if (cacheOptions.Provider == CacheProviders.Redis)
{
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = cacheOptions.ConnectionString;
options.InstanceName = "SampleInstance";
});
}
else
{
throw new Exception("Cache options are not valid");
}
services.TryAddSingleton<IAppDistributedCache, AppDistributedCache>();
return services;
}
}
}
+27
View File
@@ -0,0 +1,27 @@
using Microsoft.Extensions.Caching.Distributed;
namespace KBR.Cache
{
public interface IAppDistributedCache
{
T Get<T>(string key)
where T : class;
Task<T> GetAsync<T>(string key, CancellationToken cancellationToken = default)
where T : class;
void Remove(string key);
Task RemoveAsync(string key, CancellationToken cancellationToken = default);
void Set<T>(string key, T obj, DistributedCacheEntryOptions? options = default);
Task SetAsync<T>(string key, T obj, DistributedCacheEntryOptions? options = default, CancellationToken cancellationToken = default);
TItem GetOrCreate<TItem>(string key, Func<TItem> factory)
where TItem : class;
Task<TItem> GetOrCreateAsync<TItem>(string key, Func<Task<TItem>> factory, CancellationToken cancellationToken = default)
where TItem : class;
}
}
+26
View File
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.9" />
<PackageReference Include="Pomelo.Extensions.Caching.MySql" Version="2.2.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\KBR.Share.Lite\KBR.Shared.Lite.csproj" />
<ProjectReference Include="..\KBR.Shared\KBR.Shared.csproj" />
</ItemGroup>
</Project>
+9
View File
@@ -0,0 +1,9 @@
namespace KBR.Cache.Options
{
public class CacheOptions
{
public string Provider { get; set; } = null!;
public string? ConnectionString { get; set; }
}
}
@@ -0,0 +1,76 @@
using Newtonsoft.Json;
namespace KBR.Shared.Lite.Extensions
{
public static class JsonExtensions
{
public static T? ToObject<T>(string value)
{
return JsonConvert.DeserializeObject<T>(value);
}
public static T? ToObjectSystemText<T>(string value)
{
return System.Text.Json.JsonSerializer.Deserialize<T>(value);
}
public static T? ToObject<T>(string value, Type destType)
{
var result = JsonConvert.DeserializeObject(value, destType);
if (result is null)
{
return default;
}
return (T)result;
}
public static object? ToObject(string value, Type destType)
{
return ToObject<object?>(value, destType);
}
public static T? ToObject<T>(string value, JsonConverter[] converters)
{
return JsonConvert.DeserializeObject<T>(value, converters);
}
public static T? ToObject<T>(string value, Type destType, JsonConverter[] converters)
{
var result = JsonConvert.DeserializeObject(value, destType, converters);
if (result is null)
{
return default;
}
return (T)result;
}
public static string ToJson<T>(T value)
{
return JsonConvert.SerializeObject(value);
}
public static string ToJsonSystemText<T>(T value)
{
return System.Text.Json.JsonSerializer.Serialize(value);
}
public static string ToJson<T>(T value, JsonSerializerSettings jsonSerializerSettings)
{
return JsonConvert.SerializeObject(value, jsonSerializerSettings);
}
public static byte[] ToUtf8Bytes(object obj)
{
return System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(obj);
}
public static T? ToObject<T>(byte[] utf8Json)
{
return System.Text.Json.JsonSerializer.Deserialize<T>(utf8Json);
}
}
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
</Project>
@@ -0,0 +1,28 @@
using KBR.Shared.Lite.Extensions;
namespace KBR.Shared.Cache
{
public static class CacheExtensions
{
public static byte[] ToByteArray(this object obj)
{
if (obj is not null)
{
return JsonExtensions.ToUtf8Bytes(obj);
}
return Array.Empty<byte>();
}
public static T? FromByteArray<T>(this byte[] byteArray)
where T : class
{
if (byteArray is null)
{
return default;
}
return JsonExtensions.ToObject<T>(byteArray);
}
}
}
+21
View File
@@ -0,0 +1,21 @@
using KBR.Shared.Cache.Models;
using Microsoft.Extensions.Caching.Distributed;
namespace KBR.Shared.Cache
{
public static class CacheHelpers
{
private static readonly string _userSessions = nameof(_userSessions);
private static string UserSessionsKey(string key) => $"{_userSessions}-{key}";
public static async Task SetUserSessionsCacheAsync(IAppDistributedCache cache, string userId, UserSessionModel sessions, CancellationToken cancellationToken = default)
{
await cache.SetAsync(UserSessionsKey(userId), sessions, GetOptions(), cancellationToken);
}
private static DistributedCacheEntryOptions GetOptions()
{
return new DistributedCacheEntryOptions { AbsoluteExpiration = DateTime.MaxValue };
}
}
}
@@ -0,0 +1,27 @@
using Microsoft.Extensions.Caching.Distributed;
namespace KBR.Shared.Cache
{
public interface IAppDistributedCache
{
T Get<T>(string key)
where T : class;
Task<T> GetAsync<T>(string key, CancellationToken cancellationToken = default)
where T : class;
void Remove(string key);
Task RemoveAsync(string key, CancellationToken cancellationToken = default);
void Set<T>(string key, T obj, DistributedCacheEntryOptions? options = default);
Task SetAsync<T>(string key, T obj, DistributedCacheEntryOptions? options = default, CancellationToken cancellationToken = default);
TItem GetOrCreate<TItem>(string key, Func<TItem> factory)
where TItem : class;
Task<TItem> GetOrCreateAsync<TItem>(string key, Func<Task<TItem>> factory, CancellationToken cancellationToken = default)
where TItem : class;
}
}
@@ -0,0 +1,15 @@
namespace KBR.Shared.Cache.Models
{
public class UserSessionModel
{
public UserSessionModel()
{
Alive = new();
Killed = new();
}
public List<string> Alive { get; set; }
public List<string> Killed { get; set; }
}
}
+17
View File
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.9" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\KBR.Share.Lite\KBR.Shared.Lite.csproj" />
</ItemGroup>
</Project>
+21
View File
@@ -0,0 +1,21 @@
namespace KBR.Shared.Mail
{
public static class MailHelpers
{
// Replace email with other domain passed as a parameter
public static string ReplaceEmailDomain(string email, string newDomain)
{
if (string.IsNullOrWhiteSpace(email))
throw new ArgumentException("Email cannot be null or empty.", nameof(email));
if (string.IsNullOrWhiteSpace(newDomain))
throw new ArgumentException("New domain cannot be null or empty.", nameof(newDomain));
var atIndex = email.IndexOf('@');
if (atIndex == -1)
throw new ArgumentException("Invalid email format.", nameof(email));
return $"{email.Substring(0, atIndex + 1)}{newDomain}";
}
}
}