251 lines
8.5 KiB
C#
251 lines
8.5 KiB
C#
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<RegistrationStatusDto>();
|
||
group.MapPost("/register", Register).Produces<AuthResponseDto>();
|
||
group.MapPost("/login", Login).Produces<AuthResponseDto>();
|
||
group.MapPost("/refresh", Refresh).Produces<AuthResponseDto>();
|
||
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<CurrentUserDto>();
|
||
group
|
||
.MapDelete("/me", DeleteMe)
|
||
.RequireAuthorization()
|
||
.Produces(StatusCodes.Status204NoContent);
|
||
|
||
return app;
|
||
}
|
||
|
||
private static async Task<IResult> RegistrationStatus(
|
||
ISender sender,
|
||
CancellationToken cancellationToken
|
||
)
|
||
{
|
||
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
|
||
return Results.Ok(new RegistrationStatusDto(settings.RegistrationEnabled));
|
||
}
|
||
|
||
private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> ChangePassword(
|
||
ChangePasswordCommand command,
|
||
ISender sender,
|
||
CancellationToken cancellationToken
|
||
)
|
||
{
|
||
var result = await sender.Send(command, cancellationToken);
|
||
return result.ToHttpResult();
|
||
}
|
||
|
||
private static async Task<IResult> ChangeUserName(
|
||
ChangeUserNameCommand command,
|
||
ISender sender,
|
||
CancellationToken cancellationToken
|
||
)
|
||
{
|
||
var result = await sender.Send(command, cancellationToken);
|
||
return result.ToHttpResult();
|
||
}
|
||
|
||
private static async Task<IResult> Me(ISender sender, CancellationToken cancellationToken)
|
||
{
|
||
var result = await sender.Send(new GetCurrentUserQuery(), cancellationToken);
|
||
return result.ToHttpResult();
|
||
}
|
||
|
||
private static async Task<IResult> 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",
|
||
};
|
||
|
||
/// <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(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
|
||
);
|
||
|
||
/// <summary>Публичный ответ: включена ли открытая регистрация (для страниц входа/регистрации).</summary>
|
||
public sealed record RegistrationStatusDto(bool Enabled);
|