Return the session token in the login response instead of a cookie, and accept it as a Bearer token without CSRF.
92 lines
4.0 KiB
C#
92 lines
4.0 KiB
C#
using Fleet.Core;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Monitor.Backend.Application;
|
|
using Monitor.Protocol;
|
|
|
|
namespace Monitor.Backend.Api;
|
|
|
|
/// <summary>登录、退出、当前身份。登录接口限流,失败一律返回 401,不区分“用户不存在”和“密码错误”。</summary>
|
|
public static class AuthEndpoints
|
|
{
|
|
private const string Tag = "admin-auth";
|
|
|
|
/// <summary>登录不需要认证:挂在没有守卫的分组上。</summary>
|
|
public static void MapLogin(RouteGroupBuilder open)
|
|
{
|
|
open.MapPost("/auth/login", LoginAsync)
|
|
.Api("auth_login", "Sign in with a username and password", Tag)
|
|
.Returns<LoginResponse>().ProducesProblems(400, 401, 429);
|
|
}
|
|
|
|
public static void MapSession(RouteGroupBuilder guarded)
|
|
{
|
|
guarded.MapPost("/auth/logout", LogoutAsync)
|
|
.Api("auth_logout", "Sign out and clear the session cookie", Tag)
|
|
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401);
|
|
guarded.MapGet("/auth/me", Me)
|
|
.Api("auth_me", "The current identity", Tag)
|
|
.Returns<MeDto>().ProducesProblems(401);
|
|
}
|
|
|
|
private static async Task<IResult> LoginAsync(HttpContext http, LoginRequest request, AuthService auth, LoginRateLimiter limiter, BackendOptions options)
|
|
{
|
|
string source = http.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
|
if (!limiter.TryAcquire(source, out int retryAfter))
|
|
{
|
|
return Problems.RetryAfter(http, new FleetError(MonitorErrors.TooManyRequests, "too many sign-in attempts"), retryAfter);
|
|
}
|
|
|
|
Result<LoginResult> result = await auth.LoginAsync(request.Username ?? "", request.Password ?? "", http.RequestAborted);
|
|
if (!result.TryGet(out LoginResult? login, out FleetError? error))
|
|
{
|
|
return Problems.From(http, error);
|
|
}
|
|
|
|
// 跨域的前端(return_session)不用 Cookie:会话令牌放在响应里,之后用 Bearer 带上
|
|
bool returnSession = request.ReturnSession == true;
|
|
if (!returnSession)
|
|
{
|
|
http.Response.Cookies.Append(
|
|
PrincipalResolver.SessionCookie,
|
|
login.SessionToken,
|
|
new CookieOptions
|
|
{
|
|
HttpOnly = true,
|
|
Secure = options.SecureCookies,
|
|
SameSite = SameSiteMode.Strict,
|
|
Path = "/",
|
|
Expires = DateTimeOffset.FromUnixTimeMilliseconds(login.ExpiresAtMs),
|
|
});
|
|
}
|
|
|
|
http.Response.Headers.CacheControl = "no-store";
|
|
return TypedResults.Json(new LoginResponse(login.UserId, login.Username, login.CsrfToken, Mappers.Time(login.ExpiresAtMs), returnSession ? login.SessionToken : null), ApiJsonContext.Default.LoginResponse);
|
|
}
|
|
|
|
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth)
|
|
{
|
|
string? bearer = PrincipalResolver.BearerOf(http);
|
|
if (bearer is not null)
|
|
{
|
|
await auth.LogoutAsync(bearer, http.RequestAborted); // 登录时 return_session 拿到的会话令牌;API 令牌不是会话,这里什么也不会发生
|
|
}
|
|
else if (http.Request.Cookies.TryGetValue(PrincipalResolver.SessionCookie, out string? session))
|
|
{
|
|
await auth.LogoutAsync(session, http.RequestAborted);
|
|
}
|
|
|
|
http.Response.Cookies.Delete(PrincipalResolver.SessionCookie, new CookieOptions { Path = "/" });
|
|
return TypedResults.NoContent();
|
|
}
|
|
|
|
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<MeDto> Me(HttpContext http)
|
|
{
|
|
Principal principal = http.GetPrincipal();
|
|
return TypedResults.Json(
|
|
new MeDto(principal.Kind.ToString().ToLowerInvariant(), principal.Scope.ToString().ToLowerInvariant(), principal.UserId, principal.Username, principal.CsrfToken),
|
|
ApiJsonContext.Default.MeDto);
|
|
}
|
|
}
|