feat(api): add return_session login for cross-origin clients
Return the session token in the login response instead of a cookie, and accept it as a Bearer token without CSRF.
This commit is contained in:
1 parent
193fc3a95c
commit
91c63d0cf2
5 files changed
+79
-9
No files matched your search
+16
-2
@@ -5146,7 +5146,8 @@
|
|||||||
"LoginRequest": {
|
"LoginRequest": {
|
||||||
"required": [
|
"required": [
|
||||||
"username",
|
"username",
|
||||||
"password"
|
"password",
|
||||||
|
"return_session"
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -5155,6 +5156,12 @@
|
|||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"return_session": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"boolean"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -5163,7 +5170,8 @@
|
|||||||
"user_id",
|
"user_id",
|
||||||
"username",
|
"username",
|
||||||
"csrf_token",
|
"csrf_token",
|
||||||
"expires_at"
|
"expires_at",
|
||||||
|
"session_token"
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -5178,6 +5186,12 @@
|
|||||||
},
|
},
|
||||||
"expires_at": {
|
"expires_at": {
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"session_token": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"string"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ public static class AuthEndpoints
|
|||||||
return Problems.From(http, error);
|
return Problems.From(http, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 跨域的前端(return_session)不用 Cookie:会话令牌放在响应里,之后用 Bearer 带上
|
||||||
|
bool returnSession = request.ReturnSession == true;
|
||||||
|
if (!returnSession)
|
||||||
|
{
|
||||||
http.Response.Cookies.Append(
|
http.Response.Cookies.Append(
|
||||||
PrincipalResolver.SessionCookie,
|
PrincipalResolver.SessionCookie,
|
||||||
login.SessionToken,
|
login.SessionToken,
|
||||||
@@ -55,13 +59,20 @@ public static class AuthEndpoints
|
|||||||
Path = "/",
|
Path = "/",
|
||||||
Expires = DateTimeOffset.FromUnixTimeMilliseconds(login.ExpiresAtMs),
|
Expires = DateTimeOffset.FromUnixTimeMilliseconds(login.ExpiresAtMs),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
http.Response.Headers.CacheControl = "no-store";
|
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<IResult> LogoutAsync(HttpContext http, AuthService auth)
|
private static async Task<IResult> 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);
|
await auth.LogoutAsync(session, http.RequestAborted);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,14 +23,25 @@ public static class PrincipalResolver
|
|||||||
public const string SessionCookie = "monitor_session";
|
public const string SessionCookie = "monitor_session";
|
||||||
public const string CsrfHeader = "X-CSRF-Token";
|
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<Task> next)
|
public static async Task ResolveAsync(HttpContext http, Func<Task> next)
|
||||||
{
|
{
|
||||||
AuthService auth = http.RequestServices.GetRequiredService<AuthService>();
|
AuthService auth = http.RequestServices.GetRequiredService<AuthService>();
|
||||||
Principal? principal = null;
|
Principal? principal = null;
|
||||||
string? header = http.Request.Headers.Authorization;
|
string? bearer = BearerOf(http);
|
||||||
if (header is not null && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
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))
|
else if (http.Request.Cookies.TryGetValue(SessionCookie, out string? session) && !string.IsNullOrEmpty(session))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ using System.Text.Json;
|
|||||||
|
|
||||||
namespace Monitor.Backend.Api;
|
namespace Monitor.Backend.Api;
|
||||||
|
|
||||||
public sealed record LoginRequest(string Username, string Password);
|
/// <summary><c>return_session</c>:跨域的前端用——不设 Cookie,把会话令牌放在响应里,之后用 <c>Authorization: Bearer</c> 带上。</summary>
|
||||||
|
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);
|
public sealed record MeDto(string Kind, string Scope, string? UserId, string? Username, string? CsrfToken);
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,39 @@ public sealed class ApiBasicsTests
|
|||||||
Assert.Equal("no-store", ok.Headers.CacheControl!.ToString());
|
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<string>();
|
||||||
|
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<string>());
|
||||||
|
|
||||||
|
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]
|
[Fact]
|
||||||
public async Task SessionCookie_RequiresCsrfHeaderOnUnsafeMethods()
|
public async Task SessionCookie_RequiresCsrfHeaderOnUnsafeMethods()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user