diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index a1a7aa6..510a990 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -5146,7 +5146,8 @@ "LoginRequest": { "required": [ "username", - "password" + "password", + "return_session" ], "type": "object", "properties": { @@ -5155,6 +5156,12 @@ }, "password": { "type": "string" + }, + "return_session": { + "type": [ + "null", + "boolean" + ] } } }, @@ -5163,7 +5170,8 @@ "user_id", "username", "csrf_token", - "expires_at" + "expires_at", + "session_token" ], "type": "object", "properties": { @@ -5178,6 +5186,12 @@ }, "expires_at": { "format": "date-time" + }, + "session_token": { + "type": [ + "null", + "string" + ] } } }, diff --git a/backend/src/Monitor.Backend.Api/Admin/AuthEndpoints.cs b/backend/src/Monitor.Backend.Api/Admin/AuthEndpoints.cs index 5c8bbcf..6294413 100644 --- a/backend/src/Monitor.Backend.Api/Admin/AuthEndpoints.cs +++ b/backend/src/Monitor.Backend.Api/Admin/AuthEndpoints.cs @@ -44,24 +44,35 @@ public static class AuthEndpoints return Problems.From(http, error); } - http.Response.Cookies.Append( - PrincipalResolver.SessionCookie, - login.SessionToken, - new CookieOptions - { - HttpOnly = true, - Secure = options.SecureCookies, - SameSite = SameSiteMode.Strict, - Path = "/", - Expires = DateTimeOffset.FromUnixTimeMilliseconds(login.ExpiresAtMs), - }); + // 跨域的前端(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)), ApiJsonContext.Default.LoginResponse); + 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) { - if (http.Request.Cookies.TryGetValue(PrincipalResolver.SessionCookie, out string? session)) + 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); } diff --git a/backend/src/Monitor.Backend.Api/Common/Guards.cs b/backend/src/Monitor.Backend.Api/Common/Guards.cs index 867963b..4167689 100644 --- a/backend/src/Monitor.Backend.Api/Common/Guards.cs +++ b/backend/src/Monitor.Backend.Api/Common/Guards.cs @@ -23,14 +23,25 @@ public static class PrincipalResolver public const string SessionCookie = "monitor_session"; public const string CsrfHeader = "X-CSRF-Token"; + public static string? BearerOf(HttpContext http) + { + string? header = http.Request.Headers.Authorization; + return header is not null && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? header["Bearer ".Length..].Trim() : null; + } + public static async Task ResolveAsync(HttpContext http, Func next) { AuthService auth = http.RequestServices.GetRequiredService(); Principal? principal = null; - string? header = http.Request.Headers.Authorization; - if (header is not null && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + string? bearer = BearerOf(http); + if (bearer is not null) { - principal = await auth.AuthenticateTokenAsync(header["Bearer ".Length..].Trim(), http.RequestAborted); + principal = await auth.AuthenticateTokenAsync(bearer, http.RequestAborted); + if (principal is null && await auth.AuthenticateSessionAsync(bearer, http.RequestAborted) is { } viaSession) + { + // 登录时用 return_session 拿到的会话令牌:和 API 令牌一样走 Bearer,不是 Cookie,所以不需要 CSRF + principal = viaSession with { Kind = PrincipalKind.Token, CsrfToken = null }; + } } else if (http.Request.Cookies.TryGetValue(SessionCookie, out string? session) && !string.IsNullOrEmpty(session)) { diff --git a/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs b/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs index cc1bf64..1f578fd 100644 --- a/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs @@ -2,9 +2,10 @@ using System.Text.Json; namespace Monitor.Backend.Api; -public sealed record LoginRequest(string Username, string Password); +/// return_session:跨域的前端用——不设 Cookie,把会话令牌放在响应里,之后用 Authorization: Bearer 带上。 +public sealed record LoginRequest(string Username, string Password, bool? ReturnSession); -public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt); +public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken); public sealed record MeDto(string Kind, string Scope, string? UserId, string? Username, string? CsrfToken); diff --git a/backend/tests/Monitor.Backend.Tests/ApiBasicsTests.cs b/backend/tests/Monitor.Backend.Tests/ApiBasicsTests.cs index bf95a14..06dbe59 100644 --- a/backend/tests/Monitor.Backend.Tests/ApiBasicsTests.cs +++ b/backend/tests/Monitor.Backend.Tests/ApiBasicsTests.cs @@ -58,6 +58,39 @@ public sealed class ApiBasicsTests Assert.Equal("no-store", ok.Headers.CacheControl!.ToString()); } + [Fact] + public async Task Login_WithReturnSession_GivesABearerSession_WithoutACookie_AndNoCsrfNeeded() + { + using HttpClient client = _backend.NewClient(); + using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword, ["return_session"] = true })); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + Assert.False(login.Headers.Contains("Set-Cookie")); + string session = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue(); + Assert.False(string.IsNullOrEmpty(session)); + + // 之后当 API 令牌那样用 Bearer;不是 Cookie,所以不需要 CSRF,也能做非 GET 操作 + using var me = new HttpRequestMessage(HttpMethod.Get, "/api/admin/auth/me"); + me.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session); + using HttpResponseMessage meResponse = await client.SendAsync(me); + Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode); + Assert.Equal(BackendFixture.AdminUser, JsonNode.Parse(await meResponse.Content.ReadAsStringAsync())!["username"]!.GetValue()); + + using var create = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) }; + create.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session); + using HttpResponseMessage created = await client.SendAsync(create); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + + // 退出之后这个会话令牌就失效 + using var logout = new HttpRequestMessage(HttpMethod.Post, "/api/admin/auth/logout"); + logout.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session); + using HttpResponseMessage loggedOut = await client.SendAsync(logout); + Assert.Equal(HttpStatusCode.NoContent, loggedOut.StatusCode); + using var after = new HttpRequestMessage(HttpMethod.Get, "/api/admin/auth/me"); + after.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session); + using HttpResponseMessage afterResponse = await client.SendAsync(after); + Assert.Equal(HttpStatusCode.Unauthorized, afterResponse.StatusCode); + } + [Fact] public async Task SessionCookie_RequiresCsrfHeaderOnUnsafeMethods() {