Bladeren bron

thêm chặn limit ở website vaf api
ref token

student 3 weken geleden
bovenliggende
commit
788268d63a

+ 10 - 1
Kitty_Fall/Kitty_Fall/Common/CommonLogic.cs

@@ -268,7 +268,7 @@ public static class CommonLogic
                     new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
                 }
             ),
-            Expires = DateTime.UtcNow.AddDays(1),
+            Expires = DateTime.UtcNow.AddMinutes(GetUserTokenExpireMinutes(configuration)),
             Issuer = configuration["Jwt:Issuer"],
             Audience = configuration["Jwt:Audience"],
             SigningCredentials = new SigningCredentials(
@@ -280,6 +280,15 @@ public static class CommonLogic
         return handler.WriteToken(handler.CreateToken(tokenDescriptor));
     }
 
+    // Thoi han token (phut), cau hinh Jwt:ExpireMinutes. Khong cau hinh thi giu 1 ngay nhu cu.
+    // Website tu xin token moi khi nguoi dung con hoat dong (/apis/user/refresh-token).
+    public static int GetUserTokenExpireMinutes(IConfiguration configuration)
+    {
+        return int.TryParse(configuration["Jwt:ExpireMinutes"], out var minutes) && minutes > 0
+            ? minutes
+            : 24 * 60;
+    }
+
     /// <summary>
     /// Validate JWT (chữ ký + hạn) và trả về msisdn trong token. Null nếu token thiếu/sai/hết hạn.
     /// </summary>

+ 1 - 0
Kitty_Fall/Kitty_Fall/Common/CommonUrl.cs

@@ -13,6 +13,7 @@ public static class UrlConstant
     public const String UserResendOtp = "/apis/user/resend-otp";
     public const String UserOtpConfirm = "/apis/user/confirm-otp";
     public const String UserMyIdAutoLogin = "/apis/user/myid-auto-login";
+    public const String UserRefreshToken = "/apis/user/refresh-token";
 
     public const String UserBuyTurnUrl = "/apis/user/buy-turn";
     public const String UserBuyTurnConfirmUrl = "/apis/user/buy-turn-confirm";

+ 6 - 0
Kitty_Fall/Kitty_Fall/Common/Http/Apis/Request/UserLogin.cs

@@ -22,6 +22,12 @@ namespace Common.Http.Apis.Request
         public string? token { get; set; }
     }
 
+    public class RefreshTokenReq
+    {
+        // JwtAuthFilter ghi de msisdn tu token da xac thuc.
+        public string? msisdn { get; set; }
+    }
+
     public class MyIdAutoLoginReq
     {
         public string? msisdn { get; set; }

+ 21 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Apis/Controllers/UserController.cs

@@ -130,6 +130,27 @@ namespace RevoSystem.Apis.Controllers
             return await userBusiness.MyIdAutoLogin(HttpContext.Request, request);
         }
 
+        // Cap token moi khi token cu con han (JwtAuthFilter da xac thuc va ghi msisdn vao request).
+        // Khong dung BuildResponse de token moi khong bi ghi ra log.
+        [HttpPost]
+        [Route(UrlConstant.UserRefreshToken)]
+        public IActionResult RefreshToken([FromBody] RefreshTokenReq request)
+        {
+            if (string.IsNullOrWhiteSpace(request.msisdn))
+            {
+                return Ok(new { errorCode = 401, message = "Unauthorized", status = false, data = new { } });
+            }
+
+            log.Info("URL: " + UrlConstant.UserRefreshToken + " => token refreshed, msisdn=" + request.msisdn);
+            return Ok(new
+            {
+                errorCode = CommonErrorCode.SUCCESS,
+                message = "success",
+                status = true,
+                data = new { token = CommonLogic.GenUserToken(configuration, request.msisdn) }
+            });
+        }
+
 
         [HttpPost]
         [Route(UrlConstant.UserUpdateProfileUrl)]

+ 3 - 1
Kitty_Fall/Kitty_Fall/Kitty_Fall.Apis/appsettings.json

@@ -52,7 +52,9 @@
   "Jwt": {
     "Key": "KittyFallApiJwtSecretKey-2026-CHANGE-THIS-IN-PRODUCTION-aB7xK9pQ2wL5nR8",
     "Issuer": "KittyFallApi",
-    "Audience": "KittyFallClient"
+    "Audience": "KittyFallClient",
+    // Token het han sau so phut nay neu khong hoat dong; Website tu xin token moi khi con dung.
+    "ExpireMinutes": 15
   },
   "AllowedHosts": "*"
 }

+ 5 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Controllers/GameController.cs

@@ -1,6 +1,8 @@
 using Common.Extension;
 using Common.Http.Apis.Request;
+using Kitty_Fall.Website.Helpers;
 using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
 using System.Text.Json;
 
 namespace Kitty_Fall.Website.Controllers
@@ -17,6 +19,7 @@ namespace Kitty_Fall.Website.Controllers
         }
 
         [HttpPost]
+        [EnableRateLimiting(GameRateLimitHelper.StartPolicy)]
         public async Task<IActionResult> StartSession([FromBody] UserGameLevelResultReq request)
         {
             try
@@ -71,6 +74,7 @@ namespace Kitty_Fall.Website.Controllers
         }
 
         [HttpPost]
+        [EnableRateLimiting(GameRateLimitHelper.SavePolicy)]
         public async Task<IActionResult> SaveLevelResult([FromBody] UserGameLevelResultReq request)
         {
             try
@@ -120,6 +124,7 @@ namespace Kitty_Fall.Website.Controllers
         }
 
         [HttpPost]
+        [EnableRateLimiting(GameRateLimitHelper.StartPolicy)]
         public async Task<IActionResult> ExitGame(UserGameLevelResultReq request)
         {
             try

+ 116 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Helpers/ApiTokenRefreshMiddleware.cs

@@ -0,0 +1,116 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Net.Http.Headers;
+using System.Text;
+using Common;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace Kitty_Fall.Website.Helpers;
+
+// Giu token API ngan han (Jwt:ExpireMinutes ben API, vd 15 phut):
+// - Token da het han: xoa dang nhap, nguoi dung MyID duoc auto-login lai ngay o middleware sau,
+//   nguoi dung trinh duyet phai dang nhap lai.
+// - Token sap het han ma nguoi dung van dang hoat dong: xin token moi tu API.
+// Khong hoat dong qua thoi han token thi token tu het han, khop voi session (IdleTimeout 15 phut).
+public class ApiTokenRefreshMiddleware
+{
+    private const string LastAttemptSessionKey = "apiTokenRefreshAttemptAt";
+
+    // Dung chung 1 HttpClient, timeout ngan de API cham khong giu request cua nguoi dung.
+    private static readonly HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(10) };
+    private static readonly log4net.ILog log = log4net.LogManager.GetLogger(typeof(ApiTokenRefreshMiddleware));
+
+    private readonly RequestDelegate _next;
+    private readonly IConfiguration _configuration;
+
+    public ApiTokenRefreshMiddleware(RequestDelegate next, IConfiguration configuration)
+    {
+        _next = next;
+        _configuration = configuration;
+    }
+
+    public async Task InvokeAsync(HttpContext context)
+    {
+        try
+        {
+            await RefreshIfNeededAsync(context);
+        }
+        catch (Exception ex)
+        {
+            log.Error("API token refresh failed: ", ex);
+        }
+
+        await _next(context);
+    }
+
+    private async Task RefreshIfNeededAsync(HttpContext context)
+    {
+        var token = context.Session.GetString("apiToken");
+        if (string.IsNullOrWhiteSpace(token)) return;
+
+        DateTime expiresAt;
+        try
+        {
+            expiresAt = new JwtSecurityTokenHandler().ReadJwtToken(token).ValidTo;
+        }
+        catch (Exception)
+        {
+            LoginSessionHelper.ClearLoginSession(context);
+            return;
+        }
+
+        var remaining = expiresAt - DateTime.UtcNow;
+        if (remaining <= TimeSpan.Zero)
+        {
+            LoginSessionHelper.ClearLoginSession(context);
+            return;
+        }
+
+        var refreshBefore = TimeSpan.FromMinutes(_configuration.GetValue("ApiToken:RefreshBeforeExpireMinutes", 10));
+        if (remaining > refreshBefore) return;
+
+        // Lan lam moi truoc that bai (API loi/cham) thi cho 1 phut moi thu lai, khong goi API moi request.
+        var lastAttempt = context.Session.GetString(LastAttemptSessionKey);
+        if (long.TryParse(lastAttempt, out var lastTicks)
+            && DateTime.UtcNow - new DateTime(lastTicks, DateTimeKind.Utc) < TimeSpan.FromMinutes(1))
+        {
+            return;
+        }
+        context.Session.SetString(LastAttemptSessionKey, DateTime.UtcNow.Ticks.ToString());
+
+        var baseUrl = _configuration.GetSection("url").Value ?? _configuration.GetSection("Url").Value ?? "";
+        if (string.IsNullOrWhiteSpace(baseUrl)) return;
+
+        using var request = new HttpRequestMessage(HttpMethod.Post, baseUrl + UrlConstant.UserRefreshToken)
+        {
+            Content = new StringContent("{}", Encoding.UTF8, "application/json")
+        };
+        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+        using var response = await httpClient.SendAsync(request);
+        if (!response.IsSuccessStatusCode)
+        {
+            log.Warn($"API token refresh returned HTTP {(int)response.StatusCode}");
+            return;
+        }
+
+        var json = JsonConvert.DeserializeObject<JObject>(await response.Content.ReadAsStringAsync());
+        var errorCode = json?["errorCode"]?.ToString();
+        var newToken = json?["data"]?["token"]?.ToString();
+        if (errorCode == CommonErrorCode.SUCCESS.ToString() && !string.IsNullOrWhiteSpace(newToken))
+        {
+            context.Session.SetString("apiToken", newToken);
+            context.Session.Remove(LastAttemptSessionKey);
+            return;
+        }
+
+        if (errorCode == "401")
+        {
+            // API khong con chap nhan token nay: dang nhap lai.
+            LoginSessionHelper.ClearLoginSession(context);
+            return;
+        }
+
+        log.Warn($"API token refresh rejected. errorCode={errorCode}");
+    }
+}

+ 79 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Helpers/GameRateLimitHelper.cs

@@ -0,0 +1,79 @@
+using System.Threading.RateLimiting;
+using Common.Extension;
+using Microsoft.AspNetCore.RateLimiting;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace Kitty_Fall.Website.Helpers;
+
+// Gioi han so lan goi API game theo tung thue bao (msisdn trong session, khong co thi theo IP).
+// Chan bot ban SaveLevelResult lien tuc: request vuot nguong tra 429 ngay tai Website,
+// khong goi sang API. Nguong doc tu appsettings "RateLimit", khong co thi dung mac dinh.
+public static class GameRateLimitHelper
+{
+    public const string SavePolicy = "game-save";
+    public const string StartPolicy = "game-start";
+
+    private static readonly log4net.ILog log = log4net.LogManager.GetLogger(typeof(GameRateLimitHelper));
+
+    public static IServiceCollection AddGameRateLimiter(this IServiceCollection services, IConfiguration configuration)
+    {
+        // SaveLevelResult: nguoi choi that goi 1 lan moi level; cho phep toi da 10 lan lien tiep,
+        // sau do 10 lan moi 30 giay (~20 lan/phut).
+        var save = ReadOptions(configuration, "RateLimit:GameSave", tokenLimit: 10, tokensPerPeriod: 10, periodSeconds: 30);
+        // StartSession / ExitGame: 1 lan moi van choi.
+        var start = ReadOptions(configuration, "RateLimit:GameStart", tokenLimit: 5, tokensPerPeriod: 5, periodSeconds: 60);
+
+        services.AddRateLimiter(options =>
+        {
+            options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
+            options.AddPolicy(SavePolicy, context =>
+                RateLimitPartition.GetTokenBucketLimiter(GetPartitionKey(context), _ => save));
+            options.AddPolicy(StartPolicy, context =>
+                RateLimitPartition.GetTokenBucketLimiter(GetPartitionKey(context), _ => start));
+            options.OnRejected = async (context, cancellationToken) =>
+            {
+                LogRejected(context.HttpContext);
+                await context.HttpContext.Response.WriteAsJsonAsync(new
+                {
+                    success = false,
+                    reason = "RATE_LIMITED",
+                    message = "Too many requests. Please try again later."
+                }, cancellationToken);
+            };
+        });
+
+        return services;
+    }
+
+    private static TokenBucketRateLimiterOptions ReadOptions(
+        IConfiguration configuration, string section, int tokenLimit, int tokensPerPeriod, int periodSeconds)
+    {
+        return new TokenBucketRateLimiterOptions
+        {
+            TokenLimit = Math.Max(1, configuration.GetValue($"{section}:TokenLimit", tokenLimit)),
+            TokensPerPeriod = Math.Max(1, configuration.GetValue($"{section}:TokensPerPeriod", tokensPerPeriod)),
+            ReplenishmentPeriod = TimeSpan.FromSeconds(Math.Max(1, configuration.GetValue($"{section}:PeriodSeconds", periodSeconds))),
+            QueueLimit = 0,
+            AutoReplenishment = true
+        };
+    }
+
+    private static string GetPartitionKey(HttpContext context)
+    {
+        var msisdn = context.Session.GetComplexData<string>("msisdn");
+        if (!string.IsNullOrWhiteSpace(msisdn)) return "msisdn:" + msisdn;
+        return "ip:" + (context.Connection.RemoteIpAddress?.ToString() ?? "unknown");
+    }
+
+    // Moi thue bao chi ghi 1 dong canh bao moi phut de bot khong lam day log.
+    private static void LogRejected(HttpContext context)
+    {
+        var key = GetPartitionKey(context);
+        var cache = context.RequestServices.GetService<IMemoryCache>();
+        var cacheKey = "rate-limit-logged:" + key;
+        if (cache != null && cache.TryGetValue(cacheKey, out _)) return;
+        cache?.Set(cacheKey, true, TimeSpan.FromMinutes(1));
+
+        log.Warn($"Game request rate limited. key={key}, path={context.Request.Path}, ip={context.Connection.RemoteIpAddress}");
+    }
+}

+ 4 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Program.cs

@@ -1,3 +1,4 @@
+using Kitty_Fall.Website.Helpers;
 using System.Globalization;
 using System.Reflection;
 using System.Xml;
@@ -16,6 +17,7 @@ var builder = WebApplication.CreateBuilder(args);
 builder.Services.AddControllersWithViews();
 builder.Services.AddMemoryCache();
 builder.Services.AddDistributedMemoryCache();
+builder.Services.AddGameRateLimiter(builder.Configuration);
 
 builder.Services.Configure<Microsoft.AspNetCore.Builder.ForwardedHeadersOptions>(options =>
 {
@@ -58,10 +60,12 @@ app.UseForwardedHeaders();
 app.UseStaticFiles();
 
 app.UseSession();
+app.UseMiddleware<Kitty_Fall.Website.Helpers.ApiTokenRefreshMiddleware>();
 app.UseMiddleware<Kitty_Fall.Website.Helpers.MyIdAutoLoginMiddleware>();
 app.UseMiddleware<Kitty_Fall.Website.Helpers.LoginRequiredMiddleware>();
 
 app.UseRouting();
+app.UseRateLimiter();
 
 app.UseAuthorization();
 

+ 15 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/appsettings.json

@@ -6,6 +6,21 @@
     "CancelKeyword": "OFF",
     "CancelShortCode": "562"
   },
+  "ApiToken": {
+    "RefreshBeforeExpireMinutes": 10
+  },
+  "RateLimit": {
+    "GameSave": {
+      "TokenLimit": 10,
+      "TokensPerPeriod": 10,
+      "PeriodSeconds": 30
+    },
+    "GameStart": {
+      "TokenLimit": 5,
+      "TokensPerPeriod": 5,
+      "PeriodSeconds": 60
+    }
+  },
   "Logging": {
     "LogLevel": {
       "Default": "Information",