From 994cf64838da7c971e433eaa2bd78c6ea447da28 Mon Sep 17 00:00:00 2001 From: Andrey Bychkov Date: Wed, 23 Sep 2026 00:10:07 +0300 Subject: [PATCH] feat: add optional sliding idle timeout to PIHTTPServerSessionAuth The idle timeout is opt-in: when set with setSessionIdleTimeout(), a session expires after that period without a successful request and every successful request extends it. When unset (the default), sessions are not touched and keep the previous behavior: the absolute tokenTtl applied to created, or living until logout. Both deadlines can be combined and the earlier one wins. - SessionRec: add last_used, drop the unused refresh_token field - checkToken(): update last_used only while the idle timeout is enabled - tests: IdleTimeout, IdleActivityExtendsSession, IdleDisabledKeepsSession, HasSessionIdleTimeout --- libs/http_server/pihttpserversessionauth.cpp | 34 +++++++- .../http_server/pihttpserversessionauth.h | 28 ++++++- .../http_server_sessionauth_test.cpp | 78 +++++++++++++++++++ 3 files changed, 134 insertions(+), 6 deletions(-) diff --git a/libs/http_server/pihttpserversessionauth.cpp b/libs/http_server/pihttpserversessionauth.cpp index 6d5c7fd0..b01db6e5 100644 --- a/libs/http_server/pihttpserversessionauth.cpp +++ b/libs/http_server/pihttpserversessionauth.cpp @@ -74,10 +74,19 @@ PIHTTP::AuthInfo PIHTTPServerSessionAuth::checkToken(const PIString & token) { PIMutexLocker locker(mutex); SessionRec s = sessions.value(token); if (s.isInvalid()) return false; - if (hasTokenTtl() && PISystemTime::current() - s.created >= token_ttl) { + const PISystemTime now = PISystemTime::current(); + if (hasTokenTtl() && now - s.created >= token_ttl) { sessions.remove(token); return false; } + if (hasSessionIdleTimeout()) { + const PISystemTime base = s.last_used.isNull() ? s.created : s.last_used; + if (now - base >= session_idle_timeout) { + sessions.remove(token); + return false; + } + sessions[token].last_used = now; + } return {true, s.user_id}; } @@ -88,8 +97,9 @@ PIString PIHTTPServerSessionAuth::issueToken(int user_id) { if (data.isEmpty()) return PIString(); PIString token = data.toHex(); SessionRec & s(sessions[token]); - s.user_id = user_id; - s.created = PISystemTime::current(); + s.user_id = user_id; + s.created = PISystemTime::current(); + s.last_used = s.created; return token; } @@ -132,6 +142,24 @@ bool PIHTTPServerSessionAuth::hasTokenTtl() const { } +void PIHTTPServerSessionAuth::setSessionIdleTimeout(PISystemTime t) { + PIMutexLocker locker(mutex); + session_idle_timeout = t; +} + + +PISystemTime PIHTTPServerSessionAuth::sessionIdleTimeout() const { + PIMutexLocker locker(mutex); + return session_idle_timeout; +} + + +bool PIHTTPServerSessionAuth::hasSessionIdleTimeout() const { + PIMutexLocker locker(mutex); + return !session_idle_timeout.isNull(); +} + + PIHTTP::MessageMutable PIHTTPServerSessionAuth::handleLogin(const PIHTTP::MessageConst & request) { PIJSON j = PIJSON::fromJSON(PIString::fromUTF8(request.body())); if (!j.isObject() || !j.contains("login") || !j.contains("password") || j["login"].toString().isEmpty()) diff --git a/libs/main/http_server/pihttpserversessionauth.h b/libs/main/http_server/pihttpserversessionauth.h index cb7d966e..9b8bf00a 100644 --- a/libs/main/http_server/pihttpserversessionauth.h +++ b/libs/main/http_server/pihttpserversessionauth.h @@ -103,6 +103,25 @@ public: //! \~russian Возвращает, установлено ли время жизни сессии. bool hasTokenTtl() const; + //! \~english Enables the idle timeout: a session is denied and removed after "t" without a + //! successful request, and every successful request extends it. A null time disables the + //! idle timeout (the default), so sessions are not extended and live until \a setTokenTtl() + //! expires them or they are revoked. When both are set, the session ends at whichever + //! deadline comes first. + //! \~russian Включает idle-таймаут: сессия отклоняется и удаляется после "t" без успешного + //! запроса, а каждый успешный запрос продлевает её. Нулевое время отключает idle-таймаут + //! (по умолчанию): сессии не продлеваются и живут, пока их не ограничит \a setTokenTtl() + //! или не отзовут. Если заданы оба, сессия завершается по более раннему сроку. + void setSessionIdleTimeout(PISystemTime t); + + //! \~english Returns the idle timeout set by \a setSessionIdleTimeout(). + //! \~russian Возвращает idle-таймаут, установленный \a setSessionIdleTimeout(). + PISystemTime sessionIdleTimeout() const; + + //! \~english Returns whether the idle timeout is enabled. + //! \~russian Возвращает, включен ли idle-таймаут. + bool hasSessionIdleTimeout() const; + //! \~english Changes the route path used for login. The old route is unregistered and the //! new one registered immediately; call it before \a listen() to avoid racing with requests. //! \~russian Изменяет путь маршрута логина. Старый маршрут снимается, новый регистрируется @@ -134,9 +153,11 @@ protected: //! \~english Session creation time. //! \~russian Время создания сессии. PISystemTime created; - //! \~english Reserved for future refresh-token support; not used yet. - //! \~russian Зарезервировано для будущей поддержки refresh-токенов; пока не используется. - PIString refresh_token; + //! \~english Time of the last successful \a checkToken(). Updated only while the + //! idle timeout is enabled; used as the base of the idle timeout. + //! \~russian Время последнего успешного \a checkToken(). Обновляется только при + //! включенном idle-таймауте; служит базой idle-таймаута. + PISystemTime last_used; }; //! \~english Resolves a Bearer token to the \c AuthInfo of its owner, applying the @@ -215,6 +236,7 @@ private: mutable PIMutex mutex; PIMap sessions; PISystemTime token_ttl; + PISystemTime session_idle_timeout; uchar token_length; TokenGenerator token_generator; PIString login_path; diff --git a/tests/http_server/http_server_sessionauth_test.cpp b/tests/http_server/http_server_sessionauth_test.cpp index a70386b2..b1bc1776 100644 --- a/tests/http_server/http_server_sessionauth_test.cpp +++ b/tests/http_server/http_server_sessionauth_test.cpp @@ -429,6 +429,84 @@ TEST_F(HttpServerSessionAuthTest, HasTokenTtl) { } +TEST_F(HttpServerSessionAuthTest, HasSessionIdleTimeout) { + auto s = new TestUserServer(); + EXPECT_FALSE(s->hasSessionIdleTimeout()); + s->setSessionIdleTimeout(1_s); + EXPECT_TRUE(s->hasSessionIdleTimeout()); + EXPECT_EQ(PISystemTime::fromSeconds(1), s->sessionIdleTimeout()); + s->setSessionIdleTimeout(0_s); + EXPECT_FALSE(s->hasSessionIdleTimeout()); +} + + +TEST_F(HttpServerSessionAuthTest, IdleTimeout) { + auto s = new TestUserServer(); + ASSERT_TRUE(s->addUser("alice", "pass1")); + s->setSessionIdleTimeout(500_ms); + s->registerProtectedPath("/secret", PIHTTP::Method::Get, [](const PIHTTP::MessageConst &) { + return PIHTTP::MessageMutable::fromCode(PIHTTP::Code::Ok).setBody(PIByteArray::fromAscii("ok")); + }); + ASSERT_TRUE(start(s)); + + PIString token = login(url("/api/login"), "alice", "pass1"); + ASSERT_FALSE(token.isEmpty()); + EXPECT_EQ(PIHTTP::Code::Ok, request(PIHTTP::Method::Get, url("/secret"), "", bearer(token)).code); + + PISemaphore pause; + pause.tryAcquire(1, 900_ms); + + EXPECT_EQ(PIHTTP::Code::Unauthorized, request(PIHTTP::Method::Get, url("/secret"), "", bearer(token)).code); +} + + +TEST_F(HttpServerSessionAuthTest, IdleActivityExtendsSession) { + auto s = new TestUserServer(); + ASSERT_TRUE(s->addUser("alice", "pass1")); + s->setSessionIdleTimeout(500_ms); + s->registerProtectedPath("/secret", PIHTTP::Method::Get, [](const PIHTTP::MessageConst &) { + return PIHTTP::MessageMutable::fromCode(PIHTTP::Code::Ok).setBody(PIByteArray::fromAscii("ok")); + }); + ASSERT_TRUE(start(s)); + + PIString token = login(url("/api/login"), "alice", "pass1"); + ASSERT_FALSE(token.isEmpty()); + + // Requests spaced by ~200 ms keep the session alive for a total period well beyond the + // 500 ms idle timeout: without sliding this session would already be expired. + for (int i = 0; i < 3; ++i) { + EXPECT_EQ(PIHTTP::Code::Ok, request(PIHTTP::Method::Get, url("/secret"), "", bearer(token)).code); + PISemaphore pause; + pause.tryAcquire(1, 200_ms); + } + EXPECT_EQ(PIHTTP::Code::Ok, request(PIHTTP::Method::Get, url("/secret"), "", bearer(token)).code); + + // Once the client stops, the idle timeout does expire the session. + PISemaphore pause; + pause.tryAcquire(1, 900_ms); + EXPECT_EQ(PIHTTP::Code::Unauthorized, request(PIHTTP::Method::Get, url("/secret"), "", bearer(token)).code); +} + + +TEST_F(HttpServerSessionAuthTest, IdleDisabledKeepsSession) { + auto s = new TestUserServer(); + ASSERT_TRUE(s->addUser("alice", "pass1")); + // The idle timeout is not configured, so the session must not be extended or expired. + s->registerProtectedPath("/secret", PIHTTP::Method::Get, [](const PIHTTP::MessageConst &) { + return PIHTTP::MessageMutable::fromCode(PIHTTP::Code::Ok).setBody(PIByteArray::fromAscii("ok")); + }); + ASSERT_TRUE(start(s)); + + PIString token = login(url("/api/login"), "alice", "pass1"); + ASSERT_FALSE(token.isEmpty()); + + PISemaphore pause; + pause.tryAcquire(1, 900_ms); + + EXPECT_EQ(PIHTTP::Code::Ok, request(PIHTTP::Method::Get, url("/secret"), "", bearer(token)).code); +} + + TEST_F(HttpServerSessionAuthTest, CustomTokenGenerator) { auto s = new TestUserServer(); s->setTokenGenerator([](uint) { return PIByteArray::fromHex("01020304"); });