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" } ],
@@ -20,10 +20,10 @@ public sealed class ValidationBehavior<TRequest, TResponse>(
return await next();
var context = new ValidationContext<TRequest>(request);
var failures = validators
.Select(v => v.Validate(context))
.SelectMany(r => r.Errors)
.ToList();
var results = await Task.WhenAll(
validators.Select(v => v.ValidateAsync(context, cancellationToken))
);
var failures = results.SelectMany(r => r.Errors).ToList();
if (failures.Count == 0)
return await next();
@@ -77,11 +77,30 @@ public class ProgrammingOverride
{
if (Recurrence == OverrideRecurrence.Weekly)
{
if (
DayOfWeek is not { } day
|| StartMinute is not { } start
|| EndMinute is not { } end
)
return false;
var utc = moment.UtcDateTime;
var minuteOfDay = utc.Hour * 60 + utc.Minute;
return (int)utc.DayOfWeek == DayOfWeek
&& minuteOfDay >= StartMinute
&& minuteOfDay < EndMinute;
var today = (int)utc.DayOfWeek;
if (end > start)
// Обычное окно в пределах одних суток [start, end).
return today == day && minuteOfDay >= start && minuteOfDay < end;
if (end < start)
{
// Окно пересекает полночь: [start, 24:00) в день `day` и [00:00, end) на следующий день.
var nextDay = (day + 1) % 7;
return (today == day && minuteOfDay >= start)
|| (today == nextDay && minuteOfDay < end);
}
return false; // end == start — пустое окно
}
return moment >= StartsAtUtc && moment < EndsAtUtc;
@@ -195,12 +195,12 @@ public static class SchedulePlanner
IRandomSource random
)
{
var total = eligible.Sum(v => Math.Max(0, v.Weight));
var total = eligible.Sum(v => (long)Math.Max(0, v.Weight));
if (total <= 0)
return eligible[random.Next(eligible.Count)];
var roll = random.Next(total);
var acc = 0;
var roll = random.Next((int)Math.Min(total, int.MaxValue));
long acc = 0;
foreach (var v in eligible)
{
acc += Math.Max(0, v.Weight);
@@ -253,7 +253,9 @@ public static class SchedulePlanner
&& show.PreferredHours is { Count: > 0 } windows
&& windows.Any(w => w.Contains(hour))
)
return show.Weight * show.PreferredWeightMultiplier;
// long-умножение + clamp: экстремальные вес/множитель не переполняют int
// (иначе отрицательный total молча вырождает взвешенный выбор в первого кандидата).
return (int)Math.Min((long)show.Weight * show.PreferredWeightMultiplier, int.MaxValue);
return show.Weight;
}
@@ -262,12 +264,12 @@ public static class SchedulePlanner
IRandomSource random
)
{
var total = candidates.Sum(c => c.Weight);
var total = candidates.Sum(c => (long)c.Weight);
if (total <= 0)
return candidates[0].Show;
var roll = random.Next(total);
var acc = 0;
var roll = random.Next((int)Math.Min(total, int.MaxValue));
long acc = 0;
foreach (var (show, weight) in candidates)
{
acc += weight;
@@ -39,11 +39,30 @@ public sealed record PlannerOverride(
{
if (Recurrence == OverrideRecurrence.Weekly)
{
if (
DayOfWeek is not { } day
|| StartMinute is not { } start
|| EndMinute is not { } end
)
return false;
var utc = moment.UtcDateTime;
var minuteOfDay = utc.Hour * 60 + utc.Minute;
return (int)utc.DayOfWeek == DayOfWeek
&& minuteOfDay >= StartMinute
&& minuteOfDay < EndMinute;
var today = (int)utc.DayOfWeek;
if (end > start)
// Обычное окно в пределах одних суток [start, end).
return today == day && minuteOfDay >= start && minuteOfDay < end;
if (end < start)
{
// Окно пересекает полночь: [start, 24:00) в день `day` и [00:00, end) на следующий день.
var nextDay = (day + 1) % 7;
return (today == day && minuteOfDay >= start)
|| (today == nextDay && minuteOfDay < end);
}
return false; // end == start — пустое окно
}
return moment >= StartsAtUtc && moment < EndsAtUtc;
@@ -10,10 +10,19 @@ public sealed class ImageDownloader(IHttpClientFactory httpFactory) : IImageDown
CancellationToken cancellationToken
)
{
// Пускаем только абсолютные http/https URL: URL приходит из ответа metadata-провайдера
// (постеры TMDb/OMDb), и скомпрометированный/подменённый ответ не должен заставить сервер
// дёрнуть file://, ftp:// или иную схему.
if (
!Uri.TryCreate(url, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
)
return null;
try
{
var client = httpFactory.CreateClient("metadata");
using var response = await client.GetAsync(url, cancellationToken);
using var response = await client.GetAsync(uri, cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
@@ -26,6 +26,11 @@ public sealed class InboxScannerBackgroundService(
private readonly MediaOptions _media = mediaOptions.Value;
private readonly Dictionary<string, long> _lastSizes = new(StringComparer.Ordinal);
// Файлы, регистрация которых стабильно отклоняется командой (например, неподдерживаемый контейнер):
// помечаем, чтобы не пытаться регистрировать их заново каждые два тика и не засорять лог. Забываются,
// когда файл исчезает из inbox (тогда одноимённый новый файл снова получит попытку).
private readonly HashSet<string> _failed = new(StringComparer.Ordinal);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
paths.EnsureDirectories();
@@ -67,6 +72,9 @@ public sealed class InboxScannerBackgroundService(
var name = Path.GetFileName(path);
present.Add(name);
if (_failed.Contains(name))
continue; // уже пытались и получили отказ — ждём, пока файл уберут/заменят
long size;
try
{
@@ -89,16 +97,20 @@ public sealed class InboxScannerBackgroundService(
continue;
}
await RegisterAsync(name, cancellationToken);
var registered = await RegisterAsync(name, cancellationToken);
_lastSizes.Remove(name);
if (!registered)
_failed.Add(name); // команда отклонила файл — больше не пытаемся, пока он в inbox
}
// Забываем исчезнувшие файлы, чтобы словарь не рос.
// Забываем исчезнувшие файлы, чтобы словари не росли (и одноимённый новый файл снова попробовался).
foreach (var stale in _lastSizes.Keys.Where(k => !present.Contains(k)).ToList())
_lastSizes.Remove(stale);
foreach (var stale in _failed.Where(k => !present.Contains(k)).ToList())
_failed.Remove(stale);
}
private async Task RegisterAsync(string fileName, CancellationToken cancellationToken)
private async Task<bool> RegisterAsync(string fileName, CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var sender = scope.ServiceProvider.GetRequiredService<ISender>();
@@ -116,14 +128,14 @@ public sealed class InboxScannerBackgroundService(
result.Value,
fileName
);
return true;
}
else
{
logger.LogWarning(
"Не удалось зарегистрировать {File} из inbox: {Error}",
fileName,
result.Error.Code
);
}
logger.LogWarning(
"Не удалось зарегистрировать {File} из inbox: {Error}",
fileName,
result.Error.Code
);
return false;
}
}