diff --git a/NOTE.MD b/NOTE.MD index 67f7a67e..4fb36729 100644 --- a/NOTE.MD +++ b/NOTE.MD @@ -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ể \ No newline at end of file +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 diff --git a/services/iam-service-net/docs/en/README.md b/services/iam-service-net/docs/en/README.md index 5a516b05..080f581f 100644 --- a/services/iam-service-net/docs/en/README.md +++ b/services/iam-service-net/docs/en/README.md @@ -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 GetAsync(string key); + Task SetAsync(string key, T value, TimeSpan? expiration = null); + Task RemoveAsync(string key); + Task ExistsAsync(string key); + + // Get or create pattern + Task GetOrSetAsync(string key, Func> factory, TimeSpan? expiration = null); + + // Token blacklist support + Task BlacklistAsync(string key, TimeSpan expiration); + Task 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 GetUser(string userId) + { + return await _cache.GetAsync($"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 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 IsTokenRevoked(string tokenId) +{ + return await _cache.IsBlacklistedAsync($"token:{tokenId}"); +} + ### Password Policy - Minimum 8 characters diff --git a/services/iam-service-net/src/IamService.API/appsettings.json b/services/iam-service-net/src/IamService.API/appsettings.json index 237d7fd4..a001a7de 100644 --- a/services/iam-service-net/src/IamService.API/appsettings.json +++ b/services/iam-service-net/src/IamService.API/appsettings.json @@ -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", diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/ApplicationUser.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/ApplicationUser.cs index 70c7df16..a3ca5fe9 100644 --- a/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/ApplicationUser.cs +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/ApplicationUser.cs @@ -148,6 +148,16 @@ public class ApplicationUser : IdentityUser, IAggregateRoot AccessFailedCount = 0; } + /// + /// EN: Activate the user account. + /// VI: Kích hoạt tài khoản user. + /// + public void Activate() + { + _status = UserStatus.Active; + StatusId = UserStatus.Active.Id; + } + /// /// EN: Disable the user account. /// VI: Vô hiệu hóa tài khoản user. diff --git a/services/iam-service-net/src/IamService.Infrastructure/Caching/ICacheService.cs b/services/iam-service-net/src/IamService.Infrastructure/Caching/ICacheService.cs new file mode 100644 index 00000000..33e6eee1 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/Caching/ICacheService.cs @@ -0,0 +1,68 @@ +namespace IamService.Infrastructure.Caching; + +/// +/// EN: Cache service interface for distributed caching +/// VI: Interface cache service cho distributed caching +/// +public interface ICacheService +{ + /// + /// EN: Get a cached value by key + /// VI: Lấy giá trị cache theo key + /// + Task GetAsync(string key, CancellationToken cancellationToken = default) where T : class; + + /// + /// EN: Get a cached string value by key + /// VI: Lấy giá trị string cache theo key + /// + Task GetStringAsync(string key, CancellationToken cancellationToken = default); + + /// + /// 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 + /// + Task SetAsync(string key, T value, TimeSpan? expiration = null, CancellationToken cancellationToken = default) where T : class; + + /// + /// 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 + /// + Task SetStringAsync(string key, string value, TimeSpan? expiration = null, CancellationToken cancellationToken = default); + + /// + /// EN: Remove a cached value by key + /// VI: Xóa giá trị cache theo key + /// + Task RemoveAsync(string key, CancellationToken cancellationToken = default); + + /// + /// EN: Remove all cached values matching a pattern + /// VI: Xóa tất cả giá trị cache theo pattern + /// + Task RemoveByPatternAsync(string pattern, CancellationToken cancellationToken = default); + + /// + /// EN: Check if a key exists in cache + /// VI: Kiểm tra key có tồn tại trong cache không + /// + Task ExistsAsync(string key, CancellationToken cancellationToken = default); + + /// + /// EN: Get or set a cached value + /// VI: Lấy hoặc đặt giá trị cache + /// + Task GetOrSetAsync(string key, Func> factory, TimeSpan? expiration = null, CancellationToken cancellationToken = default) where T : class; + + /// + /// 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) + /// + Task BlacklistAsync(string key, TimeSpan expiration, CancellationToken cancellationToken = default); + + /// + /// EN: Check if a value is blacklisted + /// VI: Kiểm tra giá trị có trong blacklist không + /// + Task IsBlacklistedAsync(string key, CancellationToken cancellationToken = default); +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/Caching/RedisCacheService.cs b/services/iam-service-net/src/IamService.Infrastructure/Caching/RedisCacheService.cs new file mode 100644 index 00000000..b5270c25 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/Caching/RedisCacheService.cs @@ -0,0 +1,176 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; + +namespace IamService.Infrastructure.Caching; + +/// +/// EN: Redis-based cache service implementation +/// VI: Implementation cache service dựa trên Redis +/// +public class RedisCacheService : ICacheService +{ + private readonly IConnectionMultiplexer _redis; + private readonly IDatabase _db; + private readonly ILogger _logger; + private readonly JsonSerializerOptions _jsonOptions; + + private const string BlacklistPrefix = "blacklist:"; + + public RedisCacheService( + IConnectionMultiplexer redis, + ILogger logger) + { + _redis = redis; + _db = redis.GetDatabase(); + _logger = logger; + _jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + } + + public async Task GetAsync(string key, CancellationToken cancellationToken = default) where T : class + { + try + { + var value = await _db.StringGetAsync(key); + if (value.IsNullOrEmpty) + { + return null; + } + + return JsonSerializer.Deserialize(value.ToString(), _jsonOptions); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get value from cache for key: {Key}", key); + return null; + } + } + + public async Task 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(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 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 GetOrSetAsync(string key, Func> 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(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 IsBlacklistedAsync(string key, CancellationToken cancellationToken = default) + { + var blacklistKey = $"{BlacklistPrefix}{key}"; + return await ExistsAsync(blacklistKey, cancellationToken); + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/Caching/RedisSettings.cs b/services/iam-service-net/src/IamService.Infrastructure/Caching/RedisSettings.cs new file mode 100644 index 00000000..eb3a0d3f --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/Caching/RedisSettings.cs @@ -0,0 +1,38 @@ +namespace IamService.Infrastructure.Caching; + +/// +/// EN: Redis configuration settings +/// VI: Cấu hình Redis settings +/// +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; + + /// + /// EN: Get the connection string for StackExchange.Redis + /// VI: Lấy connection string cho StackExchange.Redis + /// + 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; + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs b/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs index b52b2355..bf32ed4c 100644 --- a/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs +++ b/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs @@ -140,6 +140,19 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); + // 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(sp => + { + var connectionString = redisSettings.GetConnectionString(); + return StackExchange.Redis.ConnectionMultiplexer.Connect(connectionString); + }); + + services.AddSingleton(); + return services; } } diff --git a/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/ChangePasswordCommandHandlerTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/ChangePasswordCommandHandlerTests.cs index 06fd8d43..d81796af 100644 --- a/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/ChangePasswordCommandHandlerTests.cs +++ b/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/ChangePasswordCommandHandlerTests.cs @@ -47,7 +47,7 @@ public class ChangePasswordCommandHandlerTests var result = await _handler.Handle(command, CancellationToken.None); // Assert - Assert.True(result); + Assert.True(result.Success); } [Fact] diff --git a/services/iam-service-net/tests/IamService.UnitTests/Domain/ApplicationUserTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Domain/ApplicationUserTests.cs index d66a3a3f..dcab513e 100644 --- a/services/iam-service-net/tests/IamService.UnitTests/Domain/ApplicationUserTests.cs +++ b/services/iam-service-net/tests/IamService.UnitTests/Domain/ApplicationUserTests.cs @@ -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); }