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:
justme committed 2026-10-06 16:59:16 +08:00
1 parent 193fc3a95c
commit 91c63d0cf2
5 files changed
+90 -20

No files matched your search

@@ -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<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);
}
@@ -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<Task> next)
{
AuthService auth = http.RequestServices.GetRequiredService<AuthService>();
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))
{
@@ -2,9 +2,10 @@ using System.Text.Json;
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);