diff --git a/SUBSONIC.md b/SUBSONIC.md index 7fd8007f..77618cea 100644 --- a/SUBSONIC.md +++ b/SUBSONIC.md @@ -17,13 +17,9 @@ OpenSubsonic is an initiative to patch and extend the legacy Subsonic API. You'l ## Authentication _LMS_ supports the [API Key Authentication](https://opensubsonic.netlify.app/docs/extensions/apikeyauth/) method. Each user has to generate their own API key on the settings page to use the Subsonic API. +If a client's login screen has no dedicated API key field, enter the API key as the password instead. -By default, API keys can also be used as passwords, provided the `user` parameter matches the API key owner. To disable this fallback authentication method, set the following in `lms.conf`: -``` -api-subsonic-support-user-password-auth = false; -``` - -__Note__: the token+salt authentication method is not supported; use the API key as the password instead (see above). +__Note__: the legacy Subsonic authentication methods can be disabled using the `lms.conf` file. ## Extra fields The following extra fields are implemented: diff --git a/conf/lms.conf b/conf/lms.conf index e53559b8..5544c62f 100644 --- a/conf/lms.conf +++ b/conf/lms.conf @@ -84,9 +84,11 @@ login-throttler-max-entries = 10000; # API api-subsonic = true; -# Enable or disable user/password authentication for the Subsonic API. -# Note: Since token/salt authentication is always disabled, setting this to 'false' means only API keys can be used to access the Subsonic API. -api-subsonic-support-user-password-auth = true; +# Enable or disable user/password authentication (params 'u' and 'p') +api-subsonic-support-password-auth = true; + +# Enable or disable token/salt authentication (params 'u', 's' and 't') +api-subsonic-support-token-auth = true; # Use this list to make the reported server version to 1.12.0 depending on the client's name # Main usage is to make auto detections for the 'p' (password) parameter work diff --git a/src/libs/core/impl/String.cpp b/src/libs/core/impl/String.cpp index f6c11c01..a74e8bfe 100644 --- a/src/libs/core/impl/String.cpp +++ b/src/libs/core/impl/String.cpp @@ -375,18 +375,6 @@ namespace lms::core::stringUtils return res; } - std::string bufferToString(std::span data) - { - std::ostringstream oss; - - for (unsigned char c : data) - { - oss << std::setw(2) << std::setfill('0') << std::hex << (int)c; - } - - return oss.str(); - } - bool stringCaseInsensitiveEqual(std::string_view strA, std::string_view strB) { if (strA.size() != strB.size()) @@ -608,16 +596,18 @@ namespace lms::core::stringUtils return res; } - std::string toHexString(std::string_view str) + std::string bufferToHexString(std::span data) { constexpr char lut[]{ "0123456789ABCDEF" }; std::string res; + res.reserve(data.size() * 2); - for (char c : str) + for (const std::byte b : data) { - res.push_back(lut[(c >> 4) & 0xF]); - res.push_back(lut[c & 0xF]); + const unsigned value{ std::to_integer(b) }; + res.push_back(lut[(value >> 4) & 0xF]); + res.push_back(lut[value & 0xF]); } return res; diff --git a/src/libs/core/include/core/String.hpp b/src/libs/core/include/core/String.hpp index 2fc564f5..3562acbb 100644 --- a/src/libs/core/include/core/String.hpp +++ b/src/libs/core/include/core/String.hpp @@ -20,6 +20,7 @@ #pragma once #include +#include #include #include #include @@ -61,8 +62,6 @@ namespace lms::core::stringUtils void stringToLower(std::string& str); [[nodiscard]] std::string stringToUpper(const std::string& str); - [[nodiscard]] std::string bufferToString(std::span data); - [[nodiscard]] bool stringCaseInsensitiveEqual(std::string_view strA, std::string_view strB); [[nodiscard]] std::string_view::size_type stringCaseInsensitiveContains(std::string_view str, std::string_view strtoFind); @@ -122,7 +121,7 @@ namespace lms::core::stringUtils [[nodiscard]] bool stringEndsWith(std::string_view str, std::string_view ending); [[nodiscard]] std::optional stringFromHex(std::string_view str); - [[nodiscard]] std::string toHexString(std::string_view str); + [[nodiscard]] std::string bufferToHexString(std::span data); [[nodiscard]] std::string toISO8601String(const Wt::WDateTime& dateTime); [[nodiscard]] std::string toISO8601String(const Wt::WDate& date); diff --git a/src/libs/core/test/String.cpp b/src/libs/core/test/String.cpp index 815e456f..66da6c27 100644 --- a/src/libs/core/test/String.cpp +++ b/src/libs/core/test/String.cpp @@ -17,6 +17,7 @@ * along with LMS. If not, see . */ +#include #include #include @@ -424,13 +425,11 @@ namespace lms::core::stringUtils::tests EXPECT_FALSE(stringCaseInsensitiveContains("", "Foo")); } - TEST(StringUtils, toHexString) + TEST(StringUtils, bufferToHexString) { - EXPECT_EQ(toHexString(""), ""); - EXPECT_EQ(toHexString("123"), "313233"); - EXPECT_EQ(toHexString("1234"), "31323334"); - EXPECT_EQ(toHexString("12345"), "3132333435"); - EXPECT_EQ(toHexString("Test"), "54657374"); + EXPECT_EQ(bufferToHexString({}), ""); + EXPECT_EQ(bufferToHexString(std::array{ std::byte{ 0x31 }, std::byte{ 0x32 }, std::byte{ 0x33 } }), "313233"); + EXPECT_EQ(bufferToHexString(std::array{ std::byte{ 0x00 }, std::byte{ 0xab }, std::byte{ 0xcd }, std::byte{ 0xff } }), "00ABCDFF"); // test back stringFromHex EXPECT_EQ(stringFromHex(""), ""); diff --git a/src/libs/services/auth/impl/AuthTokenService.cpp b/src/libs/services/auth/impl/AuthTokenService.cpp index 5fef93db..07ffe940 100644 --- a/src/libs/services/auth/impl/AuthTokenService.cpp +++ b/src/libs/services/auth/impl/AuthTokenService.cpp @@ -87,8 +87,19 @@ namespace lms::auth } } + bool AuthTokenService::isClientThrottled(const boost::asio::ip::address& clientAddress) const + { + std::shared_lock lock{ _mutex }; + + return _loginThrottler.isClientThrottled(clientAddress); + } + std::optional AuthTokenService::processAuthToken(core::LiteralString domain, std::string_view token) { + // An empty token must never match: some callers use it as a "no candidate" sentinel value, + if (token.empty()) + return std::nullopt; + db::Session& session{ getDbSession() }; auto transaction{ session.createWriteTransaction() }; diff --git a/src/libs/services/auth/impl/AuthTokenService.hpp b/src/libs/services/auth/impl/AuthTokenService.hpp index c0206774..ebe7a358 100644 --- a/src/libs/services/auth/impl/AuthTokenService.hpp +++ b/src/libs/services/auth/impl/AuthTokenService.hpp @@ -47,6 +47,7 @@ namespace lms::auth private: void registerDomain(core::LiteralString domain, const DomainParameters& params) override; + bool isClientThrottled(const boost::asio::ip::address& clientAddress) const override; AuthTokenProcessResult processAuthToken(core::LiteralString domain, const boost::asio::ip::address& clientAddress, std::string_view tokenValue) override; void visitAuthTokens(core::LiteralString domain, db::UserId userId, std::function visitor) override; void createAuthToken(core::LiteralString domain, db::UserId userId, std::string_view token) override; @@ -55,7 +56,7 @@ namespace lms::auth std::optional processAuthToken(core::LiteralString domain, std::string_view tokenValue); const DomainParameters& getDomainParameters(core::LiteralString domain) const; - std::shared_mutex _mutex; + mutable std::shared_mutex _mutex; std::map _domainParameters; LoginThrottler _loginThrottler; }; diff --git a/src/libs/services/auth/include/services/auth/IAuthTokenService.hpp b/src/libs/services/auth/include/services/auth/IAuthTokenService.hpp index 300d4b32..3a6eb8e3 100644 --- a/src/libs/services/auth/include/services/auth/IAuthTokenService.hpp +++ b/src/libs/services/auth/include/services/auth/IAuthTokenService.hpp @@ -72,6 +72,8 @@ namespace lms::auth virtual void registerDomain(core::LiteralString domain, const DomainParameters& params) = 0; + virtual bool isClientThrottled(const boost::asio::ip::address& clientAddress) const = 0; + // Processing an auth token will make its useCount increase by 1. Token is then automatically deleted if its maxUsecount is reached virtual AuthTokenProcessResult processAuthToken(core::LiteralString domain, const boost::asio::ip::address& clientAddress, std::string_view tokenValue) = 0; diff --git a/src/libs/subsonic/CMakeLists.txt b/src/libs/subsonic/CMakeLists.txt index c3c27e6e..077d7eba 100644 --- a/src/libs/subsonic/CMakeLists.txt +++ b/src/libs/subsonic/CMakeLists.txt @@ -1,5 +1,6 @@ add_library(lmssubsonic STATIC + impl/AuthUtils.cpp impl/endpoints/transcoding/AudioFileInfo.cpp impl/endpoints/transcoding/TranscodeDecision.cpp impl/endpoints/transcoding/TranscodeDecisionTracker.cpp diff --git a/src/libs/subsonic/impl/AuthUtils.cpp b/src/libs/subsonic/impl/AuthUtils.cpp new file mode 100644 index 00000000..7e93099f --- /dev/null +++ b/src/libs/subsonic/impl/AuthUtils.cpp @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "AuthUtils.hpp" + +#include + +#include + +#include "core/Md5.hpp" +#include "core/Service.hpp" +#include "core/String.hpp" +#include "core/Utils.hpp" + +#include "database/Session.hpp" +#include "services/auth/IAuthTokenService.hpp" + +#include "ParameterParsing.hpp" +#include "SubsonicResponse.hpp" + +namespace lms::api::subsonic::utils +{ + namespace + { + // Finds, among the user's stored "subsonic" API keys, the one that satisfies the legacy "t"+"s" scheme, if any (empty string if none) + std::string findMatchingApiKeyForAuthToken(db::UserId userId, std::string_view salt, std::string_view token) + { + std::string matchedApiKey; + + core::Service::get()->visitAuthTokens("subsonic", userId, [&](const auth::IAuthTokenService::AuthTokenInfo&, std::string_view apiKey) { + if (matchedApiKey.empty() && checkAuthToken(apiKey, salt, token)) + matchedApiKey = apiKey; + }); + + return matchedApiKey; + } + + // Returns null if no such user exists + db::User::pointer getUserFromLoginName(db::Session& session, std::string_view loginName) + { + auto transaction{ session.createReadTransaction() }; + + return db::User::find(session, loginName); + } + } // namespace + + AuthenticationRequest parseAndValidateAuthenticationRequest(const Wt::Http::ParameterMap& parameters, const SubsonicResourceConfig& config) + { + const std::optional user{ getParameterAs(parameters, "u") }; + const std::optional password{ getParameterAs(parameters, "p") }; + const std::optional token{ getParameterAs(parameters, "t") }; + const std::optional salt{ getParameterAs(parameters, "s") }; + const std::optional apiKey{ getParameterAs(parameters, "apiKey") }; + + const bool passwordAuthRequested{ password.has_value() }; + const bool tokenAuthRequested{ token.has_value() || salt.has_value() }; + const bool passwordAuthAttempted{ passwordAuthRequested || (user.has_value() && !tokenAuthRequested) }; + + if (!config.supportPasswordAuthentication && passwordAuthAttempted) + throw ProvidedAuthenticationMechanismNotSupportedError{}; + if (!config.supportTokenAuthentication && tokenAuthRequested) + throw ProvidedAuthenticationMechanismNotSupportedError{}; + + if (passwordAuthRequested && tokenAuthRequested) + throw MultipleConflictingAuthenticationMechanismsProvidedError{}; + + if (tokenAuthRequested) + { + if (!user) + throw RequiredParameterMissingError{ "u" }; + if (!token) + throw RequiredParameterMissingError{ "t" }; + if (!salt) + throw RequiredParameterMissingError{ "s" }; + if (apiKey) + throw MultipleConflictingAuthenticationMechanismsProvidedError{}; + return TokenAuthentication{ .user = *user, .token = *token, .salt = *salt }; + } + + if (passwordAuthRequested) + { + if (!user) + throw RequiredParameterMissingError{ "u" }; + if (apiKey) + throw MultipleConflictingAuthenticationMechanismsProvidedError{}; + return PasswordAuthentication{ .user = *user, .password = *password }; + } + + if (user) + throw RequiredParameterMissingError{ "p" }; + if (!apiKey) + throw RequiredParameterMissingError{ "apiKey" }; + + return ApiKeyAuthentication{ .apiKey = *apiKey }; + } + + bool checkAuthToken(std::string_view apiKey, std::string_view salt, std::string_view token) + { + std::string payload; + payload.reserve(apiKey.size() + salt.size()); + payload += apiKey; + payload += salt; + + return core::stringUtils::stringCaseInsensitiveEqual(core::stringUtils::bufferToHexString(core::md5(payload)), token); + } + + db::User::pointer getUserFromUserId(db::Session& session, db::UserId userId) + { + auto transaction{ session.createReadTransaction() }; + + if (db::User::pointer user{ db::User::find(session, userId) }) + return user; + + throw UserNotAuthorizedError{}; + } + + db::UserId authenticateUser(const Wt::Http::Request& request, db::Session& session, const SubsonicResourceConfig& config) + { + const AuthenticationRequest authRequest{ parseAndValidateAuthenticationRequest(request.getParameterMap(), config) }; + + const auto clientAddress{ boost::asio::ip::make_address(request.clientAddress()) }; + auto& authTokenService{ *core::Service::get() }; + + const std::string authToken{ + std::visit(core::utils::overloads{ + [&](const PasswordAuthentication& auth) { + return decodePasswordIfNeeded(auth.password); + }, + [&](const ApiKeyAuthentication& auth) { + return auth.apiKey; + }, + [&](const TokenAuthentication& auth) { + if (authTokenService.isClientThrottled(clientAddress)) + throw LoginThrottledGenericError{}; + + const db::User::pointer authUser{ getUserFromLoginName(session, auth.user) }; + return authUser ? findMatchingApiKeyForAuthToken(authUser->getId(), auth.salt, auth.token) : std::string{}; + }, + }, + authRequest) + }; + + // It is OK to have an empty authToken string here, as the auth service will reject and counts this as a bad attempt for this client address + const auto authResult{ authTokenService.processAuthToken("subsonic", clientAddress, authToken) }; + + switch (authResult.state) + { + case auth::IAuthTokenService::AuthTokenProcessResult::State::Granted: + // Only the password mechanism needs this check as the token mechanism already used the user name + if (const auto* passwordAuth{ std::get_if(&authRequest) }) + { + const auto authenticatedUser{ getUserFromUserId(session, authResult.authTokenInfo->userId) }; + if (!authenticatedUser || authenticatedUser->getLoginName() != passwordAuth->user) + throw WrongUsernameOrPasswordError{}; + } + return authResult.authTokenInfo->userId; + case auth::IAuthTokenService::AuthTokenProcessResult::State::Denied: + if (std::holds_alternative(authRequest)) + throw InvalidAPIkeyError{}; + else + throw WrongUsernameOrPasswordError{}; + case auth::IAuthTokenService::AuthTokenProcessResult::State::Throttled: + throw LoginThrottledGenericError{}; + } + + throw InternalErrorGenericError{ "Cannot authenticate user" }; + } +} // namespace lms::api::subsonic::utils diff --git a/src/libs/subsonic/impl/AuthUtils.hpp b/src/libs/subsonic/impl/AuthUtils.hpp new file mode 100644 index 00000000..f363589d --- /dev/null +++ b/src/libs/subsonic/impl/AuthUtils.hpp @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include +#include + +#include + +#include "database/objects/User.hpp" +#include "database/objects/UserId.hpp" + +#include "SubsonicResourceConfig.hpp" + +namespace lms::db +{ + class Session; +} + +namespace lms::api::subsonic::utils +{ + struct PasswordAuthentication + { + std::string user; + std::string password; + }; + + struct TokenAuthentication + { + std::string user; + std::string token; + std::string salt; + }; + + struct ApiKeyAuthentication + { + std::string apiKey; + }; + + using AuthenticationRequest = std::variant; + + // Throws on error + AuthenticationRequest parseAndValidateAuthenticationRequest(const Wt::Http::ParameterMap& parameters, const SubsonicResourceConfig& config); + + // Checks token == hex(md5(apiKey + salt)), case insensitive + bool checkAuthToken(std::string_view apiKey, std::string_view salt, std::string_view token); + + // Throws UserNotAuthorizedError if no such user exists + db::User::pointer getUserFromUserId(db::Session& session, db::UserId userId); + + // Throws on error + db::UserId authenticateUser(const Wt::Http::Request& request, db::Session& session, const SubsonicResourceConfig& config); +} // namespace lms::api::subsonic diff --git a/src/libs/subsonic/impl/SubsonicResource.cpp b/src/libs/subsonic/impl/SubsonicResource.cpp index 825d6986..169d62bf 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -32,10 +32,9 @@ #include "database/IDb.hpp" #include "database/Session.hpp" #include "database/objects/User.hpp" -#include "services/auth/IAuthTokenService.hpp" #include "services/auth/IPasswordService.hpp" -#include "ParameterParsing.hpp" +#include "AuthUtils.hpp" #include "RequestContext.hpp" #include "SubsonicResponse.hpp" #include "endpoints/AlbumSongLists.hpp" @@ -269,15 +268,6 @@ namespace lms::api::subsonic TLSMonotonicMemoryResourceCleaner& operator=(const TLSMonotonicMemoryResourceCleaner&) = delete; }; - db::User::pointer getUserFromUserId(db::Session& session, db::UserId userId) - { - auto transaction{ session.createReadTransaction() }; - - if (db::User::pointer user{ db::User::find(session, userId) }) - return user; - - throw UserNotAuthorizedError{}; - } } // namespace SubsonicResource::SubsonicResource(db::IDb& db) @@ -328,7 +318,7 @@ namespace lms::api::subsonic // Media retrieval endpoints are always authenticated but we don't reauth user for a continuation db::User::pointer user; if (!request.continuation()) - user = getUserFromUserId(_db.getTLSSession(), authenticateUser(request)); + user = utils::getUserFromUserId(_db.getTLSSession(), authenticateUser(request)); requestContext.setUser(user); @@ -396,7 +386,7 @@ namespace lms::api::subsonic db::User::pointer user; if (itEntryPoint->second.authMode == AuthenticationMode::Authenticated) { - user = getUserFromUserId(_db.getTLSSession(), authenticateUser(request)); + user = utils::getUserFromUserId(_db.getTLSSession(), authenticateUser(request)); checkUserTypeIsAllowed(user, itEntryPoint->second.allowedUserTypes); requestContext->setUser(user); } @@ -425,50 +415,6 @@ namespace lms::api::subsonic db::UserId SubsonicResource::authenticateUser(const Wt::Http::Request& request) { - const auto& parameters{ request.getParameterMap() }; - - if (hasParameter(parameters, "t")) - throw ProvidedAuthenticationMechanismNotSupportedError{}; - - const auto user{ getParameterAs(parameters, "u") }; - const auto password{ getParameterAs(parameters, "p") }; - if (!_config.supportUserPasswordAuthentication && (password || user)) - throw ProvidedAuthenticationMechanismNotSupportedError{}; - - const auto apiKey{ getParameterAs(parameters, "apiKey") }; - - if (user && !password) - throw RequiredParameterMissingError{ "p" }; - if (!user && password) - throw RequiredParameterMissingError{ "u" }; - if (apiKey && password) - throw MultipleConflictingAuthenticationMechanismsProvidedError{}; - if (!apiKey && !password) - throw RequiredParameterMissingError{ "apiKey" }; - - const auto clientAddress{ boost::asio::ip::make_address(request.clientAddress()) }; - const std::string authToken{ apiKey ? *apiKey : decodePasswordIfNeeded(*password) }; - - const auto authResult{ core::Service::get()->processAuthToken("subsonic", clientAddress, authToken) }; - switch (authResult.state) - { - case auth::IAuthTokenService::AuthTokenProcessResult::State::Granted: - if (user) - { - const auto authenticatedUser{ getUserFromUserId(_db.getTLSSession(), authResult.authTokenInfo->userId) }; - if (!authenticatedUser || authenticatedUser->getLoginName() != *user) - throw WrongUsernameOrPasswordError{}; - } - return authResult.authTokenInfo->userId; - case auth::IAuthTokenService::AuthTokenProcessResult::State::Denied: - if (apiKey) - throw InvalidAPIkeyError{}; - else - throw WrongUsernameOrPasswordError{}; - case auth::IAuthTokenService::AuthTokenProcessResult::State::Throttled: - throw LoginThrottledGenericError{}; - } - - throw InternalErrorGenericError{ "Cannot authenticate user" }; + return utils::authenticateUser(request, _db.getTLSSession(), _config); } } // namespace lms::api::subsonic diff --git a/src/libs/subsonic/impl/SubsonicResourceConfig.cpp b/src/libs/subsonic/impl/SubsonicResourceConfig.cpp index f33d50b5..85e7a88a 100644 --- a/src/libs/subsonic/impl/SubsonicResourceConfig.cpp +++ b/src/libs/subsonic/impl/SubsonicResourceConfig.cpp @@ -57,7 +57,8 @@ namespace lms::api::subsonic return SubsonicResourceConfig{ .serverProtocolVersionsByClient = readConfigProtocolVersions(config), .openSubsonicDisabledClients = readOpenSubsonicDisabledClients(config), - .supportUserPasswordAuthentication = config.getBool("api-subsonic-support-user-password-auth", true) + .supportPasswordAuthentication = config.getBool("api-subsonic-support-password-auth", true), + .supportTokenAuthentication = config.getBool("api-subsonic-support-token-auth", true) }; } } // namespace lms::api::subsonic \ No newline at end of file diff --git a/src/libs/subsonic/impl/SubsonicResourceConfig.hpp b/src/libs/subsonic/impl/SubsonicResourceConfig.hpp index 0eef5698..da3401ca 100644 --- a/src/libs/subsonic/impl/SubsonicResourceConfig.hpp +++ b/src/libs/subsonic/impl/SubsonicResourceConfig.hpp @@ -36,7 +36,8 @@ namespace lms::api::subsonic { std::unordered_map serverProtocolVersionsByClient; std::unordered_set openSubsonicDisabledClients; - bool supportUserPasswordAuthentication; + bool supportPasswordAuthentication; + bool supportTokenAuthentication; }; SubsonicResourceConfig readSubsonicResourceConfig(core::IConfig& _config); diff --git a/src/libs/subsonic/test/AuthUtils.cpp b/src/libs/subsonic/test/AuthUtils.cpp new file mode 100644 index 00000000..22df0d03 --- /dev/null +++ b/src/libs/subsonic/test/AuthUtils.cpp @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include + +#include "AuthUtils.hpp" +#include "SubsonicResponse.hpp" + +namespace lms::api::subsonic::utils::tests +{ + namespace + { + const SubsonicResourceConfig bothMechanismsSupported{ + .serverProtocolVersionsByClient = {}, + .openSubsonicDisabledClients = {}, + .supportPasswordAuthentication = true, + .supportTokenAuthentication = true, + }; + + const SubsonicResourceConfig noneSupported{ + .serverProtocolVersionsByClient = {}, + .openSubsonicDisabledClients = {}, + .supportPasswordAuthentication = false, + .supportTokenAuthentication = false, + }; + + const SubsonicResourceConfig passwordUnsupported{ + .serverProtocolVersionsByClient = {}, + .openSubsonicDisabledClients = {}, + .supportPasswordAuthentication = false, + .supportTokenAuthentication = true, + }; + } // namespace + + // Reference values from the Subsonic API documentation (apiKey = "sesame", salt = "c19b2d") + TEST(AuthUtils, checkAuthToken_ValidToken) + { + EXPECT_TRUE(checkAuthToken("sesame", "c19b2d", "26719a1196d2a940705a59634eb18eab")); + } + + TEST(AuthUtils, checkAuthToken_ValidToken_UppercaseHex) + { + EXPECT_TRUE(checkAuthToken("sesame", "c19b2d", "26719A1196D2A940705A59634EB18EAB")); + } + + TEST(AuthUtils, checkAuthToken_WrongToken) + { + EXPECT_FALSE(checkAuthToken("sesame", "c19b2d", "00000000000000000000000000000000")); + } + + TEST(AuthUtils, checkAuthToken_WrongSalt) + { + EXPECT_FALSE(checkAuthToken("sesame", "differentsalt", "26719a1196d2a940705a59634eb18eab")); + } + + TEST(AuthUtils, checkAuthToken_WrongApiKey) + { + EXPECT_FALSE(checkAuthToken("wrongkey", "c19b2d", "26719a1196d2a940705a59634eb18eab")); + } + + TEST(AuthUtils, parseAndValidateAuthenticationRequest_apiKeyOnly) + { + const auto request{ parseAndValidateAuthenticationRequest({ { "apiKey", { "apiKey" } } }, bothMechanismsSupported) }; + ASSERT_TRUE(std::holds_alternative(request)); + EXPECT_EQ(std::get(request).apiKey, "apiKey"); + + EXPECT_THROW(parseAndValidateAuthenticationRequest({}, bothMechanismsSupported), RequiredParameterMissingError); + } + + TEST(AuthUtils, parseAndValidateAuthenticationRequest_password) + { + const auto request{ parseAndValidateAuthenticationRequest({ { "u", { "user" } }, { "p", { "password" } } }, bothMechanismsSupported) }; + ASSERT_TRUE(std::holds_alternative(request)); + EXPECT_EQ(std::get(request).user, "user"); + EXPECT_EQ(std::get(request).password, "password"); + + EXPECT_THROW(parseAndValidateAuthenticationRequest({ { "p", { "password" } } }, bothMechanismsSupported), RequiredParameterMissingError); // missing u + EXPECT_THROW(parseAndValidateAuthenticationRequest({ { "u", { "user" } } }, bothMechanismsSupported), RequiredParameterMissingError); // missing p + } + + TEST(AuthUtils, parseAndValidateAuthenticationRequest_token) + { + const auto request{ parseAndValidateAuthenticationRequest({ { "u", { "user" } }, { "t", { "token" } }, { "s", { "salt" } } }, bothMechanismsSupported) }; + ASSERT_TRUE(std::holds_alternative(request)); + EXPECT_EQ(std::get(request).user, "user"); + EXPECT_EQ(std::get(request).token, "token"); + EXPECT_EQ(std::get(request).salt, "salt"); + + EXPECT_THROW(parseAndValidateAuthenticationRequest({ { "t", { "token" } }, { "s", { "salt" } } }, bothMechanismsSupported), RequiredParameterMissingError); // missing u + EXPECT_THROW(parseAndValidateAuthenticationRequest({ { "u", { "user" } }, { "s", { "salt" } } }, bothMechanismsSupported), RequiredParameterMissingError); // missing t + EXPECT_THROW(parseAndValidateAuthenticationRequest({ { "u", { "user" } }, { "t", { "token" } } }, bothMechanismsSupported), RequiredParameterMissingError); // missing s + } + + TEST(AuthUtils, parseAndValidateAuthenticationRequest_conflicts) + { + // password + token + EXPECT_THROW(parseAndValidateAuthenticationRequest({ { "u", { "user" } }, { "p", { "password" } }, { "t", { "token" } }, { "s", { "salt" } } }, bothMechanismsSupported), MultipleConflictingAuthenticationMechanismsProvidedError); + // apiKey + password + EXPECT_THROW(parseAndValidateAuthenticationRequest({ { "u", { "user" } }, { "p", { "password" } }, { "apiKey", { "apiKey" } } }, bothMechanismsSupported), MultipleConflictingAuthenticationMechanismsProvidedError); + // apiKey + token + EXPECT_THROW(parseAndValidateAuthenticationRequest({ { "u", { "user" } }, { "t", { "token" } }, { "s", { "salt" } }, { "apiKey", { "apiKey" } } }, bothMechanismsSupported), MultipleConflictingAuthenticationMechanismsProvidedError); + } + + TEST(AuthUtils, parseAndValidateAuthenticationRequest_mechanismNotSupported) + { + EXPECT_THROW(parseAndValidateAuthenticationRequest({ { "u", { "user" } }, { "p", { "password" } } }, noneSupported), ProvidedAuthenticationMechanismNotSupportedError); + EXPECT_THROW(parseAndValidateAuthenticationRequest({ { "u", { "user" } }, { "t", { "token" } }, { "s", { "salt" } } }, noneSupported), ProvidedAuthenticationMechanismNotSupportedError); + EXPECT_NO_THROW(parseAndValidateAuthenticationRequest({ { "apiKey", { "apiKey" } } }, noneSupported)); // apiKey is unaffected by these two flags + } + + TEST(AuthUtils, parseAndValidateAuthenticationRequest_usernameOnlyTreatedAsPasswordAttempt) + { + EXPECT_THROW(parseAndValidateAuthenticationRequest({ { "u", { "user" } } }, passwordUnsupported), ProvidedAuthenticationMechanismNotSupportedError); + } + + TEST(AuthUtils, parseAndValidateAuthenticationRequest_apiKeyWithPartialPassword) + { + EXPECT_THROW(parseAndValidateAuthenticationRequest({ { "p", { "password" } }, { "apiKey", { "apiKey" } } }, bothMechanismsSupported), RequiredParameterMissingError); // missing u + } + + TEST(AuthUtils, parseAndValidateAuthenticationRequest_apiKeyWithPartialToken) + { + EXPECT_THROW(parseAndValidateAuthenticationRequest({ { "s", { "salt" } }, { "apiKey", { "apiKey" } } }, bothMechanismsSupported), RequiredParameterMissingError); // missing u + } +} // namespace lms::api::subsonic::utils::tests diff --git a/src/libs/subsonic/test/CMakeLists.txt b/src/libs/subsonic/test/CMakeLists.txt index 39678b48..a97c7d39 100644 --- a/src/libs/subsonic/test/CMakeLists.txt +++ b/src/libs/subsonic/test/CMakeLists.txt @@ -1,6 +1,7 @@ include(GoogleTest) add_executable(test-subsonic + AuthUtils.cpp ClientInfo.cpp Subsonic.cpp SubsonicResponse.cpp @@ -14,6 +15,7 @@ target_include_directories(test-subsonic PRIVATE target_link_libraries(test-subsonic PRIVATE lmscore lmsaudio + lmsdatabase lmssubsonic GTest::GTest ) diff --git a/src/lms/ui/settings/SubsonicSettingsView.cpp b/src/lms/ui/settings/SubsonicSettingsView.cpp index 27cc51e5..efeb51fa 100644 --- a/src/lms/ui/settings/SubsonicSettingsView.cpp +++ b/src/lms/ui/settings/SubsonicSettingsView.cpp @@ -185,7 +185,10 @@ namespace lms::ui auto* t{ addNew(Wt::WString::tr("Lms.Settings.subsonic.template.key")) }; t->addFunction("tr", &Wt::WTemplate::Functions::tr); - t->setCondition("if-has-subsonic-token-usage", core::Service::get()->getBool("api-subsonic-support-user-password-auth", true)); + { + core::IConfig& config{ *core::Service::get() }; + t->setCondition("if-has-subsonic-token-usage", config.getBool("api-subsonic-support-password-auth", true) || config.getBool("api-subsonic-support-token-auth", true)); + } std::string currentToken; {