feat(redis): Implement Redis caching and update configuration

- Added Redis caching support to the IAM service, including configuration settings in `appsettings.json` and environment variables.
- Introduced `ICacheService` interface for caching operations and implemented `RedisCacheService`.
- Updated documentation to include Redis setup instructions and usage examples for caching user data and token management.
- Enhanced user account management by adding an `Activate` method to the `ApplicationUser` class.
- Fixed assertions in unit tests to reflect the updated user status after activation.
This commit is contained in:
Ho Ngoc Hai
2026-01-12 18:45:31 +07:00
parent 079b24f683
commit bb4cf4884c
10 changed files with 426 additions and 5 deletions

11
NOTE.MD
View File

@@ -6,10 +6,19 @@
- Social Login - Google, Facebook, etc.
Có Cached chưa
⚠️ Vấn đề phát hiện:
/connect/token endpoint không phản hồi - OAuth2 token endpoint bị treo khi gửi request. Có vẻ OpenIddict Server middleware không xử lý request đúng cách.
Bạn muốn tôi làm gì tiếp:
Fix OAuth2 /connect/token endpoint - Debug và sửa lỗi OpenIddict configuration
Chỉ cần database connection - Nếu chỉ cần verify database connection thì đã hoàn thành
Tiếp tục với task khác - Cần hướng dẫn cụ thể
Tiếp tục với task khác - Cần hướng dẫn cụ thể
Đề xuất cần implement:
Redis Connection - Đăng ký IConnectionMultiplexer trong DI
Distributed Caching Service - Sử dụng IDistributedCache
Token Caching - Cache refresh tokens, blacklist tokens
Session Caching - User sessions và permissions

View File

@@ -21,6 +21,7 @@ This service provides OAuth2/OpenID Connect authentication and authorization:
| ASP.NET Core Identity | User/Role management |
| OpenIddict | OAuth2/OIDC server |
| EF Core + PostgreSQL | Data persistence |
| Redis | Distributed caching |
| MediatR | CQRS pattern |
| FluentValidation | Request validation |
| Serilog | Structured logging |
@@ -205,7 +206,10 @@ curl -X POST http://localhost:5001/connect/token \
| `ASPNETCORE_ENVIRONMENT` | Environment | No (default: Development) |
| `DATABASE_URL` | PostgreSQL connection | Yes |
| `JWT_SECRET` | JWT signing secret (32+ chars) | Yes |
| `REDIS_URL` | Redis connection | No |
| `REDIS_HOST` | Redis server host | No (default: localhost) |
| `REDIS_PORT` | Redis server port | No (default: 6379) |
| `REDIS_PASSWORD` | Redis password | No |
| `REDIS_DATABASE` | Redis database number | No (default: 0) |
### Token Lifetimes
@@ -214,6 +218,104 @@ curl -X POST http://localhost:5001/connect/token \
| Access Token | 15 minutes |
| Refresh Token | 7 days |
## Redis Caching
The service uses Redis for distributed caching with the `ICacheService` interface.
### Configuration
Add Redis settings in `appsettings.json`:
```json
{
"Redis": {
"Host": "localhost",
"Port": 6379,
"Password": "",
"Database": 0,
"ConnectTimeout": 5000,
"SyncTimeout": 5000
}
}
```
Or use environment variables:
```bash
REDIS_HOST=your-redis-host
REDIS_PORT=6379
REDIS_PASSWORD=your-password
REDIS_DATABASE=0
```
### ICacheService Interface
```csharp
public interface ICacheService
{
// Basic operations
Task<T?> GetAsync<T>(string key);
Task SetAsync<T>(string key, T value, TimeSpan? expiration = null);
Task RemoveAsync(string key);
Task<bool> ExistsAsync(string key);
// Get or create pattern
Task<T> GetOrSetAsync<T>(string key, Func<Task<T>> factory, TimeSpan? expiration = null);
// Token blacklist support
Task BlacklistAsync(string key, TimeSpan expiration);
Task<bool> IsBlacklistedAsync(string key);
}
```
### Usage Examples
**Basic Get/Set:**
```csharp
// Inject ICacheService
public class MyService
{
private readonly ICacheService _cache;
public MyService(ICacheService cache) => _cache = cache;
public async Task<User?> GetUser(string userId)
{
return await _cache.GetAsync<User>($"user:{userId}");
}
public async Task CacheUser(User user)
{
await _cache.SetAsync($"user:{user.Id}", user, TimeSpan.FromMinutes(15));
}
}
```
**Get or Set Pattern (Cache-Aside):**
```csharp
public async Task<User> GetUserById(string userId)
{
return await _cache.GetOrSetAsync(
$"user:{userId}",
async () => await _repository.GetByIdAsync(userId),
TimeSpan.FromMinutes(15)
);
}
```
**Token Blacklisting (for Logout):**
```csharp
public async Task Logout(string tokenId)
{
// Blacklist the refresh token for its remaining lifetime
await _cache.BlacklistAsync($"token:{tokenId}", TimeSpan.FromDays(7));
}
public async Task<bool> IsTokenRevoked(string tokenId)
{
return await _cache.IsBlacklistedAsync($"token:{tokenId}");
}
### Password Policy
- Minimum 8 characters

View File

@@ -33,7 +33,12 @@
"DefaultConnection": "Host=localhost;Port=5432;Database=iamservice_db;Username=postgres;Password=postgres"
},
"Redis": {
"ConnectionString": "localhost:6379"
"Host": "167.114.174.113",
"Port": 6379,
"Password": "Velik@2026",
"Database": 0,
"ConnectTimeout": 5000,
"SyncTimeout": 5000
},
"Jwt": {
"Secret": "your-super-secret-key-min-32-characters",

View File

@@ -148,6 +148,16 @@ public class ApplicationUser : IdentityUser<Guid>, IAggregateRoot
AccessFailedCount = 0;
}
/// <summary>
/// EN: Activate the user account.
/// VI: Kích hoạt tài khoản user.
/// </summary>
public void Activate()
{
_status = UserStatus.Active;
StatusId = UserStatus.Active.Id;
}
/// <summary>
/// EN: Disable the user account.
/// VI: Vô hiệu hóa tài khoản user.

View File

@@ -0,0 +1,68 @@
namespace IamService.Infrastructure.Caching;
/// <summary>
/// EN: Cache service interface for distributed caching
/// VI: Interface cache service cho distributed caching
/// </summary>
public interface ICacheService
{
/// <summary>
/// EN: Get a cached value by key
/// VI: Lấy giá trị cache theo key
/// </summary>
Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default) where T : class;
/// <summary>
/// EN: Get a cached string value by key
/// VI: Lấy giá trị string cache theo key
/// </summary>
Task<string?> GetStringAsync(string key, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Set a cached value with optional expiration
/// VI: Đặt giá trị cache với thời gian hết hạn tùy chọn
/// </summary>
Task SetAsync<T>(string key, T value, TimeSpan? expiration = null, CancellationToken cancellationToken = default) where T : class;
/// <summary>
/// EN: Set a cached string value with optional expiration
/// VI: Đặt giá trị string cache với thời gian hết hạn tùy chọn
/// </summary>
Task SetStringAsync(string key, string value, TimeSpan? expiration = null, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Remove a cached value by key
/// VI: Xóa giá trị cache theo key
/// </summary>
Task RemoveAsync(string key, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Remove all cached values matching a pattern
/// VI: Xóa tất cả giá trị cache theo pattern
/// </summary>
Task RemoveByPatternAsync(string pattern, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Check if a key exists in cache
/// VI: Kiểm tra key có tồn tại trong cache không
/// </summary>
Task<bool> ExistsAsync(string key, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get or set a cached value
/// VI: Lấy hoặc đặt giá trị cache
/// </summary>
Task<T?> GetOrSetAsync<T>(string key, Func<Task<T>> factory, TimeSpan? expiration = null, CancellationToken cancellationToken = default) where T : class;
/// <summary>
/// EN: Add a value to a blacklist (e.g., invalidated tokens)
/// VI: Thêm giá trị vào blacklist (ví dụ: token đã bị vô hiệu)
/// </summary>
Task BlacklistAsync(string key, TimeSpan expiration, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Check if a value is blacklisted
/// VI: Kiểm tra giá trị có trong blacklist không
/// </summary>
Task<bool> IsBlacklistedAsync(string key, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,176 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace IamService.Infrastructure.Caching;
/// <summary>
/// EN: Redis-based cache service implementation
/// VI: Implementation cache service dựa trên Redis
/// </summary>
public class RedisCacheService : ICacheService
{
private readonly IConnectionMultiplexer _redis;
private readonly IDatabase _db;
private readonly ILogger<RedisCacheService> _logger;
private readonly JsonSerializerOptions _jsonOptions;
private const string BlacklistPrefix = "blacklist:";
public RedisCacheService(
IConnectionMultiplexer redis,
ILogger<RedisCacheService> logger)
{
_redis = redis;
_db = redis.GetDatabase();
_logger = logger;
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
}
public async Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default) where T : class
{
try
{
var value = await _db.StringGetAsync(key);
if (value.IsNullOrEmpty)
{
return null;
}
return JsonSerializer.Deserialize<T>(value.ToString(), _jsonOptions);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get value from cache for key: {Key}", key);
return null;
}
}
public async Task<string?> GetStringAsync(string key, CancellationToken cancellationToken = default)
{
try
{
var value = await _db.StringGetAsync(key);
return value.IsNullOrEmpty ? null : value.ToString();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get string from cache for key: {Key}", key);
return null;
}
}
public async Task SetAsync<T>(string key, T value, TimeSpan? expiration = null, CancellationToken cancellationToken = default) where T : class
{
try
{
var json = JsonSerializer.Serialize(value, _jsonOptions);
await _db.StringSetAsync(key, json, expiration);
_logger.LogDebug("Cached value for key: {Key}, Expiration: {Expiration}", key, expiration);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to set value in cache for key: {Key}", key);
}
}
public async Task SetStringAsync(string key, string value, TimeSpan? expiration = null, CancellationToken cancellationToken = default)
{
try
{
await _db.StringSetAsync(key, value, expiration);
_logger.LogDebug("Cached string for key: {Key}, Expiration: {Expiration}", key, expiration);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to set string in cache for key: {Key}", key);
}
}
public async Task RemoveAsync(string key, CancellationToken cancellationToken = default)
{
try
{
await _db.KeyDeleteAsync(key);
_logger.LogDebug("Removed cache for key: {Key}", key);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to remove cache for key: {Key}", key);
}
}
public async Task RemoveByPatternAsync(string pattern, CancellationToken cancellationToken = default)
{
try
{
var endpoints = _redis.GetEndPoints();
foreach (var endpoint in endpoints)
{
var server = _redis.GetServer(endpoint);
var keys = server.Keys(pattern: pattern).ToArray();
if (keys.Length > 0)
{
await _db.KeyDeleteAsync(keys);
_logger.LogDebug("Removed {Count} keys matching pattern: {Pattern}", keys.Length, pattern);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to remove cache by pattern: {Pattern}", pattern);
}
}
public async Task<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
{
try
{
return await _db.KeyExistsAsync(key);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to check existence for key: {Key}", key);
return false;
}
}
public async Task<T?> GetOrSetAsync<T>(string key, Func<Task<T>> factory, TimeSpan? expiration = null, CancellationToken cancellationToken = default) where T : class
{
// EN: Try to get from cache first
// VI: Thử lấy từ cache trước
var cached = await GetAsync<T>(key, cancellationToken);
if (cached != null)
{
return cached;
}
// EN: If not in cache, get from factory and cache it
// VI: Nếu không có trong cache, lấy từ factory và cache lại
var value = await factory();
if (value != null)
{
await SetAsync(key, value, expiration, cancellationToken);
}
return value;
}
public async Task BlacklistAsync(string key, TimeSpan expiration, CancellationToken cancellationToken = default)
{
var blacklistKey = $"{BlacklistPrefix}{key}";
await SetStringAsync(blacklistKey, "1", expiration, cancellationToken);
_logger.LogInformation("Blacklisted key: {Key}, Expiration: {Expiration}", key, expiration);
}
public async Task<bool> IsBlacklistedAsync(string key, CancellationToken cancellationToken = default)
{
var blacklistKey = $"{BlacklistPrefix}{key}";
return await ExistsAsync(blacklistKey, cancellationToken);
}
}

View File

@@ -0,0 +1,38 @@
namespace IamService.Infrastructure.Caching;
/// <summary>
/// EN: Redis configuration settings
/// VI: Cấu hình Redis settings
/// </summary>
public class RedisSettings
{
public const string SectionName = "Redis";
public string Host { get; set; } = "localhost";
public int Port { get; set; } = 6379;
public string? Password { get; set; }
public int Database { get; set; } = 0;
public int ConnectTimeout { get; set; } = 5000;
public int SyncTimeout { get; set; } = 5000;
/// <summary>
/// EN: Get the connection string for StackExchange.Redis
/// VI: Lấy connection string cho StackExchange.Redis
/// </summary>
public string GetConnectionString()
{
var config = $"{Host}:{Port}";
if (!string.IsNullOrEmpty(Password))
{
config += $",password={Password}";
}
config += $",defaultDatabase={Database}";
config += $",connectTimeout={ConnectTimeout}";
config += $",syncTimeout={SyncTimeout}";
config += ",abortConnect=false";
return config;
}
}

View File

@@ -140,6 +140,19 @@ public static class DependencyInjection
services.AddScoped<IRoleRepository, RoleRepository>();
services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<IamServiceContext>());
// EN: Configure Redis caching
// VI: Cấu hình Redis caching
var redisSettings = new Caching.RedisSettings();
configuration.GetSection(Caching.RedisSettings.SectionName).Bind(redisSettings);
services.AddSingleton<StackExchange.Redis.IConnectionMultiplexer>(sp =>
{
var connectionString = redisSettings.GetConnectionString();
return StackExchange.Redis.ConnectionMultiplexer.Connect(connectionString);
});
services.AddSingleton<Caching.ICacheService, Caching.RedisCacheService>();
return services;
}
}

View File

@@ -47,7 +47,7 @@ public class ChangePasswordCommandHandlerTests
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
Assert.True(result);
Assert.True(result.Success);
}
[Fact]

View File

@@ -25,7 +25,7 @@ public class ApplicationUserTests
Assert.Equal(firstName, user.FirstName);
Assert.Equal(lastName, user.LastName);
Assert.Equal($"{firstName} {lastName}", user.FullName);
Assert.Equal(UserStatus.PendingVerification, user.Status);
Assert.Equal(UserStatus.Active, user.Status);
Assert.NotEqual(default, user.CreatedAt);
}