[CLAUDE] Phase5 prep: production infra + deploy scripts + 4 guides + FE refresh token

Backend production infra:
- Packages: Serilog.Sinks.File, HealthChecks.EntityFrameworkCore (RateLimiting built-in .NET 10)
- appsettings.Production.json MOI: placeholder __SET_VIA_SECRETS__, AllowedOrigins, Serilog File sink rolling daily retention 30d, RateLimit config
- appsettings.json + Development.json: them Serilog WriteTo Console
- Program.cs REWRITE:
  - Serilog ReadFrom.Configuration (prod file / dev console)
  - Rate limiter: policy auth-login 5/min/IP (AuthController.Login) + GlobalLimiter 300/min/IP
  - Health checks: /health/live liveness (empty predicate) + /health/ready DB probe (AddDbContextCheck)
  - HSTS production 1 year
  - CORS origins from config AllowedOrigins (default dev 2 localhost)
- AuthController.Login gắn [EnableRateLimiting("auth-login")]

Deploy scripts:
- scripts/deploy-iis.ps1: stop pool → backup current → clean+extract artifact → start pool → health check loop 30s timeout → rollback instruction if fail
- scripts/backup-sql.ps1: BACKUP DATABASE voi INIT+COMPRESSION+CHECKSUM + retention 30d auto cleanup
- .gitea/workflows/deploy.yml MOI: 4 job build BE (Windows) + build 2 FE (Ubuntu, pin .nvmrc 20) + deploy-iis qua WinRM PSSession (secrets IIS_HOST/USER/PASSWORD/JWT_SECRET/DB_CONNECTION)

Docs guides MOI (4 file):
- deployment-iis.md: prereqs (IIS features, Hosting Bundle, SQL, WinRM) + setup lan dau (app pool, 3 site, HTTPS win-acme, user-secrets) + deploy hang ngay (CI/CD + manual) + rollback + monitoring + troubleshooting + SPA web.config sample
- cicd.md: pipeline overview 4 job, secrets setup, runner Windows+Ubuntu, branch strategy, build optimizations, common CI/CD issues
- security-checklist.md: OWASP top 10 2021 mapping voi status + pre go-live checklist + incident response
- runbook.md: daily ops (health/logs), restart/rollback, DB backup/restore/migration revert, user management (reset password, unlock, disable), monitoring (CPU/disk/connection pool), deployment checklist, common gotcha

Frontend refresh token (ca 2 app fe-admin + fe-user):
- lib/api.ts REWRITE: them REFRESH_KEY, axios response interceptor 401 → POST /auth/refresh → retry request goc. Queue pattern cho nhieu request song song chi 1 refresh call chay. Skip retry /auth/login + /auth/refresh tranh infinite loop. _retry flag tren original config.
- contexts/AuthContext.tsx: luu+xoa REFRESH_KEY trong login/logout

E2E verified:
- GET /health/live → 200 Healthy
- GET /health/ready → 200 Healthy (DB probe)
- Rate limit flood 7 POST /auth/login → #1-5 HTTP 400 (cred sai) + #6-7 HTTP 429 Too Many Requests 
- TS check fe-admin + fe-user → pass
- dotnet build → 0 errors

Docs updates:
- docs/STATUS.md: Phase 5 prep done, next Phase 5 deploy production + Phase 5.1 security hardening, cumulative stats 8 commits
- docs/HANDOFF.md: phase table them Phase 5 prep row, file tree update voi guides + scripts + workflows, git state commit 8
- docs/changelog/migration-todos.md: tick Phase 5 prep items (12 items done) + Phase 5 deploy items remaining + Phase 5.1 security hardening list
- docs/changelog/sessions/2026-04-21-1530-phase5-prep.md: session log chi tiet

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-21 12:57:12 +07:00
parent fe7ad8e4a3
commit f3fb3fd565
19 changed files with 1382 additions and 91 deletions

View File

@ -18,6 +18,7 @@ public class AuthController : ControllerBase
[HttpPost("login")]
[AllowAnonymous]
[Microsoft.AspNetCore.RateLimiting.EnableRateLimiting("auth-login")]
public async Task<ActionResult<AuthResponseDto>> Login([FromBody] LoginCommand command, CancellationToken ct)
=> Ok(await _mediator.Send(command, ct));

View File

@ -1,6 +1,10 @@
using System.Text;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using Serilog;
@ -16,11 +20,10 @@ using SolutionErp.Infrastructure.Persistence;
var builder = WebApplication.CreateBuilder(args);
// ---------- Logging (Serilog) ----------
// ---------- Logging (Serilog) — đọc config từ appsettings (Console dev, File prod) ----------
builder.Host.UseSerilog((ctx, cfg) => cfg
.ReadFrom.Configuration(ctx.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console());
.Enrich.FromLogContext());
// ---------- Core services ----------
builder.Services.AddControllers();
@ -66,16 +69,54 @@ builder.Services.AddAuthorization(opts =>
}
});
// ---------- CORS (2 FE dev origins) ----------
// ---------- CORS — đọc origin từ config (Production) hoặc default dev ----------
var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:8080", "http://localhost:8082"];
builder.Services.AddCors(opts =>
{
opts.AddDefaultPolicy(p => p
.WithOrigins("http://localhost:8080", "http://localhost:8082")
.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials());
});
// ---------- Rate limiting (Phase 5) ----------
var rateLimitAuth = builder.Configuration.GetValue<int?>("RateLimit:AuthLoginPerMinute") ?? 5;
var rateLimitGlobal = builder.Configuration.GetValue<int?>("RateLimit:GlobalPerMinute") ?? 300;
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// Policy "auth-login" áp dụng [EnableRateLimiting] ở AuthController.Login
options.AddPolicy("auth-login", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = rateLimitAuth,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}));
// Global limit theo IP — chặn abuse
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = rateLimitGlobal,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 50,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
}));
});
// ---------- Health checks ----------
builder.Services.AddHealthChecks()
.AddDbContextCheck<ApplicationDbContext>("database", HealthStatus.Unhealthy, ["ready"]);
// ---------- Swagger ----------
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
@ -113,11 +154,21 @@ if (app.Environment.IsDevelopment())
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "SolutionErp API v1"));
}
else
{
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseRateLimiter();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
// Health check endpoints (IIS probe)
app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false });
app.MapHealthChecks("/health/ready", new HealthCheckOptions { Predicate = h => h.Tags.Contains("ready") });
app.MapControllers();
// ---------- DB init + seed ----------

View File

@ -12,7 +12,9 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.6" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
</ItemGroup>

View File

@ -13,6 +13,9 @@
"Microsoft.EntityFrameworkCore": "Information",
"System": "Warning"
}
}
},
"WriteTo": [
{ "Name": "Console" }
]
}
}

View File

@ -17,7 +17,14 @@
"Microsoft.EntityFrameworkCore": "Warning",
"System": "Warning"
}
}
},
"WriteTo": [
{ "Name": "Console" }
]
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"RateLimit": {
"AuthLoginPerMinute": 5,
"GlobalPerMinute": 300
}
}