Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fc9f79f9b
|
||
|
|
e6977b3ccd
|
No files matched your search
@@ -117,6 +117,8 @@ public class MobileScannersRouteProvider implements QJavalinRouteProviderInterfa
|
||||
{
|
||||
try
|
||||
{
|
||||
// Tombstone only: the core handler that follows still owns the provider logout
|
||||
// (including native logout data, which needs the core session intact) and session removal.
|
||||
ExecutorSessionUtils.setupSession(context, instance);
|
||||
MobileSessionService.recordLogout();
|
||||
}
|
||||
|
||||
@@ -219,13 +219,16 @@ final class MobileSessionService
|
||||
|
||||
/***************************************************************************
|
||||
** Standard logout remains usable by clients that predate session telemetry.
|
||||
** The core /qqq/v1/logout handler that follows prepares any native provider
|
||||
** logout data from the still-valid core session and then ends it, so the
|
||||
** tombstone is recorded here without running that logout a second time.
|
||||
***************************************************************************/
|
||||
static void recordLogout() throws QException
|
||||
{
|
||||
QRecord binding = MobileService.get(MobileScannersQBitProducer.SESSION_BINDING, MobileService.hash(authenticatedUuid()));
|
||||
if(binding != null)
|
||||
{
|
||||
end(binding.getValueString("sessionId"), "SIGN_OUT", false);
|
||||
end(binding.getValueString("sessionId"), "SIGN_OUT", false, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +238,17 @@ final class MobileSessionService
|
||||
** Persist the tombstone before calling provider cleanup; never trust a no-op.
|
||||
***************************************************************************/
|
||||
static Instant end(String id, String reason, boolean administrator) throws QException
|
||||
{
|
||||
return end(id, reason, administrator, false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** With coreLogoutFollows, the request's own core logout handler owns the
|
||||
** provider logout and session removal: the status becomes DELEGATED.
|
||||
***************************************************************************/
|
||||
private static Instant end(String id, String reason, boolean administrator, boolean coreLogoutFollows) throws QException
|
||||
{
|
||||
synchronized(current())
|
||||
{
|
||||
@@ -263,10 +277,13 @@ final class MobileSessionService
|
||||
current().screens.remove(id);
|
||||
if(binding != null)
|
||||
{
|
||||
String coreUuid = binding.getValueString("coreSessionUuid");
|
||||
if(MobileService.TRANSACTION.get() == null)
|
||||
if(coreLogoutFollows)
|
||||
{
|
||||
cleanupAuthentication(id, coreUuid);
|
||||
update(new QRecord().withValue("id", id).withValue("authCleanupStatus", "DELEGATED").withValue("authCleanupAt", Instant.now()));
|
||||
}
|
||||
else if(MobileService.TRANSACTION.get() == null)
|
||||
{
|
||||
cleanupAuthentication(id, binding.getValueString("coreSessionUuid"));
|
||||
}
|
||||
}
|
||||
return endedAt;
|
||||
|
||||
@@ -7,18 +7,26 @@ package com.kingsrook.qbits.mobilescanners;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
@@ -43,12 +51,21 @@ import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.AuthScope;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QCredentialScope;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreHelper;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreRegistry;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.MockAuthenticationModule;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.LogoutSpecV1;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
|
||||
import io.javalin.Javalin;
|
||||
import io.javalin.apibuilder.ApiBuilder;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -202,6 +219,8 @@ class MobileScannersTest
|
||||
{
|
||||
MobilePermissions.REQUEST_DEVICE.remove();
|
||||
SessionMutator.hook = null;
|
||||
QSessionStoreRegistry.getInstance().clear();
|
||||
RecordingAuthenticationModule.LOGGED_OUT.clear();
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
@@ -1617,4 +1636,267 @@ class MobileScannersTest
|
||||
{
|
||||
return new HashMap<>(Map.of("eventId", UUID.randomUUID().toString(), "barcode", "00001234", "symbology", "CODE128", "scanSource", "datawedge", "capturedAt", "2026-10-29T12:00:00Z"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Mock authentication that records every core session the logout executor ends.
|
||||
***************************************************************************/
|
||||
public static class RecordingAuthenticationModule extends MockAuthenticationModule
|
||||
{
|
||||
static final List<String> LOGGED_OUT = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Provider logout removes the stored core session, as the OAuth2 module does.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void logout(QInstance qInstance, String sessionUUID)
|
||||
{
|
||||
LOGGED_OUT.add(sessionUUID);
|
||||
QSessionStoreHelper.removeSession(sessionUUID);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** In-memory core session store that records removals in order.
|
||||
***************************************************************************/
|
||||
private static final class RecordingSessionStore implements QSessionStoreProviderInterface
|
||||
{
|
||||
private final Map<String, QSession> sessions = new ConcurrentHashMap<>();
|
||||
private final List<String> removed = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void store(String sessionUuid, QSession session, Duration ttl)
|
||||
{
|
||||
sessions.put(sessionUuid, session);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Optional<QSession> load(String sessionUuid)
|
||||
{
|
||||
return Optional.ofNullable(sessions.get(sessionUuid));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void remove(String sessionUuid)
|
||||
{
|
||||
removed.add(sessionUuid);
|
||||
sessions.remove(sessionUuid);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void touch(String sessionUuid)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Duration getDefaultTtl()
|
||||
{
|
||||
return Duration.ofHours(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What the core logout handler found when it started handling one request.
|
||||
***************************************************************************/
|
||||
private record CoreLogoutObservation(boolean coreSessionPresent, int removalsBefore, int logoutsBefore)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Install the recording authentication module and core session store.
|
||||
***************************************************************************/
|
||||
private void recordCoreLogouts(RecordingSessionStore store)
|
||||
{
|
||||
instance.registerAuthenticationProvider(AuthScope.instanceDefault(), new QAuthenticationMetaData().withName("recording")
|
||||
.withType(QAuthenticationType.CUSTOM).withModuleCodeReference(new QCodeReference(RecordingAuthenticationModule.class)));
|
||||
QSessionStoreRegistry.getInstance().register(store);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The real route chain: scanner pre-handler, an observer, then core LogoutSpecV1.
|
||||
***************************************************************************/
|
||||
private Javalin logoutServer(RecordingSessionStore store, List<CoreLogoutObservation> observations)
|
||||
{
|
||||
recordCoreLogouts(store);
|
||||
MobileScannersRouteProvider provider = new MobileScannersRouteProvider(instance);
|
||||
return Javalin.create(config ->
|
||||
{
|
||||
config.jetty.host = "127.0.0.1";
|
||||
config.routes.apiBuilder(provider.getJavalinEndpointGroup());
|
||||
config.routes.apiBuilder(() ->
|
||||
{
|
||||
ApiBuilder.before("/qqq/v1/logout", context -> observations.add(new CoreLogoutObservation(
|
||||
store.load(context.cookie("sessionUUID")).isPresent(), store.removed.size(), RecordingAuthenticationModule.LOGGED_OUT.size())));
|
||||
LogoutSpecV1 logout = new LogoutSpecV1();
|
||||
logout.setQInstance(instance);
|
||||
logout.defineRoute(new MiddlewareVersionV1(), "v1");
|
||||
});
|
||||
}).start(0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** POST the core logout with the given session cookie.
|
||||
***************************************************************************/
|
||||
private HttpResponse<String> postLogout(Javalin server, String uuid, String body) throws Exception
|
||||
{
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + server.port() + "/qqq/v1/logout"))
|
||||
.timeout(Duration.ofSeconds(10)).header("Cookie", "sessionUUID=" + uuid).header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
|
||||
return HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Start an active heartbeat session whose core session is in the store.
|
||||
***************************************************************************/
|
||||
private String activeSession(RecordingSessionStore store) throws Exception
|
||||
{
|
||||
bind(approvedDevice());
|
||||
String sessionId = (String) MobileSessionService.heartbeat(Map.of("foreground", true, "screenAvailable", true)).get("sessionId");
|
||||
store.store(scanner.getUuid(), scanner, Duration.ofHours(1));
|
||||
assertEquals("ACTIVE", MobileService.get(MobileScannersQBitProducer.SESSION, sessionId).getValueString("status"));
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Provider sign-out: the pre-handler tombstones the row but leaves the core
|
||||
** session to the core handler, which prepares native logout data before
|
||||
** ending it. Previously the pre-handler ended the core session first.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void coreLogoutWithNativeLogoutKeepsCoreSessionUntilCoreHandlerRuns() throws Exception
|
||||
{
|
||||
RecordingSessionStore store = new RecordingSessionStore();
|
||||
List<CoreLogoutObservation> observations = Collections.synchronizedList(new ArrayList<>());
|
||||
Javalin server = logoutServer(store, observations);
|
||||
try
|
||||
{
|
||||
String sessionId = activeSession(store);
|
||||
var response = postLogout(server, scanner.getUuid(), "{\"nativeLogout\":{\"postLogoutRedirectUri\":\"qqq-mobile://logout\"}}");
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(1, observations.size());
|
||||
assertTrue(observations.getFirst().coreSessionPresent(), "the pre-handler must not remove the core session");
|
||||
assertEquals(0, observations.getFirst().removalsBefore());
|
||||
assertEquals(0, observations.getFirst().logoutsBefore());
|
||||
assertEquals(List.of(scanner.getUuid()), RecordingAuthenticationModule.LOGGED_OUT);
|
||||
assertEquals(List.of(scanner.getUuid()), store.removed);
|
||||
assertTrue(store.load(scanner.getUuid()).isEmpty());
|
||||
QRecord session = MobileService.get(MobileScannersQBitProducer.SESSION, sessionId);
|
||||
assertEquals("ENDED", session.getValueString("status"));
|
||||
assertEquals("SIGN_OUT", session.getValueString("endReason"));
|
||||
assertEquals("DELEGATED", session.getValueString("authCleanupStatus"));
|
||||
assertNotNull(session.getValueInstant("authCleanupAt"));
|
||||
assertNotNull(session.getValueInstant("endedAt"));
|
||||
assertFalse(session.getValueBoolean("screenAvailable"));
|
||||
assertFalse(session.getValueBoolean("foreground"));
|
||||
assertThrows(QException.class, MobileSessionService::requireNotEnded);
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.stop();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Ending the current session directly still runs the core logout itself.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void endCurrentStillPerformsCoreLogout() throws Exception
|
||||
{
|
||||
RecordingSessionStore store = new RecordingSessionStore();
|
||||
recordCoreLogouts(store);
|
||||
String sessionId = activeSession(store);
|
||||
Map<String, Object> ended = MobileSessionService.endCurrent(Map.of("reason", "SIGN_OUT"));
|
||||
assertEquals(sessionId, ended.get("sessionId"));
|
||||
assertEquals("ENDED", ended.get("status"));
|
||||
assertEquals(List.of(scanner.getUuid()), RecordingAuthenticationModule.LOGGED_OUT);
|
||||
// The provider logout and the service's own removal both clear the store; neither is skipped.
|
||||
assertEquals(List.of(scanner.getUuid(), scanner.getUuid()), store.removed);
|
||||
assertTrue(store.load(scanner.getUuid()).isEmpty());
|
||||
QRecord session = MobileService.get(MobileScannersQBitProducer.SESSION, sessionId);
|
||||
assertEquals("SIGN_OUT", session.getValueString("endReason"));
|
||||
assertEquals("REQUESTED", session.getValueString("authCleanupStatus"));
|
||||
assertNotNull(session.getValueInstant("authCleanupAt"));
|
||||
assertThrows(QException.class, MobileSessionService::requireNotEnded);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Ordinary logout ends the mobile row and the core session exactly once, and
|
||||
** an unbound cookie still reaches the core handler.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void plainCoreLogoutEndsMobileRowAndCoreSessionOnce() throws Exception
|
||||
{
|
||||
RecordingSessionStore store = new RecordingSessionStore();
|
||||
List<CoreLogoutObservation> observations = Collections.synchronizedList(new ArrayList<>());
|
||||
Javalin server = logoutServer(store, observations);
|
||||
try
|
||||
{
|
||||
String sessionId = activeSession(store);
|
||||
var response = postLogout(server, scanner.getUuid(), "");
|
||||
assertEquals(200, response.statusCode());
|
||||
assertTrue(observations.getFirst().coreSessionPresent());
|
||||
assertEquals(List.of(scanner.getUuid()), RecordingAuthenticationModule.LOGGED_OUT);
|
||||
assertEquals(List.of(scanner.getUuid()), store.removed);
|
||||
QRecord session = MobileService.get(MobileScannersQBitProducer.SESSION, sessionId);
|
||||
assertEquals("ENDED", session.getValueString("status"));
|
||||
assertEquals("SIGN_OUT", session.getValueString("endReason"));
|
||||
assertEquals("DELEGATED", session.getValueString("authCleanupStatus"));
|
||||
assertThrows(QException.class, MobileSessionService::requireNotEnded);
|
||||
|
||||
String unbound = UUID.randomUUID().toString();
|
||||
assertEquals(200, postLogout(server, unbound, "").statusCode());
|
||||
assertEquals(List.of(scanner.getUuid(), unbound), RecordingAuthenticationModule.LOGGED_OUT);
|
||||
assertEquals("DELEGATED", MobileService.get(MobileScannersQBitProducer.SESSION, sessionId).getValueString("authCleanupStatus"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -39,9 +39,9 @@ Limits: the file store is the single-host demo adapter and the database is the d
|
||||
|
||||
## Session cleanup status
|
||||
|
||||
Ending a session (`MobileSessionService`) first persists the ended state with `authCleanupStatus = PENDING`. Cleanup then sets `REQUESTED` (with `authCleanupAt`) **before** calling the logout executor and removing the stored core session. If that call throws a `QException`, the status becomes `RETRY_NEEDED`. So `REQUESTED` means cleanup was attempted and no failure was reported; it is not independent proof that the provider session is gone. Cleanup is skipped when a binding does not exist, and for a single end inside an open transaction; a status left at `PENDING` also needs a retry. The bulk End Session process commits all tombstones first and runs cleanup afterwards.
|
||||
Ending a session (`MobileSessionService`) first persists the ended state with `authCleanupStatus = PENDING`. Cleanup then sets `REQUESTED` (with `authCleanupAt`) **before** calling the logout executor and removing the stored core session. If that call throws a `QException`, the status becomes `RETRY_NEEDED`. So `REQUESTED` means cleanup was attempted and no failure was reported; it is not independent proof that the provider session is gone. Cleanup is skipped when a binding does not exist, and for a single end inside an open transaction; a status left at `PENDING` also needs a retry. The bulk End Session process commits all tombstones first and runs cleanup afterwards. A sign-out through the core `POST /qqq/v1/logout` route sets `DELEGATED` (with `authCleanupAt`) instead: the scanner only tombstones the row, and the core logout handler in the same request prepares any native provider logout data and then ends the core session itself (the scanner no longer runs that logout first, which had removed the session before the core handler could read it). `DELEGATED` therefore means the core handler owned the cleanup; it needs no retry unless that core request failed.
|
||||
|
||||
To retry: find sessions with `authCleanupStatus` of `RETRY_NEEDED` or `PENDING` (admin view of the session table), then run End Session again for them. Ending an already ended session does not change its end time and reruns cleanup when a binding exists. Confirm the status reaches `REQUESTED`. There is no automatic monitor in this repository; the operator must check for these statuses.
|
||||
To retry: find sessions with `authCleanupStatus` of `RETRY_NEEDED` or `PENDING` (admin view of the session table), then run End Session again for them (that administrator end runs cleanup itself, also for a `DELEGATED` row whose core request failed). Ending an already ended session does not change its end time and reruns cleanup when a binding exists. Confirm the status reaches `REQUESTED`. There is no automatic monitor in this repository; the operator must check for these statuses.
|
||||
|
||||
## Retention
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ The [local demo](LOCAL-DEMO.md) source defaults remain QQQ `4.1.0-RC.21` and Pri
|
||||
|
||||
## Verification
|
||||
|
||||
- **Provider sign-out fix (2026-11-07, branch `fix/logout-keep-core-session-for-native-logout`).** On a physical MC9400 with the production 1.0.2 APK against a real Authentik fixture, company sign-out never received `nativeLogoutData` from `POST /qqq/v1/logout {"nativeLogout":...}` while a mobile session was active: the scanner's `/qqq/v1/logout` pre-handler ran the core logout executor and removed the stored core session before the core handler could prepare native logout data. The pre-handler now only tombstones the mobile session (`ENDED`, `SIGN_OUT`, `authCleanupStatus = DELEGATED`, see [OPERATIONS](OPERATIONS.md#session-cleanup-status)) and leaves the provider logout and session removal to the core handler in the same request. `/sessions/current/end` and administrator ends are unchanged. Three focused tests drive the real route chain (scanner pre-handler, then core `LogoutSpecV1`) with a recording authentication module and core session store. The OpenAPI contract and extension version are unchanged. Native Authentik sign-out on the device still needs a rerun with a build that includes this fix.
|
||||
- Photo/admin change set (root-reported, not rerun in this review): 69 owner tests and 197 scanner/Echo tests passed (runs of 31, 158 and 8), 13 focused Android tests passed, and the relevant Checkstyle passed. These groupings differ from the module split below, so no per-module counts are claimed. Public immutable dependency qualification is still pending.
|
||||
- Live evidence: a fresh synthetic gallery PNG uploaded from the production-signed MC9400 app returned "Saved photo", and the server showed `AVAILABLE` with the exact parent, type, size and SHA-256. An older photo's Available state, thumbnail and Preview control were observed. The new photo's full preview was not clicked because ADB disconnected. No camera-hardware or full-preview proof is claimed.
|
||||
- The earlier combined five-module `verify` (2026-11-05, base `9e8685f` plus the storage fixes) passed **196 server tests** with zero failures, errors and skips and zero Checkstyle violations: scanner 53, base demo 8, mobile Record Files 22, Echo 74, Echo demo 39. It used qualified private owner dependencies; there is no public-dependency or physical-output qualification, and the gate must be repeated when the public cohort is adopted. The new build parent's report-only schema-location/contract-test findings remain visible.
|
||||
|
||||
Reference in new issue
Block a user