From ef671700e1ab5f699cf0491866b32c75b626e886 Mon Sep 17 00:00:00 2001 From: Brett Laptop Date: Fri, 18 Aug 2023 02:21:39 -0400 Subject: [PATCH] SQLite --- CMakeLists.txt | 8 + crow_test/data/db/users.sqlite | Bin 0 -> 20480 bytes crow_test/data/session/.expirations | 3 +- crow_test/data/session/jlbpNSZuchBeVInZ.json | 1 - crow_test/data/session/vUJR5OaiqtXupR8v.json | 1 + crow_test/data/session/wDTvp2olKnTzXs0q.json | 1 + include/crowsite/requests/jellyfin.h | 4 +- include/crowsite/site/auth.h | 58 +++++- include/crowsite/sql_helper.h | 126 ++++++++++++ libs/BLT | 2 +- src/crowsite/requests/jellyfin.cpp | 55 +++--- src/crowsite/site/auth.cpp | 198 ++++++++++++++++++- src/main.cpp | 33 ++-- 13 files changed, 435 insertions(+), 55 deletions(-) create mode 100644 crow_test/data/db/users.sqlite delete mode 100644 crow_test/data/session/jlbpNSZuchBeVInZ.json create mode 100644 crow_test/data/session/vUJR5OaiqtXupR8v.json create mode 100644 crow_test/data/session/wDTvp2olKnTzXs0q.json create mode 100644 include/crowsite/sql_helper.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 135981a..be56fd7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,8 +12,13 @@ cmake_policy(SET CMP0057 NEW) find_package(Crow) find_package(CURL) find_package(OpenSSL) +find_package(SQLite3) message("SSL ${OPENSSL_INCLUDE_DIR}") +if (NOT SQLite3_FOUND) + message("Failed to find SQLite3") +endif () +message("SQLite ${SQLite3_INCLUDE_DIRS} ${SQLite3_LIBRARIES}") if (NOT CURL_FOUND) message("libcurl is required!") @@ -24,6 +29,8 @@ endif () add_subdirectory(libs/BLT) include_directories(include/) include_directories(${CURL_INCLUDE_DIRS}) +include_directories(${SQLite3_INCLUDE_DIRS}) +include_directories(${OPENSSL_INCLUDE_DIR}) file(GLOB_RECURSE source_files src/*.cpp) @@ -33,6 +40,7 @@ target_link_libraries(crowsite BLT) target_link_libraries(crowsite Crow::Crow) target_link_libraries(crowsite ${CURL_LIBRARIES}) target_link_libraries(crowsite OpenSSL::SSL OpenSSL::Crypto) +target_link_libraries(crowsite SQLite::SQLite3) target_compile_options(crowsite PRIVATE -Wall -Wextra -Wpedantic) if (${ENABLE_ADDRSAN} MATCHES ON) diff --git a/crow_test/data/db/users.sqlite b/crow_test/data/db/users.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..4656b89d07cdc4d295d45fa06ec53c71b3cff7b5 GIT binary patch literal 20480 zcmeI(Jxjwt7{KvMZLJ7>*$Pg#wS`uYqTpt%0gJ6@3}UAe>nXuB)g}ekf*;A%&*Il` zG>MI^6*{?S|AAcY$%VUne!1lcxj1h-fs(hw(ZCL5LllLki32Hx5Sh6%HLa(^YjS8w zWyv)ow)Rw}<}yfv)X(WBO|sH=9pweBBYCNt&12oHH1?{sILFV7;*p~S z?Yr Token. + * Username is passed directly as this function should only be called after checkUserAuthorization(...) returns true. + * @param username username of the user. + * @param useragent user-agent of the user + * @param tokens generated client tokens + * @return false if something failed (error will be logged!) + * @related createUserAuthTokens(...) + * @related checkUserAuthorization(...) + */ + bool storeUserData(const std::string& username, const std::string& useragent, const cookie_data& tokens); + + bool isUserLoggedIn(const std::string& clientID, const std::string& token); + + std::string getUserFromID(const std::string& clientID); + bool isUserAdmin(const std::string& username); + uint32_t getUserPermissions(const std::string& username); + } #endif //CROWSITE_AUTH_H diff --git a/include/crowsite/sql_helper.h b/include/crowsite/sql_helper.h new file mode 100644 index 0000000..db7fcb1 --- /dev/null +++ b/include/crowsite/sql_helper.h @@ -0,0 +1,126 @@ +// +// Created by brett on 17/08/23. +// + +#ifndef CROWSITE_SQL_HELPER_H +#define CROWSITE_SQL_HELPER_H + +#include +#include + +namespace cs::sql +{ + + static int prepareStatement(sqlite3* db, const std::string& sqlStatement, sqlite3_stmt** ppStmt) + { + return sqlite3_prepare_v2(db, sqlStatement.c_str(), static_cast(sqlStatement.size()) + 1, ppStmt, nullptr); + } + + class statement + { + private: + sqlite3_stmt* stmt = nullptr; + sqlite3* db; + int err; + public: + statement(sqlite3* db, const std::string& statement): db(db) + { + err = prepareStatement(db, statement, &stmt); + if (err) + BLT_ERROR("Failed to execute statement, error code %d, error: %s.", err, sqlite3_errstr(err)); + } + + /** + * @return true if the last statement failed. Should be checked after construction! + */ + [[nodiscard]] bool fail() const + { + return !(err == SQLITE_OK || err == SQLITE_DONE || err == SQLITE_ROW); + } + + [[nodiscard]] bool hasRow() const + { return err == SQLITE_ROW; } + + [[nodiscard]] bool complete() const + { return err == SQLITE_DONE; } + + [[nodiscard]] int error() const + { + return err; + } + + bool execute() + { + if (err != SQLITE_ROW) + sqlite3_reset(stmt); + err = sqlite3_step(stmt); + return !fail(); + } + + template + statement* set(const T& t, int column) + { + // make api consistent + column = column - 1; + if constexpr (std::is_floating_point_v) + { + err = sqlite3_bind_double(stmt, column, t); + } else if constexpr (std::is_integral_v) + { + if constexpr (std::is_same_v || std::is_same_v) + err = sqlite3_bind_int64(stmt, column, t); + else + err = sqlite3_bind_int(stmt, column, t); + } else if constexpr (std::is_same_v) + { + err = sqlite3_bind_text(stmt, column, t.c_str(), -1, nullptr); + } + return this; + } + + template + T get(int column) + { + if (err != SQLITE_ROW) + throw std::runtime_error("Unable to get data as statement didn't return a row!"); + if constexpr (std::is_floating_point_v) + { + return sqlite3_column_double(stmt, column); + } else if constexpr (std::is_integral_v) + { + if constexpr (std::is_same_v || std::is_same_v) + return sqlite3_column_int64(stmt, column); + else + return sqlite3_column_int(stmt, column); + } else if constexpr (std::is_same_v) + { + return std::string(reinterpret_cast(sqlite3_column_text(stmt, column))); + } else + { + return sqlite3_column_blob(stmt, column); + } + } + + /** + * do not run execute before this function. ever. + * This function will return a default initialized T if execute doesn't return a row. + */ + template + T executeAndGet(int column) + { + execute(); + if (err != SQLITE_ROW) + return T{}; + return get(column); + } + + ~statement() + { + sqlite3_finalize(stmt); + } + + }; + +} + +#endif //CROWSITE_SQL_HELPER_H diff --git a/libs/BLT b/libs/BLT index 1e8f431..1d03938 160000 --- a/libs/BLT +++ b/libs/BLT @@ -1 +1 @@ -Subproject commit 1e8f431f9eb06582083efde6489b59380f3e19ac +Subproject commit 1d03938f950568dd1082abfd55f664ede6023995 diff --git a/src/crowsite/requests/jellyfin.cpp b/src/crowsite/requests/jellyfin.cpp index a4981a2..14f125f 100644 --- a/src/crowsite/requests/jellyfin.cpp +++ b/src/crowsite/requests/jellyfin.cpp @@ -13,8 +13,7 @@ namespace cs::jellyfin struct { std::string token; - HASHMAP user_ids; - HASHMAP logged_in_users; + HASHMAP user_ids; } GLOBALS; void setToken(std::string_view token) @@ -24,16 +23,29 @@ namespace cs::jellyfin void processUserData() { - auto data = getUserData(); + auto usr_data = getUserData(); - auto json = crow::json::load(data); + auto json = crow::json::load(usr_data); - for (const auto& user : json) + for (const auto& users : json) { - auto username = std::string(user["Name"].s()); - auto userid = std::string(user["Id"].s()); + const auto& policy = users["Policy"]; + + client_data data; + data.isAdmin = policy["IsAdministrator"].b(); + auto& user = data.user; + user.name = users["Name"].s(); + user.serverId = users["ServerId"].s(); + user.Id = users["Id"].s(); + user.hasPassword = users["HasPassword"].b(); + user.hasConfiguredPassword = users["HasConfiguredPassword"].b(); + user.hasConfiguredEasyPassword = users["HasConfiguredEasyPassword"].b(); + user.enableAutoLogin = users["EnableAutoLogin"].b(); + user.lastLoginDate = users["LastLoginDate"].s(); + user.lastActivityDate = users["LastActivityDate"].s(); + //BLT_TRACE("Processing %s = %s", username.operator std::string().c_str(), userid.operator std::string().c_str()); - GLOBALS.user_ids[username] = userid; + GLOBALS.user_ids[user.name] = data; } } @@ -81,31 +93,14 @@ namespace cs::jellyfin auto response = cs::request::getResponseAndClear(l_url); if (post.status() == 200) - { - crow::json::rvalue read = crow::json::load(response); - - const auto& users = read["User"]; - - client_data data; - data.accessToken = read["AccessToken"].s(); - auto& user = data.user; - user.name = users["Name"].s(); - user.serverId = users["ServerId"].s(); - user.Id = users["Id"].s(); - user.primaryImageTag = users["PrimaryImageTag"].s(); - user.hasPassword = users["HasPassword"].b(); - user.hasConfiguredPassword = users["HasConfiguredPassword"].b(); - user.hasConfiguredEasyPassword = users["HasConfiguredEasyPassword"].b(); - user.enableAutoLogin = users["EnableAutoLogin"].b(); - user.lastLoginDate = users["LastLoginDate"].s(); - user.lastActivityDate = users["LastActivityDate"].s(); - - GLOBALS.logged_in_users[std::string(username)] = data; - return auth_response::AUTHORIZED; - } return auth_response::ERROR; } + const client_data& jellyfin::getUserData(const std::string& username) + { + return GLOBALS.user_ids[username]; + } + } \ No newline at end of file diff --git a/src/crowsite/site/auth.cpp b/src/crowsite/site/auth.cpp index d55d8b0..a40783d 100644 --- a/src/crowsite/site/auth.cpp +++ b/src/crowsite/site/auth.cpp @@ -2,15 +2,49 @@ // Created by brett on 16/08/23. // #include +#include #include #include "blt/std/logging.h" #include "blt/std/uuid.h" +#include +#include +#include +#include +#include using namespace blt; -namespace cs { +namespace cs +{ + sqlite3* user_database; - bool handleLoginPost(parser::Post& postData, cookie_data& cookieOut) + // https://stackoverflow.com/questions/5288076/base64-encoding-and-decoding-with-openssl + + char* base64(const unsigned char* input, int length) + { + const auto pl = 4 * ((length + 2) / 3); + auto output = reinterpret_cast(calloc(pl + 1, 1)); //+1 for the terminating null that EVP_EncodeBlock adds on + const auto ol = EVP_EncodeBlock(reinterpret_cast(output), input, length); + if (pl != ol) + { + std::cerr << "Whoops, encode predicted " << pl << " but we got " << ol << "\n"; + } + return output; + } + + unsigned char* decode64(const char* input, int length) + { + const auto pl = 3 * length / 4; + auto output = reinterpret_cast(calloc(pl + 1, 1)); + const auto ol = EVP_DecodeBlock(output, reinterpret_cast(input), length); + if (pl != ol) + { + std::cerr << "Whoops, decode predicted " << pl << " but we got " << ol << "\n"; + } + return output; + } + + bool checkUserAuthorization(cs::parser::Post& postData) { // javascript should make sure we don't send post requests without information // this way it can be interactive @@ -18,9 +52,163 @@ namespace cs { return false; auto auth = jellyfin::authenticateUser(postData["username"], postData["password"]); - cookieOut.clientID = uuid::toString(uuid::genV5("ClientID?")); - cookieOut.clientToken = uuid::toString(uuid::genV4()); - return auth == jellyfin::auth_response::AUTHORIZED; } + + cookie_data createUserAuthTokens(parser::Post& postData, const std::string& useragent) + { + cookie_data cookieOut; + cookieOut.clientID = uuid::toString(uuid::genV5(postData["username"] + "::" + useragent)); + + std::string token; + + std::random_device rd; + std::seed_seq seed{rd(), rd(), rd(), rd()}; + std::mt19937_64 gen(seed); + std::uniform_int_distribution charDist(0, 92); + + for (int i = 0; i < SHA512_DIGEST_LENGTH * 8; i++) + token += char(33 + charDist(gen)); + + unsigned char hash[SHA512_DIGEST_LENGTH + 1]; + hash[SHA512_DIGEST_LENGTH] = '\0'; + + SHA512(reinterpret_cast(token.c_str()), token.size(), hash); + + auto b64str = base64(hash, SHA512_DIGEST_LENGTH); + + cookieOut.clientToken = std::string(reinterpret_cast(b64str)); + + free(b64str); + + return cookieOut; + } + + bool storeUserData(const std::string& username, const std::string& useragent, const cookie_data& tokens) + { + sql::statement insertStmt{ + user_database, + "INSERT OR REPLACE INTO user_sessions (clientID, username, useragent, token) VALUES (?, ?, ?, ?);" + }; + + if (insertStmt.fail()) + return false; + + insertStmt.set(tokens.clientID, 0); + insertStmt.set(username, 1); + insertStmt.set(useragent, 2); + insertStmt.set(tokens.clientToken, 3); + + if (!insertStmt.execute()) + return false; + + sql::statement insertAuth { + user_database, + "INSERT OR REPLACE INTO user_permissions (username, permission) VALUES (?, ?);" + }; + if (insertAuth.fail()) + return false; + insertStmt.set(username, 0); + insertStmt.set(PERM_DEFAULT | (jellyfin::getUserData(username).isAdmin ? PERM_ADMIN : 0), 1); + + if (!insertAuth.execute()) + return false; + + return true; + } + + bool isUserLoggedIn(const std::string& clientID, const std::string& token) + { + sql::statement stmt { + user_database, + "SELECT username FROM user_sessions WHERE clientID='?' AND token='?';" + }; + if (stmt.fail()) + return false; + stmt.set(clientID, 0); + stmt.set(token, 1); + stmt.execute(); + return stmt.hasRow(); + } + + bool isUserAdmin(const std::string& username) + { + return getUserPermissions(username) & PERM_ADMIN; + } + + std::string getUserFromID(const std::string& clientID) + { + sql::statement stmt { + user_database, + "SELECT username FROM user_sessions WHERE clientID='?';" + }; + if (stmt.fail()) + return ""; + stmt.set(clientID, 0); + return stmt.executeAndGet(0); + } + + uint32_t getUserPermissions(const std::string& username) + { + sql::statement stmt { + user_database, + "SELECT permission FROM user_permissions WHERE username='?';" + }; + if (stmt.fail()) + return 0; + stmt.set(username, 0); + return static_cast(stmt.executeAndGet(0)); + } + + void auth::init() + { + // TODO: proper multithreading + auto path = (std::string(SITE_FILES_PATH) + "/data/db/"); + auto dbname = "users.sqlite"; + auto full_path = path + dbname; + BLT_TRACE("Using %s for users database", full_path.c_str()); + std::filesystem::create_directories(path); + if (int err = sqlite3_open_v2(full_path.c_str(), &user_database, + SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, + nullptr + ) != SQLITE_OK) + { + BLT_FATAL("Unable to create database connection! err %d msg %s", err, sqlite3_errstr(err)); + std::exit(1); + } + + sql::statement v { + user_database, + "SELECT SQLITE_VERSION()" + }; + + if (v.fail()) + BLT_WARN("Failed to create statement with error code: %d msg: %s", v.error(), sqlite3_errstr(v.error())); + + if (!v.execute()) + BLT_WARN("Failed to execute statement with error code: %d msg: %s", v.error(), sqlite3_errstr(v.error())); + + BLT_INFO("SQLite Version: %s", v.get(0).c_str()); + + sql::statement tableStmt{ + user_database, + "CREATE TABLE IF NOT EXISTS user_sessions (clientID VARCHAR(36), username TEXT, useragent TEXT, token TEXT, PRIMARY KEY(clientID));" + }; + + if (tableStmt.fail() || !tableStmt.execute()) + BLT_ERROR("Failed to execute user_sessions table creation! %d : %s", tableStmt.error(), sqlite3_errstr(tableStmt.error())); + + sql::statement permsStmt{ + user_database, + "CREATE TABLE IF NOT EXISTS user_permissions (username TEXT, permission INT, PRIMARY KEY(username));" + }; + + if (permsStmt.fail() || !permsStmt.execute()) + BLT_ERROR("Failed to execute user_permissions table creation! %d : %s", permsStmt.error(), sqlite3_errstr(permsStmt.error())); + } + + void auth::cleanup() + { + sqlite3_close_v2(user_database); + } } \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index 293ec4a..43717cd 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -42,7 +42,8 @@ class BLT_CrowLogger : public crow::ILogHandler } }; -inline crow::response redirect(const std::string& loc){ +inline crow::response redirect(const std::string& loc) +{ crow::response res; res.redirect(loc); return res; @@ -63,6 +64,7 @@ int main(int argc, const char** argv) auto args = parser.parse_args(argc, argv); cs::jellyfin::setToken(blt::arg_parse::get(args["token"])); cs::jellyfin::processUserData(); + cs::auth::init(); BLT_INFO("Starting site %s.", SITE_NAME); crow::mustache::set_global_base(SITE_FILES_PATH); @@ -75,7 +77,7 @@ int main(int argc, const char** argv) const auto cookie_age = 180 * 24 * 60 * 60; BLT_INFO("Init Crow with compression and logging enabled!"); - crow::App app {Session{ + crow::App app{Session{ // customize cookies crow::CookieParser::Cookie("session").max_age(session_age).path("/"), // set session id length (small value only for demonstration purposes) @@ -147,26 +149,32 @@ int main(int argc, const char** argv) crow::response res(303); - cs::cookie_data data; + std::string user_agent; + + for (const auto& h : req.headers) + { + if (h.first == "User-Agent") + user_agent = h.second; + } // either redirect to clear the form if failed or pass user to index - if (cs::handleLoginPost(pp, data)) + if (cs::checkUserAuthorization(pp)) { + cs::cookie_data data = cs::createUserAuthTokens(pp, user_agent); + cs::storeUserData(pp["username"], user_agent, data); + session.set("clientID", data.clientID); session.set("clientToken", data.clientToken); - if (pp.hasKey("remember_me")){ - auto value = pp["remember_me"]; + if (pp.hasKey("remember_me") && pp["remember_me"][0] == 'T') + { auto& cookie_context = app.get_context(req); - if (value[0] == 'T') - { - cookie_context.set_cookie("clientID", data.clientID).path("/").max_age(cookie_age); - cookie_context.set_cookie("clientToken", data.clientToken).path("/").max_age(cookie_age); - } + cookie_context.set_cookie("clientID", data.clientID).path("/").max_age(cookie_age); + cookie_context.set_cookie("clientToken", data.clientToken).path("/").max_age(cookie_age); } res.set_header("Location", pp.hasKey("referer") ? pp["referer"] : "/"); } else res.set_header("Location", "/login.html"); - + return res; } ); @@ -199,6 +207,7 @@ int main(int argc, const char** argv) app.port(8080).multithreaded().run(); cs::requests::cleanup(); + cs::auth::cleanup(); return 0; }