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
This commit is contained in:
2026-09-23 00:10:07 +03:00
parent 2f63a49c69
commit 994cf64838
3 changed files with 134 additions and 6 deletions
+31 -3
View File
@@ -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())
@@ -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<PIString, SessionRec> sessions;
PISystemTime token_ttl;
PISystemTime session_idle_timeout;
uchar token_length;
TokenGenerator token_generator;
PIString login_path;
@@ -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"); });