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;
/// 登录、退出、当前身份。登录接口限流,失败一律返回 401,不区分“用户不存在”和“密码错误”。
public static class AuthEndpoints
{
private const string Tag = "admin-auth";
/// 登录不需要认证:挂在没有守卫的分组上。
public static void MapLogin(RouteGroupBuilder open)
{
open.MapPost("/auth/login", LoginAsync)
.Api("auth_login", "Sign in with a username and password", Tag)
.Returns().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(StatusCodes.Status204NoContent).ProducesProblems(401);
guarded.MapGet("/auth/me", Me)
.Api("auth_me", "The current identity", Tag)
.Returns().ProducesProblems(401);
}
private static async Task 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 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 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 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);
}
}