Files
pip/tests/http_server/http_server_sessionauth_test.cpp
andrey 6bf50344fd fix: harden PIHTTPServerSessionAuth session table
- 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
2026-09-23 08:49:10 +03:00

689 lines
26 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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);
}