using LiteCqrs; using Microsoft.Extensions.Hosting; using TeleWave.Api.Common; using TeleWave.Application.Auth; using TeleWave.Application.Auth.ChangePassword; using TeleWave.Application.Auth.ChangeUserName; using TeleWave.Application.Auth.DeleteMyAccount; using TeleWave.Application.Auth.Login; using TeleWave.Application.Auth.Logout; using TeleWave.Application.Auth.Me; using TeleWave.Application.Auth.Refresh; using TeleWave.Application.Auth.Register; using TeleWave.Application.Settings.GetSiteSettings; namespace TeleWave.Api.Endpoints; public static class AuthEndpoints { private const string RefreshCookieName = "tw_refresh_token"; public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api/auth") .WithTags("Auth") .RequireRateLimiting(RateLimiting.AuthPolicy); group.MapGet("/registration", RegistrationStatus).Produces(); group.MapPost("/register", Register).Produces(); group.MapPost("/login", Login).Produces(); group.MapPost("/refresh", Refresh).Produces(); group .MapPost("/logout", Logout) .RequireAuthorization() .Produces(StatusCodes.Status204NoContent); group .MapPost("/change-password", ChangePassword) .RequireAuthorization() .Produces(StatusCodes.Status204NoContent); group .MapPost("/change-username", ChangeUserName) .RequireAuthorization() .Produces(StatusCodes.Status204NoContent); group.MapGet("/me", Me).RequireAuthorization().Produces(); group .MapDelete("/me", DeleteMe) .RequireAuthorization() .Produces(StatusCodes.Status204NoContent); return app; } private static async Task RegistrationStatus( ISender sender, CancellationToken cancellationToken ) { var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken); return Results.Ok(new RegistrationStatusDto(settings.RegistrationEnabled)); } private static async Task Register( RegisterCommand command, ISender sender, HttpRequest request, HttpResponse response, IHostEnvironment env, CancellationToken cancellationToken ) { var result = await sender.Send(command, cancellationToken); if (!result.IsSuccess) return result.ToHttpResult(); SetRefreshCookie( response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt, UseSecureCookie(request, env) ); return Results.Ok(ToLoginResponse(result.Value)); } private static async Task Login( LoginCommand command, ISender sender, HttpRequest request, HttpResponse response, IHostEnvironment env, CancellationToken cancellationToken ) { var result = await sender.Send(command, cancellationToken); if (!result.IsSuccess) return result.ToHttpResult(); SetRefreshCookie( response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt, UseSecureCookie(request, env) ); return Results.Ok(ToLoginResponse(result.Value)); } private static async Task Refresh( HttpRequest request, HttpResponse response, ISender sender, IHostEnvironment env, CancellationToken cancellationToken ) { if ( !request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) || string.IsNullOrEmpty(rawToken) ) 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(secure)); return result.ToHttpResult(); } SetRefreshCookie( response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt, secure ); return Results.Ok(ToLoginResponse(result.Value)); } internal static AuthResponseDto ToLoginResponse(AuthResult auth) => new(auth.AccessToken, auth.AccessTokenExpiresAt, auth.User); private static async Task Logout( HttpRequest request, HttpResponse response, ISender sender, IHostEnvironment env, CancellationToken cancellationToken ) { if ( request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) && !string.IsNullOrEmpty(rawToken) ) await sender.Send(new LogoutCommand(rawToken), cancellationToken); var secure = UseSecureCookie(request, env); response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(secure)); DeleteStreamCookie(response, secure); return Results.NoContent(); } private static async Task ChangePassword( ChangePasswordCommand command, ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send(command, cancellationToken); return result.ToHttpResult(); } private static async Task ChangeUserName( ChangeUserNameCommand command, ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send(command, cancellationToken); return result.ToHttpResult(); } private static async Task Me(ISender sender, CancellationToken cancellationToken) { var result = await sender.Send(new GetCurrentUserQuery(), cancellationToken); return result.ToHttpResult(); } private static async Task DeleteMe( HttpRequest request, HttpResponse response, ISender sender, IHostEnvironment env, CancellationToken cancellationToken ) { var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken); var secure = UseSecureCookie(request, env); response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(secure)); DeleteStreamCookie(response, secure); return result.ToHttpResult(); } private static void SetRefreshCookie( HttpResponse response, string rawToken, DateTimeOffset expiresAt, bool secure ) { var options = BuildCookieOptions(secure); options.Expires = expiresAt; response.Cookies.Append(RefreshCookieName, rawToken, options); } private static CookieOptions BuildCookieOptions(bool secure) => new() { HttpOnly = true, Secure = secure, SameSite = SameSiteMode.Strict, Path = "/api/auth", }; /// Ставить ли cookie с флагом Secure. Вне Development — всегда true: прод работает за /// внешним TLS-прокси, а ненадёжен (при неполной настройке /// ForwardedHeaders он false, и долгоживущий refresh-cookie ушёл бы без Secure). В Development /// допускаем HTTP-разработку. private static bool UseSecureCookie(HttpRequest request, IHostEnvironment env) => !env.IsDevelopment() || request.IsHttps; /// Гасит stream-cookie (tw_stream, Path=/api) — иначе после выхода эфир можно смотреть /// по прямой ссылке ещё до истечения токена. private static void DeleteStreamCookie(HttpResponse response, bool secure) => response.Cookies.Delete( "tw_stream", new CookieOptions { HttpOnly = true, Secure = secure, SameSite = SameSiteMode.Strict, Path = "/api", } ); } public sealed record AuthResponseDto( string AccessToken, DateTimeOffset ExpiresAt, CurrentUserDto User ); /// Публичный ответ: включена ли открытая регистрация (для страниц входа/регистрации). public sealed record RegistrationStatusDto(bool Enabled);