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); } }