Fix broken login on omsorgWeb: restore refreshToken in auth responses
Docker-Images bauen und veröffentlichen / build (, omsorgCore/Dockerfile, omsorgcore) (push) Successful in 14s
Docker-Images bauen und veröffentlichen / build (, omsorgWeb/Dockerfile, omsorgweb) (push) Successful in 5s
Docker-Images bauen und veröffentlichen / build (, omsorgapp/Dockerfile, omsorgapp) (push) Successful in 17s
Docker-Images bauen und veröffentlichen / build (, omsorgCore/Dockerfile, omsorgcore) (push) Successful in 14s
Docker-Images bauen und veröffentlichen / build (, omsorgWeb/Dockerfile, omsorgweb) (push) Successful in 5s
Docker-Images bauen und veröffentlichen / build (, omsorgapp/Dockerfile, omsorgapp) (push) Successful in 17s
The cookie-only refresh-token migration earlier this session broke both mitarbeiter-app and mitarbeiter-app-legacy: they're server-to-server PHP clients (cURL/Guzzle calling omsorgCore directly) with no browser cookie jar, so dropping refreshToken from the login/refresh response body left them with nothing to store - login appeared to succeed, redirected to the dashboard, but the very next page's session check failed silently (mitarbeiter-app's _ensure_fresh_token() bails out whenever $_SESSION['omsorgcore_refresh_token'] is empty), bouncing the user back to the login form every time. Fix: dual-mode refresh token transport instead of cookie-only. - LoginResponse includes refreshToken again (restores the pre-migration contract PHP already expected) alongside the HttpOnly cookie. - AuthController.Refresh/Logout accept an optional body-carried RefreshRequest/LogoutRequest as a fallback: cookie is checked first (browser/omsorgapp), body second (server-to-server clients). - omsorgapp keeps ignoring the body's refreshToken and relies solely on the cookie (XSS-safe) - only its authApi.js needed a small update since the regenerated client now requires an explicit (empty) parameter object for refresh/logout. - Regenerated omsorgcore-client-ts; api-client-php's lib/ was already consistent (never regenerated during the original migration, so it still expected refreshToken all along - only the backend had stopped providing it). Verified end-to-end against a live instance: PHP login+refresh via omsorgcore_login()/omsorgcore_refresh(), and the browser cookie-only flow via curl with Origin/credentials headers - both work. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
dca0349e8c
commit
09dce2ab98
@@ -1,3 +1,8 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record LoginResponse(string AccessToken, DateTime ExpiresAt, bool MustChangePassword);
|
||||
// RefreshToken steht hier UND als HttpOnly-Cookie (siehe AuthController.SetRefreshTokenCookie) -
|
||||
// zwei Konsumenten mit unterschiedlichem Transport: omsorgapp (Browser) ignoriert dieses Feld
|
||||
// bewusst und verlässt sich nur auf die Cookie (XSS-sicher, siehe omsorgapp/src/api/authApi.js).
|
||||
// omsorgWeb (PHP, server-seitiger Aufrufer ohne Browser-Cookie-Jar) MUSS den Wert hier lesen und
|
||||
// selbst in der PHP-Session verwalten, siehe omsorgWeb/mitarbeiter-app/lib/auth.php.
|
||||
public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt, bool MustChangePassword);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
// Optional - nur für Aufrufer ohne Browser-Cookie-Jar (server-seitige API-Clients wie omsorgWeb),
|
||||
// siehe AuthController.Logout. Ein Browser schickt keinen Body, die HttpOnly-Cookie reicht.
|
||||
public record LogoutRequest(string? RefreshToken);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
// Optional - nur für Aufrufer ohne Browser-Cookie-Jar (server-seitige API-Clients wie omsorgWeb),
|
||||
// siehe AuthController.Refresh. Ein Browser schickt keinen Body, die HttpOnly-Cookie reicht.
|
||||
public record RefreshRequest(string? RefreshToken);
|
||||
@@ -82,13 +82,26 @@ public class AuthController : ControllerBase
|
||||
|
||||
await _dispatcher.DispatchAsync(new AuditEvent(result.UserId, result.Username, ipAddress, "Login"), cancellationToken);
|
||||
SetRefreshTokenCookie(result.RefreshToken);
|
||||
return Ok(new LoginResponse(result.Token, result.ExpiresAt.Value, result.MustChangePassword));
|
||||
return Ok(new LoginResponse(result.Token, result.RefreshToken, result.ExpiresAt.Value, result.MustChangePassword));
|
||||
}
|
||||
|
||||
// Zwei Transportwege für den vorgelegten Refresh-Token, in dieser Reihenfolge geprüft:
|
||||
// 1. HttpOnly-Cookie (Browser, omsorgapp) - schickt bewusst keinen Body.
|
||||
// 2. Body (server-seitige API-Clients ohne Cookie-Jar, z. B. omsorgWeb per PHP-cURL/Guzzle).
|
||||
private string? ResolveRefreshToken(string? bodyRefreshToken)
|
||||
{
|
||||
if (Request.Cookies.TryGetValue(RefreshTokenCookieName, out var cookieToken) && !string.IsNullOrEmpty(cookieToken))
|
||||
{
|
||||
return cookieToken;
|
||||
}
|
||||
return string.IsNullOrEmpty(bodyRefreshToken) ? null : bodyRefreshToken;
|
||||
}
|
||||
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult<LoginResponse>> Refresh(CancellationToken cancellationToken)
|
||||
public async Task<ActionResult<LoginResponse>> Refresh(RefreshRequest? request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Request.Cookies.TryGetValue(RefreshTokenCookieName, out var refreshToken) || string.IsNullOrEmpty(refreshToken))
|
||||
var refreshToken = ResolveRefreshToken(request?.RefreshToken);
|
||||
if (refreshToken is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
@@ -99,19 +112,28 @@ public class AuthController : ControllerBase
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
SetRefreshTokenCookie(result.RefreshToken);
|
||||
return Ok(new LoginResponse(result.Token, result.ExpiresAt.Value, result.MustChangePassword));
|
||||
// Cookie nur erneuern, wenn der Aufrufer auch eine Cookie vorgelegt hat - ein reiner
|
||||
// API-Client (Body-basiert) soll keine Cookie untergeschoben bekommen, die er nie abfragt.
|
||||
if (Request.Cookies.ContainsKey(RefreshTokenCookieName))
|
||||
{
|
||||
SetRefreshTokenCookie(result.RefreshToken);
|
||||
}
|
||||
return Ok(new LoginResponse(result.Token, result.RefreshToken, result.ExpiresAt.Value, result.MustChangePassword));
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
|
||||
public async Task<IActionResult> Logout(LogoutRequest? request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (Request.Cookies.TryGetValue(RefreshTokenCookieName, out var refreshToken) && !string.IsNullOrEmpty(refreshToken))
|
||||
var refreshToken = ResolveRefreshToken(request?.RefreshToken);
|
||||
if (refreshToken is not null)
|
||||
{
|
||||
await _authService.RevokeAsync(refreshToken, cancellationToken);
|
||||
}
|
||||
|
||||
Response.Cookies.Delete(RefreshTokenCookieName, new CookieOptions { Path = "/api/auth" });
|
||||
if (Request.Cookies.ContainsKey(RefreshTokenCookieName))
|
||||
{
|
||||
Response.Cookies.Delete(RefreshTokenCookieName, new CookieOptions { Path = "/api/auth" });
|
||||
}
|
||||
await _dispatcher.DispatchAsync(
|
||||
new AuditEvent(_currentUserService.UserId, _currentUserService.Username, _currentUserService.IpAddress, "Logout"),
|
||||
cancellationToken);
|
||||
|
||||
Reference in New Issue
Block a user