Refactor authentication and streaming cookie handling: implement secure cookie logic based on environment in AuthEndpoints and StreamingEndpoints, enhance rate limiting policy in Program.cs, and update logging configuration in appsettings.json. Fix validation behavior to use asynchronous validation methods and improve error handling in frontend components.
build / backend (push) Successful in 1m52s
build / frontend (push) Successful in 1m0s
tests / backend-tests (push) Successful in 2m27s

This commit is contained in:
Leonid Pershin
2026-07-25 21:18:31 +03:00
parent 53e0eeb776
commit 058cbc6994
15 changed files with 282 additions and 99 deletions
@@ -1,4 +1,5 @@
using LiteCqrs;
using Microsoft.Extensions.Hosting;
using TeleWave.Api.Common;
using TeleWave.Application.Auth;
using TeleWave.Application.Auth.ChangePassword;
@@ -62,6 +63,7 @@ public static class AuthEndpoints
ISender sender,
HttpRequest request,
HttpResponse response,
IHostEnvironment env,
CancellationToken cancellationToken
)
{
@@ -70,10 +72,10 @@ public static class AuthEndpoints
return result.ToHttpResult();
SetRefreshCookie(
request,
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt
result.Value.RefreshTokenExpiresAt,
UseSecureCookie(request, env)
);
return Results.Ok(ToLoginResponse(result.Value));
}
@@ -83,6 +85,7 @@ public static class AuthEndpoints
ISender sender,
HttpRequest request,
HttpResponse response,
IHostEnvironment env,
CancellationToken cancellationToken
)
{
@@ -91,10 +94,10 @@ public static class AuthEndpoints
return result.ToHttpResult();
SetRefreshCookie(
request,
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt
result.Value.RefreshTokenExpiresAt,
UseSecureCookie(request, env)
);
return Results.Ok(ToLoginResponse(result.Value));
}
@@ -103,6 +106,7 @@ public static class AuthEndpoints
HttpRequest request,
HttpResponse response,
ISender sender,
IHostEnvironment env,
CancellationToken cancellationToken
)
{
@@ -112,18 +116,19 @@ public static class AuthEndpoints
)
return Results.Unauthorized();
var secure = UseSecureCookie(request, env);
var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken);
if (!result.IsSuccess)
{
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(secure));
return result.ToHttpResult();
}
SetRefreshCookie(
request,
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt
result.Value.RefreshTokenExpiresAt,
secure
);
return Results.Ok(ToLoginResponse(result.Value));
}
@@ -135,6 +140,7 @@ public static class AuthEndpoints
HttpRequest request,
HttpResponse response,
ISender sender,
IHostEnvironment env,
CancellationToken cancellationToken
)
{
@@ -144,8 +150,9 @@ public static class AuthEndpoints
)
await sender.Send(new LogoutCommand(rawToken), cancellationToken);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
DeleteStreamCookie(request, response);
var secure = UseSecureCookie(request, env);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(secure));
DeleteStreamCookie(response, secure);
return Results.NoContent();
}
@@ -179,45 +186,54 @@ public static class AuthEndpoints
HttpRequest request,
HttpResponse response,
ISender sender,
IHostEnvironment env,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
DeleteStreamCookie(request, response);
var secure = UseSecureCookie(request, env);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(secure));
DeleteStreamCookie(response, secure);
return result.ToHttpResult();
}
private static void SetRefreshCookie(
HttpRequest request,
HttpResponse response,
string rawToken,
DateTimeOffset expiresAt
DateTimeOffset expiresAt,
bool secure
)
{
var options = BuildCookieOptions(request);
var options = BuildCookieOptions(secure);
options.Expires = expiresAt;
response.Cookies.Append(RefreshCookieName, rawToken, options);
}
private static CookieOptions BuildCookieOptions(HttpRequest request) =>
private static CookieOptions BuildCookieOptions(bool secure) =>
new()
{
HttpOnly = true,
Secure = request.IsHttps,
Secure = secure,
SameSite = SameSiteMode.Strict,
Path = "/api/auth",
};
/// <summary>Ставить ли cookie с флагом Secure. Вне Development — всегда true: прод работает за
/// внешним TLS-прокси, а <see cref="HttpRequest.IsHttps"/> ненадёжен (при неполной настройке
/// ForwardedHeaders он false, и долгоживущий refresh-cookie ушёл бы без Secure). В Development
/// допускаем HTTP-разработку.</summary>
private static bool UseSecureCookie(HttpRequest request, IHostEnvironment env) =>
!env.IsDevelopment() || request.IsHttps;
/// <summary>Гасит stream-cookie (tw_stream, Path=/api) — иначе после выхода эфир можно смотреть
/// по прямой ссылке ещё до истечения токена.</summary>
private static void DeleteStreamCookie(HttpRequest request, HttpResponse response) =>
private static void DeleteStreamCookie(HttpResponse response, bool secure) =>
response.Cookies.Delete(
"tw_stream",
new CookieOptions
{
HttpOnly = true,
Secure = request.IsHttps,
Secure = secure,
SameSite = SameSiteMode.Strict,
Path = "/api",
}
@@ -2,6 +2,7 @@ using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using LiteCqrs;
using Microsoft.Extensions.Hosting;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
@@ -47,7 +48,8 @@ public static class StreamingEndpoints
ICurrentUser currentUser,
StreamTokenService tokens,
HttpRequest request,
HttpResponse response
HttpResponse response,
IHostEnvironment env
)
{
if (currentUser.UserId is not { } userId)
@@ -60,7 +62,9 @@ public static class StreamingEndpoints
new CookieOptions
{
HttpOnly = true,
Secure = request.IsHttps,
// Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен
// при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints.
Secure = !env.IsDevelopment() || request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api",
Expires = expiresAt,
+15 -10
View File
@@ -1,5 +1,6 @@
using System.Net;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Scalar.AspNetCore;
@@ -63,19 +64,23 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter(
// Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый
// непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех.
options.AddPolicy(
RateLimiting.AuthPolicy,
limiterOptions =>
{
limiterOptions.PermitLimit = builder.Configuration.GetValue(
"RateLimiting:AuthPermitLimit",
20
);
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueLimit = 0;
}
httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = authPermitLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}
)
);
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
+2 -1
View File
@@ -61,7 +61,8 @@
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
"Microsoft.EntityFrameworkCore": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
},
"WriteTo": [ { "Name": "Console" } ],