- cleanupExpired() drops sessions expired by tokenTtl or sessionIdleTimeout and is called opportunistically on issuing a token, bounding the session table; it is protected and self-locking so subclasses can run it periodically - issueToken() refuses a token already used by an active session instead of silently merging two sessions (login replies 500 on a generator collision) - document that the token generator must be collision-resistant - tests: SessionCleanupOnLogin, ManualCleanup, TokenCollisionRejected
689 lines
26 KiB
C++
689 lines
26 KiB
C++
#include "pidigest.h"
|
||
#include "pihttpclient.h"
|
||
#include "pihttpservermodule.h"
|
||
#include "pijson.h"
|
||
#include "piliterals_string.h"
|
||
#include "piliterals_time.h"
|
||
#include "pimutex.h"
|
||
#include "pisemaphore.h"
|
||
|
||
#include "gtest/gtest.h"
|
||
|
||
|
||
//! \~english Test session server owning a small user table. It demonstrates the intended usage:
|
||
//! \c PIHTTPServerSessionAuth stores only sessions, while the client subclass owns the users and
|
||
//! implements \a checkCredentials().
|
||
//! \~russian Тестовый сервер сессий с небольшой таблицей пользователей. Демонстрирует
|
||
//! предполагаемое использование: \c PIHTTPServerSessionAuth хранит только сессии, а клиентский
|
||
//! наследник владеет пользователями и реализует \a checkCredentials().
|
||
class TestUserServer: public PIHTTPServerSessionAuth {
|
||
public:
|
||
bool addUser(const PIString & login, const PIString & pass) {
|
||
if (login.isEmpty()) return false;
|
||
PIMutexLocker locker(users_mutex);
|
||
if (users.contains(login)) return false;
|
||
TestUser u;
|
||
u.id = next_id++;
|
||
u.pass = pass;
|
||
users[login] = u;
|
||
return true;
|
||
}
|
||
|
||
bool removeUser(const PIString & login) {
|
||
int id = 0;
|
||
{
|
||
PIMutexLocker locker(users_mutex);
|
||
if (!users.contains(login)) return false;
|
||
id = users.value(login).id;
|
||
users.remove(login);
|
||
}
|
||
revokeUserTokens(id);
|
||
return true;
|
||
}
|
||
|
||
bool userExists(const PIString & login) const {
|
||
PIMutexLocker locker(users_mutex);
|
||
return users.contains(login);
|
||
}
|
||
|
||
int userCount() const {
|
||
PIMutexLocker locker(users_mutex);
|
||
return users.size();
|
||
}
|
||
|
||
//! \~english Public wrapper around the protected \a revokeToken() for tests.
|
||
//! \~russian Публичная обертка вокруг protected \a revokeToken() для тестов.
|
||
bool revoke(const PIString & token) { return revokeToken(token); }
|
||
|
||
//! \~english Number of active sessions in the internal table.
|
||
//! \~russian Количество активных сессий во внутренней таблице.
|
||
int sessionCount() {
|
||
PIMutexLocker locker(lock());
|
||
return sessionTable().size();
|
||
}
|
||
|
||
//! \~english Public wrapper around the protected \a cleanupExpired() for tests.
|
||
//! \~russian Публичная обертка вокруг protected \a cleanupExpired() для тестов.
|
||
void cleanup() { cleanupExpired(); }
|
||
|
||
protected:
|
||
struct TestUser {
|
||
int id = 0;
|
||
PIString pass;
|
||
};
|
||
|
||
PIHTTP::AuthInfo checkCredentials(const PIString & login, const PIString & pass) override {
|
||
PIMutexLocker locker(users_mutex);
|
||
if (!users.contains(login)) return PIHTTP::AuthInfo();
|
||
const TestUser u = users.value(login);
|
||
return u.pass == pass ? PIHTTP::AuthInfo{true, u.id} : PIHTTP::AuthInfo();
|
||
}
|
||
|
||
mutable PIMutex users_mutex;
|
||
PIMap<PIString, TestUser> users;
|
||
int next_id = 1;
|
||
};
|
||
|
||
|
||
//! \~english Test server that stores password hashes: \a checkCredentials() hashes the submitted
|
||
//! password before delegating to the plain comparison of \c TestUserServer.
|
||
//! \~russian Тестовый сервер, хранящий хеши паролей: \a checkCredentials() хеширует переданный
|
||
//! пароль перед делегированием в обычное сравнение \c TestUserServer.
|
||
class HashedUserServer: public TestUserServer {
|
||
protected:
|
||
PIHTTP::AuthInfo checkCredentials(const PIString & login, const PIString & pass) override {
|
||
return TestUserServer::checkCredentials(login, PIDigest::calculate(pass.toByteArray(), PIDigest::Type::SHA2_256).toHex());
|
||
}
|
||
};
|
||
|
||
|
||
class HttpServerSessionAuthTest: public ::testing::Test {
|
||
protected:
|
||
struct Reply {
|
||
PIHTTP::Code code = PIHTTP::Code::Unknown;
|
||
PIString body;
|
||
PIString www_authenticate;
|
||
PIString x_trace;
|
||
PIString error;
|
||
bool finished = false;
|
||
bool transport_error = false;
|
||
};
|
||
|
||
//! \~english Performs a request to "url" with optional JSON body and "Authorization"
|
||
//! header value and waits for the reply with a 10 seconds timeout.
|
||
//! \~russian Выполняет запрос к "url" с необязательным JSON-телом и значением заголовка
|
||
//! "Authorization" и ждет ответ с таймаутом 10 секунд.
|
||
static Reply request(PIHTTP::Method method, const PIString & url, const PIString & body, const PIString & auth) {
|
||
// The reply state and semaphore are heap-allocated: the callbacks run in the
|
||
// HTTP client thread pool and may outlive this function on a wait timeout.
|
||
Reply * rep = new Reply();
|
||
PISemaphore * sem = new PISemaphore();
|
||
PIHTTP::MessageMutable req;
|
||
if (auth.isNotEmpty()) req.addHeader(PIHTTP::Header::Authorization, auth);
|
||
if (body.isNotEmpty()) {
|
||
req.addHeader(PIHTTP::Header::ContentType, "application/json");
|
||
req.setBody(body.toByteArray());
|
||
}
|
||
auto client = PIHTTPClient::create(url, method, req);
|
||
client->onFinish([rep, sem](const PIHTTP::MessageConst & r) {
|
||
rep->code = r.code();
|
||
rep->body = PIString::fromUTF8(r.body());
|
||
rep->www_authenticate = r.headers().value(PIHTTP::Header::WWWAuthenticate);
|
||
rep->x_trace = r.headers().value("X-Trace");
|
||
rep->finished = true;
|
||
sem->release();
|
||
});
|
||
client->onError([client, rep, sem](const PIHTTP::MessageConst &) {
|
||
rep->error = client->lastError();
|
||
rep->transport_error = true;
|
||
rep->finished = true;
|
||
sem->release();
|
||
});
|
||
client->start();
|
||
// The client object is owned by the HTTP client thread pool after start()
|
||
// and deleted there, so it must not be touched or deleted here.
|
||
if (sem->tryAcquire(1, 10_s)) {
|
||
Reply out = *rep;
|
||
delete rep;
|
||
delete sem;
|
||
return out;
|
||
}
|
||
// On timeout the callbacks may still run in the client thread pool,
|
||
// so "rep"/"sem" are intentionally leaked (the test fails anyway).
|
||
Reply out;
|
||
return out;
|
||
}
|
||
|
||
static PIString loginJson(const PIString & login, const PIString & password) {
|
||
PIJSON j;
|
||
j["login"] = login;
|
||
j["password"] = password;
|
||
return j.toJSON(PIJSON::Compact);
|
||
}
|
||
|
||
//! \~english Builds a "Bearer <token>" authorization header value.
|
||
//! \~russian Формирует значение заголовка "Authorization" схемы Bearer из токена.
|
||
static PIString bearer(const PIString & token) { return "Bearer %1"_a.arg(token); }
|
||
|
||
//! \~english Logs in with the given credentials and returns the issued token (empty on failure).
|
||
//! \~russian Выполняет вход с данными учетными данными и возвращает выданный токен (пустой при неудаче).
|
||
static PIString login(const PIString & url, const PIString & login, const PIString & password) {
|
||
auto rep = request(PIHTTP::Method::Post, url, loginJson(login, password), "");
|
||
if (rep.code != PIHTTP::Code::Ok) return PIString();
|
||
return PIJSON::fromJSON(rep.body)["token"].toString();
|
||
}
|
||
|
||
//! \~english Starts listening on the first free port from 18461 and keeps the server
|
||
//! for the teardown. Returns \c false if no port is available.
|
||
//! \~russian Запускает прослушивание на первом свободном порту от 18461 и сохраняет сервер
|
||
//! для завершения теста. Возвращает \c false, если свободный порт не найден.
|
||
bool start(PIHTTPServer * s) {
|
||
server = s;
|
||
for (port = 18461; port < 18561; ++port) {
|
||
if (s->listenAll((ushort)port)) return true;
|
||
}
|
||
port = -1;
|
||
return false;
|
||
}
|
||
|
||
PIString url(const char * path) const { return "http://127.0.0.1:%1"_a.arg(port) + path; }
|
||
|
||
void TearDown() override {
|
||
if (server) {
|
||
server->stop();
|
||
delete server;
|
||
server = nullptr;
|
||
}
|
||
}
|
||
|
||
PIHTTPServer * server = nullptr;
|
||
int port = -1;
|
||
};
|
||
|
||
|
||
//! \~english Test server overriding the login reply and the token validation: every login
|
||
//! reply gets an "X-Trace" header, and the token from "banned_token" is denied.
|
||
//! \~russian Тестовый сервер, переопределяющий ответ логина и проверку токена: каждый ответ
|
||
//! логина получает заголовок "X-Trace", а токен из "banned_token" отклоняется.
|
||
class OverriddenServer: public TestUserServer {
|
||
public:
|
||
int login_calls = 0;
|
||
PIString banned_token;
|
||
|
||
protected:
|
||
PIHTTP::AuthInfo checkToken(const PIString & token) override {
|
||
if (!banned_token.isEmpty() && token == banned_token) return PIHTTP::AuthInfo();
|
||
return PIHTTPServerSessionAuth::checkToken(token);
|
||
}
|
||
|
||
PIHTTP::MessageMutable handleLogin(const PIHTTP::MessageConst & request) override {
|
||
++login_calls;
|
||
return PIHTTPServerSessionAuth::handleLogin(request).addHeader("X-Trace", "overridden");
|
||
}
|
||
};
|
||
|
||
|
||
TEST_F(HttpServerSessionAuthTest, LoginOk) {
|
||
auto s = new TestUserServer();
|
||
ASSERT_TRUE(s->addUser("alice", "pass1"));
|
||
s->registerProtectedPath("/secret", PIHTTP::Method::Get, [](const PIHTTP::MessageConst &) {
|
||
return PIHTTP::MessageMutable::fromCode(PIHTTP::Code::Ok).setBody(PIByteArray::fromAscii("ok"));
|
||
});
|
||
ASSERT_TRUE(start(s));
|
||
|
||
auto rep = request(PIHTTP::Method::Post, url("/api/login"), loginJson("alice", "pass1"), "");
|
||
EXPECT_TRUE(rep.finished);
|
||
EXPECT_FALSE(rep.transport_error);
|
||
EXPECT_EQ(PIHTTP::Code::Ok, rep.code);
|
||
PIString token = PIJSON::fromJSON(rep.body)["token"].toString();
|
||
EXPECT_FALSE(token.isEmpty());
|
||
EXPECT_EQ(1, PIJSON::fromJSON(rep.body)["user_id"].toInt());
|
||
|
||
auto sec = request(PIHTTP::Method::Get, url("/secret"), "", bearer(token));
|
||
EXPECT_EQ(PIHTTP::Code::Ok, sec.code);
|
||
EXPECT_EQ("ok"_a, sec.body);
|
||
}
|
||
|
||
|
||
TEST_F(HttpServerSessionAuthTest, LoginWrongCredentials) {
|
||
auto s = new TestUserServer();
|
||
ASSERT_TRUE(s->addUser("alice", "pass1"));
|
||
ASSERT_TRUE(start(s));
|
||
|
||
auto wrong_pass = request(PIHTTP::Method::Post, url("/api/login"), loginJson("alice", "pass2"), "");
|
||
EXPECT_TRUE(wrong_pass.finished);
|
||
EXPECT_FALSE(wrong_pass.transport_error);
|
||
EXPECT_EQ(PIHTTP::Code::Unauthorized, wrong_pass.code);
|
||
EXPECT_TRUE(wrong_pass.body.contains("invalid credentials"));
|
||
|
||
auto unknown = request(PIHTTP::Method::Post, url("/api/login"), loginJson("eve", "pass3"), "");
|
||
EXPECT_EQ(PIHTTP::Code::Unauthorized, unknown.code);
|
||
EXPECT_TRUE(unknown.body.contains("invalid credentials"));
|
||
}
|
||
|
||
|
||
TEST_F(HttpServerSessionAuthTest, LoginBadJson) {
|
||
auto s = new TestUserServer();
|
||
ASSERT_TRUE(s->addUser("alice", "pass1"));
|
||
ASSERT_TRUE(start(s));
|
||
|
||
auto not_json = request(PIHTTP::Method::Post, url("/api/login"), "not a json", "");
|
||
EXPECT_TRUE(not_json.finished);
|
||
EXPECT_FALSE(not_json.transport_error);
|
||
EXPECT_EQ(PIHTTP::Code::BadRequest, not_json.code);
|
||
EXPECT_TRUE(not_json.body.contains("invalid json"));
|
||
|
||
auto missing_pass = request(PIHTTP::Method::Post, url("/api/login"), "{\"login\":\"alice\"}", "");
|
||
EXPECT_EQ(PIHTTP::Code::BadRequest, missing_pass.code);
|
||
|
||
auto empty_login = request(PIHTTP::Method::Post, url("/api/login"), "{\"login\":\"\",\"password\":\"pass1\"}", "");
|
||
EXPECT_EQ(PIHTTP::Code::BadRequest, empty_login.code);
|
||
}
|
||
|
||
|
||
TEST_F(HttpServerSessionAuthTest, ProtectedNoToken) {
|
||
auto s = new TestUserServer();
|
||
ASSERT_TRUE(s->addUser("alice", "pass1"));
|
||
s->registerProtectedPath("/secret", PIHTTP::Method::Get, [](const PIHTTP::MessageConst &) {
|
||
return PIHTTP::MessageMutable::fromCode(PIHTTP::Code::Ok).setBody(PIByteArray::fromAscii("ok"));
|
||
});
|
||
ASSERT_TRUE(start(s));
|
||
|
||
auto rep = request(PIHTTP::Method::Get, url("/secret"), "", "");
|
||
EXPECT_TRUE(rep.finished);
|
||
EXPECT_FALSE(rep.transport_error);
|
||
EXPECT_EQ(PIHTTP::Code::Unauthorized, rep.code);
|
||
EXPECT_TRUE(rep.www_authenticate.contains("Bearer"));
|
||
|
||
auto wrong = request(PIHTTP::Method::Get, url("/secret"), "", "Bearer deadbeef");
|
||
EXPECT_EQ(PIHTTP::Code::Unauthorized, wrong.code);
|
||
}
|
||
|
||
|
||
TEST_F(HttpServerSessionAuthTest, MultiUser) {
|
||
auto s = new TestUserServer();
|
||
ASSERT_TRUE(s->addUser("alice", "pass1"));
|
||
ASSERT_TRUE(s->addUser("bob", "pass2"));
|
||
s->registerProtectedPath("/whoami", PIHTTP::Method::Get, [](const PIHTTP::MessageConst &, const PIHTTP::AuthInfo & info) {
|
||
return PIHTTP::MessageMutable::fromCode(PIHTTP::Code::Ok).setBody(PIString::fromNumber(info.user_id).toByteArray());
|
||
});
|
||
ASSERT_TRUE(start(s));
|
||
|
||
PIString alice_token = login(url("/api/login"), "alice", "pass1");
|
||
PIString bob_token = login(url("/api/login"), "bob", "pass2");
|
||
ASSERT_FALSE(alice_token.isEmpty());
|
||
ASSERT_FALSE(bob_token.isEmpty());
|
||
|
||
auto alice = request(PIHTTP::Method::Get, url("/whoami"), "", bearer(alice_token));
|
||
EXPECT_EQ(PIHTTP::Code::Ok, alice.code);
|
||
EXPECT_EQ("1"_a, alice.body);
|
||
|
||
auto bob = request(PIHTTP::Method::Get, url("/whoami"), "", bearer(bob_token));
|
||
EXPECT_EQ(PIHTTP::Code::Ok, bob.code);
|
||
EXPECT_EQ("2"_a, bob.body);
|
||
}
|
||
|
||
|
||
TEST_F(HttpServerSessionAuthTest, Logout) {
|
||
auto s = new TestUserServer();
|
||
ASSERT_TRUE(s->addUser("alice", "pass1"));
|
||
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());
|
||
|
||
auto no_auth = request(PIHTTP::Method::Post, url("/api/logout"), "", "");
|
||
EXPECT_EQ(PIHTTP::Code::Unauthorized, no_auth.code);
|
||
|
||
auto out = request(PIHTTP::Method::Post, url("/api/logout"), "", bearer(token));
|
||
EXPECT_TRUE(out.finished);
|
||
EXPECT_FALSE(out.transport_error);
|
||
EXPECT_EQ(PIHTTP::Code::NoContent, out.code);
|
||
|
||
auto dead = request(PIHTTP::Method::Get, url("/secret"), "", bearer(token));
|
||
EXPECT_EQ(PIHTTP::Code::Unauthorized, dead.code);
|
||
|
||
auto again = request(PIHTTP::Method::Post, url("/api/logout"), "", bearer(token));
|
||
EXPECT_EQ(PIHTTP::Code::Unauthorized, again.code);
|
||
}
|
||
|
||
|
||
TEST_F(HttpServerSessionAuthTest, RevokeToken) {
|
||
auto s = new TestUserServer();
|
||
ASSERT_TRUE(s->addUser("alice", "pass1"));
|
||
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);
|
||
|
||
EXPECT_TRUE(s->revoke(token));
|
||
EXPECT_FALSE(s->revoke(token));
|
||
EXPECT_EQ(PIHTTP::Code::Unauthorized, request(PIHTTP::Method::Get, url("/secret"), "", bearer(token)).code);
|
||
}
|
||
|
||
|
||
TEST_F(HttpServerSessionAuthTest, RemoveUserRevokesTokens) {
|
||
auto s = new TestUserServer();
|
||
ASSERT_TRUE(s->addUser("alice", "pass1"));
|
||
ASSERT_TRUE(s->addUser("bob", "pass2"));
|
||
s->registerProtectedPath("/secret", PIHTTP::Method::Get, [](const PIHTTP::MessageConst &) {
|
||
return PIHTTP::MessageMutable::fromCode(PIHTTP::Code::Ok).setBody(PIByteArray::fromAscii("ok"));
|
||
});
|
||
ASSERT_TRUE(start(s));
|
||
|
||
// Two sessions of the same user must both be revoked.
|
||
PIString first = login(url("/api/login"), "alice", "pass1");
|
||
PIString second = login(url("/api/login"), "alice", "pass1");
|
||
ASSERT_FALSE(first.isEmpty());
|
||
ASSERT_FALSE(second.isEmpty());
|
||
|
||
EXPECT_TRUE(s->removeUser("alice"));
|
||
EXPECT_EQ(1, s->userCount());
|
||
EXPECT_FALSE(s->removeUser("alice"));
|
||
|
||
EXPECT_EQ(PIHTTP::Code::Unauthorized, request(PIHTTP::Method::Get, url("/secret"), "", bearer(first)).code);
|
||
EXPECT_EQ(PIHTTP::Code::Unauthorized, request(PIHTTP::Method::Get, url("/secret"), "", bearer(second)).code);
|
||
|
||
PIString bob_token = login(url("/api/login"), "bob", "pass2");
|
||
ASSERT_FALSE(bob_token.isEmpty());
|
||
EXPECT_EQ(PIHTTP::Code::Ok, request(PIHTTP::Method::Get, url("/secret"), "", bearer(bob_token)).code);
|
||
}
|
||
|
||
|
||
TEST_F(HttpServerSessionAuthTest, UserTable) {
|
||
auto s = new TestUserServer();
|
||
EXPECT_TRUE(s->addUser("alice", "pass1"));
|
||
EXPECT_FALSE(s->addUser("alice", "other"));
|
||
EXPECT_FALSE(s->addUser("", "pass"));
|
||
EXPECT_TRUE(s->userExists("alice"));
|
||
EXPECT_FALSE(s->userExists("bob"));
|
||
EXPECT_EQ(1, s->userCount());
|
||
}
|
||
|
||
|
||
TEST_F(HttpServerSessionAuthTest, TokenTtl) {
|
||
auto s = new TestUserServer();
|
||
ASSERT_TRUE(s->addUser("alice", "pass1"));
|
||
s->setTokenTtl(100_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, 300_ms);
|
||
|
||
EXPECT_EQ(PIHTTP::Code::Unauthorized, request(PIHTTP::Method::Get, url("/secret"), "", bearer(token)).code);
|
||
}
|
||
|
||
|
||
TEST_F(HttpServerSessionAuthTest, HasTokenTtl) {
|
||
auto s = new TestUserServer();
|
||
EXPECT_FALSE(s->hasTokenTtl());
|
||
s->setTokenTtl(1_s);
|
||
EXPECT_TRUE(s->hasTokenTtl());
|
||
EXPECT_EQ(PISystemTime::fromSeconds(1), s->tokenTtl());
|
||
s->setTokenTtl(0_s);
|
||
EXPECT_FALSE(s->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, SessionCleanupOnLogin) {
|
||
auto s = new TestUserServer();
|
||
ASSERT_TRUE(s->addUser("alice", "pass1"));
|
||
s->setSessionIdleTimeout(200_ms);
|
||
ASSERT_TRUE(start(s));
|
||
|
||
PIString first = login(url("/api/login"), "alice", "pass1");
|
||
ASSERT_FALSE(first.isEmpty());
|
||
EXPECT_EQ(1, s->sessionCount());
|
||
|
||
// The first session expires by idle timeout; the next login must reclaim it instead of
|
||
// growing the table.
|
||
PISemaphore pause;
|
||
pause.tryAcquire(1, 400_ms);
|
||
|
||
PIString second = login(url("/api/login"), "alice", "pass1");
|
||
ASSERT_FALSE(second.isEmpty());
|
||
EXPECT_EQ(1, s->sessionCount());
|
||
}
|
||
|
||
|
||
TEST_F(HttpServerSessionAuthTest, ManualCleanup) {
|
||
auto s = new TestUserServer();
|
||
ASSERT_TRUE(s->addUser("alice", "pass1"));
|
||
s->setSessionIdleTimeout(200_ms);
|
||
ASSERT_TRUE(start(s));
|
||
|
||
PIString token = login(url("/api/login"), "alice", "pass1");
|
||
ASSERT_FALSE(token.isEmpty());
|
||
EXPECT_EQ(1, s->sessionCount());
|
||
|
||
PISemaphore pause;
|
||
pause.tryAcquire(1, 400_ms);
|
||
|
||
// The protected cleanupExpired() is available to subclasses for periodic maintenance.
|
||
s->cleanup();
|
||
EXPECT_EQ(0, s->sessionCount());
|
||
}
|
||
|
||
|
||
TEST_F(HttpServerSessionAuthTest, TokenCollisionRejected) {
|
||
auto s = new TestUserServer();
|
||
// A constant generator produces the same token on every call: the second login must be
|
||
// refused (500) rather than reusing/overwriting the active session.
|
||
s->setTokenGenerator([](uint) { return PIByteArray::fromHex("01020304"); });
|
||
ASSERT_TRUE(s->addUser("alice", "pass1"));
|
||
ASSERT_TRUE(s->addUser("bob", "pass2"));
|
||
ASSERT_TRUE(start(s));
|
||
|
||
auto first = request(PIHTTP::Method::Post, url("/api/login"), loginJson("alice", "pass1"), "");
|
||
EXPECT_EQ(PIHTTP::Code::Ok, first.code);
|
||
EXPECT_EQ("01020304"_a, PIJSON::fromJSON(first.body)["token"].toString());
|
||
|
||
auto second = request(PIHTTP::Method::Post, url("/api/login"), loginJson("bob", "pass2"), "");
|
||
EXPECT_EQ(PIHTTP::Code::InternalServerError, second.code);
|
||
EXPECT_TRUE(second.body.contains("token generation failed"));
|
||
}
|
||
|
||
|
||
TEST_F(HttpServerSessionAuthTest, CustomTokenGenerator) {
|
||
auto s = new TestUserServer();
|
||
s->setTokenGenerator([](uint) { return PIByteArray::fromHex("01020304"); });
|
||
ASSERT_TRUE(s->addUser("alice", "pass1"));
|
||
ASSERT_TRUE(start(s));
|
||
|
||
auto rep = request(PIHTTP::Method::Post, url("/api/login"), loginJson("alice", "pass1"), "");
|
||
EXPECT_EQ(PIHTTP::Code::Ok, rep.code);
|
||
EXPECT_EQ("01020304"_a, PIJSON::fromJSON(rep.body)["token"].toString());
|
||
}
|
||
|
||
|
||
TEST_F(HttpServerSessionAuthTest, CustomPasswordCheck) {
|
||
auto s = new HashedUserServer();
|
||
PIString hash = PIDigest::calculate(PIString("pass1").toByteArray(), PIDigest::Type::SHA2_256).toHex();
|
||
ASSERT_TRUE(s->addUser("alice", hash));
|
||
ASSERT_TRUE(start(s));
|
||
|
||
auto ok = request(PIHTTP::Method::Post, url("/api/login"), loginJson("alice", "pass1"), "");
|
||
EXPECT_TRUE(ok.finished);
|
||
EXPECT_FALSE(ok.transport_error);
|
||
EXPECT_EQ(PIHTTP::Code::Ok, ok.code);
|
||
|
||
auto bad = request(PIHTTP::Method::Post, url("/api/login"), loginJson("alice", "other"), "");
|
||
EXPECT_EQ(PIHTTP::Code::Unauthorized, bad.code);
|
||
}
|
||
|
||
|
||
TEST_F(HttpServerSessionAuthTest, LowercaseBearerScheme) {
|
||
auto s = new TestUserServer();
|
||
ASSERT_TRUE(s->addUser("alice", "pass1"));
|
||
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());
|
||
|
||
auto rep = request(PIHTTP::Method::Get, url("/secret"), "", "bearer " + token);
|
||
EXPECT_EQ(PIHTTP::Code::Ok, rep.code);
|
||
EXPECT_EQ("ok"_a, rep.body);
|
||
}
|
||
|
||
|
||
TEST_F(HttpServerSessionAuthTest, ConfigurablePaths) {
|
||
auto s = new TestUserServer();
|
||
ASSERT_TRUE(s->addUser("alice", "pass1"));
|
||
s->setLoginPath("/auth/signin");
|
||
s->setLogoutPath("/auth/signout");
|
||
EXPECT_EQ("/auth/signin"_a, s->loginPath());
|
||
EXPECT_EQ("/auth/signout"_a, s->logoutPath());
|
||
s->registerProtectedPath("/secret", PIHTTP::Method::Get, [](const PIHTTP::MessageConst &) {
|
||
return PIHTTP::MessageMutable::fromCode(PIHTTP::Code::Ok).setBody(PIByteArray::fromAscii("ok"));
|
||
});
|
||
ASSERT_TRUE(start(s));
|
||
|
||
// The default routes are gone.
|
||
EXPECT_EQ(PIHTTP::Code::NotFound, request(PIHTTP::Method::Post, url("/api/login"), loginJson("alice", "pass1"), "").code);
|
||
|
||
PIString token = login(url("/auth/signin"), "alice", "pass1");
|
||
ASSERT_FALSE(token.isEmpty());
|
||
EXPECT_EQ(PIHTTP::Code::Ok, request(PIHTTP::Method::Get, url("/secret"), "", bearer(token)).code);
|
||
|
||
auto out = request(PIHTTP::Method::Post, url("/auth/signout"), "", bearer(token));
|
||
EXPECT_EQ(PIHTTP::Code::NoContent, out.code);
|
||
EXPECT_EQ(PIHTTP::Code::Unauthorized, request(PIHTTP::Method::Get, url("/secret"), "", bearer(token)).code);
|
||
}
|
||
|
||
|
||
TEST_F(HttpServerSessionAuthTest, Inheritance_LoginOverride) {
|
||
auto s = new OverriddenServer();
|
||
ASSERT_TRUE(s->addUser("alice", "pass1"));
|
||
s->registerProtectedPath("/secret", PIHTTP::Method::Get, [](const PIHTTP::MessageConst &) {
|
||
return PIHTTP::MessageMutable::fromCode(PIHTTP::Code::Ok).setBody(PIByteArray::fromAscii("ok"));
|
||
});
|
||
ASSERT_TRUE(start(s));
|
||
|
||
auto rep = request(PIHTTP::Method::Post, url("/api/login"), loginJson("alice", "pass1"), "");
|
||
EXPECT_TRUE(rep.finished);
|
||
EXPECT_FALSE(rep.transport_error);
|
||
EXPECT_EQ(PIHTTP::Code::Ok, rep.code);
|
||
EXPECT_EQ(1, s->login_calls);
|
||
EXPECT_EQ("overridden"_a, rep.x_trace);
|
||
PIString token = PIJSON::fromJSON(rep.body)["token"].toString();
|
||
|
||
auto sec = request(PIHTTP::Method::Get, url("/secret"), "", bearer(token));
|
||
EXPECT_EQ(PIHTTP::Code::Ok, sec.code);
|
||
}
|
||
|
||
|
||
TEST_F(HttpServerSessionAuthTest, Inheritance_CheckToken) {
|
||
auto s = new OverriddenServer();
|
||
s->setTokenGenerator([](uint) { return PIByteArray::fromHex("00112233"); });
|
||
ASSERT_TRUE(s->addUser("alice", "pass1"));
|
||
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());
|
||
|
||
auto allowed = request(PIHTTP::Method::Get, url("/secret"), "", bearer(token));
|
||
EXPECT_EQ(PIHTTP::Code::Ok, allowed.code);
|
||
|
||
s->banned_token = token;
|
||
auto banned = request(PIHTTP::Method::Get, url("/secret"), "", bearer(token));
|
||
EXPECT_EQ(PIHTTP::Code::Unauthorized, banned.code);
|
||
} |